Skip to content

Commit 523372c

Browse files
committed
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.
1 parent 9bb8ffd commit 523372c

File tree

6 files changed

+129
-31
lines changed

6 files changed

+129
-31
lines changed

docs/src/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,4 +341,5 @@ StyledStrings.SimpleColor
341341
StyledStrings.parse(::Type{StyledStrings.SimpleColor}, ::String)
342342
StyledStrings.tryparse(::Type{StyledStrings.SimpleColor}, ::String)
343343
StyledStrings.merge(::StyledStrings.Face, ::StyledStrings.Face)
344+
StyledStrings.blend(::StyledStrings.SimpleColor, ::StyledStrings.SimpleColor, ::Real)
344345
```

docs/src/internals.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ opening a pull request or issue to discuss making them part of the public API.
88
```@docs
99
StyledStrings.ANSI_4BIT_COLORS
1010
StyledStrings.FACES
11+
StyledStrings.MAX_COLOR_FORWARDS
12+
StyledStrings.UNRESOLVED_COLOR_FALLBACK
1113
StyledStrings.Legacy.ANSI_256_COLORS
1214
StyledStrings.Legacy.NAMED_COLORS
1315
StyledStrings.Legacy.RENAMED_COLORS
@@ -16,13 +18,14 @@ StyledStrings.Legacy.load_env_colors!
1618
StyledStrings.ansi_4bit
1719
StyledStrings.face!
1820
StyledStrings.getface
21+
StyledStrings.load_customisations!
1922
StyledStrings.loadface!
2023
StyledStrings.loaduserfaces!
2124
StyledStrings.resetfaces!
25+
StyledStrings.rgbcolor
2226
StyledStrings.termcolor
2327
StyledStrings.termcolor24bit
2428
StyledStrings.termcolor8bit
25-
StyledStrings.load_customisations!
2629
```
2730

2831
## Styled Markup parsing

src/StyledStrings.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ using Base.ScopedValues: ScopedValue, with, @with
99
export AnnotatedString, AnnotatedChar, AnnotatedIOBuffer, annotations, annotate!, annotatedstring
1010

1111
export @styled_str
12-
public Face, addface!, withfaces, styled, SimpleColor
12+
public Face, addface!, withfaces, styled, SimpleColor, blend
1313

1414
include("faces.jl")
1515
include("io.jl")

src/faces.jl

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,3 +752,111 @@ function Base.convert(::Type{Face}, spec::Dict{String,Any})
752752
Symbol[]
753753
end)
754754
end
755+
756+
## Color utils ##
757+
758+
"""
759+
UNRESOLVED_COLOR_FALLBACK
760+
761+
The fallback `RGBTuple` used when asking for a color that is not defined.
762+
"""
763+
const UNRESOLVED_COLOR_FALLBACK = (r = 0xff, g = 0x00, b = 0xff) # Pink
764+
765+
"""
766+
MAX_COLOR_FORWARDS
767+
768+
The maximum number of times to follow color references when resolving a color.
769+
"""
770+
const MAX_COLOR_FORWARDS = 12
771+
772+
"""
773+
rgbcolor(color::Union{Symbol, SimpleColor})
774+
775+
Resolve a `color` to an `RGBTuple`.
776+
777+
The resolution follows these steps:
778+
1. If `color` is a `SimpleColor` holding an `RGBTuple`, that is returned.
779+
2. If `color` names a face, the face's foreground color is used.
780+
3. If `color` names a base color, that color is used.
781+
4. Otherwise, `UNRESOLVED_COLOR_FALLBACK` (bright pink) is returned.
782+
"""
783+
function rgbcolor(name::Symbol)
784+
for _ in 1:MAX_COLOR_FORWARDS # Do this instead of a while loop to prevent cyclic lookups
785+
fg = get(FACES.current[], name, Face()).foreground
786+
isnothing(fg) && break
787+
fg.value isa RGBTuple && return fg.value
788+
name = fg.value
789+
end
790+
get(FACES.basecolors, name, UNRESOLVED_COLOR_FALLBACK)
791+
end
792+
793+
function rgbcolor(color::SimpleColor)
794+
val = color.value
795+
if val isa RGBTuple
796+
val
797+
else
798+
rgbcolor(val)
799+
end
800+
end
801+
802+
"""
803+
blend(a::Union{Symbol, SimpleColor}, b::Union{Symbol, SimpleColor}, α::Real)
804+
805+
Blend colors `a` and `b` in Oklab space, with mix ratio `α` (0–1).
806+
807+
The colors `a` and `b` can either be `SimpleColor`s, or `Symbol`s naming a face
808+
or base color. The mix ratio `α` combines `(1 - α)` of `a` with `α` of `b`.
809+
810+
# Examples
811+
812+
```julia-repl
813+
julia> blend(SimpleColor(0xff0000), SimpleColor(0x0000ff), 0.5)
814+
SimpleColor(■ #8b54a1)
815+
816+
julia> blend(:red, :yellow, 0.7)
817+
SimpleColor(■ #d47f24)
818+
819+
julia> blend(:green, SimpleColor(0xffffff), 0.3)
820+
SimpleColor(■ #74be93)
821+
```
822+
"""
823+
function blend(c1::SimpleColor, c2::SimpleColor, α::Real)
824+
function oklab(rgb::RGBTuple)
825+
r, g, b = (Tuple(rgb) ./ 255) .^ 2.2
826+
l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
827+
m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
828+
s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
829+
L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
830+
a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
831+
b = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
832+
(; L, a, b)
833+
end
834+
function rgb((; L, a, b))
835+
tohex(v) = round(UInt8, min(255.0, 255 * max(0.0, v)^(1 / 2.2)))
836+
l = (L + 0.3963377774 * a + 0.2158037573 * b)^3
837+
m = (L - 0.1055613458 * a - 0.0638541728 * b)^3
838+
s = (L - 0.0894841775 * a - 1.2914855480 * b)^3
839+
r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
840+
g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
841+
b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
842+
(r = tohex(r), g = tohex(g), b = tohex(b))
843+
end
844+
lab1 = oklab(rgbcolor(c1))
845+
lab2 = oklab(rgbcolor(c2))
846+
mix = (L = (1 - α) * lab1.L + α * lab2.L,
847+
a = (1 - α) * lab1.a + α * lab2.a,
848+
b = (1 - α) * lab1.b + α * lab2.b)
849+
SimpleColor(rgb(mix))
850+
end
851+
852+
function blend(f1::Union{Symbol, SimpleColor}, f2::Union{Symbol, SimpleColor}, α::Real)
853+
function face_or_color(name::Symbol)
854+
c = getface(name).foreground
855+
if c.value === :foreground && haskey(FACES.basecolors, name)
856+
c = SimpleColor(name)
857+
end
858+
c
859+
end
860+
face_or_color(c::SimpleColor) = c
861+
blend(face_or_color(f1), face_or_color(f2), α)
862+
end

src/io.jl

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,6 @@ function termcolor24bit(io::IO, color::RGBTuple, category::Char)
9292
string(color.b), 'm')
9393
end
9494

95-
const MAX_COLOR_FORWARDS = 12
96-
9795
"""
9896
termcolor(io::IO, color::SimpleColor, category::Char)
9997
@@ -311,32 +309,20 @@ Base.AnnotatedDisplay.show_annot(io::IO, ::MIME"text/html", s::Union{<:Annotated
311309

312310
function htmlcolor(io::IO, color::SimpleColor, background::Bool = false)
313311
default = getface()
314-
if color.value isa Symbol
315-
if background && color.value == :background
316-
print(io, "initial")
317-
elseif !background && color.value == :foreground
318-
print(io, "initial")
319-
elseif (fg = get(FACES.current[], color.value, default).foreground) != SimpleColor(color.value)
320-
htmlcolor(io, fg)
321-
elseif haskey(FACES.basecolors, color.value)
322-
htmlcolor(io, SimpleColor(FACES.basecolors[color.value]))
323-
else
324-
print(io, "inherit")
325-
end
326-
elseif background && color.value == default.background
327-
htmlcolor(io, SimpleColor(:background), true)
328-
elseif !background && color.value ==default.foreground
329-
htmlcolor(io, SimpleColor(:foreground))
330-
else
331-
(; r, g, b) = color.value
332-
print(io, '#')
333-
r < 0x10 && print(io, '0')
334-
print(io, string(r, base=16))
335-
g < 0x10 && print(io, '0')
336-
print(io, string(g, base=16))
337-
b < 0x10 && print(io, '0')
338-
print(io, string(b, base=16))
312+
if background && color.value (:background, default.background)
313+
return print(io, "initial")
314+
elseif !background && color.value (:foreground, default.foreground)
315+
return print(io, "initial")
339316
end
317+
(; r, g, b) = rgbcolor(color)
318+
default = getface()
319+
print(io, '#')
320+
r < 0x10 && print(io, '0')
321+
print(io, string(r, base=16))
322+
g < 0x10 && print(io, '0')
323+
print(io, string(g, base=16))
324+
b < 0x10 && print(io, '0')
325+
print(io, string(b, base=16))
340326
end
341327

342328
const HTML_WEIGHT_MAP = Dict{Symbol, Int}(

test/runtests.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,7 @@ end
571571
@test sprint(StyledStrings.htmlcolor, SimpleColor(:black)) == "#1c1a23"
572572
@test sprint(StyledStrings.htmlcolor, SimpleColor(:green)) == "#25a268"
573573
@test sprint(StyledStrings.htmlcolor, SimpleColor(:warning)) == "#e5a509"
574-
@test sprint(StyledStrings.htmlcolor, SimpleColor(:nonexistant)) == "initial"
574+
@test sprint(StyledStrings.htmlcolor, SimpleColor(:nonexistant)) == "#ff00ff"
575575
@test sprint(StyledStrings.htmlcolor, SimpleColor(0x40, 0x63, 0xd8)) == "#4063d8"
576576
function html_change(; attrs...)
577577
face = getface(Face(; attrs...))
@@ -609,7 +609,7 @@ end
609609
<span style=\"color: #803d9b\">`</span><span style=\"color: #25a268\">AnnotatedString</span><span style=\"color: #803d9b\">`</span> \
610610
<a href=\"https://en.wikipedia.org/wiki/Type_system\">type</a> to provide a <span style=\"text-decoration: #a51c2c wavy underline\">\
611611
full-fledged</span> textual <span style=\"font-weight: 700; color: #adbdf8; background-color: #4063d8; text-decoration: line-through\">\
612-
styling</span> system, suitable for <span style=\"color: initial; background-color: #000000\">terminal</span> and graphical displays."
612+
styling</span> system, suitable for <span style=\"color: #ffffff; background-color: #000000\">terminal</span> and graphical displays."
613613
end
614614

615615
@testset "Legacy" begin

0 commit comments

Comments
 (0)