diff --git a/docs/src/internals.md b/docs/src/internals.md index 6cfca87..04e4873 100644 --- a/docs/src/internals.md +++ b/docs/src/internals.md @@ -6,6 +6,7 @@ about the internals, read on, but if you want to depend on them, please consider opening a pull request or issue to discuss making them part of the public API. ```@docs +StyledStrings.FaceRef StyledStrings.ANSI_4BIT_COLORS StyledStrings.FACES StyledStrings.HTML_BASIC_COLORS diff --git a/src/faces.jl b/src/faces.jl index 7d94215..348dc7a 100644 --- a/src/faces.jl +++ b/src/faces.jl @@ -2,6 +2,22 @@ const RGBTuple = NamedTuple{(:r, :g, :b), NTuple{3, UInt8}} +""" + struct FaceRef + +A reference to a lazily-resolved Face. This is required so that the +AnnotatedString printer in Base can dispatch to StyledStrings (w/o +type-piracy) for the display of annotations. +""" +struct FaceRef + # At some point in the future, this may also include a handle + # to a 'Palette' where this face will be looked up. + face::Symbol +end + +wrap_symbol(face) = face +wrap_symbol(face::Symbol) = FaceRef(face) + """ struct SimpleColor @@ -546,13 +562,14 @@ Base.merge(a::Face, b::Face, others::Face...) = merge(merge(a, b), others...) # Putting these inside `getface` causes the julia compiler to box it _mergedface(face::Face) = face _mergedface(face::Symbol) = get(Face, FACES.current[], face) +_mergedface(ref::FaceRef) = get(Face, FACES.current[], ref.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. +[`Face`](@ref)s, [`FaceRef`](@ref)s, face name `Symbol`s, and lists thereof. """ function getface(faces) isempty(faces) && return FACES.current[][:default] @@ -575,6 +592,7 @@ end getface(face::Face) = merge(FACES.current[][:default], merge(Face(), face)) getface(face::Symbol) = getface(get(Face, FACES.current[], face)) +getface(ref::FaceRef) = getface(get(Face, FACES.current[], ref.face)) """ getface() @@ -606,13 +624,17 @@ getface(c::AnnotatedChar) = getface(c.annotations) 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}}, + range::UnitRange{Int}, face::Union{Symbol, Face, FaceRef}) = + annotate!(s, range, :face, wrap_symbol(face)) + +face!(s::Union{AnnotatedString, SubString{<:AnnotatedString}}, + range::UnitRange{Int}, faces::Vector{<:Union{Symbol, Face, FaceRef}}) = + annotate!(s, range, :face, wrap_symbol.(faces)) -face!(s::Union{<:AnnotatedString, <:SubString{<:AnnotatedString}}, - face::Union{Symbol, Face, <:Vector{<:Union{Symbol, Face}}}) = - annotate!(s, firstindex(s):lastindex(s), :face, face) +face!(s::Union{AnnotatedString, SubString{<:AnnotatedString}}, + face::Union{Symbol, Face, FaceRef, Vector{<:Union{Symbol, Face, FaceRef}}}) = + face!(s, firstindex(s):lastindex(s), face) ## Reading face definitions from a dictionary ## diff --git a/src/regioniterator.jl b/src/regioniterator.jl index 6a35264..e93ee03 100644 --- a/src/regioniterator.jl +++ b/src/regioniterator.jl @@ -29,11 +29,12 @@ an iterator which provides each substring and the applicable annotations as a ```jldoctest julia> collect(StyledStrings.eachregion(AnnotatedString( - "hey there", [(1:3, :face, :bold), (5:9, :face, :italic)]))) + "hey there", [(1:3, :face, StyledStrings.FaceRef(:bold)), + (5:9, :face, StyledStrings.FaceRef(:italic))]))) 3-element Vector{Tuple{SubString{String}, Vector{@NamedTuple{label::Symbol, value}}}}: - ("hey", [@NamedTuple{label::Symbol, value}((:face, :bold))]) + ("hey", [@NamedTuple{label::Symbol, value}((:face, StyledStrings.FaceRef(:bold)))]) (" ", []) - ("there", [@NamedTuple{label::Symbol, value}((:face, :italic))]) + ("there", [@NamedTuple{label::Symbol, value}((:face, StyledStrings.FaceRef(:italic)))]) ``` """ function eachregion(s::AnnotatedString, subregion::UnitRange{Int}=firstindex(s):lastindex(s)) diff --git a/src/styledmarkup.jl b/src/styledmarkup.jl index ceb195b..5d1a4fa 100644 --- a/src/styledmarkup.jl +++ b/src/styledmarkup.jl @@ -46,7 +46,7 @@ Of course, as usual, the devil is in the details. module StyledMarkup using Base: AnnotatedString, annotations, annotatedstring -using ..StyledStrings: Face, SimpleColor +using ..StyledStrings: Face, FaceRef, SimpleColor, wrap_symbol export @styled_str, styled @@ -325,7 +325,7 @@ function readexpr!(state::State, pos::Int = first(popfirst!(state.s)) + 1) if isempty(state.s) styerr!(state, AnnotatedString("Identifier or parenthesised expression expected after \$ in string", - [(55:55, :face, :warning)]), + [(55:55, :face, FaceRef(:warning))]), -1, "right here") return "", pos end @@ -401,7 +401,7 @@ and register it in the active styles list. """ function begin_style!(state::State, i::Int, char::Char) hasvalue = false - newstyles = Vector{Tuple{Int, Int, Union{Symbol, Expr, Tuple{Symbol, Any}}}}() + newstyles = Vector{Tuple{Int, Int, Union{FaceRef, Expr, Tuple{Symbol, Any}}}}() while read_annotation!(state, i, char, newstyles) end push!(state.active_styles, reverse!(newstyles)) # Adjust bytes/offset based on how much the index @@ -535,9 +535,9 @@ function read_inlineface!(state::State, i::Int, char::Char, newstyles) valid_options = join(VALID_UNDERLINE_STYLES, ", ", ", or ") styerr!(state, AnnotatedString("Invalid underline style '$ustyle_word' (should be $valid_options)", - [(26:25+ncodeunits(ustyle_word), :face, :warning) + [(26:25+ncodeunits(ustyle_word), :face, FaceRef(:warning)) (28+ncodeunits(ustyle_word):39+ncodeunits(ustyle_word)+ncodeunits(valid_options), - :face, :light)]), + :face, FaceRef(:light))]), -length(ustyle_word) - 3) end ustyle = Symbol(ustyle_word) @@ -658,7 +658,7 @@ function read_inlineface!(state::State, i::Int, char::Char, newstyles) else invalid, lastchar = readsymbol!(state, lastchar) styerr!(state, AnnotatedString("Invalid height '$invalid', should be a natural number or positive float", - [(17:16+ncodeunits(string(invalid)), :face, :warning)]), + [(17:16+ncodeunits(string(invalid)), :face, FaceRef(:warning))]), -3) end elseif key ∈ (:weight, :slant) @@ -666,16 +666,16 @@ function read_inlineface!(state::State, i::Int, char::Char, newstyles) if key == :weight && v ∉ VALID_WEIGHTS valid_options = join(VALID_WEIGHTS, ", ", ", or ") styerr!(state, AnnotatedString("Invalid weight '$v' (should be $valid_options)", - [(17:16+ncodeunits(v), :face, :warning), + [(17:16+ncodeunits(v), :face, FaceRef(:warning)), (19+ncodeunits(v):30+ncodeunits(v)+ncodeunits(valid_options), - :face, :light)]), + :face, FaceRef(:light))]), -3) elseif key == :slant && v ∉ VALID_SLANTS valid_options = join(VALID_SLANTS, ", ", ", or ") styerr!(state, AnnotatedString("Invalid slant '$v' (should be $valid_options)", - [(16:15+ncodeunits(v), :face, :warning), + [(16:15+ncodeunits(v), :face, FaceRef(:warning)), (18+ncodeunits(v):29+ncodeunits(v)+ncodeunits(valid_options), - :face, :light)]), + :face, FaceRef(:light))]), -3) end Symbol(v) |> if ismacro(state) QuoteNode else identity end @@ -702,7 +702,7 @@ function read_inlineface!(state::State, i::Int, char::Char, newstyles) else styerr!(state, AnnotatedString( "Uses unrecognised face key '$key'. Recognised keys are: $(join(VALID_FACE_ATTRS, ", ", ", and "))", - [(29:28+ncodeunits(String(key)), :face, :warning)]), + [(29:28+ncodeunits(String(key)), :face, FaceRef(:warning))]), -length(str_key) - 2) end if ismacro(state) && !any(k -> first(k.args) == key, kwargs) @@ -711,7 +711,7 @@ function read_inlineface!(state::State, i::Int, char::Char, newstyles) push!(kwargs, key => val) else styerr!(state, AnnotatedString("Contains repeated face key '$key'", - [(29:28+ncodeunits(String(key)), :face, :warning)]), + [(29:28+ncodeunits(String(key)), :face, FaceRef(:warning))]), -length(str_key) - 2) end isempty(state.s) && styerr!(state, "Incomplete inline face declaration", -1) @@ -723,16 +723,16 @@ function read_inlineface!(state::State, i::Int, char::Char, newstyles) break end end - face = Expr(:call, Face, kwargs...) - push!(newstyles, - (i, i + state.offset + 1, - if !ismacro(state) - :face, Face(; NamedTuple(kwargs)...) - elseif needseval - :((:face, $face)) - else - :face, hygienic_eval(state, face) - end)) + face_expr = Expr(:call, Face, kwargs...) + if !ismacro(state) + face = (:face, Face(; NamedTuple(kwargs)...)) + elseif needseval + face = :((:face, $face_expr)) + else + face = (:face, hygienic_eval(state, face_expr)) + end + offset = i + state.offset + 1 + push!(newstyles, (i, offset, face)) end """ @@ -800,23 +800,25 @@ function read_face_or_keyval!(state::State, i::Int, char::Char, newstyles) end String(chars) end - push!(newstyles, - (i, i + state.offset + ncodeunits('{'), - if key isa String && !(value isa Symbol || value isa Expr) - Symbol(key), value - elseif key isa Expr || key isa Symbol - :(($key, $value)) - else - :(($(QuoteNode(Symbol(key))), $value)) - end)) + if key isa String && !(value isa Symbol || value isa Expr) + face = (Symbol(key), value) + elseif key isa Expr || key isa Symbol + face = :(($key, $value)) + else + face = :(($(QuoteNode(Symbol(key))), $value)) + end + offset = i + state.offset + ncodeunits('{') + push!(newstyles, (i, offset, face)) elseif key !== "" - push!(newstyles, - (i, i + state.offset + ncodeunits('{'), - if key isa Symbol || key isa Expr - :((:face, $key)) - else # Face symbol - :face, Symbol(key) - end)) + if key isa Symbol || key isa Expr + face = :((:face, $wrap_symbol($key))) + elseif ismacro(state) # Face symbol + face = :((:face, $FaceRef($(QuoteNode(Symbol(key)))))) + else # Face symbol + face = (:face, FaceRef(Symbol(key))) + end + offset = i + state.offset + ncodeunits('{') + push!(newstyles, (i, offset, face)) end if isempty(state.s) || last(peek(state.s)) ∉ (' ', '\t', '\n', '\r', ',', ':') styerr!(state, "Incomplete annotation declaration", prevind(state.content, i), "starts here") @@ -857,7 +859,7 @@ function run_state_machine!(state::State) end for incomplete in Iterators.flatten(state.active_styles) styerr!(state, AnnotatedString("Unterminated annotation (missing closing '}')", - [(43:43, :face, :warning)]), + [(43:43, :face, FaceRef(:warning))]), prevind(state.content, first(incomplete)), "starts here") end end diff --git a/test/runtests.jl b/test/runtests.jl index 006556e..bd8554d 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -2,9 +2,9 @@ using Test -using StyledStrings: StyledStrings, Legacy, SimpleColor, FACES, Face, +using StyledStrings: StyledStrings, Legacy, SimpleColor, FACES, Face, FaceRef, @styled_str, styled, StyledMarkup, eachregion, getface, addface!, loadface!, resetfaces!, - AnnotatedString, AnnotatedChar, AnnotatedIOBuffer, annotations + wrap_symbol, AnnotatedString, AnnotatedChar, AnnotatedIOBuffer, annotations using .StyledMarkup: MalformedStylingMacro const NON_STDLIB_TESTS = Main == @__MODULE__ @@ -39,47 +39,47 @@ choppkg(s::String) = chopprefix(s, "StyledStrings.") annregions(str::String, annots::Vector{<:Tuple{UnitRange{Int}, Symbol, <:Any}}) = [(s, Tuple.(a)) for (s, a) in eachregion(AnnotatedString(str, annots))] # Regions that do/don't extend to the left/right edges - @test annregions(" abc ", [(2:4, :face, :bold)]) == + @test annregions(" abc ", [(2:4, :face, FaceRef(:bold))]) == [(" ", []), - ("abc", [(:face, :bold)]), + ("abc", [(:face, FaceRef(:bold))]), (" ", [])] - @test annregions(" x ", [(2:2, :face, :bold)]) == + @test annregions(" x ", [(2:2, :face, FaceRef(:bold))]) == [(" ", []), - ("x", [(:face, :bold)]), + ("x", [(:face, FaceRef(:bold))]), (" ", [])] - @test annregions(" x", [(2:2, :face, :bold)]) == + @test annregions(" x", [(2:2, :face, FaceRef(:bold))]) == [(" ", []), - ("x", [(:face, :bold)])] - @test annregions("x ", [(1:1, :face, :bold)]) == - [("x", [(:face, :bold)]), + ("x", [(:face, FaceRef(:bold))])] + @test annregions("x ", [(1:1, :face, FaceRef(:bold))]) == + [("x", [(:face, FaceRef(:bold))]), (" ", [])] - @test annregions("x", [(1:1, :face, :bold)]) == - [("x", [(:face, :bold)])] + @test annregions("x", [(1:1, :face, FaceRef(:bold))]) == + [("x", [(:face, FaceRef(:bold))])] # Overlapping/nested regions - @test annregions(" abc ", [(2:4, :face, :bold), (3:3, :face, :italic)]) == + @test annregions(" abc ", [(2:4, :face, FaceRef(:bold)), (3:3, :face, FaceRef(:italic))]) == [(" ", []), - ("a", [(:face, :bold)]), - ("b", [(:face, :bold), (:face, :italic)]), - ("c", [(:face, :bold)]), + ("a", [(:face, FaceRef(:bold))]), + ("b", [(:face, FaceRef(:bold)), (:face, FaceRef(:italic))]), + ("c", [(:face, FaceRef(:bold))]), (" ", [])] - @test annregions("abc-xyz", [(1:7, :face, :bold), (1:3, :face, :green), (4:4, :face, :yellow), (4:7, :face, :italic)]) == - [("abc", [(:face, :bold), (:face, :green)]), - ("-", [(:face, :bold), (:face, :yellow), (:face, :italic)]), - ("xyz", [(:face, :bold), (:face, :italic)])] + @test annregions("abc-xyz", [(1:7, :face, FaceRef(:bold)), (1:3, :face, FaceRef(:green)), (4:4, :face, FaceRef(:yellow)), (4:7, :face, FaceRef(:italic))]) == + [("abc", [(:face, FaceRef(:bold)), (:face, FaceRef(:green))]), + ("-", [(:face, FaceRef(:bold)), (:face, FaceRef(:yellow)), (:face, FaceRef(:italic))]), + ("xyz", [(:face, FaceRef(:bold)), (:face, FaceRef(:italic))])] # Preserving annotation order - @test annregions("abcd", [(1:3, :face, :red), (2:2, :face, :yellow), (2:3, :face, :green), (2:4, :face, :blue)]) == - [("a", [(:face, :red)]), - ("b", [(:face, :red), (:face, :yellow), (:face, :green), (:face, :blue)]), - ("c", [(:face, :red), (:face, :green), (:face, :blue)]), - ("d", [(:face, :blue)])] - @test annregions("abcd", [(2:4, :face, :blue), (1:3, :face, :red), (2:3, :face, :green), (2:2, :face, :yellow)]) == - [("a", [(:face, :red)]), - ("b", [(:face, :blue), (:face, :red), (:face, :green), (:face, :yellow)]), - ("c", [(:face, :blue), (:face, :red), (:face, :green)]), - ("d", [(:face, :blue)])] + @test annregions("abcd", [(1:3, :face, FaceRef(:red)), (2:2, :face, FaceRef(:yellow)), (2:3, :face, FaceRef(:green)), (2:4, :face, FaceRef(:blue))]) == + [("a", [(:face, FaceRef(:red))]), + ("b", [(:face, FaceRef(:red)), (:face, FaceRef(:yellow)), (:face, FaceRef(:green)), (:face, FaceRef(:blue))]), + ("c", [(:face, FaceRef(:red)), (:face, FaceRef(:green)), (:face, FaceRef(:blue))]), + ("d", [(:face, FaceRef(:blue))])] + @test annregions("abcd", [(2:4, :face, FaceRef(:blue)), (1:3, :face, FaceRef(:red)), (2:3, :face, FaceRef(:green)), (2:2, :face, FaceRef(:yellow))]) == + [("a", [(:face, FaceRef(:red))]), + ("b", [(:face, FaceRef(:blue)), (:face, FaceRef(:red)), (:face, FaceRef(:green)), (:face, FaceRef(:yellow))]), + ("c", [(:face, FaceRef(:blue)), (:face, FaceRef(:red)), (:face, FaceRef(:green))]), + ("d", [(:face, FaceRef(:blue))])] # Region starting after a character spanning multiple codepoints. - @test annregions("𝟏x", [(1:4, :face, :red)]) == - [("𝟏", [(:face, :red)]), + @test annregions("𝟏x", [(1:4, :face, FaceRef(:red))]) == + [("𝟏", [(:face, FaceRef(:red))]), ("x", [])] end @@ -342,16 +342,16 @@ end @test styled"some {thing=val:string}" == AnnotatedString("some string", [(6:11, :thing, "val")]) @test styled"some {a=1:s}trin{b=2:g}" == AnnotatedString("some string", [(6:6, :a, "1"), (11:11, :b, "2")]) @test styled"{thing=val with spaces:some} string" == AnnotatedString("some string", [(1:4, :thing, "val with spaces")]) - @test styled"{aface:some} string" == AnnotatedString("some string", [(1:4, :face, :aface)]) + @test styled"{aface:some} string" == AnnotatedString("some string", [(1:4, :face, FaceRef(:aface))]) # Annotation prioritisation @test styled"{aface,bface:some} string" == - AnnotatedString("some string", [(1:4, :face, :aface), (1:4, :face, :bface)]) + AnnotatedString("some string", [(1:4, :face, FaceRef(:aface)), (1:4, :face, FaceRef(:bface))]) @test styled"{aface:{bface:some}} string" == - AnnotatedString("some string", [(1:4, :face, :aface), (1:4, :face, :bface)]) + AnnotatedString("some string", [(1:4, :face, FaceRef(:aface)), (1:4, :face, FaceRef(:bface))]) @test styled"{aface,bface:$(1)} string" == - AnnotatedString("1 string", [(1:1, :face, :aface), (1:1, :face, :bface)]) + AnnotatedString("1 string", [(1:1, :face, FaceRef(:aface)), (1:1, :face, FaceRef(:bface))]) @test styled"{aface:{bface:$(1)}} string" == - AnnotatedString("1 string", [(1:1, :face, :aface), (1:1, :face, :bface)]) + AnnotatedString("1 string", [(1:1, :face, FaceRef(:aface)), (1:1, :face, FaceRef(:bface))]) # Inline face attributes @test styled"{(slant=italic):some} string" == AnnotatedString("some string", [(1:4, :face, Face(slant=:italic))]) @@ -387,15 +387,15 @@ end @test styled"some string\}" == AnnotatedString("some string}") @test styled"some \{string\}" == AnnotatedString("some {string}") @test styled"some \{str:ing\}" == AnnotatedString("some {str:ing}") - @test styled"some \{{bold:string}\}" == AnnotatedString("some {string}", [(7:12, :face, :bold)]) - @test styled"some {bold:string \{other\}}" == AnnotatedString("some string {other}", [(6:19, :face, :bold)]) + @test styled"some \{{bold:string}\}" == AnnotatedString("some {string}", [(7:12, :face, FaceRef(:bold))]) + @test styled"some {bold:string \{other\}}" == AnnotatedString("some string {other}", [(6:19, :face, FaceRef(:bold))]) # Nesting @test styled"{bold:nest{italic:ed st{red:yling}}}" == AnnotatedString( - "nested styling", [(1:14, :face, :bold), (5:14, :face, :italic), (10:14, :face, :red)]) + "nested styling", [(1:14, :face, FaceRef(:bold)), (5:14, :face, FaceRef(:italic)), (10:14, :face, FaceRef(:red))]) # Production of a `(AnnotatedString)` value instead of an expression when possible @test AnnotatedString("val") == @macroexpand styled"val" - @test AnnotatedString("val", [(1:3, :face, :style)]) == @macroexpand styled"{style:val}" + @test AnnotatedString("val", [(1:3, :face, FaceRef(:style))]) == @macroexpand styled"{style:val}" # Interpolation let annotatedstring = GlobalRef(StyledMarkup, :annotatedstring) AnnotatedString = GlobalRef(StyledMarkup, :AnnotatedString) @@ -410,14 +410,14 @@ end @test :($annotatedstring(val)) == @macroexpand styled"$val" @test :($chain($annotatedstring("a", val), $annotatedstring_optimize!)) == @macroexpand styled"a$val" @test :($chain($annotatedstring("a", val, "b"), $annotatedstring_optimize!)) == @macroexpand styled"a$(val)b" - # @test :($annotatedstring(StyledStrings.AnnotatedString(string(val), $(Pair{Symbol, Any}(:face, :style))))) == + # @test :($annotatedstring(StyledStrings.AnnotatedString(string(val), $(Pair{Symbol, Any}(:face, FaceRef(:style)))))) == # @macroexpand styled"{style:$val}" @test :($annotatedstring($AnnotatedString( - "val", [$merge((; region=$(1:3)), $NamedTupleLV((:face, face)))]))) == + "val", [$merge((; region=$(1:3)), $NamedTupleLV((:face, $wrap_symbol(face))))]))) == @macroexpand styled"{$face:val}" @test :($chain($annotatedstring($AnnotatedString( - "v1v2", [$merge((; region=$(1:2)), $NamedTupleLV((:face, f1))), - $merge((; region=$(3:4)), $NamedTupleLV((:face, f2)))])), + "v1v2", [$merge((; region=$(1:2)), $NamedTupleLV((:face, $wrap_symbol(f1)))), + $merge((; region=$(3:4)), $NamedTupleLV((:face, $wrap_symbol(f2))))])), $annotatedstring_optimize!)) == @macroexpand styled"{$f1:v1}{$f2:v2}" @test :($annotatedstring($AnnotatedString( @@ -570,9 +570,9 @@ end end # AnnotatedChar @test sprint(print, AnnotatedChar('a')) == "a" - @test sprint(print, AnnotatedChar('a', [(:face, :red)]), context = :color => true) == "\e[31ma\e[39m" + @test sprint(print, AnnotatedChar('a', [(:face, FaceRef(:red))]), context = :color => true) == "\e[31ma\e[39m" @test sprint(show, AnnotatedChar('a')) == "'a'" - @test sprint(show, AnnotatedChar('a', [(:face, :red)]), context = :color => true) == "'\e[31ma\e[39m'" + @test sprint(show, AnnotatedChar('a', [(:face, FaceRef(:red))]), context = :color => true) == "'\e[31ma\e[39m'" # Might as well put everything together for a final test fancy_string = styled"The {magenta:`{green:StyledStrings}`} package {italic:builds}\ {bold: on top} of the {magenta:`{green:AnnotatedString}`} {link={https://en.wikipedia.org/wiki/Type_system}:type} \