diff --git a/docs/src/index.md b/docs/src/index.md index 984582d..866711b 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -341,4 +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.recolor ``` diff --git a/docs/src/internals.md b/docs/src/internals.md index 4bff0b6..05c8c8f 100644 --- a/docs/src/internals.md +++ b/docs/src/internals.md @@ -8,21 +8,26 @@ 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 StyledStrings.Legacy.legacy_color StyledStrings.Legacy.load_env_colors! StyledStrings.ansi_4bit +StyledStrings.setcolors! 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..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 +export @styled_str, Face, blend +public addface!, withfaces, styled, SimpleColor, recolor include("faces.jl") include("io.jl") diff --git a/src/faces.jl b/src/faces.jl index b9f7b05..bad12ef 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), @@ -394,8 +400,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), @@ -403,19 +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)), + (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. @@ -428,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 @@ -448,9 +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 + if current === FACES.current.default # Only when top-level + map(empty!, values(FACES.modifications)) + end current end end @@ -464,12 +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) - 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 @@ -655,11 +670,17 @@ Face (sample) foreground: #ff0000 ``` """ -function loadface!((name, update)::Pair{Symbol, Face}) - @lock FACES.lock if haskey(FACES.current[], name) - FACES.current[][name] = merge(FACES.current[][name], update) - else - FACES.current[][name] = update +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 @@ -674,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 @@ -684,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 @@ -752,3 +775,209 @@ function Base.convert(::Type{Face}, spec::Dict{String,Any}) 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/io.jl b/src/io.jl index 3070ca8..613e121 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) @@ -245,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 @@ -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..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 @@ -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