From 1317eec16ef49680e59b9657c8d713884f9cd21e Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 26 Oct 2025 02:59:56 +0800 Subject: [PATCH 1/7] Introduce colour blending utility When making terminal-friendly interfaces, it is easy to run into the limits of 4-bit ANSI colouring. This is easily seen when trying to show selections or highlighting, and a shaded background is required. Without knowing if the terminal is light or dark, and what shades its ANSI colours are, it is not possible to pick an appropriate colour. To generate appropriate colours, some form of blending is required. Instead of encouraging packages to just pick a colour, or do ad-hoc blending themselves, it makes sense for us to provide a single colour blending function that does a good job: here, by transforming the sRGB colour into OKLab space to do the blending in, and then back to sRGB at the end. This extra work pays off in markedly better results. While terminal colour detection and retheming is left for later, this work together with the base colours lays the foundation for consistently appropriate colouring. --- docs/src/index.md | 1 + docs/src/internals.md | 6 ++- src/StyledStrings.jl | 2 +- src/faces.jl | 122 ++++++++++++++++++++++++++++++++++++++++++ src/io.jl | 40 +++++--------- test/runtests.jl | 4 +- 6 files changed, 144 insertions(+), 31 deletions(-) diff --git a/docs/src/index.md b/docs/src/index.md index 984582d..41d6e51 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -341,4 +341,5 @@ StyledStrings.SimpleColor StyledStrings.parse(::Type{StyledStrings.SimpleColor}, ::String) StyledStrings.tryparse(::Type{StyledStrings.SimpleColor}, ::String) StyledStrings.merge(::StyledStrings.Face, ::StyledStrings.Face) +StyledStrings.blend(::StyledStrings.SimpleColor, ::StyledStrings.SimpleColor, ::Real) ``` diff --git a/docs/src/internals.md b/docs/src/internals.md index 4bff0b6..336ae1b 100644 --- a/docs/src/internals.md +++ b/docs/src/internals.md @@ -8,6 +8,8 @@ opening a pull request or issue to discuss making them part of the public API. ```@docs StyledStrings.ANSI_4BIT_COLORS StyledStrings.FACES +StyledStrings.MAX_COLOR_FORWARDS +StyledStrings.UNRESOLVED_COLOR_FALLBACK StyledStrings.Legacy.ANSI_256_COLORS StyledStrings.Legacy.NAMED_COLORS StyledStrings.Legacy.RENAMED_COLORS @@ -16,13 +18,15 @@ StyledStrings.Legacy.load_env_colors! StyledStrings.ansi_4bit StyledStrings.face! StyledStrings.getface +StyledStrings.load_customisations! StyledStrings.loadface! StyledStrings.loaduserfaces! StyledStrings.resetfaces! +StyledStrings.rgbcolor StyledStrings.termcolor StyledStrings.termcolor24bit StyledStrings.termcolor8bit -StyledStrings.load_customisations! +StyledStrings.try_rgbcolor ``` ## Styled Markup parsing diff --git a/src/StyledStrings.jl b/src/StyledStrings.jl index dc7e246..29e5527 100644 --- a/src/StyledStrings.jl +++ b/src/StyledStrings.jl @@ -9,7 +9,7 @@ using Base.ScopedValues: ScopedValue, with, @with export AnnotatedString, AnnotatedChar, AnnotatedIOBuffer, annotations, annotate!, annotatedstring export @styled_str -public Face, addface!, withfaces, styled, SimpleColor +public Face, addface!, withfaces, styled, SimpleColor, blend include("faces.jl") include("io.jl") diff --git a/src/faces.jl b/src/faces.jl index b9f7b05..f93c0f0 100644 --- a/src/faces.jl +++ b/src/faces.jl @@ -752,3 +752,125 @@ function Base.convert(::Type{Face}, spec::Dict{String,Any}) Symbol[] end) end + +## Color utils ## + +""" + UNRESOLVED_COLOR_FALLBACK + +The fallback `RGBTuple` used when asking for a color that is not defined. +""" +const UNRESOLVED_COLOR_FALLBACK = (r = 0xff, g = 0x00, b = 0xff) # Pink + +""" + MAX_COLOR_FORWARDS + +The maximum number of times to follow color references when resolving a color. +""" +const MAX_COLOR_FORWARDS = 12 + +""" + try_rgbcolor(name::Symbol, stamina::Int = MAX_COLOR_FORWARDS) + +Attempt to resolve `name` to an `RGBTuple`, taking up to `stamina` steps. +""" +function try_rgbcolor(name::Symbol, stamina::Int = MAX_COLOR_FORWARDS) + for s in stamina:-1:1 # Do this instead of a while loop to prevent cyclic lookups + face = get(FACES.current[], name, Face()) + fg = face.foreground + if isnothing(fg) + isempty(face.inherit) && break + for iname in face.inherit + irgb = try_rgbcolor(iname, s - 1) + !isnothing(irgb) && return irgb + end + end + fg.value isa RGBTuple && return fg.value + fg.value == name && return get(FACES.basecolors, name, nothing) + name = fg.value + end +end + +""" + rgbcolor(color::Union{Symbol, SimpleColor}) + +Resolve a `color` to an `RGBTuple`. + +The resolution follows these steps: +1. If `color` is a `SimpleColor` holding an `RGBTuple`, that is returned. +2. If `color` names a face, the face's foreground color is used. +3. If `color` names a base color, that color is used. +4. Otherwise, `UNRESOLVED_COLOR_FALLBACK` (bright pink) is returned. +""" +function rgbcolor(color::Union{Symbol, SimpleColor}) + name = if color isa Symbol + color + elseif color isa SimpleColor + color.value + end + name isa RGBTuple && return name + @something(try_rgbcolor(name), + get(FACES.basecolors, name, UNRESOLVED_COLOR_FALLBACK)) +end + +""" + blend(a::Union{Symbol, SimpleColor}, b::Union{Symbol, SimpleColor}, α::Real) + +Blend colors `a` and `b` in Oklab space, with mix ratio `α` (0–1). + +The colors `a` and `b` can either be `SimpleColor`s, or `Symbol`s naming a face +or base color. The mix ratio `α` combines `(1 - α)` of `a` with `α` of `b`. + +# Examples + +```julia-repl +julia> blend(SimpleColor(0xff0000), SimpleColor(0x0000ff), 0.5) +SimpleColor(■ #8b54a1) + +julia> blend(:red, :yellow, 0.7) +SimpleColor(■ #d47f24) + +julia> blend(:green, SimpleColor(0xffffff), 0.3) +SimpleColor(■ #74be93) +``` +""" +function blend(c1::SimpleColor, c2::SimpleColor, α::Real) + function oklab(rgb::RGBTuple) + r, g, b = (Tuple(rgb) ./ 255) .^ 2.2 + l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) + m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) + s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) + L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s + a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s + b = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s + (; L, a, b) + end + function rgb((; L, a, b)) + tohex(v) = round(UInt8, min(255.0, 255 * max(0.0, v)^(1 / 2.2))) + l = (L + 0.3963377774 * a + 0.2158037573 * b)^3 + m = (L - 0.1055613458 * a - 0.0638541728 * b)^3 + s = (L - 0.0894841775 * a - 1.2914855480 * b)^3 + r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s + g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s + b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s + (r = tohex(r), g = tohex(g), b = tohex(b)) + end + lab1 = oklab(rgbcolor(c1)) + lab2 = oklab(rgbcolor(c2)) + mix = (L = (1 - α) * lab1.L + α * lab2.L, + a = (1 - α) * lab1.a + α * lab2.a, + b = (1 - α) * lab1.b + α * lab2.b) + SimpleColor(rgb(mix)) +end + +function blend(f1::Union{Symbol, SimpleColor}, f2::Union{Symbol, SimpleColor}, α::Real) + function face_or_color(name::Symbol) + c = getface(name).foreground + if c.value === :foreground && haskey(FACES.basecolors, name) + c = SimpleColor(name) + end + c + end + face_or_color(c::SimpleColor) = c + blend(face_or_color(f1), face_or_color(f2), α) +end diff --git a/src/io.jl b/src/io.jl index 3070ca8..d9eb01d 100644 --- a/src/io.jl +++ b/src/io.jl @@ -92,8 +92,6 @@ function termcolor24bit(io::IO, color::RGBTuple, category::Char) string(color.b), 'm') end -const MAX_COLOR_FORWARDS = 12 - """ termcolor(io::IO, color::SimpleColor, category::Char) @@ -311,32 +309,20 @@ Base.AnnotatedDisplay.show_annot(io::IO, ::MIME"text/html", s::Union{<:Annotated function htmlcolor(io::IO, color::SimpleColor, background::Bool = false) default = getface() - if color.value isa Symbol - if background && color.value == :background - print(io, "initial") - elseif !background && color.value == :foreground - print(io, "initial") - elseif (fg = get(FACES.current[], color.value, default).foreground) != SimpleColor(color.value) - htmlcolor(io, fg) - elseif haskey(FACES.basecolors, color.value) - htmlcolor(io, SimpleColor(FACES.basecolors[color.value])) - else - print(io, "inherit") - end - elseif background && color.value == default.background - htmlcolor(io, SimpleColor(:background), true) - elseif !background && color.value ==default.foreground - htmlcolor(io, SimpleColor(:foreground)) - else - (; r, g, b) = color.value - print(io, '#') - r < 0x10 && print(io, '0') - print(io, string(r, base=16)) - g < 0x10 && print(io, '0') - print(io, string(g, base=16)) - b < 0x10 && print(io, '0') - print(io, string(b, base=16)) + if background && color.value ∈ (:background, default.background) + return print(io, "initial") + elseif !background && color.value ∈ (:foreground, default.foreground) + return print(io, "initial") end + (; r, g, b) = rgbcolor(color) + default = getface() + print(io, '#') + r < 0x10 && print(io, '0') + print(io, string(r, base=16)) + g < 0x10 && print(io, '0') + print(io, string(g, base=16)) + b < 0x10 && print(io, '0') + print(io, string(b, base=16)) end const HTML_WEIGHT_MAP = Dict{Symbol, Int}( diff --git a/test/runtests.jl b/test/runtests.jl index 0e30034..bb0625a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -571,7 +571,7 @@ end @test sprint(StyledStrings.htmlcolor, SimpleColor(:black)) == "#1c1a23" @test sprint(StyledStrings.htmlcolor, SimpleColor(:green)) == "#25a268" @test sprint(StyledStrings.htmlcolor, SimpleColor(:warning)) == "#e5a509" - @test sprint(StyledStrings.htmlcolor, SimpleColor(:nonexistant)) == "initial" + @test sprint(StyledStrings.htmlcolor, SimpleColor(:nonexistant)) == "#ff00ff" @test sprint(StyledStrings.htmlcolor, SimpleColor(0x40, 0x63, 0xd8)) == "#4063d8" function html_change(; attrs...) face = getface(Face(; attrs...)) @@ -609,7 +609,7 @@ end `AnnotatedString` \ type to provide a \ full-fledged textual \ - styling system, suitable for terminal and graphical displays." + styling system, suitable for terminal and graphical displays." end @testset "Legacy" begin From 0877ce98e8ad8bd421510bf59b839368d558933f Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 26 Oct 2025 13:41:17 +0800 Subject: [PATCH 2/7] Record modifications made to current faces This will make it easier to reapply modifications after recolouring. --- src/faces.jl | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/faces.jl b/src/faces.jl index f93c0f0..43639b0 100644 --- a/src/faces.jl +++ b/src/faces.jl @@ -405,6 +405,7 @@ const FACES = let default = Dict{Symbol, Face}( :bright_white => (r = 0xf6, g = 0xf5, b = 0xf4)) (; default, basecolors, current = ScopedValue(copy(default)), + modifications = ScopedValue(Dict{Symbol, Face}()), lock = ReentrantLock()) end @@ -451,6 +452,7 @@ function resetfaces!() for (key, val) in FACES.default current[key] = val end + empty!(FACES.modifications[]) current end end @@ -467,6 +469,7 @@ it is deleted, a warning message is printed, and `nothing` returned. function resetfaces!(name::Symbol) @lock FACES.lock if !haskey(FACES.current[], name) elseif haskey(FACES.default, name) + delete!(FACES.modifications[], name) FACES.current[][name] = copy(FACES.default[name]) else # This shouldn't happen delete!(FACES.current[], name) @@ -656,9 +659,16 @@ Face (sample) ``` """ function loadface!((name, update)::Pair{Symbol, Face}) - @lock FACES.lock if haskey(FACES.current[], name) - FACES.current[][name] = merge(FACES.current[][name], update) - else + @lock FACES.lock begin + mface = get(FACES.modifications[], name, nothing) + if !isnothing(mface) + update = merge(mface, update) + end + FACES.modifications[][name] = update + cface = get(FACES.current[], name, nothing) + if !isnothing(cface) + update = merge(cface, update) + end FACES.current[][name] = update end end From 66f0d1b120c08d3e4732dbf2b7ae564dd71bdfaa Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 26 Oct 2025 13:48:37 +0800 Subject: [PATCH 3/7] Introduce recolouring hooks --- docs/src/index.md | 1 + src/StyledStrings.jl | 2 +- src/faces.jl | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/src/index.md b/docs/src/index.md index 41d6e51..3ed857e 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -342,4 +342,5 @@ StyledStrings.parse(::Type{StyledStrings.SimpleColor}, ::String) StyledStrings.tryparse(::Type{StyledStrings.SimpleColor}, ::String) StyledStrings.merge(::StyledStrings.Face, ::StyledStrings.Face) StyledStrings.blend(::StyledStrings.SimpleColor, ::StyledStrings.SimpleColor, ::Real) +StyledStrings.recolor ``` diff --git a/src/StyledStrings.jl b/src/StyledStrings.jl index 29e5527..ba4f528 100644 --- a/src/StyledStrings.jl +++ b/src/StyledStrings.jl @@ -9,7 +9,7 @@ using Base.ScopedValues: ScopedValue, with, @with export AnnotatedString, AnnotatedChar, AnnotatedIOBuffer, annotations, annotate!, annotatedstring export @styled_str -public Face, addface!, withfaces, styled, SimpleColor, blend +public Face, addface!, withfaces, styled, SimpleColor, blend, recolor include("faces.jl") include("io.jl") diff --git a/src/faces.jl b/src/faces.jl index 43639b0..b4db497 100644 --- a/src/faces.jl +++ b/src/faces.jl @@ -763,6 +763,46 @@ function Base.convert(::Type{Face}, spec::Dict{String,Any}) end) end +## Recolouring ## + +const recolor_hooks = Function[] +const recolor_lock = ReentrantLock() + +""" + recolor(f::Function) + +Register a hook function `f` to be called whenever the colors change. + +Usually hooks will be called once after terminal colors have been +determined. These hooks enable dynamic retheming, but are specifically *not* run when faces +are changed. They sit in between the default faces and modifications layered on +top with `loadface!` and user customisations. +""" +function recolor(f::Function) + @lock recolor_lock push!(recolor_hooks, f) + nothing +end + +function setcolors!(color::Vector{Pair{Symbol, RGBTuple}}) + @lock recolor_lock begin + for (name, rgb) in color + FACES.basecolors[name] = rgb + end + current = FACES.current[] + for (name, _) in FACES.modifications[] + default = get(FACES.default, name, nothing) + isnothing(default) && continue + current[name] = default + end + for hook in recolor_hooks + hook() + end + for (name, face) in FACES.modifications[] + current[name] = merge(current[name], face) + end + end +end + ## Color utils ## """ From b9b6c30a6c9207c71c7102b7fd1af4e44019e8ed Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 26 Oct 2025 14:00:05 +0800 Subject: [PATCH 4/7] Export more public API: Face and blend --- src/StyledStrings.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/StyledStrings.jl b/src/StyledStrings.jl index ba4f528..9f95021 100644 --- a/src/StyledStrings.jl +++ b/src/StyledStrings.jl @@ -8,8 +8,8 @@ using Base.ScopedValues: ScopedValue, with, @with # While these are imported from Base, we claim them as part of the `StyledStrings` API. export AnnotatedString, AnnotatedChar, AnnotatedIOBuffer, annotations, annotate!, annotatedstring -export @styled_str -public Face, addface!, withfaces, styled, SimpleColor, blend, recolor +export @styled_str, Face, blend +public addface!, withfaces, styled, SimpleColor, recolor include("faces.jl") include("io.jl") From 9472b021c3d7e0c1e0be0f2bf94ccbd87e8b0907 Mon Sep 17 00:00:00 2001 From: TEC Date: Mon, 27 Oct 2025 22:24:38 +0800 Subject: [PATCH 5/7] Remove alias colors from basecolors These were never supposed to make the cut in the first place. --- src/faces.jl | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/faces.jl b/src/faces.jl index b4db497..0a7f3fc 100644 --- a/src/faces.jl +++ b/src/faces.jl @@ -394,8 +394,6 @@ const FACES = let default = Dict{Symbol, Face}( :cyan => (r = 0x00, g = 0x97, b = 0xa7), :white => (r = 0xdd, g = 0xdc, b = 0xd9), :bright_black => (r = 0x76, g = 0x75, b = 0x7a), - :grey => (r = 0x76, g = 0x75, b = 0x7a), - :gray => (r = 0x76, g = 0x75, b = 0x7a), :bright_red => (r = 0xed, g = 0x33, b = 0x3b), :bright_green => (r = 0x33, g = 0xd0, b = 0x79), :bright_yellow => (r = 0xf6, g = 0xd2, b = 0x2c), From 5cee19a29a1b3ff88420a2068333b2b63bb7fd34 Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 26 Oct 2025 16:37:56 +0800 Subject: [PATCH 6/7] Split default+modified faces into base/light/dark --- docs/src/internals.md | 1 + src/faces.jl | 132 +++++++++++++++++++++++++++++------------- src/io.jl | 2 +- test/runtests.jl | 24 ++++---- 4 files changed, 106 insertions(+), 53 deletions(-) diff --git a/docs/src/internals.md b/docs/src/internals.md index 336ae1b..05c8c8f 100644 --- a/docs/src/internals.md +++ b/docs/src/internals.md @@ -16,6 +16,7 @@ StyledStrings.Legacy.RENAMED_COLORS StyledStrings.Legacy.legacy_color StyledStrings.Legacy.load_env_colors! StyledStrings.ansi_4bit +StyledStrings.setcolors! StyledStrings.face! StyledStrings.getface StyledStrings.load_customisations! diff --git a/src/faces.jl b/src/faces.jl index 0a7f3fc..777b437 100644 --- a/src/faces.jl +++ b/src/faces.jl @@ -312,8 +312,8 @@ Globally named [`Face`](@ref)s. (potentially modified) set of faces. This two-set system allows for any modifications to the active faces to be undone. """ -const FACES = let default = Dict{Symbol, Face}( - # Default is special, it must be completely specified +const FACES = let base = Dict{Symbol, Face}( + # Base is special, it must be completely specified # and everything inherits from it. :default => Face( "monospace", 120, # font, height @@ -350,7 +350,7 @@ const FACES = let default = Dict{Symbol, Face}( :bright_white => Face(foreground=:bright_white), # Useful common faces :shadow => Face(foreground=:bright_black), - :region => Face(background=0x3a3a3a), + :region => Face(background=0x636363), :emphasis => Face(foreground=:blue), :highlight => Face(inherit=:emphasis, inverse=true), :code => Face(foreground=:cyan), @@ -382,6 +382,12 @@ const FACES = let default = Dict{Symbol, Face}( :repl_prompt_pkg => Face(inherit=[:blue, :repl_prompt]), :repl_prompt_beep => Face(inherit=[:shadow, :repl_prompt]), ) + light = Dict{Symbol, Face}( + :region => Face(background=0xaaaaaa), + ) + dark = Dict{Symbol, Face}( + :region => Face(background=0x363636), + ) basecolors = Dict{Symbol, RGBTuple}( :background => (r = 0xff, g = 0xff, b = 0xff), :foreground => (r = 0x00, g = 0x00, b = 0x00), @@ -401,20 +407,23 @@ const FACES = let default = Dict{Symbol, Face}( :bright_magenta => (r = 0xbf, g = 0x60, b = 0xca), :bright_cyan => (r = 0x26, g = 0xc6, b = 0xda), :bright_white => (r = 0xf6, g = 0xf5, b = 0xf4)) - (; default, basecolors, - current = ScopedValue(copy(default)), - modifications = ScopedValue(Dict{Symbol, Face}()), + (themes = (; base, light, dark), + modifications = (base = Dict{Symbol, Face}(), light = Dict{Symbol, Face}(), dark = Dict{Symbol, Face}()), + current = ScopedValue(copy(base)), + basecolors = basecolors, lock = ReentrantLock()) end ## Adding and resetting faces ## """ - addface!(name::Symbol => default::Face) + addface!(name::Symbol => default::Face, theme::Symbol = :base) Create a new face by the name `name`. So long as no face already exists by this -name, `default` is added to both `FACES``.default` and (a copy of) to -`FACES`.`current`, with the current value returned. +name, `default` is added to both `FACES.themes[theme]` and (a copy of) to +`FACES.current`, with the current value returned. + +The `theme` should be either `:base`, `:light`, or `:dark`. Should the face `name` already exist, `nothing` is returned. @@ -427,11 +436,12 @@ Face (sample) underline: true ``` """ -function addface!((name, default)::Pair{Symbol, Face}) - @lock FACES.lock if !haskey(FACES.default, name) - FACES.default[name] = default - FACES.current[][name] = if haskey(FACES.current[], name) - merge(copy(default), FACES.current[][name]) +function addface!((name, default)::Pair{Symbol, Face}, theme::Symbol = :base) + current = FACES.current[] + @lock FACES.lock if !haskey(FACES.themes[theme], name) + FACES.themes[theme][name] = default + current[name] = if haskey(current, name) + merge(copy(default), current[name]) else copy(default) end @@ -447,10 +457,12 @@ function resetfaces!() @lock FACES.lock begin current = FACES.current[] empty!(current) - for (key, val) in FACES.default + for (key, val) in FACES.themes.base current[key] = val end - empty!(FACES.modifications[]) + if current === FACES.current.default # Only when top-level + map(empty!, values(FACES.modifications)) + end current end end @@ -464,13 +476,15 @@ If the face `name` does not exist, nothing is done and `nothing` returned. In the unlikely event that the face `name` does not have a default value, it is deleted, a warning message is printed, and `nothing` returned. """ -function resetfaces!(name::Symbol) - @lock FACES.lock if !haskey(FACES.current[], name) - elseif haskey(FACES.default, name) - delete!(FACES.modifications[], name) - FACES.current[][name] = copy(FACES.default[name]) +function resetfaces!(name::Symbol, theme::Symbol = :base) + current = FACES.current[] + @lock FACES.lock if !haskey(current, name) # Nothing to reset + elseif haskey(FACES.themes[theme], name) + current === FACES.current.default && + delete!(FACES.modifications[theme], name) + current[name] = copy(FACES.themes[theme][name]) else # This shouldn't happen - delete!(FACES.current[], name) + delete!(current, name) @warn """The face $name was reset, but it had no default value, and so has been deleted instead!, This should not have happened, perhaps the face was added without using `addface!`?""" end @@ -656,18 +670,17 @@ Face (sample) foreground: #ff0000 ``` """ -function loadface!((name, update)::Pair{Symbol, Face}) +function loadface!((name, update)::Pair{Symbol, Face}, theme::Symbol = :base) @lock FACES.lock begin - mface = get(FACES.modifications[], name, nothing) - if !isnothing(mface) - update = merge(mface, update) - end - FACES.modifications[][name] = update - cface = get(FACES.current[], name, nothing) - if !isnothing(cface) - update = merge(cface, update) + current = FACES.current[] + if FACES.current.default === current # Only save top-level modifications + mface = get(FACES.modifications[theme], name, nothing) + isnothing(mface) || (update = merge(mface, update)) + FACES.modifications[theme][name] = update end - FACES.current[][name] = update + cface = get(current, name, nothing) + isnothing(cface) || (update = merge(cface, update)) + current[name] = update end end @@ -682,7 +695,9 @@ end For each face specified in `Dict`, load it to `FACES``.current`. """ -function loaduserfaces!(faces::Dict{String, Any}, prefix::Union{String, Nothing}=nothing) +function loaduserfaces!(faces::Dict{String, Any}, prefix::Union{String, Nothing}=nothing, theme::Symbol = :base) + theme == :base && prefix ∈ map(String, setdiff(keys(FACES.themes), (:base,))) && + return loaduserfaces!(faces, nothing, Symbol(prefix)) for (name, spec) in faces fullname = if isnothing(prefix) name @@ -692,9 +707,9 @@ function loaduserfaces!(faces::Dict{String, Any}, prefix::Union{String, Nothing} fspec = filter((_, v)::Pair -> !(v isa Dict), spec) fnest = filter((_, v)::Pair -> v isa Dict, spec) !isempty(fspec) && - loadface!(Symbol(fullname) => convert(Face, fspec)) + loadface!(Symbol(fullname) => convert(Face, fspec), theme) !isempty(fnest) && - loaduserfaces!(fnest, fullname) + loaduserfaces!(fnest, fullname, theme) end end @@ -781,23 +796,60 @@ function recolor(f::Function) nothing end +""" + setcolors!(color::Vector{Pair{Symbol, RGBTuple}}) + +Update the known base colors with those in `color`, and recalculate current faces. + +`color` should be a complete list of known colours. If `:foreground` and +`:background` are both specified, the faces in the light/dark theme will be +loaded. Otherwise, only the base theme will be applied. +""" function setcolors!(color::Vector{Pair{Symbol, RGBTuple}}) - @lock recolor_lock begin + lock(recolor_lock) + lock(FACES.lock) + try + # Apply colors + fg, bg = nothing, nothing for (name, rgb) in color FACES.basecolors[name] = rgb + if name === :foreground + fg = rgb + elseif name === :background + bg = rgb + end + end + newtheme = if isnothing(fg) || isnothing(bg) + :unknown + else + ifelse(sum(fg) > sum(bg), :dark, :light) end + # Reset all themes to defaults current = FACES.current[] - for (name, _) in FACES.modifications[] - default = get(FACES.default, name, nothing) + for theme in keys(FACES.themes), (name, _) in FACES.modifications[theme] + default = get(FACES.themes.base, name, nothing) isnothing(default) && continue current[name] = default end + if newtheme ∈ keys(FACES.themes) + for (name, face) in FACES.themes[newtheme] + current[name] = merge(current[name], face) + end + end + # Run recolor hooks for hook in recolor_hooks hook() end - for (name, face) in FACES.modifications[] - current[name] = merge(current[name], face) + # Layer on modifications + for theme in keys(FACES.themes) + theme ∈ (:base, newtheme) || continue + for (name, face) in FACES.modifications[theme] + current[name] = merge(current[name], face) + end end + finally + unlock(FACES.lock) + unlock(recolor_lock) end end diff --git a/src/io.jl b/src/io.jl index d9eb01d..613e121 100644 --- a/src/io.jl +++ b/src/io.jl @@ -243,7 +243,7 @@ function _ansi_writer(string_writer::F, io::IO, s::Union{<:AnnotatedString, SubS # We need to make sure that the customisations are loaded # before we start outputting any styled content. load_customisations!() - default = FACES.default[:default] + default = FACES.themes.base[:default] if get(io, :color, false)::Bool buf = IOBuffer() # Avoid the overhead in repeatedly printing to `stdout` lastface::Face = default diff --git a/test/runtests.jl b/test/runtests.jl index bb0625a..553b1a6 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -254,35 +254,35 @@ end strikethrough: false inverse: false\ """ - @test sprint(show, MIME("text/plain"), FACES.default[:red], context = :color => true) |> choppkg == + @test sprint(show, MIME("text/plain"), FACES.themes.base[:red], context = :color => true) |> choppkg == """ Face (\e[31msample\e[39m) foreground: \e[31m■\e[39m red\ """ - @test sprint(show, FACES.default[:red]) |> choppkg == + @test sprint(show, FACES.themes.base[:red]) |> choppkg == "Face(foreground=SimpleColor(:red))" - @test sprint(show, MIME("text/plain"), FACES.default[:red], context = :compact => true) |> choppkg == + @test sprint(show, MIME("text/plain"), FACES.themes.base[:red], context = :compact => true) |> choppkg == "Face(foreground=SimpleColor(:red))" - @test sprint(show, MIME("text/plain"), FACES.default[:red], context = (:compact => true, :color => true)) |> choppkg == + @test sprint(show, MIME("text/plain"), FACES.themes.base[:red], context = (:compact => true, :color => true)) |> choppkg == "Face(\e[31msample\e[39m)" - @test sprint(show, MIME("text/plain"), FACES.default[:highlight], context = :compact => true) |> choppkg == + @test sprint(show, MIME("text/plain"), FACES.themes.base[:highlight], context = :compact => true) |> choppkg == "Face(inverse=true, inherit=[:emphasis])" with_terminfo(vt100) do # Not truecolor capable - @test sprint(show, MIME("text/plain"), FACES.default[:region], context = :color => true) |> choppkg == + @test sprint(show, MIME("text/plain"), FACES.themes.base[:region], context = :color => true) |> choppkg == """ - Face (\e[48;5;237msample\e[49m) - background: \e[38;5;237m■\e[39m #3a3a3a\ + Face (\e[48;5;241msample\e[49m) + background: \e[38;5;241m■\e[39m #636363\ """ end with_terminfo(fancy_term) do # Truecolor capable - @test sprint(show, MIME("text/plain"), FACES.default[:region], context = :color => true) |> choppkg == + @test sprint(show, MIME("text/plain"), FACES.themes.base[:region], context = :color => true) |> choppkg == """ - Face (\e[48;2;58;58;58msample\e[49m) - background: \e[38;2;58;58;58m■\e[39m #3a3a3a\ + Face (\e[48;2;99;99;99msample\e[49m) + background: \e[38;2;99;99;99m■\e[39m #636363\ """ end with_terminfo(vt100) do # Ensure `enter_reverse_mode` exists - @test sprint(show, MIME("text/plain"), FACES.default[:highlight], context = :color => true) |> choppkg == + @test sprint(show, MIME("text/plain"), FACES.themes.base[:highlight], context = :color => true) |> choppkg == """ Face (\e[34m\e[7msample\e[39m\e[27m) inverse: true From 7e5812fb50191628079735bc30eaf2fde5a9dfa1 Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 2 Nov 2025 17:01:52 +0800 Subject: [PATCH 7/7] Support blending of N colours at once --- docs/src/index.md | 2 +- src/faces.jl | 49 +++++++++++++++++++++++++++-------------------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/docs/src/index.md b/docs/src/index.md index 3ed857e..866711b 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -341,6 +341,6 @@ StyledStrings.SimpleColor StyledStrings.parse(::Type{StyledStrings.SimpleColor}, ::String) StyledStrings.tryparse(::Type{StyledStrings.SimpleColor}, ::String) StyledStrings.merge(::StyledStrings.Face, ::StyledStrings.Face) -StyledStrings.blend(::StyledStrings.SimpleColor, ::StyledStrings.SimpleColor, ::Real) +StyledStrings.blend StyledStrings.recolor ``` diff --git a/src/faces.jl b/src/faces.jl index 777b437..bad12ef 100644 --- a/src/faces.jl +++ b/src/faces.jl @@ -914,13 +914,15 @@ function rgbcolor(color::Union{Symbol, SimpleColor}) end """ - blend(a::Union{Symbol, SimpleColor}, b::Union{Symbol, SimpleColor}, α::Real) + blend(a::Union{Symbol, SimpleColor}, [b::Union{Symbol, SimpleColor} => α::Real]...) Blend colors `a` and `b` in Oklab space, with mix ratio `α` (0–1). The colors `a` and `b` can either be `SimpleColor`s, or `Symbol`s naming a face or base color. The mix ratio `α` combines `(1 - α)` of `a` with `α` of `b`. +Multiple colors can be blended at once by providing multiple `b => α` pairs. + # Examples ```julia-repl @@ -934,9 +936,11 @@ julia> blend(:green, SimpleColor(0xffffff), 0.3) SimpleColor(■ #74be93) ``` """ -function blend(c1::SimpleColor, c2::SimpleColor, α::Real) - function oklab(rgb::RGBTuple) - r, g, b = (Tuple(rgb) ./ 255) .^ 2.2 +function blend end + +function blend(primaries::Pair{RGBTuple, <:Real}...) + function oklab(rgb::RGBTuple) + r, g, b = (rgb.r / 255)^2.2, (rgb.g / 255)^2.2, (rgb.b / 255)^2.2 l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) @@ -955,22 +959,25 @@ function blend(c1::SimpleColor, c2::SimpleColor, α::Real) b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s (r = tohex(r), g = tohex(g), b = tohex(b)) end - lab1 = oklab(rgbcolor(c1)) - lab2 = oklab(rgbcolor(c2)) - mix = (L = (1 - α) * lab1.L + α * lab2.L, - a = (1 - α) * lab1.a + α * lab2.a, - b = (1 - α) * lab1.b + α * lab2.b) - SimpleColor(rgb(mix)) -end - -function blend(f1::Union{Symbol, SimpleColor}, f2::Union{Symbol, SimpleColor}, α::Real) - function face_or_color(name::Symbol) - c = getface(name).foreground - if c.value === :foreground && haskey(FACES.basecolors, name) - c = SimpleColor(name) - end - c + L′, a′, b′ = 0.0, 0.0, 0.0 + for (color, α) in primaries + lab = oklab(color) + L′ += lab.L * α + a′ += lab.a * α + b′ += lab.b * α end - face_or_color(c::SimpleColor) = c - blend(face_or_color(f1), face_or_color(f2), α) + mix = (L = L′, a = a′, b = b′) + rgb(mix) end + +blend(base::RGBTuple, primaries::Pair{RGBTuple, <:Real}...) = + blend(base => 1.0 - sum(last, primaries), primaries...) + +blend(primaries::Pair{<:Union{Symbol, SimpleColor}, <:Real}...) = + SimpleColor(blend((rgbcolor(c) => w for (c, w) in primaries)...)) + +blend(base::Union{Symbol, SimpleColor}, primaries::Pair{<:Union{Symbol, SimpleColor}, <:Real}...) = + SimpleColor(blend(rgbcolor(base), (rgbcolor(c) => w for (c, w) in primaries)...)) + +blend(a::Union{Symbol, SimpleColor}, b::Union{Symbol, SimpleColor}, α::Real) = + blend(a => 1 - α, b => α)