From 1317eec16ef49680e59b9657c8d713884f9cd21e Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 26 Oct 2025 02:59:56 +0800 Subject: [PATCH 01/14] 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 02/14] 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 03/14] 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 04/14] 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 05/14] 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 06/14] 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 07/14] 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 => α) From ca5f1abace76c3e8ec31583f4f9307fea2fa5bf6 Mon Sep 17 00:00:00 2001 From: TEC Date: Thu, 6 Nov 2025 01:34:39 +0800 Subject: [PATCH 08/14] Set default basecolours to be white on black --- src/faces.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/faces.jl b/src/faces.jl index bad12ef..5be8106 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), From 0a7d678c987a6dc1bc6218d1e29872d5c3aa59e5 Mon Sep 17 00:00:00 2001 From: TEC Date: Thu, 6 Nov 2025 02:04:57 +0800 Subject: [PATCH 09/14] 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 9f95021..ec38591 100644 --- a/src/StyledStrings.jl +++ b/src/StyledStrings.jl @@ -12,6 +12,7 @@ export @styled_str, Face, blend public addface!, withfaces, styled, SimpleColor, 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 5be8106..ced1d09 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 0000000..4243826 --- /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!(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 => α) From ca13558e4d94daa91998d0f817830f4185b8762d Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 9 Nov 2025 19:11:09 +0800 Subject: [PATCH 10/14] 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 0ae30d1..3525314 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) From d9a1ebc04671564942296ab9d8208e722a2c8188 Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 9 Nov 2025 23:08:40 +0800 Subject: [PATCH 11/14] Start supporting value-parameterised annotations --- src/theme.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/theme.jl b/src/theme.jl index 4243826..64b4823 100644 --- a/src/theme.jl +++ b/src/theme.jl @@ -256,11 +256,11 @@ function getface(faces) end """ - getface(annotations::Vector{@NamedTuple{label::Symbol, value::Any}}) + getface(annotations::Vector{@NamedTuple{label::Symbol, value}}) Combine all of the `:face` annotations with `getfaces`. """ -function getface(annotations::Vector{@NamedTuple{label::Symbol, value::Any}}) +function getface(annotations::Vector{@NamedTuple{label::Symbol, value::V}}) where {V} faces = (ann.value for ann in annotations if ann.label === :face) getface(faces) end From 829eae8e5da33ab0244b0b329428305f2fc54bdb Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 9 Nov 2025 23:11:17 +0800 Subject: [PATCH 12/14] Add recolouring tests --- test/runtests.jl | 75 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/test/runtests.jl b/test/runtests.jl index 553b1a6..0461e25 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -3,7 +3,8 @@ using Test using StyledStrings: StyledStrings, Legacy, SimpleColor, FACES, Face, - @styled_str, styled, StyledMarkup, getface, addface!, loadface!, resetfaces!, + @styled_str, styled, StyledMarkup, getface, addface!, loadface!, withfaces, resetfaces!, + rgbcolor, blend, recolor, setcolors!, AnnotatedString, AnnotatedChar, AnnotatedIOBuffer, annotations using .StyledMarkup: MalformedStylingMacro @@ -160,28 +161,28 @@ end slant = :oblique, foreground = :green, background = :magenta, underline = (:blue, :curly), strikethrough = true, inverse = true, inherit = [:iface]) - StyledStrings.resetfaces!() + resetfaces!() @test get(FACES.current[], :bold, nothing) == Face(weight=:bold) @test haskey(FACES.current[], :testface) == false @test haskey(FACES.current[], :anotherface) == false # `withfaces` - @test StyledStrings.withfaces(:testface => Face(font="test")) do + @test withfaces(:testface => Face(font="test")) do get(FACES.current[], :testface, nothing) end == Face(font="test") @test haskey(FACES.current[], :testface) == false - @test StyledStrings.withfaces(:red => :green) do + @test withfaces(:red => :green) do get(FACES.current[], :red, nothing) end == Face(foreground=:green) - @test StyledStrings.withfaces(:red => [:green, :inverse]) do + @test withfaces(:red => [:green, :inverse]) do get(FACES.current[], :red, nothing) end == Face(inherit=[:green, :inverse]) - @test StyledStrings.withfaces(:red => nothing) do + @test withfaces(:red => nothing) do get(FACES.current[], :red, nothing) end === nothing - @test StyledStrings.withfaces(Dict(:green => Face(foreground=:blue))) do + @test withfaces(Dict(:green => Face(foreground=:blue))) do get(FACES.current[], :green, nothing) end == Face(foreground=:blue) - @test StyledStrings.withfaces(() -> 1) == 1 + @test withfaces(() -> 1) == 1 # Basic merging let f1 = Face(height=140, weight=:bold, inherit=[:a]) f2 = Face(height=1.5, weight=:light, inherit=[:b]) @@ -216,7 +217,7 @@ end @test getface([:d, :c]).foreground.value == :red @test getface([[:d, :c]]).foreground.value == :blue @test getface(:f).foreground.value == :blue - StyledStrings.resetfaces!() + resetfaces!() end # Equality/hashing equivalence let testfaces = [Face(foreground=:blue), @@ -641,3 +642,59 @@ end @test printstyled(aio, "e", color=:green) |> isnothing @test read(seekstart(aio), AnnotatedString) == styled"{bold:a}{italic:b}{underline:c}{inverse:d}{(fg=green):e}" end + +@testset "Recoloring" begin + @testset "RGB" begin + @test rgbcolor(:red) == FACES.basecolors[:red] + @test rgbcolor(SimpleColor(:red)) == FACES.basecolors[:red] + withfaces([:indirect => Face(foreground=:another), + :another => Face(foreground=:final), + :final => Face(foreground=:red)]) do + @test rgbcolor(:indirect) == FACES.basecolors[:red] + end + @test rgbcolor(:unknown) == StyledStrings.UNRESOLVED_COLOR_FALLBACK + end + @testset "Blending" begin + @test blend((r = 0x00, g = 0x00, b = 0xff) => 0.5, (r = 0xff, g=0xff, b=0x00) => 0.5) == + (r = 0x6b, g = 0xaa, b = 0xc6) + @test blend(SimpleColor(0x0000ff) => 0.5, SimpleColor(0xffff00) => 0.5) == + SimpleColor(0x6baac6) + @test blend(SimpleColor(0x000000) => 0.2, SimpleColor(0xffffff) => 0.6, SimpleColor(0x00ff00) => 0.2) == + SimpleColor(0x9fbe9c) + withfaces([:blue => Face(foreground=0x0000ff), + :yellow => Face(foreground=0xffff00)]) do + @test blend(:blue => 0.5, :yellow => 0.5) == SimpleColor(0x6baac6) + end + end + @testset "Theme change" begin + lightfbg = [:foreground => (r = 0x00, g = 0x00, b = 0x00), + :background => (r = 0xff, g = 0xff, b = 0xff), + :yellow => (r = 0xfc, g = 0xce, b = 0x7b)] + darkfbg = [:foreground => (r = 0xff, g = 0xff, b = 0xff), + :background => (r = 0x00, g = 0x00, b = 0x00), + :yellow => (r = 0xa7, g = 0x7e, b = 0x27)] + setcolors!(lightfbg) + counter = Ref(0) + recolor() do + counter[] += 1 + end + @test counter[] == 0 + addface!(:test_lightdark => Face(foreground=0x000001)) + addface!(:test_lightdark => Face(foreground=0x000002), :light) + addface!(:test_lightdark => Face(foreground=0x000003), :dark) + @test rgbcolor(:test_lightdark).b == 0x01 + setcolors!(lightfbg) + @test counter[] == 1 + @test rgbcolor(:test_lightdark).b == 0x02 + setcolors!(darkfbg) + @test counter[] == 2 + @test rgbcolor(:test_lightdark).b == 0x03 + recolor() do + loadface!(:test_lightdark => Face(foreground=blend(:background => 0.6, :foreground => 0.3, :yellow => 0.1))) + end + setcolors!(lightfbg) + @test getface(:test_lightdark).foreground.value == (r = 0x9d, g = 0x99, b = 0x92) + setcolors!(darkfbg) + @test getface(:test_lightdark).foreground.value == (r = 0x43, g = 0x40, b = 0x3a) + end +end From c404a47347037502d5738f1b2e1eadbd7d61a9bc Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 9 Nov 2025 23:11:33 +0800 Subject: [PATCH 13/14] Speculative merge compat for mixed installs --- src/theme.jl | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/theme.jl b/src/theme.jl index 64b4823..7186c23 100644 --- a/src/theme.jl +++ b/src/theme.jl @@ -240,6 +240,27 @@ _mergedface(face::Face) = face _mergedface(face::Symbol) = get(Face, FACES.current[], face) _mergedface(faces::Vector) = mapfoldl(_mergedface, merge, Iterators.reverse(faces)) +# To support mixed sysimage/external copies of the package +function _mergedface(maybeface::Any) + ftype = typeof(maybeface) + if nameof(ftype) == :Face && + nameof(parentmodule(ftype)) == :StyledStrings && + fieldnames(ftype) == fieldnames(Face) + Face(maybeface.font, + maybeface.height, + maybeface.weight, + maybeface.slant, + maybeface.foreground, + maybeface.background, + maybeface.underline, + maybeface.strikethrough, + maybeface.inverse, + maybeface.inherit) + else + throw(MethodError(_mergedface, (maybeface,))) + end +end + """ getface(faces) From 565a163b7fd4a44ec50b3d2f282ee3a64394ffba Mon Sep 17 00:00:00 2001 From: TEC Date: Mon, 10 Nov 2025 03:02:55 +0800 Subject: [PATCH 14/14] Allow hard/soft nothings in Face, optimise layout TODO: elaborate on the justification and methodology. --- src/faces.jl | 250 ++++++++++++++++++++++++++++++++++++--------------- src/theme.jl | 139 +++++++++++++++++----------- 2 files changed, 266 insertions(+), 123 deletions(-) diff --git a/src/faces.jl b/src/faces.jl index ced1d09..cc249ab 100644 --- a/src/faces.jl +++ b/src/faces.jl @@ -83,6 +83,20 @@ function Base.parse(::Type{SimpleColor}, rgb::String) color end +struct _Face + font::String + height::UInt64 + weight::Symbol + slant::Symbol + foreground::SimpleColor + background::SimpleColor + underline::SimpleColor + underline_style::Symbol + strikethrough::UInt8 + inverse::UInt8 + inherit::Memory{Symbol} +end + """ A [`Face`](@ref) is a collection of graphical attributes for displaying text. Faces control how text is displayed in the terminal, and possibly other @@ -121,20 +135,53 @@ All attributes can be set via the keyword constructor, and default to `nothing`. - `inherit` (a `Vector{Symbol}`): Names of faces to inherit from, with earlier faces taking priority. All faces inherit from the `:default` face. """ -struct Face - font::Union{Nothing, String} - height::Union{Nothing, Int, Float64} - weight::Union{Nothing, Symbol} - slant::Union{Nothing, Symbol} - foreground::Union{Nothing, SimpleColor} - background::Union{Nothing, SimpleColor} - underline::Union{Nothing, Bool, SimpleColor, - Tuple{<:Union{Nothing, SimpleColor}, Symbol}} - strikethrough::Union{Nothing, Bool} - inverse::Union{Nothing, Bool} - inherit::Vector{Symbol} +mutable struct Face # We want to force reference semantics here + const f::_Face end +# NOTE: We use shenanigans instead of `Union{Nothing, X}` in the +# face fields for two reasons: +# 1. To improve the memory layout of `Face`, so there's less indirection +# 2. To allow for having two flavours of `nothing`, "weak" and "strong". +# This allows for us to distinguish between unset attributes (weak) +# and attributes that should replace a set value with weak nothing (strong). +# We can only do this because we have full knowledge and dominion +# over the semantics of `Face`. + +const WEAK_NOTHING_SYMB = gensym("nothing") +const WEAK_NOTHING_STR = String(WEAK_NOTHING_SYMB) + +weaknothing(::Type{Symbol}) = WEAK_NOTHING_SYMB +weaknothing(::Type{String}) = WEAK_NOTHING_STR +weaknothing(::Type{N}) where {N<:Number} = -one(N) +weaknothing(::Type{SimpleColor}) = SimpleColor(WEAK_NOTHING_SYMB) +weaknothing(::Type{Bool}) = strongnothing(UInt8) +weaknothing(::Type) = nothing +weaknothing(x) = weaknothing(typeof(x)) + +isweaknothing(x) = x === weaknothing(typeof(x)) +isweaknothing(s::String) = pointer(s) == pointer(WEAK_NOTHING_STR) +isweaknothing(c::SimpleColor) = c.value === WEAK_NOTHING_SYMB + +const STRONG_NOTHING_SYMB = gensym("nothing") +const STRONG_NOTHING_STR = String(STRONG_NOTHING_SYMB) + +strongnothing(::Type{Symbol}) = STRONG_NOTHING_SYMB +strongnothing(::Type{String}) = STRONG_NOTHING_STR +strongnothing(::Type{N}) where {N<:Number} = - (0x2 * one(N)) +strongnothing(::Type{SimpleColor}) = SimpleColor(STRONG_NOTHING_SYMB) +strongnothing(::Type{Bool}) = strongnothing(UInt8) +strongnothing(::Type) = nothing +strongnothing(x) = strongnothing(typeof(x)) + +isstrongnothing(x) = x === strongnothing(typeof(x)) +isstrongnothing(s::String) = pointer(s) == pointer(STRONG_NOTHING_STR) +isstrongnothing(c::SimpleColor) = c.value === STRONG_NOTHING_SYMB + +isnothinglike(x) = isweaknothing(x) || isstrongnothing(x) + +# With our flavours of nothing defined, we can now define the Face constructor. + function Face(; font::Union{Nothing, String} = nothing, height::Union{Nothing, Int, Float64} = nothing, weight::Union{Nothing, Symbol} = nothing, @@ -143,53 +190,90 @@ function Face(; font::Union{Nothing, String} = nothing, background = nothing, # nothing, or SimpleColor-able value underline::Union{Nothing, Bool, SimpleColor, Symbol, RGBTuple, UInt32, - Tuple{<:Any, Symbol} - } = nothing, + Tuple{<:Any, Symbol}} = nothing, strikethrough::Union{Nothing, Bool} = nothing, inverse::Union{Nothing, Bool} = nothing, inherit::Union{Symbol, Vector{Symbol}} = Symbol[], _...) # Simply ignore unrecognised keyword arguments. - ascolor(::Nothing) = nothing + inheritlen = if inherit isa Vector length(inherit) else 1 end + inheritlist = if inherit isa Vector + inherit.ref.mem + else + mem = Memory{Symbol}(undef, 1) + mem[1] = inherit + mem + end + ascolor(::Nothing) = SimpleColor(WEAK_NOTHING_SYMB) ascolor(c::AbstractString) = parse(SimpleColor, c) ascolor(c::Any) = convert(SimpleColor, c) - Face(font, height, weight, slant, - ascolor(foreground), ascolor(background), - if underline isa Tuple{Any, Symbol} - (ascolor(underline[1]), underline[2]) - elseif underline in (:straight, :double, :curly, :dotted, :dashed) - (nothing, underline) - elseif underline isa Bool - underline - else - ascolor(underline) - end, - strikethrough, - inverse, - if inherit isa Symbol - [inherit] - else inherit end) + ul, ulstyle = if underline isa Tuple{Any, Symbol} + ascolor(underline[1]), underline[2] + elseif underline in (:straight, :double, :curly, :dotted, :dashed) + weaknothing(SimpleColor), underline + elseif underline isa Bool + SimpleColor(ifelse(underline, :foreground, :background)), :straight + else + ascolor(underline), :straight + end + f = _Face(something(font, weaknothing(String)), + if isnothing(height) + weaknothing(UInt64) + elseif height isa Float64 + reinterpret(UInt64, height) & ~(typemax(UInt64) >> 1) + else + UInt64(height) + end, + something(weight, weaknothing(Symbol)), + something(slant, weaknothing(Symbol)), + ascolor(foreground), + ascolor(background), + ul, ulstyle, + something(strikethrough, weaknothing(Bool)), + something(inverse, weaknothing(Bool)), + inheritlist) + Face(f) +end + +function Base.getproperty(face::Face, attr::Symbol) + val = getfield(getfield(face, :f), attr) + if isnothinglike(val) + elseif attr ∈ (:strikethrough, :inverse) + if attr != 0x3 + attr == 0x1 + end + elseif attr == :underline + style = getfield(getfield(face, :f), :underline_style) + val, style + else + val + end end +Base.propertynames(::Face) = setdiff(fieldnames(_Face), (:underline_style,)) + function Base.:(==)(a::Face, b::Face) - a.font == b.font && - a.height === b.height && - a.weight == b.weight && - a.slant == b.slant && - a.foreground == b.foreground && - a.background == b.background && - a.underline == b.underline && - a.strikethrough == b.strikethrough && - a.inverse == b.inverse && - a.inherit == b.inherit + af, bf = getfield(a, :f), getfield(b, :f) + af.font == bf.font && + af.height === bf.height && + af.weight == bf.weight && + af.slant == bf.slant && + af.foreground == bf.foreground && + af.background == bf.background && + af.underline == bf.underline && + af.strikethrough == bf.strikethrough && + af.inverse == bf.inverse && + af.inherit == bf.inherit end Base.hash(f::Face, h::UInt) = - mapfoldr(Base.Fix1(getfield, f), hash, fieldnames(Face), init=hash(Face, h)) + mapfoldr(Base.Fix1(getfield, getfield(f, :f)), hash, fieldnames(Face), init=hash(Face, h)) -Base.copy(f::Face) = - Face(f.font, f.height, f.weight, f.slant, - f.foreground, f.background, f.underline, - f.strikethrough, f.inverse, copy(f.inherit)) +function Base.copy(f::Face) + ff = getfield(f, :f) + Face(_Face(ff.font, ff.height, ff.weight, ff.slant, + ff.foreground, ff.background, ff.underline, + ff.strikethrough, ff.inverse, copy(ff.inherit))) +end """ merge(initial::StyledStrings.Face, others::StyledStrings.Face...) @@ -199,31 +283,57 @@ Merge the properties of the `initial` face and `others`, with later faces taking 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) + af, bf = getfield(a, :f), getfield(b, :f) + function mergeattr(a₀, b₀, attr::Symbol) + a′ = getfield(a₀, attr) + b′ = getfield(b₀, attr) + if isweaknothing(b′) + a′ + elseif isstrongnothing(b′) + weaknothing(b′) + else + b′ + end + end + if isempty(bf.inherit) + abheight = if isstrongnothing(bf.height) + weaknothing(bf.height) + elseif isweaknothing(bf.height) + af.height + elseif isnothinglike(af.height) + bf.height + elseif iszero(bf.height & ~(typemax(UInt64) >> 1)) # bf.height::Int + bf.height + elseif iszero(af.height & ~(typemax(UInt64) >> 1)) # af.height::Int + aint = reinterpret(Int64, af.height) % UInt + bfloat = reinterpret(Float64, bf.height & (typemax(UInt64) >> 1)) + aint * bfloat + else # af.height::Float64, bf.height::Float64 + afloat = reinterpret(Float64, af.height & (typemax(UInt64) >> 1)) + bfloat = reinterpret(Float64, bf.height & (typemax(UInt64) >> 1)) + afloat * bfloat + end + Face(_Face( + mergeattr(af, bf, :font), + abheight, + mergeattr(af, bf, :weight), + mergeattr(af, bf, :slant), + mergeattr(af, bf, :foreground), + mergeattr(af, bf, :background), + mergeattr(af, bf, :underline), + if isweaknothing(bf.underline_style) + af.underline_style + else + bf.underline_style + end, + mergeattr(af, bf, :strikethrough), + mergeattr(af, bf, :inverse), + af.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_noinherit = Face(_Face( + bf.font, bf.height, bf.weight, bf.slant, bf.foreground, bf.background, + bf.underline, bf.strikethrough, bf.inverse, Symbol[])) + b_inheritance = map(fname -> get(Face, FACES.current[], fname), Iterators.reverse(bf.inherit)) b_resolved = merge(foldl(merge, b_inheritance), b_noinherit) merge(a, b_resolved) end diff --git a/src/theme.jl b/src/theme.jl index 7186c23..c910e10 100644 --- a/src/theme.jl +++ b/src/theme.jl @@ -395,59 +395,92 @@ 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) + function safeget(s::Dict{String, Any}, ::Type{T}, keys::String...) where {T} + val = nothing + for key in keys + val = get(spec, key, nothing) + !isnothing(val) && break + end + if isnothing(val) + weaknothing(T) + elseif val == "inherit" + strongnothing(T) + elseif T == SimpleColor && val isa String + something(tryparse(SimpleColor, val), weaknothing(T)) + elseif T != SimpleColor && val isa T + if T == Bool + UInt8(val) + else + val + end + else + weaknothing(T) + end + end + font = safeget(spec, String, "font") + height = if !haskey(spec, "height") + weaknothing(UInt64) + elseif spec["height"] == "inherit" + strongnothing(UInt64) + elseif spec["height"] isa Int + UInt64(spec["height"]) + elseif spec["height"] isa Float64 + reinterpret(UInt64, Float64(spec["height"])) & ~(typemax(UInt64) >> 1) + else + weaknothing(UInt64) + end + weight = if haskey(spec, "weight") && spec["weight"] isa String + if spec["weight"]::String =="inherit" + strongnothing(Symbol) + else + Symbol(spec["weight"]::String) + end + elseif haskey(spec, "bold") && spec["bold"] isa Bool + ifelse(spec["bold"]::Bool, :bold, :normal) + end + slant = if haskey(spec, "slant") && spec["slant"] isa String + if spec["slant"]::String =="inherit" + strongnothing(Symbol) + else + Symbol(spec["slant"]::String) + end + elseif haskey(spec, "italic") && spec["italic"] isa Bool + ifelse(spec["italic"]::Bool, :italic, :normal) + end + foreground = safeget(spec, SimpleColor, "foreground", "fg") + background = safeget(spec, SimpleColor, "background", "bg") + ul, ul_style = if !haskey(spec, "underline") + weaknothing(SimpleColor), weaknothing(Symbol) + elseif spec["underline"] isa Bool + SimpleColor(spec["underline"]::Bool, :foreground, :background), weaknothing(Symbol) + elseif spec["underline"] isa String + if spec["underline"]::String == "inherit" + strongnothing(SimpleColor), strongnothing(Symbol) + else + something(tryparse(SimpleColor, spec["underline"]::String), + weaknothing(SimpleColor)), :straight + end + elseif spec["underline"] isa Vector{String} && length(spec["underline"]::Vector{String}) == 2 + color_str, style_str = (spec["underline"]::Vector{String}) + color = something(tryparse(SimpleColor, color_str), weaknothing(SimpleColor)) + color, Symbol(style_str) + else + weaknothing(SimpleColor), weaknothing(Symbol) + end + strikethrough = safeget(spec, Bool, "strikethrough") + inverse = safeget(spec, Bool, "inverse") + inherit = 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 + Face(_Face(font, height, weight, slant, + foreground, background, ul, ulstyle, + strikethrough, inverse, inherit.ref.mem)) end ## Recolouring ##