diff --git a/docs/src/index.md b/docs/src/index.md
index 984582dd..866711b0 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 4bff0b62..05c8c8fa 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 dc7e2469..9490e7dd 100644
--- a/src/StyledStrings.jl
+++ b/src/StyledStrings.jl
@@ -9,9 +9,10 @@ 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, 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 b9f7b056..ced1d094 100644
--- a/src/faces.jl
+++ b/src/faces.jl
@@ -191,6 +191,46 @@ Base.copy(f::Face) =
f.foreground, f.background, f.underline,
f.strikethrough, f.inverse, copy(f.inherit))
+"""
+ merge(initial::StyledStrings.Face, others::StyledStrings.Face...)
+
+Merge the properties of the `initial` face and `others`, with later faces taking priority.
+
+This is used to combine the styles of multiple faces, and to resolve inheritance.
+"""
+function Base.merge(a::Face, b::Face)
+ if isempty(b.inherit)
+ # Extract the heights to help type inference a bit to be able
+ # to narrow the types in e.g. `aheight * bheight`
+ aheight = a.height
+ bheight = b.height
+ abheight = if isnothing(bheight) aheight
+ elseif isnothing(aheight) bheight
+ elseif bheight isa Int bheight
+ elseif aheight isa Int round(Int, aheight * bheight)
+ else aheight * bheight end
+ Face(if isnothing(b.font) a.font else b.font end,
+ abheight,
+ if isnothing(b.weight) a.weight else b.weight end,
+ if isnothing(b.slant) a.slant else b.slant end,
+ if isnothing(b.foreground) a.foreground else b.foreground end,
+ if isnothing(b.background) a.background else b.background end,
+ if isnothing(b.underline) a.underline else b.underline end,
+ if isnothing(b.strikethrough) a.strikethrough else b.strikethrough end,
+ if isnothing(b.inverse) a.inverse else b.inverse end,
+ a.inherit)
+ else
+ b_noinherit = Face(
+ b.font, b.height, b.weight, b.slant, b.foreground, b.background,
+ b.underline, b.strikethrough, b.inverse, Symbol[])
+ b_inheritance = map(fname -> get(Face, FACES.current[], fname), Iterators.reverse(b.inherit))
+ b_resolved = merge(foldl(merge, b_inheritance), b_noinherit)
+ merge(a, b_resolved)
+ end
+end
+
+Base.merge(a::Face, b::Face, others::Face...) = merge(merge(a, b), others...)
+
function Base.show(io::IO, ::MIME"text/plain", color::SimpleColor)
skiptype = get(io, :typeinfo, nothing) === SimpleColor
skiptype || show(io, SimpleColor)
@@ -304,451 +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 default = Dict{Symbol, Face}(
- # Default 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=0x3a3a3a),
- :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]),
- )
- basecolors = Dict{Symbol, RGBTuple}(
- :background => (r = 0xff, g = 0xff, b = 0xff),
- :foreground => (r = 0x00, g = 0x00, b = 0x00),
- :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),
- :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),
- :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))
- (; default, basecolors,
- current = ScopedValue(copy(default)),
- lock = ReentrantLock())
-end
-
-## Adding and resetting faces ##
-
-"""
- addface!(name::Symbol => default::Face)
-
-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.
-
-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})
- @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])
- 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.default
- current[key] = val
- 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)
- @lock FACES.lock if !haskey(FACES.current[], name)
- elseif haskey(FACES.default, name)
- FACES.current[][name] = copy(FACES.default[name])
- else # This shouldn't happen
- delete!(FACES.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})
- @lock FACES.lock if haskey(FACES.current[], name)
- FACES.current[][name] = merge(FACES.current[][name], update)
- else
- FACES.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)
- 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))
- !isempty(fnest) &&
- loaduserfaces!(fnest, fullname)
- 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
diff --git a/src/io.jl b/src/io.jl
index f0b3cf69..8325f886 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/src/precompile.jl b/src/precompile.jl
index 0ae30d19..3525314c 100644
--- a/src/precompile.jl
+++ b/src/precompile.jl
@@ -8,7 +8,7 @@ print(colorio, styled"{(foreground=red):with color}")
show(colorio, MIME("text/plain"), styled"{red:with color}")
show(colorio, MIME("text/html"), styled"{red:with color}")
print(colorio, styled"{red:with color}"[1])
-print(colorio, styled"{underline=(blue,curly):more styling}")
+print(colorio, styled"{(underline=(blue,curly)):more styling}")
convert(StyledStrings.SimpleColor, (r = 0x01, g = 0x02, b = 0x03))
convert(StyledStrings.SimpleColor, 0x010203)
diff --git a/src/theme.jl b/src/theme.jl
new file mode 100644
index 00000000..e5ac3320
--- /dev/null
+++ b/src/theme.jl
@@ -0,0 +1,636 @@
+# This file is a part of Julia. License is MIT: https://julialang.org/license
+
+"""
+Globally named [`Face`](@ref)s.
+
+`default` gives the initial values of the faces, and `current` holds the active
+(potentially modified) set of faces. This two-set system allows for any
+modifications to the active faces to be undone.
+"""
+const FACES = let base = Dict{Symbol, Face}(
+ # Base is special, it must be completely specified
+ # and everything inherits from it.
+ :default => Face(
+ "monospace", 120, # font, height
+ :normal, :normal, # weight, slant
+ SimpleColor(:foreground), # foreground
+ SimpleColor(:background), # background
+ false, false, false, # underline, strikethrough, overline
+ Symbol[]), # inherit
+ # Property faces
+ :bold => Face(weight=:bold),
+ :light => Face(weight=:light),
+ :italic => Face(slant=:italic),
+ :underline => Face(underline=true),
+ :strikethrough => Face(strikethrough=true),
+ :inverse => Face(inverse=true),
+ # Basic color faces
+ :black => Face(foreground=:black),
+ :red => Face(foreground=:red),
+ :green => Face(foreground=:green),
+ :yellow => Face(foreground=:yellow),
+ :blue => Face(foreground=:blue),
+ :magenta => Face(foreground=:magenta),
+ :cyan => Face(foreground=:cyan),
+ :white => Face(foreground=:white),
+ :bright_black => Face(foreground=:bright_black),
+ :grey => Face(foreground=:bright_black),
+ :gray => Face(foreground=:bright_black),
+ :bright_red => Face(foreground=:bright_red),
+ :bright_green => Face(foreground=:bright_green),
+ :bright_yellow => Face(foreground=:bright_yellow),
+ :bright_blue => Face(foreground=:bright_blue),
+ :bright_magenta => Face(foreground=:bright_magenta),
+ :bright_cyan => Face(foreground=:bright_cyan),
+ :bright_white => Face(foreground=:bright_white),
+ # Useful common faces
+ :shadow => Face(foreground=:bright_black),
+ :region => Face(background=0x636363),
+ :emphasis => Face(foreground=:blue),
+ :highlight => Face(inherit=:emphasis, inverse=true),
+ :code => Face(foreground=:cyan),
+ # Styles of generic content categories
+ :error => Face(foreground=:bright_red),
+ :warning => Face(foreground=:yellow),
+ :success => Face(foreground=:green),
+ :info => Face(foreground=:bright_cyan),
+ :note => Face(foreground=:grey),
+ :tip => Face(foreground=:bright_green),
+ # Stacktraces (on behalf of Base)
+ :julia_stacktrace_frameindex => Face(),
+ :julia_stacktrace_location => Face(inherit=:shadow),
+ :julia_stacktrace_filename => Face(underline=true, inherit=:julia_stacktrace_location),
+ :julia_stacktrace_fileline => Face(inherit=:julia_stacktrace_filename),
+ :julia_stacktrace_repetition => Face(inherit=:warning),
+ :julia_stacktrace_inlined => Face(inherit=:julia_stacktrace_repetition),
+ :julia_stacktrace_basemodule => Face(inherit=:shadow),
+ # Log messages
+ :log_error => Face(inherit=[:error, :bold]),
+ :log_warn => Face(inherit=[:warning, :bold]),
+ :log_info => Face(inherit=[:info, :bold]),
+ :log_debug => Face(foreground=:blue, inherit=:bold),
+ # Julia prompts
+ :repl_prompt => Face(weight=:bold),
+ :repl_prompt_julia => Face(inherit=[:green, :repl_prompt]),
+ :repl_prompt_help => Face(inherit=[:yellow, :repl_prompt]),
+ :repl_prompt_shell => Face(inherit=[:red, :repl_prompt]),
+ :repl_prompt_pkg => Face(inherit=[:blue, :repl_prompt]),
+ :repl_prompt_beep => Face(inherit=[:shadow, :repl_prompt]),
+ )
+ light = Dict{Symbol, Face}(
+ :region => Face(background=0xaaaaaa),
+ )
+ dark = Dict{Symbol, Face}(
+ :region => Face(background=0x363636),
+ )
+ basecolors = Dict{Symbol, RGBTuple}( # Based on Gnome HIG colours
+ :foreground => (r = 0xf6, g = 0xf5, b = 0xf4),
+ :background => (r = 0x24, g = 0x1f, b = 0x31),
+ :black => (r = 0x1c, g = 0x1a, b = 0x23),
+ :red => (r = 0xa5, g = 0x1c, b = 0x2c),
+ :green => (r = 0x25, g = 0xa2, b = 0x68),
+ :yellow => (r = 0xe5, g = 0xa5, b = 0x09),
+ :blue => (r = 0x19, g = 0x5e, b = 0xb3),
+ :magenta => (r = 0x80, g = 0x3d, b = 0x9b),
+ :cyan => (r = 0x00, g = 0x97, b = 0xa7),
+ :white => (r = 0xdd, g = 0xdc, b = 0xd9),
+ :bright_black => (r = 0x76, g = 0x75, b = 0x7a),
+ :bright_red => (r = 0xed, g = 0x33, b = 0x3b),
+ :bright_green => (r = 0x33, g = 0xd0, b = 0x79),
+ :bright_yellow => (r = 0xf6, g = 0xd2, b = 0x2c),
+ :bright_blue => (r = 0x35, g = 0x83, b = 0xe4),
+ :bright_magenta => (r = 0xbf, g = 0x60, b = 0xca),
+ :bright_cyan => (r = 0x26, g = 0xc6, b = 0xda),
+ :bright_white => (r = 0xf6, g = 0xf5, b = 0xf4))
+ (themes = (; base, light, dark),
+ modifications = (base = Dict{Symbol, Face}(), light = Dict{Symbol, Face}(), dark = Dict{Symbol, Face}()),
+ current = ScopedValue(copy(base)),
+ basecolors = basecolors,
+ lock = ReentrantLock())
+end
+
+## Adding and resetting faces ##
+
+"""
+ addface!(name::Symbol => default::Face, theme::Symbol = :base)
+
+Create a new face by the name `name`. So long as no face already exists by this
+name, `default` is added to both `FACES.themes[theme]` and (a copy of) to
+`FACES.current`, with the current value returned.
+
+The `theme` should be either `:base`, `:light`, or `:dark`.
+
+Should the face `name` already exist, `nothing` is returned.
+
+# Examples
+
+```jldoctest; setup = :(import StyledStrings: Face, addface!)
+julia> addface!(:mypkg_myface => Face(slant=:italic, underline=true))
+Face (sample)
+ slant: italic
+ underline: true
+```
+"""
+function addface!((name, default)::Pair{Symbol, Face}, theme::Symbol = :base)
+ current = FACES.current[]
+ @lock FACES.lock if !haskey(FACES.themes[theme], name)
+ FACES.themes[theme][name] = default
+ current[name] = if haskey(current, name)
+ merge(copy(default), current[name])
+ else
+ copy(default)
+ end
+ end
+end
+
+"""
+ resetfaces!()
+
+Reset the current global face dictionary to the default value.
+"""
+function resetfaces!()
+ @lock FACES.lock begin
+ current = FACES.current[]
+ empty!(current)
+ for (key, val) in FACES.themes.base
+ current[key] = val
+ end
+ if current === FACES.current.default # Only when top-level
+ map(empty!, values(FACES.modifications))
+ end
+ current
+ end
+end
+
+"""
+ resetfaces!(name::Symbol)
+
+Reset the face `name` to its default value, which is returned.
+
+If the face `name` does not exist, nothing is done and `nothing` returned.
+In the unlikely event that the face `name` does not have a default value,
+it is deleted, a warning message is printed, and `nothing` returned.
+"""
+function resetfaces!(name::Symbol, theme::Symbol = :base)
+ current = FACES.current[]
+ @lock FACES.lock if !haskey(current, name) # Nothing to reset
+ elseif haskey(FACES.themes[theme], name)
+ current === FACES.current.default &&
+ delete!(FACES.modifications[theme], name)
+ current[name] = copy(FACES.themes[theme][name])
+ else # This shouldn't happen
+ delete!(current, name)
+ @warn """The face $name was reset, but it had no default value, and so has been deleted instead!,
+ This should not have happened, perhaps the face was added without using `addface!`?"""
+ end
+end
+
+"""
+ withfaces(f, kv::Pair...)
+ withfaces(f, kvpair_itr)
+
+Execute `f` with `FACES``.current` temporarily modified by zero or more `:name
+=> val` arguments `kv`, or `kvpair_itr` which produces `kv`-form values.
+
+`withfaces` is generally used via the `withfaces(kv...) do ... end` syntax. A
+value of `nothing` can be used to temporarily unset a face (if it has been
+set). When `withfaces` returns, the original `FACES``.current` has been
+restored.
+
+# Examples
+
+```jldoctest; setup = :(import StyledStrings: Face, withfaces)
+julia> withfaces(:yellow => Face(foreground=:red), :green => :blue) do
+ println(styled"{yellow:red} and {green:blue} mixed make {magenta:purple}")
+ end
+red and blue mixed make purple
+```
+"""
+function withfaces(f, keyvals_itr)
+ # Before modifying the current `FACES`, we should ensure
+ # that we've loaded the user's customisations.
+ load_customisations!()
+ if !(eltype(keyvals_itr) <: Pair{Symbol})
+ throw(MethodError(withfaces, (f, keyvals_itr)))
+ end
+ newfaces = copy(FACES.current[])
+ for (name, face) in keyvals_itr
+ if face isa Face
+ newfaces[name] = face
+ elseif face isa Symbol
+ newfaces[name] = get(Face, FACES.current[], face)
+ elseif face isa Vector{Symbol}
+ newfaces[name] = Face(inherit=face)
+ elseif haskey(newfaces, name)
+ delete!(newfaces, name)
+ end
+ end
+ @with(FACES.current => newfaces, f())
+end
+
+withfaces(f, keyvals::Pair{Symbol, <:Union{Face, Symbol, Vector{Symbol}, Nothing}}...) =
+ withfaces(f, keyvals)
+
+withfaces(f) = f()
+
+## Getting the combined face from a set of properties ##
+
+# Putting these inside `getface` causes the julia compiler to box it
+_mergedface(face::Face) = face
+_mergedface(face::Symbol) = get(Face, FACES.current[], face)
+_mergedface(faces::Vector) = mapfoldl(_mergedface, merge, Iterators.reverse(faces))
+
+"""
+ getface(faces)
+
+Obtain the final merged face from `faces`, an iterator of
+[`Face`](@ref)s, face name `Symbol`s, and lists thereof.
+"""
+function getface(faces)
+ isempty(faces) && return FACES.current[][:default]
+ combined = mapfoldl(_mergedface, merge, faces)::Face
+ if !isempty(combined.inherit)
+ combined = merge(Face(), combined)
+ end
+ merge(FACES.current[][:default], combined)
+end
+
+"""
+ getface(annotations::Vector{@NamedTuple{label::Symbol, value::Any}})
+
+Combine all of the `:face` annotations with `getfaces`.
+"""
+function getface(annotations::Vector{@NamedTuple{label::Symbol, value::Any}})
+ faces = (ann.value for ann in annotations if ann.label === :face)
+ getface(faces)
+end
+
+getface(face::Face) = merge(FACES.current[][:default], merge(Face(), face))
+getface(face::Symbol) = getface(get(Face, FACES.current[], face))
+
+"""
+ getface()
+
+Obtain the default face.
+"""
+getface() = FACES.current[][:default]
+
+## Face/AnnotatedString integration ##
+
+"""
+ getface(s::AnnotatedString, i::Integer)
+
+Get the merged [`Face`](@ref) that applies to `s` at index `i`.
+"""
+getface(s::AnnotatedString, i::Integer) =
+ getface(map(last, annotations(s, i)))
+
+"""
+ getface(c::AnnotatedChar)
+
+Get the merged [`Face`](@ref) that applies to `c`.
+"""
+getface(c::AnnotatedChar) = getface(c.annotations)
+
+"""
+ face!(str::Union{<:AnnotatedString, <:SubString{<:AnnotatedString}},
+ [range::UnitRange{Int},] face::Union{Symbol, Face})
+
+Apply `face` to `str`, along `range` if specified or the whole of `str`.
+"""
+face!(s::Union{<:AnnotatedString, <:SubString{<:AnnotatedString}},
+ range::UnitRange{Int}, face::Union{Symbol, Face, <:Vector{<:Union{Symbol, Face}}}) =
+ annotate!(s, range, :face, face)
+
+face!(s::Union{<:AnnotatedString, <:SubString{<:AnnotatedString}},
+ face::Union{Symbol, Face, <:Vector{<:Union{Symbol, Face}}}) =
+ annotate!(s, firstindex(s):lastindex(s), :face, face)
+
+## Reading face definitions from a dictionary ##
+
+"""
+ loadface!(name::Symbol => update::Face)
+
+Merge the face `name` in `FACES``.current` with `update`. If the face `name`
+does not already exist in `FACES``.current`, then it is set to `update`. To
+reset a face, `update` can be set to `nothing`.
+
+# Examples
+
+```jldoctest; setup = :(import StyledStrings: Face, loadface!)
+julia> loadface!(:red => Face(foreground=0xff0000))
+Face (sample)
+ foreground: #ff0000
+```
+"""
+function loadface!((name, update)::Pair{Symbol, Face}, theme::Symbol = :base)
+ @lock FACES.lock begin
+ current = FACES.current[]
+ if FACES.current.default === current # Only save top-level modifications
+ mface = get(FACES.modifications[theme], name, nothing)
+ isnothing(mface) || (update = merge(mface, update))
+ FACES.modifications[theme][name] = update
+ end
+ cface = get(current, name, nothing)
+ isnothing(cface) || (update = merge(cface, update))
+ current[name] = update
+ end
+end
+
+function loadface!((name, _)::Pair{Symbol, Nothing})
+ if haskey(FACES.current[], name)
+ resetfaces!(name)
+ end
+end
+
+"""
+ loaduserfaces!(faces::Dict{String, Any})
+
+For each face specified in `Dict`, load it to `FACES``.current`.
+"""
+function loaduserfaces!(faces::Dict{String, Any}, prefix::Union{String, Nothing}=nothing, theme::Symbol = :base)
+ theme == :base && prefix ∈ map(String, setdiff(keys(FACES.themes), (:base,))) &&
+ return loaduserfaces!(faces, nothing, Symbol(prefix))
+ for (name, spec) in faces
+ fullname = if isnothing(prefix)
+ name
+ else
+ string(prefix, '_', name)
+ end
+ fspec = filter((_, v)::Pair -> !(v isa Dict), spec)
+ fnest = filter((_, v)::Pair -> v isa Dict, spec)
+ !isempty(fspec) &&
+ loadface!(Symbol(fullname) => convert(Face, fspec), theme)
+ !isempty(fnest) &&
+ loaduserfaces!(fnest, fullname, theme)
+ end
+end
+
+"""
+ loaduserfaces!(tomlfile::String)
+
+Load all faces declared in the Faces.toml file `tomlfile`.
+"""
+loaduserfaces!(tomlfile::String) = loaduserfaces!(Base.parsed_toml(tomlfile))
+
+function Base.convert(::Type{Face}, spec::Dict{String,Any})
+ Face(if haskey(spec, "font") && spec["font"] isa String
+ spec["font"]::String
+ end,
+ if haskey(spec, "height") && (spec["height"] isa Int || spec["height"] isa Float64)
+ spec["height"]::Union{Int,Float64}
+ end,
+ if haskey(spec, "weight") && spec["weight"] isa String
+ Symbol(spec["weight"]::String)
+ elseif haskey(spec, "bold") && spec["bold"] isa Bool
+ ifelse(spec["bold"]::Bool, :bold, :normal)
+ end,
+ if haskey(spec, "slant") && spec["slant"] isa String
+ Symbol(spec["slant"]::String)
+ elseif haskey(spec, "italic") && spec["italic"] isa Bool
+ ifelse(spec["italic"]::Bool, :italic, :normal)
+ end,
+ if haskey(spec, "foreground") && spec["foreground"] isa String
+ tryparse(SimpleColor, spec["foreground"]::String)
+ elseif haskey(spec, "fg") && spec["fg"] isa String
+ tryparse(SimpleColor, spec["fg"]::String)
+ end,
+ if haskey(spec, "background") && spec["background"] isa String
+ tryparse(SimpleColor, spec["background"]::String)
+ elseif haskey(spec, "bg") && spec["bg"] isa String
+ tryparse(SimpleColor, spec["bg"]::String)
+ end,
+ if !haskey(spec, "underline")
+ elseif spec["underline"] isa Bool
+ spec["underline"]::Bool
+ elseif spec["underline"] isa String
+ tryparse(SimpleColor, spec["underline"]::String)
+ elseif spec["underline"] isa Vector{String} && length(spec["underline"]::Vector{String}) == 2
+ color_str, style_str = (spec["underline"]::Vector{String})
+ color = tryparse(SimpleColor, color_str)
+ (color, Symbol(style_str))
+ end,
+ if !haskey(spec, "strikethrough")
+ elseif spec["strikethrough"] isa Bool
+ spec["strikethrough"]::Bool
+ elseif spec["strikethrough"] isa String
+ tryparse(SimpleColor, spec["strikethrough"]::String)
+ end,
+ if haskey(spec, "inverse") && spec["inverse"] isa Bool
+ spec["inverse"]::Bool end,
+ if !haskey(spec, "inherit")
+ Symbol[]
+ elseif spec["inherit"] isa String
+ [Symbol(spec["inherit"]::String)]
+ elseif spec["inherit"] isa Vector{String}
+ [Symbol(name) for name in spec["inherit"]::Vector{String}]
+ else
+ Symbol[]
+ end)
+end
+
+## Recolouring ##
+
+const recolor_hooks = Function[]
+const recolor_lock = ReentrantLock()
+
+"""
+ recolor(f::Function)
+
+Register a hook function `f` to be called whenever the colors change.
+
+Usually hooks will be called once after terminal colors have been
+determined. These hooks enable dynamic retheming, but are specifically *not* run when faces
+are changed. They sit in between the default faces and modifications layered on
+top with `loadface!` and user customisations.
+"""
+function recolor(f::Function)
+ @lock recolor_lock push!(recolor_hooks, f)
+ nothing
+end
+
+"""
+ setcolors!(colors::Vector{Pair{Symbol, RGBTuple}})
+
+Update the known base colors with those in `colors`, and recalculate current faces.
+
+`color` should be a complete list of known colours. If `:foreground` and
+`:background` are both specified, the faces in the light/dark theme will be
+loaded. Otherwise, only the base theme will be applied.
+"""
+function setcolors!(colors::Vector{Pair{Symbol, RGBTuple}})
+ lock(recolor_lock)
+ lock(FACES.lock)
+ try
+ # Apply colors
+ fg, bg = nothing, nothing
+ for (name, rgb) in colors
+ FACES.basecolors[name] = rgb
+ if name === :foreground
+ fg = rgb
+ elseif name === :background
+ bg = rgb
+ end
+ end
+ newtheme = if isnothing(fg) || isnothing(bg)
+ :unknown
+ else
+ ifelse(sum(fg) > sum(bg), :dark, :light)
+ end
+ # Reset all themes to defaults
+ current = FACES.current[]
+ for theme in keys(FACES.themes), (name, _) in FACES.modifications[theme]
+ default = get(FACES.themes.base, name, nothing)
+ isnothing(default) && continue
+ current[name] = default
+ end
+ if newtheme ∈ keys(FACES.themes)
+ for (name, face) in FACES.themes[newtheme]
+ current[name] = merge(current[name], face)
+ end
+ end
+ # Run recolor hooks
+ for hook in recolor_hooks
+ hook()
+ end
+ # Layer on modifications
+ for theme in keys(FACES.themes)
+ theme ∈ (:base, newtheme) || continue
+ for (name, face) in FACES.modifications[theme]
+ current[name] = merge(current[name], face)
+ end
+ end
+ finally
+ unlock(FACES.lock)
+ unlock(recolor_lock)
+ end
+end
+
+## Color utils ##
+
+"""
+ UNRESOLVED_COLOR_FALLBACK
+
+The fallback `RGBTuple` used when asking for a color that is not defined.
+"""
+const UNRESOLVED_COLOR_FALLBACK = (r = 0xff, g = 0x00, b = 0xff) # Pink
+
+"""
+ MAX_COLOR_FORWARDS
+
+The maximum number of times to follow color references when resolving a color.
+"""
+const MAX_COLOR_FORWARDS = 12
+
+"""
+ try_rgbcolor(name::Symbol, stamina::Int = MAX_COLOR_FORWARDS)
+
+Attempt to resolve `name` to an `RGBTuple`, taking up to `stamina` steps.
+"""
+function try_rgbcolor(name::Symbol, stamina::Int = MAX_COLOR_FORWARDS)
+ for s in stamina:-1:1 # Do this instead of a while loop to prevent cyclic lookups
+ face = get(FACES.current[], name, Face())
+ fg = face.foreground
+ if isnothing(fg)
+ isempty(face.inherit) && break
+ for iname in face.inherit
+ irgb = try_rgbcolor(iname, s - 1)
+ !isnothing(irgb) && return irgb
+ end
+ end
+ fg.value isa RGBTuple && return fg.value
+ fg.value == name && return get(FACES.basecolors, name, nothing)
+ name = fg.value
+ end
+end
+
+"""
+ rgbcolor(color::Union{Symbol, SimpleColor})
+
+Resolve a `color` to an `RGBTuple`.
+
+The resolution follows these steps:
+1. If `color` is a `SimpleColor` holding an `RGBTuple`, that is returned.
+2. If `color` names a face, the face's foreground color is used.
+3. If `color` names a base color, that color is used.
+4. Otherwise, `UNRESOLVED_COLOR_FALLBACK` (bright pink) is returned.
+"""
+function rgbcolor(color::Union{Symbol, SimpleColor})
+ name = if color isa Symbol
+ color
+ elseif color isa SimpleColor
+ color.value
+ end
+ name isa RGBTuple && return name
+ @something(try_rgbcolor(name),
+ get(FACES.basecolors, name, UNRESOLVED_COLOR_FALLBACK))
+end
+
+"""
+ blend(a::Union{Symbol, SimpleColor}, [b::Union{Symbol, SimpleColor} => α::Real]...)
+
+Blend colors `a` and `b` in Oklab space, with mix ratio `α` (0–1).
+
+The colors `a` and `b` can either be `SimpleColor`s, or `Symbol`s naming a face
+or base color. The mix ratio `α` combines `(1 - α)` of `a` with `α` of `b`.
+
+Multiple colors can be blended at once by providing multiple `b => α` pairs.
+
+# Examples
+
+```julia-repl
+julia> blend(SimpleColor(0xff0000), SimpleColor(0x0000ff), 0.5)
+SimpleColor(■ #8b54a1)
+
+julia> blend(:red, :yellow, 0.7)
+SimpleColor(■ #d47f24)
+
+julia> blend(:green, SimpleColor(0xffffff), 0.3)
+SimpleColor(■ #74be93)
+```
+"""
+function blend end
+
+function blend(primaries::Pair{RGBTuple, <:Real}...)
+ function oklab(rgb::RGBTuple)
+ r, g, b = (rgb.r / 255)^2.2, (rgb.g / 255)^2.2, (rgb.b / 255)^2.2
+ l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
+ m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
+ s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
+ L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
+ a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
+ b = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
+ (; L, a, b)
+ end
+ function rgb((; L, a, b))
+ tohex(v) = round(UInt8, min(255.0, 255 * max(0.0, v)^(1 / 2.2)))
+ l = (L + 0.3963377774 * a + 0.2158037573 * b)^3
+ m = (L - 0.1055613458 * a - 0.0638541728 * b)^3
+ s = (L - 0.0894841775 * a - 1.2914855480 * b)^3
+ r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
+ g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
+ b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
+ (r = tohex(r), g = tohex(g), b = tohex(b))
+ end
+ L′, a′, b′ = 0.0, 0.0, 0.0
+ for (color, α) in primaries
+ lab = oklab(color)
+ L′ += lab.L * α
+ a′ += lab.a * α
+ b′ += lab.b * α
+ end
+ mix = (L = L′, a = a′, b = b′)
+ rgb(mix)
+end
+
+blend(base::RGBTuple, primaries::Pair{RGBTuple, <:Real}...) =
+ blend(base => 1.0 - sum(last, primaries), primaries...)
+
+blend(primaries::Pair{<:Union{Symbol, SimpleColor}, <:Real}...) =
+ SimpleColor(blend((rgbcolor(c) => w for (c, w) in primaries)...))
+
+blend(base::Union{Symbol, SimpleColor}, primaries::Pair{<:Union{Symbol, SimpleColor}, <:Real}...) =
+ SimpleColor(blend(rgbcolor(base), (rgbcolor(c) => w for (c, w) in primaries)...))
+
+blend(a::Union{Symbol, SimpleColor}, b::Union{Symbol, SimpleColor}, α::Real) =
+ blend(a => 1 - α, b => α)
diff --git a/test/runtests.jl b/test/runtests.jl
index 0e300349..59a07533 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