From 680137bff0a97ca4b0175ea9c760c394a4890444 Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 26 Oct 2025 02:59:56 +0800 Subject: [PATCH 1/9] 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 984582dd..41d6e51e 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 4bff0b62..336ae1b7 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 dc7e2469..29e55271 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 b9f7b056..f93c0f03 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 f0b3cf69..26ac03c3 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 0e300349..bb0625a0 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 058ff31aca2115934768939a33a16e234cf2fb4b Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 26 Oct 2025 13:41:17 +0800 Subject: [PATCH 2/9] 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 f93c0f03..43639b08 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 14813518005cf32fbae0f196485dd68b9ad3f9f1 Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 26 Oct 2025 13:48:37 +0800 Subject: [PATCH 3/9] 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 41d6e51e..3ed857e8 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 29e55271..ba4f528c 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 43639b08..b4db497d 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 b2b76d935b631a7a2d777885b5fbf82b0eeb18df Mon Sep 17 00:00:00 2001 From: TEC Date: Mon, 27 Oct 2025 22:24:38 +0800 Subject: [PATCH 4/9] 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 b4db497d..0a7f3fc3 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 b24e99954504dfdc5def1150b2c2887404a21034 Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 26 Oct 2025 16:37:56 +0800 Subject: [PATCH 5/9] 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 336ae1b7..05c8c8fa 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 0a7f3fc3..777b4373 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 26ac03c3..8325f886 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 bb0625a0..553b1a6b 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 7f95b51a2c2ffbfd9b3af8d4e62af903fac59072 Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 2 Nov 2025 17:01:52 +0800 Subject: [PATCH 6/9] 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 3ed857e8..866711b0 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 777b4373..bad12ef3 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 => α) From 91ecd7c25d662868d95357680612a01ab81342bd Mon Sep 17 00:00:00 2001 From: TEC Date: Thu, 6 Nov 2025 01:34:39 +0800 Subject: [PATCH 7/9] Set default basecolours to be white on black --- src/faces.jl | 6 +++--- test/runtests.jl | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/faces.jl b/src/faces.jl index bad12ef3..5be8106f 100644 --- a/src/faces.jl +++ b/src/faces.jl @@ -388,9 +388,9 @@ const FACES = let base = Dict{Symbol, Face}( 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), + basecolors = Dict{Symbol, RGBTuple}( # Based on Gnome HIG colours + :foreground => (r = 0xf6, g = 0xf5, b = 0xf4), + :background => (r = 0x24, g = 0x1f, b = 0x31), :black => (r = 0x1c, g = 0x1a, b = 0x23), :red => (r = 0xa5, g = 0x1c, b = 0x2c), :green => (r = 0x25, g = 0xa2, b = 0x68), diff --git a/test/runtests.jl b/test/runtests.jl index 553b1a6b..59a07533 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -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 9b7f229ba8df6ca7371da531384ef4a609871649 Mon Sep 17 00:00:00 2001 From: TEC Date: Thu, 6 Nov 2025 02:04:57 +0800 Subject: [PATCH 8/9] Split faces.jl config/theming code into theme.jl --- src/StyledStrings.jl | 1 + src/faces.jl | 717 +++---------------------------------------- src/theme.jl | 636 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 677 insertions(+), 677 deletions(-) create mode 100644 src/theme.jl diff --git a/src/StyledStrings.jl b/src/StyledStrings.jl index ba4f528c..9490e7dd 100644 --- a/src/StyledStrings.jl +++ b/src/StyledStrings.jl @@ -12,6 +12,7 @@ export @styled_str public Face, addface!, withfaces, styled, SimpleColor, blend, recolor include("faces.jl") +include("theme.jl") include("io.jl") include("styledmarkup.jl") include("legacy.jl") diff --git a/src/faces.jl b/src/faces.jl index 5be8106f..ced1d094 100644 --- a/src/faces.jl +++ b/src/faces.jl @@ -191,6 +191,46 @@ Base.copy(f::Face) = f.foreground, f.background, f.underline, f.strikethrough, f.inverse, copy(f.inherit)) +""" + merge(initial::StyledStrings.Face, others::StyledStrings.Face...) + +Merge the properties of the `initial` face and `others`, with later faces taking priority. + +This is used to combine the styles of multiple faces, and to resolve inheritance. +""" +function Base.merge(a::Face, b::Face) + if isempty(b.inherit) + # Extract the heights to help type inference a bit to be able + # to narrow the types in e.g. `aheight * bheight` + aheight = a.height + bheight = b.height + abheight = if isnothing(bheight) aheight + elseif isnothing(aheight) bheight + elseif bheight isa Int bheight + elseif aheight isa Int round(Int, aheight * bheight) + else aheight * bheight end + Face(if isnothing(b.font) a.font else b.font end, + abheight, + if isnothing(b.weight) a.weight else b.weight end, + if isnothing(b.slant) a.slant else b.slant end, + if isnothing(b.foreground) a.foreground else b.foreground end, + if isnothing(b.background) a.background else b.background end, + if isnothing(b.underline) a.underline else b.underline end, + if isnothing(b.strikethrough) a.strikethrough else b.strikethrough end, + if isnothing(b.inverse) a.inverse else b.inverse end, + a.inherit) + else + b_noinherit = Face( + b.font, b.height, b.weight, b.slant, b.foreground, b.background, + b.underline, b.strikethrough, b.inverse, Symbol[]) + b_inheritance = map(fname -> get(Face, FACES.current[], fname), Iterators.reverse(b.inherit)) + b_resolved = merge(foldl(merge, b_inheritance), b_noinherit) + merge(a, b_resolved) + end +end + +Base.merge(a::Face, b::Face, others::Face...) = merge(merge(a, b), others...) + function Base.show(io::IO, ::MIME"text/plain", color::SimpleColor) skiptype = get(io, :typeinfo, nothing) === SimpleColor skiptype || show(io, SimpleColor) @@ -304,680 +344,3 @@ end function Base.show(io::IO, face::Face) show(IOContext(io, :compact => true), MIME("text/plain"), face) end - -""" -Globally named [`Face`](@ref)s. - -`default` gives the initial values of the faces, and `current` holds the active -(potentially modified) set of faces. This two-set system allows for any -modifications to the active faces to be undone. -""" -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 - :normal, :normal, # weight, slant - SimpleColor(:foreground), # foreground - SimpleColor(:background), # background - false, false, false, # underline, strikethrough, overline - Symbol[]), # inherit - # Property faces - :bold => Face(weight=:bold), - :light => Face(weight=:light), - :italic => Face(slant=:italic), - :underline => Face(underline=true), - :strikethrough => Face(strikethrough=true), - :inverse => Face(inverse=true), - # Basic color faces - :black => Face(foreground=:black), - :red => Face(foreground=:red), - :green => Face(foreground=:green), - :yellow => Face(foreground=:yellow), - :blue => Face(foreground=:blue), - :magenta => Face(foreground=:magenta), - :cyan => Face(foreground=:cyan), - :white => Face(foreground=:white), - :bright_black => Face(foreground=:bright_black), - :grey => Face(foreground=:bright_black), - :gray => Face(foreground=:bright_black), - :bright_red => Face(foreground=:bright_red), - :bright_green => Face(foreground=:bright_green), - :bright_yellow => Face(foreground=:bright_yellow), - :bright_blue => Face(foreground=:bright_blue), - :bright_magenta => Face(foreground=:bright_magenta), - :bright_cyan => Face(foreground=:bright_cyan), - :bright_white => Face(foreground=:bright_white), - # Useful common faces - :shadow => Face(foreground=:bright_black), - :region => Face(background=0x636363), - :emphasis => Face(foreground=:blue), - :highlight => Face(inherit=:emphasis, inverse=true), - :code => Face(foreground=:cyan), - # Styles of generic content categories - :error => Face(foreground=:bright_red), - :warning => Face(foreground=:yellow), - :success => Face(foreground=:green), - :info => Face(foreground=:bright_cyan), - :note => Face(foreground=:grey), - :tip => Face(foreground=:bright_green), - # Stacktraces (on behalf of Base) - :julia_stacktrace_frameindex => Face(), - :julia_stacktrace_location => Face(inherit=:shadow), - :julia_stacktrace_filename => Face(underline=true, inherit=:julia_stacktrace_location), - :julia_stacktrace_fileline => Face(inherit=:julia_stacktrace_filename), - :julia_stacktrace_repetition => Face(inherit=:warning), - :julia_stacktrace_inlined => Face(inherit=:julia_stacktrace_repetition), - :julia_stacktrace_basemodule => Face(inherit=:shadow), - # Log messages - :log_error => Face(inherit=[:error, :bold]), - :log_warn => Face(inherit=[:warning, :bold]), - :log_info => Face(inherit=[:info, :bold]), - :log_debug => Face(foreground=:blue, inherit=:bold), - # Julia prompts - :repl_prompt => Face(weight=:bold), - :repl_prompt_julia => Face(inherit=[:green, :repl_prompt]), - :repl_prompt_help => Face(inherit=[:yellow, :repl_prompt]), - :repl_prompt_shell => Face(inherit=[:red, :repl_prompt]), - :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}( # Based on Gnome HIG colours - :foreground => (r = 0xf6, g = 0xf5, b = 0xf4), - :background => (r = 0x24, g = 0x1f, b = 0x31), - :black => (r = 0x1c, g = 0x1a, b = 0x23), - :red => (r = 0xa5, g = 0x1c, b = 0x2c), - :green => (r = 0x25, g = 0xa2, b = 0x68), - :yellow => (r = 0xe5, g = 0xa5, b = 0x09), - :blue => (r = 0x19, g = 0x5e, b = 0xb3), - :magenta => (r = 0x80, g = 0x3d, b = 0x9b), - :cyan => (r = 0x00, g = 0x97, b = 0xa7), - :white => (r = 0xdd, g = 0xdc, b = 0xd9), - :bright_black => (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), - :bright_blue => (r = 0x35, g = 0x83, b = 0xe4), - :bright_magenta => (r = 0xbf, g = 0x60, b = 0xca), - :bright_cyan => (r = 0x26, g = 0xc6, b = 0xda), - :bright_white => (r = 0xf6, g = 0xf5, b = 0xf4)) - (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, 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.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. - -# Examples - -```jldoctest; setup = :(import StyledStrings: Face, addface!) -julia> addface!(:mypkg_myface => Face(slant=:italic, underline=true)) -Face (sample) - slant: italic - underline: true -``` -""" -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 - end -end - -""" - resetfaces!() - -Reset the current global face dictionary to the default value. -""" -function resetfaces!() - @lock FACES.lock begin - current = FACES.current[] - empty!(current) - for (key, val) in FACES.themes.base - current[key] = val - end - if current === FACES.current.default # Only when top-level - map(empty!, values(FACES.modifications)) - end - current - end -end - -""" - resetfaces!(name::Symbol) - -Reset the face `name` to its default value, which is returned. - -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, 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!(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 -end - -""" - withfaces(f, kv::Pair...) - withfaces(f, kvpair_itr) - -Execute `f` with `FACES``.current` temporarily modified by zero or more `:name -=> val` arguments `kv`, or `kvpair_itr` which produces `kv`-form values. - -`withfaces` is generally used via the `withfaces(kv...) do ... end` syntax. A -value of `nothing` can be used to temporarily unset a face (if it has been -set). When `withfaces` returns, the original `FACES``.current` has been -restored. - -# Examples - -```jldoctest; setup = :(import StyledStrings: Face, withfaces) -julia> withfaces(:yellow => Face(foreground=:red), :green => :blue) do - println(styled"{yellow:red} and {green:blue} mixed make {magenta:purple}") - end -red and blue mixed make purple -``` -""" -function withfaces(f, keyvals_itr) - # Before modifying the current `FACES`, we should ensure - # that we've loaded the user's customisations. - load_customisations!() - if !(eltype(keyvals_itr) <: Pair{Symbol}) - throw(MethodError(withfaces, (f, keyvals_itr))) - end - newfaces = copy(FACES.current[]) - for (name, face) in keyvals_itr - if face isa Face - newfaces[name] = face - elseif face isa Symbol - newfaces[name] = get(Face, FACES.current[], face) - elseif face isa Vector{Symbol} - newfaces[name] = Face(inherit=face) - elseif haskey(newfaces, name) - delete!(newfaces, name) - end - end - @with(FACES.current => newfaces, f()) -end - -withfaces(f, keyvals::Pair{Symbol, <:Union{Face, Symbol, Vector{Symbol}, Nothing}}...) = - withfaces(f, keyvals) - -withfaces(f) = f() - -## Face combination and inheritance ## - -""" - merge(initial::StyledStrings.Face, others::StyledStrings.Face...) - -Merge the properties of the `initial` face and `others`, with later faces taking priority. - -This is used to combine the styles of multiple faces, and to resolve inheritance. -""" -function Base.merge(a::Face, b::Face) - if isempty(b.inherit) - # Extract the heights to help type inference a bit to be able - # to narrow the types in e.g. `aheight * bheight` - aheight = a.height - bheight = b.height - abheight = if isnothing(bheight) aheight - elseif isnothing(aheight) bheight - elseif bheight isa Int bheight - elseif aheight isa Int round(Int, aheight * bheight) - else aheight * bheight end - Face(if isnothing(b.font) a.font else b.font end, - abheight, - if isnothing(b.weight) a.weight else b.weight end, - if isnothing(b.slant) a.slant else b.slant end, - if isnothing(b.foreground) a.foreground else b.foreground end, - if isnothing(b.background) a.background else b.background end, - if isnothing(b.underline) a.underline else b.underline end, - if isnothing(b.strikethrough) a.strikethrough else b.strikethrough end, - if isnothing(b.inverse) a.inverse else b.inverse end, - a.inherit) - else - b_noinherit = Face( - b.font, b.height, b.weight, b.slant, b.foreground, b.background, - b.underline, b.strikethrough, b.inverse, Symbol[]) - b_inheritance = map(fname -> get(Face, FACES.current[], fname), Iterators.reverse(b.inherit)) - b_resolved = merge(foldl(merge, b_inheritance), b_noinherit) - merge(a, b_resolved) - end -end - -Base.merge(a::Face, b::Face, others::Face...) = merge(merge(a, b), others...) - -## Getting the combined face from a set of properties ## - -# Putting these inside `getface` causes the julia compiler to box it -_mergedface(face::Face) = face -_mergedface(face::Symbol) = get(Face, FACES.current[], face) -_mergedface(faces::Vector) = mapfoldl(_mergedface, merge, Iterators.reverse(faces)) - -""" - getface(faces) - -Obtain the final merged face from `faces`, an iterator of -[`Face`](@ref)s, face name `Symbol`s, and lists thereof. -""" -function getface(faces) - isempty(faces) && return FACES.current[][:default] - combined = mapfoldl(_mergedface, merge, faces)::Face - if !isempty(combined.inherit) - combined = merge(Face(), combined) - end - merge(FACES.current[][:default], combined) -end - -""" - getface(annotations::Vector{@NamedTuple{label::Symbol, value::Any}}) - -Combine all of the `:face` annotations with `getfaces`. -""" -function getface(annotations::Vector{@NamedTuple{label::Symbol, value::Any}}) - faces = (ann.value for ann in annotations if ann.label === :face) - getface(faces) -end - -getface(face::Face) = merge(FACES.current[][:default], merge(Face(), face)) -getface(face::Symbol) = getface(get(Face, FACES.current[], face)) - -""" - getface() - -Obtain the default face. -""" -getface() = FACES.current[][:default] - -## Face/AnnotatedString integration ## - -""" - getface(s::AnnotatedString, i::Integer) - -Get the merged [`Face`](@ref) that applies to `s` at index `i`. -""" -getface(s::AnnotatedString, i::Integer) = - getface(map(last, annotations(s, i))) - -""" - getface(c::AnnotatedChar) - -Get the merged [`Face`](@ref) that applies to `c`. -""" -getface(c::AnnotatedChar) = getface(c.annotations) - -""" - face!(str::Union{<:AnnotatedString, <:SubString{<:AnnotatedString}}, - [range::UnitRange{Int},] face::Union{Symbol, Face}) - -Apply `face` to `str`, along `range` if specified or the whole of `str`. -""" -face!(s::Union{<:AnnotatedString, <:SubString{<:AnnotatedString}}, - range::UnitRange{Int}, face::Union{Symbol, Face, <:Vector{<:Union{Symbol, Face}}}) = - annotate!(s, range, :face, face) - -face!(s::Union{<:AnnotatedString, <:SubString{<:AnnotatedString}}, - face::Union{Symbol, Face, <:Vector{<:Union{Symbol, Face}}}) = - annotate!(s, firstindex(s):lastindex(s), :face, face) - -## Reading face definitions from a dictionary ## - -""" - loadface!(name::Symbol => update::Face) - -Merge the face `name` in `FACES``.current` with `update`. If the face `name` -does not already exist in `FACES``.current`, then it is set to `update`. To -reset a face, `update` can be set to `nothing`. - -# Examples - -```jldoctest; setup = :(import StyledStrings: Face, loadface!) -julia> loadface!(:red => Face(foreground=0xff0000)) -Face (sample) - foreground: #ff0000 -``` -""" -function loadface!((name, update)::Pair{Symbol, Face}, theme::Symbol = :base) - @lock FACES.lock begin - 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 - cface = get(current, name, nothing) - isnothing(cface) || (update = merge(cface, update)) - current[name] = update - end -end - -function loadface!((name, _)::Pair{Symbol, Nothing}) - if haskey(FACES.current[], name) - resetfaces!(name) - end -end - -""" - loaduserfaces!(faces::Dict{String, Any}) - -For each face specified in `Dict`, load it to `FACES``.current`. -""" -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 - else - string(prefix, '_', name) - end - fspec = filter((_, v)::Pair -> !(v isa Dict), spec) - fnest = filter((_, v)::Pair -> v isa Dict, spec) - !isempty(fspec) && - loadface!(Symbol(fullname) => convert(Face, fspec), theme) - !isempty(fnest) && - loaduserfaces!(fnest, fullname, theme) - end -end - -""" - loaduserfaces!(tomlfile::String) - -Load all faces declared in the Faces.toml file `tomlfile`. -""" -loaduserfaces!(tomlfile::String) = loaduserfaces!(Base.parsed_toml(tomlfile)) - -function Base.convert(::Type{Face}, spec::Dict{String,Any}) - Face(if haskey(spec, "font") && spec["font"] isa String - spec["font"]::String - end, - if haskey(spec, "height") && (spec["height"] isa Int || spec["height"] isa Float64) - spec["height"]::Union{Int,Float64} - end, - if haskey(spec, "weight") && spec["weight"] isa String - Symbol(spec["weight"]::String) - elseif haskey(spec, "bold") && spec["bold"] isa Bool - ifelse(spec["bold"]::Bool, :bold, :normal) - end, - if haskey(spec, "slant") && spec["slant"] isa String - Symbol(spec["slant"]::String) - elseif haskey(spec, "italic") && spec["italic"] isa Bool - ifelse(spec["italic"]::Bool, :italic, :normal) - end, - if haskey(spec, "foreground") && spec["foreground"] isa String - tryparse(SimpleColor, spec["foreground"]::String) - elseif haskey(spec, "fg") && spec["fg"] isa String - tryparse(SimpleColor, spec["fg"]::String) - end, - if haskey(spec, "background") && spec["background"] isa String - tryparse(SimpleColor, spec["background"]::String) - elseif haskey(spec, "bg") && spec["bg"] isa String - tryparse(SimpleColor, spec["bg"]::String) - end, - if !haskey(spec, "underline") - elseif spec["underline"] isa Bool - spec["underline"]::Bool - elseif spec["underline"] isa String - tryparse(SimpleColor, spec["underline"]::String) - elseif spec["underline"] isa Vector{String} && length(spec["underline"]::Vector{String}) == 2 - color_str, style_str = (spec["underline"]::Vector{String}) - color = tryparse(SimpleColor, color_str) - (color, Symbol(style_str)) - end, - if !haskey(spec, "strikethrough") - elseif spec["strikethrough"] isa Bool - spec["strikethrough"]::Bool - elseif spec["strikethrough"] isa String - tryparse(SimpleColor, spec["strikethrough"]::String) - end, - if haskey(spec, "inverse") && spec["inverse"] isa Bool - spec["inverse"]::Bool end, - if !haskey(spec, "inherit") - Symbol[] - elseif spec["inherit"] isa String - [Symbol(spec["inherit"]::String)] - elseif spec["inherit"] isa Vector{String} - [Symbol(name) for name in spec["inherit"]::Vector{String}] - else - Symbol[] - 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 - -""" - 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) - 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 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 - # 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 - -## 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`. - -Multiple colors can be blended at once by providing multiple `b => α` pairs. - -# 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 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) - 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 - 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 - 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 => α) diff --git a/src/theme.jl b/src/theme.jl new file mode 100644 index 00000000..e5ac3320 --- /dev/null +++ b/src/theme.jl @@ -0,0 +1,636 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +""" +Globally named [`Face`](@ref)s. + +`default` gives the initial values of the faces, and `current` holds the active +(potentially modified) set of faces. This two-set system allows for any +modifications to the active faces to be undone. +""" +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 + :normal, :normal, # weight, slant + SimpleColor(:foreground), # foreground + SimpleColor(:background), # background + false, false, false, # underline, strikethrough, overline + Symbol[]), # inherit + # Property faces + :bold => Face(weight=:bold), + :light => Face(weight=:light), + :italic => Face(slant=:italic), + :underline => Face(underline=true), + :strikethrough => Face(strikethrough=true), + :inverse => Face(inverse=true), + # Basic color faces + :black => Face(foreground=:black), + :red => Face(foreground=:red), + :green => Face(foreground=:green), + :yellow => Face(foreground=:yellow), + :blue => Face(foreground=:blue), + :magenta => Face(foreground=:magenta), + :cyan => Face(foreground=:cyan), + :white => Face(foreground=:white), + :bright_black => Face(foreground=:bright_black), + :grey => Face(foreground=:bright_black), + :gray => Face(foreground=:bright_black), + :bright_red => Face(foreground=:bright_red), + :bright_green => Face(foreground=:bright_green), + :bright_yellow => Face(foreground=:bright_yellow), + :bright_blue => Face(foreground=:bright_blue), + :bright_magenta => Face(foreground=:bright_magenta), + :bright_cyan => Face(foreground=:bright_cyan), + :bright_white => Face(foreground=:bright_white), + # Useful common faces + :shadow => Face(foreground=:bright_black), + :region => Face(background=0x636363), + :emphasis => Face(foreground=:blue), + :highlight => Face(inherit=:emphasis, inverse=true), + :code => Face(foreground=:cyan), + # Styles of generic content categories + :error => Face(foreground=:bright_red), + :warning => Face(foreground=:yellow), + :success => Face(foreground=:green), + :info => Face(foreground=:bright_cyan), + :note => Face(foreground=:grey), + :tip => Face(foreground=:bright_green), + # Stacktraces (on behalf of Base) + :julia_stacktrace_frameindex => Face(), + :julia_stacktrace_location => Face(inherit=:shadow), + :julia_stacktrace_filename => Face(underline=true, inherit=:julia_stacktrace_location), + :julia_stacktrace_fileline => Face(inherit=:julia_stacktrace_filename), + :julia_stacktrace_repetition => Face(inherit=:warning), + :julia_stacktrace_inlined => Face(inherit=:julia_stacktrace_repetition), + :julia_stacktrace_basemodule => Face(inherit=:shadow), + # Log messages + :log_error => Face(inherit=[:error, :bold]), + :log_warn => Face(inherit=[:warning, :bold]), + :log_info => Face(inherit=[:info, :bold]), + :log_debug => Face(foreground=:blue, inherit=:bold), + # Julia prompts + :repl_prompt => Face(weight=:bold), + :repl_prompt_julia => Face(inherit=[:green, :repl_prompt]), + :repl_prompt_help => Face(inherit=[:yellow, :repl_prompt]), + :repl_prompt_shell => Face(inherit=[:red, :repl_prompt]), + :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}( # Based on Gnome HIG colours + :foreground => (r = 0xf6, g = 0xf5, b = 0xf4), + :background => (r = 0x24, g = 0x1f, b = 0x31), + :black => (r = 0x1c, g = 0x1a, b = 0x23), + :red => (r = 0xa5, g = 0x1c, b = 0x2c), + :green => (r = 0x25, g = 0xa2, b = 0x68), + :yellow => (r = 0xe5, g = 0xa5, b = 0x09), + :blue => (r = 0x19, g = 0x5e, b = 0xb3), + :magenta => (r = 0x80, g = 0x3d, b = 0x9b), + :cyan => (r = 0x00, g = 0x97, b = 0xa7), + :white => (r = 0xdd, g = 0xdc, b = 0xd9), + :bright_black => (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), + :bright_blue => (r = 0x35, g = 0x83, b = 0xe4), + :bright_magenta => (r = 0xbf, g = 0x60, b = 0xca), + :bright_cyan => (r = 0x26, g = 0xc6, b = 0xda), + :bright_white => (r = 0xf6, g = 0xf5, b = 0xf4)) + (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, 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.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. + +# Examples + +```jldoctest; setup = :(import StyledStrings: Face, addface!) +julia> addface!(:mypkg_myface => Face(slant=:italic, underline=true)) +Face (sample) + slant: italic + underline: true +``` +""" +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 + end +end + +""" + resetfaces!() + +Reset the current global face dictionary to the default value. +""" +function resetfaces!() + @lock FACES.lock begin + current = FACES.current[] + empty!(current) + for (key, val) in FACES.themes.base + current[key] = val + end + if current === FACES.current.default # Only when top-level + map(empty!, values(FACES.modifications)) + end + current + end +end + +""" + resetfaces!(name::Symbol) + +Reset the face `name` to its default value, which is returned. + +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, 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!(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 +end + +""" + withfaces(f, kv::Pair...) + withfaces(f, kvpair_itr) + +Execute `f` with `FACES``.current` temporarily modified by zero or more `:name +=> val` arguments `kv`, or `kvpair_itr` which produces `kv`-form values. + +`withfaces` is generally used via the `withfaces(kv...) do ... end` syntax. A +value of `nothing` can be used to temporarily unset a face (if it has been +set). When `withfaces` returns, the original `FACES``.current` has been +restored. + +# Examples + +```jldoctest; setup = :(import StyledStrings: Face, withfaces) +julia> withfaces(:yellow => Face(foreground=:red), :green => :blue) do + println(styled"{yellow:red} and {green:blue} mixed make {magenta:purple}") + end +red and blue mixed make purple +``` +""" +function withfaces(f, keyvals_itr) + # Before modifying the current `FACES`, we should ensure + # that we've loaded the user's customisations. + load_customisations!() + if !(eltype(keyvals_itr) <: Pair{Symbol}) + throw(MethodError(withfaces, (f, keyvals_itr))) + end + newfaces = copy(FACES.current[]) + for (name, face) in keyvals_itr + if face isa Face + newfaces[name] = face + elseif face isa Symbol + newfaces[name] = get(Face, FACES.current[], face) + elseif face isa Vector{Symbol} + newfaces[name] = Face(inherit=face) + elseif haskey(newfaces, name) + delete!(newfaces, name) + end + end + @with(FACES.current => newfaces, f()) +end + +withfaces(f, keyvals::Pair{Symbol, <:Union{Face, Symbol, Vector{Symbol}, Nothing}}...) = + withfaces(f, keyvals) + +withfaces(f) = f() + +## Getting the combined face from a set of properties ## + +# Putting these inside `getface` causes the julia compiler to box it +_mergedface(face::Face) = face +_mergedface(face::Symbol) = get(Face, FACES.current[], face) +_mergedface(faces::Vector) = mapfoldl(_mergedface, merge, Iterators.reverse(faces)) + +""" + getface(faces) + +Obtain the final merged face from `faces`, an iterator of +[`Face`](@ref)s, face name `Symbol`s, and lists thereof. +""" +function getface(faces) + isempty(faces) && return FACES.current[][:default] + combined = mapfoldl(_mergedface, merge, faces)::Face + if !isempty(combined.inherit) + combined = merge(Face(), combined) + end + merge(FACES.current[][:default], combined) +end + +""" + getface(annotations::Vector{@NamedTuple{label::Symbol, value::Any}}) + +Combine all of the `:face` annotations with `getfaces`. +""" +function getface(annotations::Vector{@NamedTuple{label::Symbol, value::Any}}) + faces = (ann.value for ann in annotations if ann.label === :face) + getface(faces) +end + +getface(face::Face) = merge(FACES.current[][:default], merge(Face(), face)) +getface(face::Symbol) = getface(get(Face, FACES.current[], face)) + +""" + getface() + +Obtain the default face. +""" +getface() = FACES.current[][:default] + +## Face/AnnotatedString integration ## + +""" + getface(s::AnnotatedString, i::Integer) + +Get the merged [`Face`](@ref) that applies to `s` at index `i`. +""" +getface(s::AnnotatedString, i::Integer) = + getface(map(last, annotations(s, i))) + +""" + getface(c::AnnotatedChar) + +Get the merged [`Face`](@ref) that applies to `c`. +""" +getface(c::AnnotatedChar) = getface(c.annotations) + +""" + face!(str::Union{<:AnnotatedString, <:SubString{<:AnnotatedString}}, + [range::UnitRange{Int},] face::Union{Symbol, Face}) + +Apply `face` to `str`, along `range` if specified or the whole of `str`. +""" +face!(s::Union{<:AnnotatedString, <:SubString{<:AnnotatedString}}, + range::UnitRange{Int}, face::Union{Symbol, Face, <:Vector{<:Union{Symbol, Face}}}) = + annotate!(s, range, :face, face) + +face!(s::Union{<:AnnotatedString, <:SubString{<:AnnotatedString}}, + face::Union{Symbol, Face, <:Vector{<:Union{Symbol, Face}}}) = + annotate!(s, firstindex(s):lastindex(s), :face, face) + +## Reading face definitions from a dictionary ## + +""" + loadface!(name::Symbol => update::Face) + +Merge the face `name` in `FACES``.current` with `update`. If the face `name` +does not already exist in `FACES``.current`, then it is set to `update`. To +reset a face, `update` can be set to `nothing`. + +# Examples + +```jldoctest; setup = :(import StyledStrings: Face, loadface!) +julia> loadface!(:red => Face(foreground=0xff0000)) +Face (sample) + foreground: #ff0000 +``` +""" +function loadface!((name, update)::Pair{Symbol, Face}, theme::Symbol = :base) + @lock FACES.lock begin + 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 + cface = get(current, name, nothing) + isnothing(cface) || (update = merge(cface, update)) + current[name] = update + end +end + +function loadface!((name, _)::Pair{Symbol, Nothing}) + if haskey(FACES.current[], name) + resetfaces!(name) + end +end + +""" + loaduserfaces!(faces::Dict{String, Any}) + +For each face specified in `Dict`, load it to `FACES``.current`. +""" +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 + else + string(prefix, '_', name) + end + fspec = filter((_, v)::Pair -> !(v isa Dict), spec) + fnest = filter((_, v)::Pair -> v isa Dict, spec) + !isempty(fspec) && + loadface!(Symbol(fullname) => convert(Face, fspec), theme) + !isempty(fnest) && + loaduserfaces!(fnest, fullname, theme) + end +end + +""" + loaduserfaces!(tomlfile::String) + +Load all faces declared in the Faces.toml file `tomlfile`. +""" +loaduserfaces!(tomlfile::String) = loaduserfaces!(Base.parsed_toml(tomlfile)) + +function Base.convert(::Type{Face}, spec::Dict{String,Any}) + Face(if haskey(spec, "font") && spec["font"] isa String + spec["font"]::String + end, + if haskey(spec, "height") && (spec["height"] isa Int || spec["height"] isa Float64) + spec["height"]::Union{Int,Float64} + end, + if haskey(spec, "weight") && spec["weight"] isa String + Symbol(spec["weight"]::String) + elseif haskey(spec, "bold") && spec["bold"] isa Bool + ifelse(spec["bold"]::Bool, :bold, :normal) + end, + if haskey(spec, "slant") && spec["slant"] isa String + Symbol(spec["slant"]::String) + elseif haskey(spec, "italic") && spec["italic"] isa Bool + ifelse(spec["italic"]::Bool, :italic, :normal) + end, + if haskey(spec, "foreground") && spec["foreground"] isa String + tryparse(SimpleColor, spec["foreground"]::String) + elseif haskey(spec, "fg") && spec["fg"] isa String + tryparse(SimpleColor, spec["fg"]::String) + end, + if haskey(spec, "background") && spec["background"] isa String + tryparse(SimpleColor, spec["background"]::String) + elseif haskey(spec, "bg") && spec["bg"] isa String + tryparse(SimpleColor, spec["bg"]::String) + end, + if !haskey(spec, "underline") + elseif spec["underline"] isa Bool + spec["underline"]::Bool + elseif spec["underline"] isa String + tryparse(SimpleColor, spec["underline"]::String) + elseif spec["underline"] isa Vector{String} && length(spec["underline"]::Vector{String}) == 2 + color_str, style_str = (spec["underline"]::Vector{String}) + color = tryparse(SimpleColor, color_str) + (color, Symbol(style_str)) + end, + if !haskey(spec, "strikethrough") + elseif spec["strikethrough"] isa Bool + spec["strikethrough"]::Bool + elseif spec["strikethrough"] isa String + tryparse(SimpleColor, spec["strikethrough"]::String) + end, + if haskey(spec, "inverse") && spec["inverse"] isa Bool + spec["inverse"]::Bool end, + if !haskey(spec, "inherit") + Symbol[] + elseif spec["inherit"] isa String + [Symbol(spec["inherit"]::String)] + elseif spec["inherit"] isa Vector{String} + [Symbol(name) for name in spec["inherit"]::Vector{String}] + else + Symbol[] + 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 + +""" + setcolors!(colors::Vector{Pair{Symbol, RGBTuple}}) + +Update the known base colors with those in `colors`, 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!(colors::Vector{Pair{Symbol, RGBTuple}}) + lock(recolor_lock) + lock(FACES.lock) + try + # Apply colors + fg, bg = nothing, nothing + for (name, rgb) in colors + 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 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 + # 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 + +## 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`. + +Multiple colors can be blended at once by providing multiple `b => α` pairs. + +# 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 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) + 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 + 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 + 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 => α) From e1ec223295136df92161052dc0f9a57ad222cecb Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 9 Nov 2025 19:11:09 +0800 Subject: [PATCH 9/9] Fix typo in precompile script --- src/precompile.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/precompile.jl b/src/precompile.jl index 0ae30d19..3525314c 100644 --- a/src/precompile.jl +++ b/src/precompile.jl @@ -8,7 +8,7 @@ print(colorio, styled"{(foreground=red):with color}") show(colorio, MIME("text/plain"), styled"{red:with color}") show(colorio, MIME("text/html"), styled"{red:with color}") print(colorio, styled"{red:with color}"[1]) -print(colorio, styled"{underline=(blue,curly):more styling}") +print(colorio, styled"{(underline=(blue,curly)):more styling}") convert(StyledStrings.SimpleColor, (r = 0x01, g = 0x02, b = 0x03)) convert(StyledStrings.SimpleColor, 0x010203)