From 7489a3190432d0a9e77372dd6b6801c3e1589238 Mon Sep 17 00:00:00 2001 From: TEC Date: Sat, 29 Apr 2023 17:09:05 +0800 Subject: [PATCH 01/17] Allow SubString construction without index shift --- base/strings/substring.jl | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/base/strings/substring.jl b/base/strings/substring.jl index 792925f24b12b..dfd8770b08d47 100644 --- a/base/strings/substring.jl +++ b/base/strings/substring.jl @@ -36,9 +36,18 @@ struct SubString{T<:AbstractString} <: AbstractString end return new(s, i-1, nextind(s,j)-i) end + function SubString{T}(s::T, i::Int, j::Int, ::Val{:noshift}) where T<:AbstractString + @boundscheck begin + si, sj = i + 1, prevind(s, j + i + 1) + @inbounds isvalid(s, si) || string_index_err(s, si) + @inbounds isvalid(s, sj) || string_index_err(s, sj) + end + new(s, i, j) + end end @propagate_inbounds SubString(s::T, i::Int, j::Int) where {T<:AbstractString} = SubString{T}(s, i, j) +@propagate_inbounds SubString(s::T, i::Int, j::Int, v::Val{:noshift}) where {T<:AbstractString} = SubString{T}(s, i, j, v) @propagate_inbounds SubString(s::AbstractString, i::Integer, j::Integer=lastindex(s)) = SubString(s, Int(i), Int(j)) @propagate_inbounds SubString(s::AbstractString, r::AbstractUnitRange{<:Integer}) = SubString(s, first(r), last(r)) From 7bf226bf8a3c4a99a01e596a6a4c2de6b21ffd40 Mon Sep 17 00:00:00 2001 From: TEC Date: Sat, 29 Apr 2023 17:10:36 +0800 Subject: [PATCH 02/17] Parametrize RegexMatch string type This allows for the construction of matches built on non-String AbstractStrings. --- base/regex.jl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/base/regex.jl b/base/regex.jl index 3e161806c50ea..88f43629bd084 100644 --- a/base/regex.jl +++ b/base/regex.jl @@ -212,9 +212,9 @@ julia> hr "11" ``` """ -struct RegexMatch <: AbstractMatch - match::SubString{String} - captures::Vector{Union{Nothing,SubString{String}}} +struct RegexMatch{S<:AbstractString} <: AbstractMatch + match::SubString{S} + captures::Vector{Union{Nothing,SubString{S}}} offset::Int offsets::Vector{Int} regex::Regex @@ -418,7 +418,7 @@ function match(re::Regex, str::Union{SubString{String}, String}, idx::Integer, SubString(str, unsafe_load(p,2i+1)+1, prevind(str, unsafe_load(p,2i+2)+1)) for i=1:n] off = Int[ unsafe_load(p,2i+1)+1 for i=1:n ] - result = RegexMatch(mat, cap, unsafe_load(p,1)+1, off, re) + result = RegexMatch{String}(mat, cap, unsafe_load(p,1)+1, off, re) PCRE.free_match_data(data) return result end From 6113e1c338b23c16424b8f077b8f3b31f828067b Mon Sep 17 00:00:00 2001 From: TEC Date: Sat, 29 Apr 2023 17:53:44 +0800 Subject: [PATCH 03/17] Introduce Styled{String,Char} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These new types allow for arbitrary properties to be attached to regions of an AbstractString or AbstractChar. The most common expected use of this is for styled content, where the styling is attached as special properties. This has the major benefit of separating styling from content, allowing both to be treated better — functions that operate on the content won't need variants that work around styling, and operations that interact with the styling will have many less edge cases (e.g. printing a substring and having to work around unterminated ANSI styling codes). Other use cases are also enabled by this, such as text links and the preserving of line information in string processing. --- base/exports.jl | 3 + base/regex.jl | 49 ++++- base/strings/basic.jl | 20 +- base/strings/io.jl | 23 +++ base/strings/strings.jl | 1 + base/strings/styled.jl | 432 ++++++++++++++++++++++++++++++++++++++++ base/strings/util.jl | 16 +- 7 files changed, 524 insertions(+), 20 deletions(-) create mode 100644 base/strings/styled.jl diff --git a/base/exports.jl b/base/exports.jl index 81296f7d34b18..87b52189983ad 100644 --- a/base/exports.jl +++ b/base/exports.jl @@ -92,6 +92,8 @@ export StridedMatrix, StridedVecOrMat, StridedVector, + StyledChar, + StyledString, SubArray, SubString, SubstitutionString, @@ -629,6 +631,7 @@ export split, string, strip, + styledstring, textwidth, thisind, titlecase, diff --git a/base/regex.jl b/base/regex.jl index 88f43629bd084..61a138de1ee78 100644 --- a/base/regex.jl +++ b/base/regex.jl @@ -220,6 +220,10 @@ struct RegexMatch{S<:AbstractString} <: AbstractMatch regex::Regex end +RegexMatch(match::SubString{S}, captures::Vector{Union{Nothing,SubString{S}}}, + offset::Union{Int, UInt}, offsets::Vector{Int}, regex::Regex) where {S<:AbstractString} = + RegexMatch{S}(match, captures, offset, offsets, regex) + """ keys(m::RegexMatch) -> Vector @@ -418,11 +422,37 @@ function match(re::Regex, str::Union{SubString{String}, String}, idx::Integer, SubString(str, unsafe_load(p,2i+1)+1, prevind(str, unsafe_load(p,2i+2)+1)) for i=1:n] off = Int[ unsafe_load(p,2i+1)+1 for i=1:n ] - result = RegexMatch{String}(mat, cap, unsafe_load(p,1)+1, off, re) + result = RegexMatch(mat, cap, unsafe_load(p,1)+1, off, re) PCRE.free_match_data(data) return result end +function _styledmatch(m::RegexMatch{S}, str::StyledString{S}) where {S<:AbstractString} + RegexMatch{StyledString{S}}( + (@inbounds SubString{StyledString{S}}( + str, m.match.offset, m.match.ncodeunits, Val(:noshift))), + Union{Nothing,SubString{StyledString{S}}}[ + if !isnothing(cap) + (@inbounds SubString{StyledString{S}}( + str, cap.offset, cap.ncodeunits, Val(:noshift))) + end for cap in m.captures], + m.offset, m.offsets, m.regex) +end + +function match(re::Regex, str::StyledString) + m = match(re, str.string) + if !isnothing(m) + _styledmatch(m, str) + end +end + +function match(re::Regex, str::StyledString, idx::Integer, add_opts::UInt32=UInt32(0)) + m = match(re, str.string, idx, add_opts) + if !isnothing(m) + _styledmatch(m, str) + end +end + match(r::Regex, s::AbstractString) = match(r, s, firstindex(s)) match(r::Regex, s::AbstractString, i::Integer) = throw(ArgumentError( "regex matching is only available for the String type; use String(s) to convert" @@ -671,18 +701,19 @@ function _replace(io, repl_s::SubstitutionString, str, r, re) end end -struct RegexMatchIterator +struct RegexMatchIterator{S <: AbstractString} regex::Regex - string::String + string::S overlap::Bool - function RegexMatchIterator(regex::Regex, string::AbstractString, ovr::Bool=false) - new(regex, string, ovr) - end + RegexMatchIterator(regex::Regex, string::AbstractString, ovr::Bool=false) = + new{String}(regex, String(string), ovr) + RegexMatchIterator(regex::Regex, string::StyledString, ovr::Bool=false) = + new{StyledString{String}}(regex, StyledString(String(string.string), string.properties), ovr) end compile(itr::RegexMatchIterator) = (compile(itr.regex); itr) -eltype(::Type{RegexMatchIterator}) = RegexMatch -IteratorSize(::Type{RegexMatchIterator}) = SizeUnknown() +eltype(::Type{<:RegexMatchIterator}) = RegexMatch +IteratorSize(::Type{<:RegexMatchIterator}) = SizeUnknown() function iterate(itr::RegexMatchIterator, (offset,prevempty)=(1,false)) opts_nonempty = UInt32(PCRE.ANCHORED | PCRE.NOTEMPTY_ATSTART) @@ -727,7 +758,7 @@ julia> rx = r"a.a" r"a.a" julia> m = eachmatch(rx, "a1a2a3a") -Base.RegexMatchIterator(r"a.a", "a1a2a3a", false) +Base.RegexMatchIterator{String}(r"a.a", "a1a2a3a", false) julia> collect(m) 2-element Vector{RegexMatch}: diff --git a/base/strings/basic.jl b/base/strings/basic.jl index d2bc157aefd94..468ee4476da7e 100644 --- a/base/strings/basic.jl +++ b/base/strings/basic.jl @@ -241,9 +241,10 @@ end """ *(s::Union{AbstractString, AbstractChar}, t::Union{AbstractString, AbstractChar}...) -> AbstractString -Concatenate strings and/or characters, producing a [`String`](@ref). This is equivalent -to calling the [`string`](@ref) function on the arguments. Concatenation of built-in -string types always produces a value of type `String` but other string types may choose +Concatenate strings and/or characters, producing a [`String`](@ref) or +[`StyledString`](@ref) (as appropriate). This is equivalent to calling the +[`string`](@ref) or [`styledstring`](@ref) function on the arguments. Concatenation of built-in string +types always produces a value of type `String` but other string types may choose to return a string of a different type as appropriate. # Examples @@ -255,7 +256,15 @@ julia> 'j' * "ulia" "julia" ``` """ -(*)(s1::Union{AbstractChar, AbstractString}, ss::Union{AbstractChar, AbstractString}...) = string(s1, ss...) +function (*)(s1::Union{AbstractChar, AbstractString}, ss::Union{AbstractChar, AbstractString}...) + isstyled = s1 isa StyledString || s1 isa StyledChar || + any(s -> s isa StyledString || s isa StyledChar, ss) + if isstyled + styledstring(s1, ss...) + else + string(s1, ss...) + end +end one(::Union{T,Type{T}}) where {T<:AbstractString} = convert(T, "") @@ -309,7 +318,8 @@ end ==(a::AbstractString, b::AbstractString) -> Bool Test whether two strings are equal character by character (technically, Unicode -code point by code point). +code point by code point). Should either string be a [`StyledString`](@ref) the +string properties must match too. # Examples ```jldoctest diff --git a/base/strings/io.jl b/base/strings/io.jl index 987a64798d3da..d15bcf412f121 100644 --- a/base/strings/io.jl +++ b/base/strings/io.jl @@ -764,3 +764,26 @@ function String(chars::AbstractVector{<:AbstractChar}) end end end + +function StyledString(chars::AbstractVector{C}) where {C<:AbstractChar} + str = if C <: StyledChar + String(getfield.(chars, :char)) + else + sprint(sizehint=length(chars)) do io + for c in chars + print(io, c) + end + end + end + props = Tuple{UnitRange{Int}, Pair{Symbol, Any}}[] + point = 1 + for c in chars + if c isa StyledChar + for prop in c.properties + push!(props, (point:point, prop)) + end + end + point += ncodeunits(c) + end + StyledString(str, props) +end diff --git a/base/strings/strings.jl b/base/strings/strings.jl index d995d8535e24b..17f329bedb208 100644 --- a/base/strings/strings.jl +++ b/base/strings/strings.jl @@ -1,5 +1,6 @@ # This file is a part of Julia. License is MIT: https://julialang.org/license +include("strings/styled.jl") include("strings/search.jl") include("strings/unicode.jl") diff --git a/base/strings/styled.jl b/base/strings/styled.jl new file mode 100644 index 0000000000000..d1a6e58ece3c4 --- /dev/null +++ b/base/strings/styled.jl @@ -0,0 +1,432 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +""" + StyledString{S <: AbstractString} <: AbstractString + +A string with annotated regions (often styling information). + +More specifically, this is a thin wrapper around any other [`AbstractString`](@ref), +which adds arbitrary named annotations to regions of the wrapped string. + +See also [`StyledChar`](@ref), [`styledstring`](@ref), [`S""`](@ref @S_str), and [`Face`](@ref). + +# Constructors + +In most cases the easiest way to construct a `StyledString` is via the +[`S""`](@ref @S_str) string macro (which see), however a number of constructors +are also availible. + +```julia +StyledString(s::S<:AbstractString) -> StyledString{S} +StyledString(s::S<:AbstractString, props::Pair{Symbol, <:Any}...) +StyledString(s::S<:AbstractString, properties::Vector{Tuple{UnitRange{Int}, Pair{Symbol, <:Any}}}) +``` + +A StyledString can also be created with [`styledstring`](@ref), which acts much +like [`string`](@ref) but preserves any styling present in the arguments. + +# Examples + +```jldoctest +julia> StyledString("hello there", :face => :italic) +"hello there" + +julia> StyledString("more text", :tag => 1) +"more text" +``` +""" +struct StyledString{S <: AbstractString} <: AbstractString + string::S + properties::Vector{Tuple{UnitRange{Int}, Pair{Symbol, Any}}} +end + +""" + StyledChar{S <: AbstractChar} <: AbstractChar + +A Char annotated by properties (often styling information). + +More specifically, this is a thin wrapper around any other [`AbstractChar`](@ref), +which adds arbitrary named annotations to the wrapped character. + +# Constructors + +```julia +StyledChar(s::S) -> StyledChar{S} +StyledChar(s::S, props::Pair{Symbol, <:Any}...) +StyledChar(s::S, properties::Vector{Pair{Symbol, <:Any}}) +``` + +# Examples + +```jldoctest +julia> StyledChar('j', :face => :blue) +'j': ASCII/Unicode U+006A (category Ll: Letter, lowercase) + +julia> StyledChar('j', :tag => :1) +'j': ASCII/Unicode U+006A (category Ll: Letter, lowercase) +``` +""" +struct StyledChar{C <: AbstractChar} <: AbstractChar + char::C + properties::Vector{Pair{Symbol, Any}} +end + +## Constructors ## + +StyledString(s::AbstractString, prop::Pair{Symbol, <:Any}, props::Pair{Symbol, <:Any}...) = + StyledString(s, firstindex(s):lastindex(s), prop, props...) + +StyledString(s::AbstractString, region::UnitRange{Int}, props::Pair{Symbol, <:Any}...) = + StyledString(s, [(region, Pair{Symbol, Any}(first(p), last(p))) + for p in props]) + +StyledString(s::AbstractString, props::Vector{<:Pair{Symbol, <:Any}}) = + StyledString(s, [(firstindex(s):lastindex(s), p) for p in props]) + +# Constructors called with overly-specialised arguments + +StyledString(s::AbstractString, props::Vector{<:Tuple{UnitRange{Int}, <:Pair{Symbol, <:Any}}}) = + StyledString(s, Vector{Tuple{UnitRange{Int}, Pair{Symbol, Any}}}(props)) + +StyledChar(c::AbstractChar, prop::Pair{Symbol, <:Any}, props::Pair{Symbol, <:Any}...) = + StyledChar(c, Vector{Pair{Symbol, Any}}(vcat(prop, props...))) + +# Constructors to avoid recursive wrapping + +StyledString(s::StyledString, props::Vector{Tuple{UnitRange{Int}, Pair{Symbol, Any}}}) = + StyledString(s.string, vcat(s.properties, props)) + +StyledChar(c::StyledChar, props::Vector{Pair{Symbol, Any}}) = + StyledChar(c.char, vcat(s.properties, props)) + +# To avoid pointless overhead +String(s::StyledString{String}) = s.string + +## Conversion/promotion ## + +convert(::Type{StyledString}, s::StyledString) = s +convert(::Type{StyledString{S}}, s::S) where {S <: AbstractString} = + StyledString(s, Vector{Tuple{UnitRange{Int}, Pair{Symbol, Any}}}()) +convert(::Type{StyledString}, s::S) where {S <: AbstractString} = + convert(StyledString{S}, s) +StyledString(s::S) where {S <: AbstractString} = convert(StyledString{S}, s) + +convert(::Type{StyledChar}, c::StyledChar) = c +convert(::Type{StyledChar{C}}, c::C) where { C <: AbstractChar } = + StyledChar{C}(c, Vector{Pair{Symbol, Any}}()) +convert(::Type{StyledChar}, c::C) where { C <: AbstractChar } = + convert(StyledChar{C}, c) + +StyledChar(c::AbstractChar) = convert(StyledChar, c) +StyledChar(c::UInt32) = convert(StyledChar, Char(c)) +StyledChar{C}(c::UInt32) where {C <: AbstractChar} = convert(StyledChar, C(c)) + +promote_rule(::Type{<:StyledString}, ::Type{<:AbstractString}) = StyledString + +## AbstractString interface ## + +ncodeunits(s::StyledString) = ncodeunits(s.string) +codeunits(s::StyledString) = codeunits(s.string) +codeunit(s::StyledString) = codeunit(s.string) +codeunit(s::StyledString, i::Integer) = codeunit(s.string, i) +isvalid(s::StyledString, i::Integer) = isvalid(s.string, i) +@propagate_inbounds iterate(s::StyledString, i::Integer=firstindex(s)) = + if i <= lastindex(s.string); (s[i], nextind(s, i)) end +eltype(::Type{<:StyledString{S}}) where {S} = StyledChar{eltype(S)} +firstindex(s::StyledString) = firstindex(s.string) +lastindex(s::StyledString) = lastindex(s.string) + +function getindex(s::StyledString, i::Integer) + @boundscheck checkbounds(s, i) + @inbounds if isvalid(s, i) + StyledChar(s.string[i], textproperties(s, i)) + else + string_index_err(s, i) + end +end + +## AbstractChar interface ## + +ncodeunits(c::StyledChar) = ncodeunits(c.char) +codepoint(c::StyledChar) = codepoint(c.char) + +# Avoid the iteration fallback with comparison +cmp(a::StyledString, b::AbstractString) = cmp(a.string, b) +cmp(a::AbstractString, b::StyledString) = cmp(a, b.string) +# To avoid method ambiguity +cmp(a::StyledString, b::StyledString) = cmp(a.string, b.string) + +==(a::StyledString, b::StyledString) = + a.string == b.string && a.properties == b.properties + +==(a::StyledString, b::AbstractString) = isempty(a.properties) && a.string == b +==(a::AbstractString, b::StyledString) = isempty(b.properties) && a == b.string + +""" + styledstring(values...) + +Create a `StyledString` from any number of `values` using their +[`print`](@ref)ed representation. + +This acts like [`string`](@ref), but takes care to preserve any properties +present (in the form of [`StyledString`](@ref) or [`StyledChar`](@ref) values). + +See also [`StyledString`](@ref), [`StyledChar`](@ref), and [`S""`](@ref @S_str). + +## Examples + +``` +julia> styledstring("now a StyledString") +"now a StyledString" + +julia> styledstring(S"{yellow:styled text}", ", and unstyled") +"styled text, and unstyled" +``` +""" +function styledstring(xs...) + isempty(xs) && return StyledString("") + size = mapreduce(_str_sizehint, +, xs) + s = IOContext(IOBuffer(sizehint=size), :color => true) + properties = Vector{Tuple{UnitRange{Int}, Pair{Symbol, Any}}}() + for x in xs + if x isa StyledString + for (region, prop) in x.properties + push!(properties, (s.io.size .+ (region), prop)) + end + print(s, x.string) + elseif x isa SubString{<:StyledString} + for (substr, props) in eachstyle(x) + region = s.io.size .+ (1+substr.offset:prevind(substr.string, 1+substr.offset+substr.ncodeunits)) .- x.offset + for prop in props + push!(properties, (region, prop)) + end + end + print(s, SubString(x.string.string, x.offset, x.ncodeunits, Val(:noshift))) + elseif x isa StyledChar + for prop in x.properties + push!(properties, (1+s.io.size:1+s.io.size, prop)) + end + print(s, x.char) + else + print(s, x) + end + end + str = String(resize!(s.io.data, s.io.size)) + StyledString(str, properties) +end + +""" + styledstring_optimize!(str::StyledString) + +Merge contiguous identical properties in `str`. +""" +function styledstring_optimize!(s::StyledString) + last_seen = Dict{Pair{Symbol, Any}, Int}() + i = 1 + while i <= length(s.properties) + region, keyval = s.properties[i] + prev = get(last_seen, keyval, 0) + if prev > 0 + lregion, _ = s.properties[prev] + if last(lregion) + 1 == first(region) + s.properties[prev] = + setindex(s.properties[prev], + first(lregion):last(region), + 1) + deleteat!(s.properties, i) + else + delete!(last_seen, keyval) + end + else + last_seen[keyval] = i + i += 1 + end + end + s +end + +styledstring(s::StyledString) = s +styledstring(c::StyledChar) = StyledString(string(c.char), c.properties) + +StyledString(s::SubString{<:StyledString}) = styledstring(s) + +function join(iterator, delim::StyledString, last=delim) + xs = zip(iterator, Iterators.repeated(delim)) |> Iterators.flatten |> collect + xs = xs[1:end-1] + if length(xs) > 1 + xs[end-1] = last + end + styledstring(xs...) +end + +function repeat(str::StyledString, r::Integer) + r == 0 && return one(StyledString) + r == 1 && return str + unstyled = repeat(str.string, r) + properties = Vector{Tuple{UnitRange{Int}, Pair{Symbol, Any}}}() + len = ncodeunits(str) + fullregion = firstindex(str):lastindex(str) + for (region, prop) in str.properties + if region == fullregion + push!(properties, (firstindex(unstyled):lastindex(unstyled), prop)) + end + end + for offset in 0:len:(r-1)*len + for (region, prop) in str.properties + if region != fullregion + push!(properties, (region .+ offset, prop)) + end + end + end + StyledString(unstyled, properties) |> styledstring_optimize! +end + +repeat(str::SubString{<:StyledString}, r::Integer) = + repeat(StyledString(str), r) + +function repeat(c::StyledChar, r::Integer) + str = repeat(c.char, r) + fullregion = firstindex(str):lastindex(str) + StyledString(str, [(fullregion, prop) for prop in c.properties]) +end + +function reverse(s::StyledString) + StyledString(reverse(s.string), + [(UnitRange(1 + lastindex(s) - last(region), + 1 + lastindex(s) - first(region)), + prop) + for (region, prop) in s.properties]) +end + +# TODO optimise? +reverse(s::SubString{<:StyledString}) = reverse(StyledString(s)) + +# TODO implement `replace(::StyledString, ...)` + +## End AbstractString interface ## + +""" + textproperty!(s::StyledString, [range::UnitRange{Int}], prop::Symbol, val) + textproperty!(s::SubString{StyledString}, [range::UnitRange{Int}], prop::Symbol, val) + +Set `prop` to `val` in `s`, over either `range` if specified or the whole string. +""" +function textproperty!(s::StyledString, range::UnitRange{Int}, prop::Symbol, val) + indices = searchsorted(s.properties, (range,), by=first) + propindex = filter(i -> first(s.properties[i][2]) === prop, indices) + if length(propindex) == 1 + if val === nothing + deleteat!(s.properties, first(propindex)) + else + s.properties[first(propindex)] = (range, Pair{Symbol, Any}(prop, val)) + end + else + splice!(s.properties, indices, [(range, Pair{Symbol, Any}(prop, val))]) + end + s +end + +textproperty!(ss::StyledString, prop::Symbol, value) = + textproperty!(ss, firstindex(ss):lastindex(ss), prop, value) + +textproperty!(s::SubString{<:StyledString}, range::UnitRange{Int}, prop::Symbol, value) = + (textproperty!(s.string, s.offset .+ (range), prop, value); s) + +textproperty!(s::SubString{<:StyledString}, prop::Symbol, value) = + (textproperty!(s.string, s.offset .+ (1:s.ncodeunits), prop, value); s) + +# TODO optimise +""" + textproperties(s::StyledString, i::Integer) + textproperties(s::SubString{StyledString}, i::Integer) + +Get the text properties that apply to `s` at index `i`. +""" +function textproperties(s::StyledString, i::Integer) + props = filter(prop -> !isempty(intersect(i:i, first(prop))), + s.properties) + last.(props) +end + +textproperties(s::SubString{<:StyledString}, i::Integer) = + textproperties(s.string, s.offset + i) + +""" + textproperties(c::StyledChar) + +Get the properties that apply to `c`. +""" +textproperties(c::StyledChar) = c.properties + +## Iterating over styles ## + +struct StyleIterator{S <: AbstractString} + str::S + regions::Vector{UnitRange{Int}} + styles::Vector{Vector{Pair{Symbol, Any}}} +end + +length(si::StyleIterator) = length(si.regions) + +@propagate_inbounds function iterate(si::StyleIterator, i::Integer=1) + if i <= length(si.regions) + @inbounds ((SubString(si.str, si.regions[i]), si.styles[i]), i+1) + end +end + +eltype(::StyleIterator{S}) where { S <: AbstractString} = + Tuple{SubString{S}, Vector{Pair{Symbol, Any}}} + +""" + eachstyle(s::StyledString{S}) + eachstyle(s::SubString{StyledString{S}}) + +Identify the contiguous substrings of `s` with a constant style, and return +an iterator which provides each substring and the applicable styles as a +`Tuple{SubString{S}, Vector{Pair{Symbol, Any}}}`. + +# Examples + +```jldoctest +julia> eachstyle(StyledString("hey there", [(1:3, :face => :bold), + (5:9, :face => :italic)])) |> collect +3-element Vector{Tuple{SubString{String}, Vector{Pair{Symbol, Any}}}}: + ("hey", [:face => :bold]) + (" ", []) + ("there", [:face => :italic]) +``` +""" +function eachstyle(s::StyledString, region::UnitRange{Int}=firstindex(s):lastindex(s)) + isempty(s) || isempty(region) && + return StyleIterator(s, Vector{UnitRange{Int}}(), Vector{Vector{Pair{Symbol, Any}}}()) + regions = Vector{UnitRange{Int}}() + styles = Vector{Vector{Pair{Symbol, Any}}}() + changepoints = filter(c -> c in region, + Iterators.flatten((first(region), nextind(s, last(region))) + for region in first.(s.properties)) |> + unique |> sort) + isempty(changepoints) && + return StyleIterator(s.string, [region], [textproperties(s, first(region))]) + function registerchange!(start, stop) + push!(regions, start:stop) + push!(styles, textproperties(s, start)) + end + if first(region) < first(changepoints) + registerchange!(first(region), prevind(s, first(changepoints))) + end + for (start, stop) in zip(changepoints, changepoints[2:end]) + registerchange!(start, prevind(s, stop)) + end + if last(changepoints) <= last(region) + registerchange!(last(changepoints), last(region)) + end + StyleIterator(s.string, regions, styles) +end + +function eachstyle(s::SubString{<:StyledString}, region::UnitRange{Int}=firstindex(s):lastindex(s)) + if isempty(s) + StyleIterator(s, Vector{UnitRange{Int}}(), Vector{Vector{Pair{Symbol, Any}}}()) + else + eachstyle(s.string, first(region)+s.offset:last(region)+s.offset) + end +end diff --git a/base/strings/util.jl b/base/strings/util.jl index 890afaf62b2ee..337131d823824 100644 --- a/base/strings/util.jl +++ b/base/strings/util.jl @@ -458,13 +458,15 @@ function lpad( s::Union{AbstractChar,AbstractString}, n::Integer, p::Union{AbstractChar,AbstractString}=' ', -) :: String +) + stringfn = if any(isa.((s, p), Union{StyledString, StyledChar, SubString{<:StyledString}})) + styledstring else string end n = Int(n)::Int m = signed(n) - Int(textwidth(s))::Int - m ≤ 0 && return string(s) + m ≤ 0 && return stringfn(s) l = textwidth(p) q, r = divrem(m, l) - r == 0 ? string(p^q, s) : string(p^q, first(p, r), s) + r == 0 ? stringfn(p^q, s) : stringfn(p^q, first(p, r), s) end """ @@ -488,13 +490,15 @@ function rpad( s::Union{AbstractChar,AbstractString}, n::Integer, p::Union{AbstractChar,AbstractString}=' ', -) :: String +) + stringfn = if any(isa.((s, p), Union{StyledString, StyledChar, SubString{<:StyledString}})) + styledstring else string end n = Int(n)::Int m = signed(n) - Int(textwidth(s))::Int - m ≤ 0 && return string(s) + m ≤ 0 && return stringfn(s) l = textwidth(p) q, r = divrem(m, l) - r == 0 ? string(s, p^q) : string(s, p^q, first(p, r)) + r == 0 ? stringfn(s, p^q) : stringfn(s, p^q, first(p, r)) end """ From c505b047ac44b2bbd4e536bef6b84cb11531abff Mon Sep 17 00:00:00 2001 From: TEC Date: Tue, 2 May 2023 00:24:06 +0800 Subject: [PATCH 04/17] Introduce text faces To easy text styling, a "Face" type is introduced which bundles a collection of stylistic attributes together (essentially constituting a typeface). This builds on the recently added Styled{String,Char} types, and together allow for an ergonomic way of handling styled text. --- base/client.jl | 15 ++ base/exports.jl | 3 + base/strings/faces.jl | 561 ++++++++++++++++++++++++++++++++++++++++ base/strings/strings.jl | 1 + 4 files changed, 580 insertions(+) create mode 100644 base/strings/faces.jl diff --git a/base/client.jl b/base/client.jl index 7339bf0870990..ac7fabe19a458 100644 --- a/base/client.jl +++ b/base/client.jl @@ -394,6 +394,19 @@ function load_InteractiveUtils(mod::Module=Main) return MainInclude.InteractiveUtils end +""" + loadfaces!(facetoml::IO) + +Parse `facetoml` as TOML, and load all faces described. +The loading is done with `loadfaces!`, which see. + +Face entries should be of the following form: +```toml +[face_name] +property = "value" +""" +loadfaces!(facetoml::IO) = loadfaces!(TOML.parse(TOML.Parser(facetoml))) + function load_REPL() # load interactive-only libraries try @@ -416,6 +429,8 @@ function run_main_repl(interactive::Bool, quiet::Bool, banner::Symbol, history_f end end # TODO cleanup REPL_MODULE_REF + userfaces = joinpath(first(DEPOT_PATH), "config", "faces.toml") + isfile(userfaces) && open(loadfaces!, userfaces) if !fallback_repl && interactive && isassigned(REPL_MODULE_REF) invokelatest(REPL_MODULE_REF[]) do REPL diff --git a/base/exports.jl b/base/exports.jl index 87b52189983ad..93b7ee8181065 100644 --- a/base/exports.jl +++ b/base/exports.jl @@ -50,6 +50,7 @@ export Dims, Enum, ExponentialBackOff, + Face, IndexCartesian, IndexLinear, IndexStyle, @@ -582,6 +583,7 @@ export ∪, # strings + addface!, ascii, bitstring, bytes2hex, @@ -610,6 +612,7 @@ export isspace, isuppercase, isxdigit, + loadfaces!, lowercase, lowercasefirst, isvalid, diff --git a/base/strings/faces.jl b/base/strings/faces.jl new file mode 100644 index 0000000000000..c5b82bb7da1d7 --- /dev/null +++ b/base/strings/faces.jl @@ -0,0 +1,561 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +const RGBTuple = NamedTuple{(:r, :g, :b), NTuple{3, UInt8}} + +""" + struct SimpleColor + +A basic representation of a color, intended for string styling purposes. +It can either contain a named color (like `:red`), or an `RGBTuple` which +is a NamedTuple specifying an `r`, `g`, `b` color with a bit-depth of 8. + +# Constructors + +```julia +SimpleColor(name::Symbol) # e.g. :red +SimpleColor(rgb::RGBTuple) # e.g. (r=1, b=2, g=3) +SimpleColor(r::Integer, b::Integer, b::Integer) +SimpleColor(rgb::UInt32) # e.g. 0x123456 +``` + +Also see `tryparse(SimpleColor, rgb::String)`. +""" +struct SimpleColor + value::Union{Symbol, RGBTuple} +end + +SimpleColor(r::Integer, g::Integer, b::Integer) = SimpleColor((; r=UInt8(r), g=UInt8(g), b=UInt8(b))) +SimpleColor(rgb::UInt32) = SimpleColor(reverse(reinterpret(UInt8, [rgb]))[2:end]...) + +""" + tryparse(::Type{SimpleColor}, rgb::String) + +Attempt to parse `rgb` as a `SimpleColor`. If `rgb` starts with +`#` and has a length of 7, it is converted into a `RGBTuple`-backed `SimpleColor`. +If `rgb` starts with `a`-`z`, `rgb` is interpreted as a color name +and converted to a `Symbol`-backed `SimpleColor`. + +Otherwise, `nothing` is returned. + +# Examples + +```jldoctest +julia> tryparse(SimpleColor, "blue") +SimpleColor(:blue) + +julia> tryparse(SimpleColor, "#9558b2") +SimpleColor((r = 0x95, g = 0x58, b = 0xb2)) + +julia> tryparse(SimpleColor, "#nocolor") +``` +""" +function tryparse(::Type{SimpleColor}, rgb::String) + if ncodeunits(rgb) == 7 && first(rgb) == '#' && + all(∈(('#',) ∪ ('0':'9') ∪ ('a':'f') ∪ ('A':'F')), rgb) + SimpleColor(parse(UInt8, rgb[2:3], base=16), + parse(UInt8, rgb[4:5], base=16), + parse(UInt8, rgb[6:7], base=16)) + elseif startswith(rgb, 'a':'z') || startswith(rgb, 'A':'Z') + SimpleColor(Symbol(rgb)) + else + nothing + end +end + +""" + parse(::Type{SimpleColor}, rgb::String) + +An analogue of `tryparse(SimpleColor, rgb::String)` (which see), +that raises an error instead of returning `nothing`. +""" +function parse(::Type{SimpleColor}, rgb::String) + color = tryparse(SimpleColor, rgb) + !isnothing(color) || + throw(ArgumentError("invalid color \"$rgb\"")) + color +end + +""" +A [`Face`](@ref) is a collection of graphical attributes for displaying text. +Faces control how text is displayed in the terminal, and possibly other +places too. + +Most of the time, a [`Face`](@ref) will be stored in the global faces dicts as a +unique association with a *face name* Symbol, and will be most often referred to +by this name instead of the [`Face`](@ref) object itself. + +# Attributes + +All attributes can be set via the keyword constructor, and default to `nothing`. + +- `height` (an `Int` or `Float64`): The height in either deci-pt (when an `Int`), + or as a factor of the base size (when a `Float64`). +- `weight` (a `Symbol`): One of the symbols (from faintest to densest) + `:thin`, `:extralight`, `:light`, `:semilight`, `:normal`, + `:medium`, `:semibold`, `:bold`, `:extrabold`, or `:black`. + In terminals any weight greater than `:normal` is displayed as bold, + and in terminals that support variable-brightness text, any weight + less than `:normal` is displayed as faint. +- `slant` (a `Symbol`): One of the symbols `:italic`, `:oblique`, or `:normal`. +- `foreground` (a `SimpleColor`): The text foreground color. +- `background` (a `SimpleColor`): The text background color. +- `underline`, the text underline, which takes one of the following forms: + - a `Bool`: Whether the text should be underlined or not.\\ + - a `SimpleColor`: The text should be underlined with this color.\\ + - a `Tuple{Nothing, Symbol}`: The text should be underlined using the style + set by the Symbol, one of `:straight`, `:double`, `:curly`, `:dotted`, + or `:dashed`.\\ + - a `Tuple{SimpleColor, Symbol}`: The text should be underlined in the specified + SimpleColor, and using the style specified by the Symbol, as before. +- `strikethrough` (a `Bool`): Whether the text should be struck through. +- `inverse` (a `Bool`): Whether the foreground and background colors should be + inverted. +- `inherit` (a `Vector{Symbol}`): Names of faces to inherit from, + with earlier faces taking priority. All faces inherit from the `:default` face. +""" +struct Face + font::Union{Nothing, String} + height::Union{Nothing, Int, Float64} + weight::Union{Nothing, Symbol} + slant::Union{Nothing, Symbol} + foreground::Union{Nothing, SimpleColor} + background::Union{Nothing, SimpleColor} + underline::Union{Nothing, Bool, SimpleColor, + Tuple{<:Union{Nothing, SimpleColor}, Symbol}} + strikethrough::Union{Nothing, Bool} + inverse::Union{Nothing, Bool} + inherit::Vector{Symbol} +end + +function Face(; font::Union{Nothing, String} = nothing, + height::Union{Nothing, Int, Float64} = nothing, + weight::Union{Nothing, Symbol} = nothing, + slant::Union{Nothing, Symbol} = nothing, + foreground::Union{Nothing, SimpleColor, Symbol, RGBTuple, UInt32} = nothing, + background::Union{Nothing, SimpleColor, Symbol, RGBTuple, UInt32} = nothing, + underline::Union{Nothing, Bool, SimpleColor, + Symbol, RGBTuple, UInt32, + Tuple{<:Union{Nothing, SimpleColor, Symbol, RGBTuple, UInt32}, Symbol} + } = nothing, + strikethrough::Union{Nothing, Bool} = nothing, + inverse::Union{Nothing, Bool} = nothing, + inherit::Union{Symbol, Vector{Symbol}} = Symbol[], + _...) # Simply ignore unrecognised keyword arguments. + ascolor(::Nothing) = nothing + ascolor(c::SimpleColor) = c + ascolor(c::Union{Symbol, RGBTuple, UInt32}) = SimpleColor(c) + Face(font, height, weight, slant, + ascolor(foreground), ascolor(background), + if underline isa Tuple + (ascolor(underline[1]), underline[2]) + elseif underline isa Symbol || underline isa RGBTuple || underline isa UInt32 + ascolor(underline) + else + underline + end, + strikethrough, + inverse, + if inherit isa Symbol + [inherit] + else inherit end) +end + +==(a::Face, b::Face) = + getfield.(Ref(a), fieldnames(Face)) == + getfield.(Ref(b), fieldnames(Face)) + +""" +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(:default), # foreground + SimpleColor(:default), # background + false, false, false, # underline, strikethrough, overline + Symbol[]), # inherit + # Property faces + :bold => Face(weight=:bold), + :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)) + (; default, current=Ref(copy(default))) +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 +julia> addface!(:mypkg_myface => Face(slant=:italic, underline=true)) +Face (sample) + slant: italic + underline: true +``` +""" +function addface!((name, default)::Pair{Symbol, Face}) + if !haskey(FACES.default, name) + FACES.default[name] = default + FACES.current[][name] = if haskey(FACES.current[], name) + merge(deepcopy(default), FACES.current[][name]) + else + deepcopy(default) + end + end +end + +""" + resetfaces!() + +Reset the current global face dictionary to the default value. +""" +function resetfaces!() + FACES.current[] = copy(FACES.default) +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) + if !haskey(FACES.current[], name) + elseif haskey(FACES.current[], name) + FACES.current[][name] = copy(FACES.default[name]) + else # This shouldn't happen + delete!(FACES.current[], name) + println(stderr, + """! 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...) + +Execute `f` with `FACES``.current` temporarily modified by zero or more +`:name => val` arguments `kv`. `withfaces` is generally used via the +`withfaces(kv...) do ... end` syntax. A value of `nothing` can be used to +temporarily unset an face (if if has been set). When `withfaces` returns, the +original `FACES``.current` has been restored. + + !!! warning + Changing faces is not thread-safe. + +# Examples + +```jldoctest +julia> withfaces(:yellow => Face(foreground=:red), :green => :blue) do + println(S"{yellow:red} and {green:blue} mixed make {magenta:purple}") + end +"red and blue mixed make purple" +``` +""" +function withfaces(f, keyvals::Pair{Symbol, <:Union{Face, Symbol, Nothing}}...) + old = Dict{Symbol, Union{Face, Nothing}}() + for (name, face) in keyvals + old[name] = get(FACES.current[], name, nothing) + if face isa Face + FACES.current[][name] = face + elseif face isa Symbol + FACES.current[][name] = + @something(get(old, face, nothing), get(FACES.current[], face, Face())) + elseif haskey(FACES.current[], name) + delete!(FACES.current[], name) + end + end + try f() + finally + for (name, face) in old + if isnothing(face) + delete!(FACES.current[], name) + else + FACES.current[][name] = face + end + end + end +end + +""" + withfaces(f, altfaces::Dict{Symbol, Face}) + +Execute `f` with `FACES``.current` temporarily swapped out with `altfaces` +When `withfaces` returns, the original `FACES``.current` has been restored. + + !!! warning + Changing faces is not thread-safe. +""" +function withfaces(f, altfaces::Dict{Symbol, Face}) + oldfaces, FACES.current[] = FACES.current[], altfaces + try f() + finally + FACES.current[] = oldfaces + end +end + +withfaces(f) = f() + +## Face combination and inheritance ## + +""" + merge(initial::Face, others::Face...) + +Merge the properties of the `initial` face and `others`, with +later faces taking priority. +""" +function merge(a::Face, b::Face) + if isempty(a.inherit) + Face(ifelse(isnothing(b.font), a.font, b.font), + if isnothing(b.height) a.height + elseif isnothing(a.height) b.height + elseif b.height isa Int b.height + elseif a.height isa Int round(Int, a.height * b.height) + else a.height * b.height end, + ifelse(isnothing(b.weight), a.weight, b.weight), + ifelse(isnothing(b.slant), a.slant, b.slant), + ifelse(isnothing(b.foreground), a.foreground, b.foreground), + ifelse(isnothing(b.background), a.background, b.background), + ifelse(isnothing(b.underline), a.underline, b.underline), + ifelse(isnothing(b.strikethrough), a.strikethrough, b.strikethrough), + ifelse(isnothing(b.inverse), a.inverse, b.inverse), + b.inherit) + else + a_noinherit = Face( + a.font, a.height, a.weight, a.slant, a.foreground, a.background, + a.underline, a.strikethrough, a.inverse, Symbol[]) + a_inheritance = map(fname -> get(FACES.current[], fname, Face()), Iterators.reverse(a.inherit)) + a_resolved = merge(foldl(merge, a_inheritance), a_noinherit) + merge(a_resolved, b) + end +end + +merge(a::Face, b::Face, others::Face...) = merge(merge(a, b), others...) + +## Getting the combined face from a set of properties ## + +""" + 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] + mergedface(face::Face) = face + mergedface(face::Symbol) = get(FACES.current[], face, Face()) + mergedface(faces::Vector) = mapfoldl(mergedface, merge, Iterators.reverse(faces)) + combined = mapfoldl(mergedface, merge, Iterators.reverse(faces))::Face + if !isempty(combined.inherit) + combined = merge(combined, Face()) + end + merge(FACES.current[][:default], combined) +end + +""" + getface(styles::Vector{Pair{Symbol, Any}}) + +Combine all of the `:face` styles with `getfaces`. +""" +function getface(styles::Vector{Pair{Symbol, Any}}) + faces = (last(prop) for prop in styles if first(prop) === :face) + getface(faces) +end + +getface(face::Face) = merge(FACES.current[][:default], merge(face, Face())) +getface(face::Symbol) = getface(get(FACES.current[], face, Face())) + +""" + getface() + +Obtain the default face. +""" +getface() = FACES.current[][:default] + +## Face/StyledString integration ## + +""" + getface(s::StyledString, i::Integer) + +Get the merged [`Face`](@ref) that applies to `s` at index `i`. +""" +getface(s::StyledString, i::Integer) = + getface(textproperties(s, i)) + +""" + getface(c::StyledChar) + +Get the merged [`Face`](@ref) that applies to `c`. +""" +getface(c::StyledChar) = getface(c.properties) + +""" + face!(s::Union{<:StyledString, <:SubString{<:StyledString}}, + [range::UnitRange{Int},] face::Union{Symbol, Face}) + +Apply `face` to `s`, along `range` if specified, or the whole of `s`. +""" +face!(s::Union{<:StyledString, <:SubString{<:StyledString}}, + range::UnitRange{Int}, face::Union{Symbol, Face}) = + textproperty!(s, range, :face, face) + +face!(s::Union{<:StyledString, <:SubString{<:StyledString}}, + face::Union{Symbol, Face}) = + textproperty!(s, firstindex(s):lastindex(s), :face, face) + +## Reading face definitions from a dictionary ## + +""" + loadfaces!(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.` + +# Examples + +```jldoctest +julia> loadfaces!(:red => Face(foreground=0xff0000)) +Face (sample) + foreground: ■ #ff0000 +``` +""" +function loadfaces!((name, update)::Pair{Symbol, Face}) + if haskey(FACES.current[], name) + FACES.current[][name] = merge(FACES.current[][name], update) + else + FACES.current[][name] = update + end +end + +function loadfaces!((name, _)::Pair{Symbol, Nothing}) + if haskey(FACES.current[], name) + resetfaces!(name) + end +end + +""" + loadfaces!(faces::Dict{String, Any}) + +For each face specified in `Dict`, load it to `FACES``.current`. +""" +function loadfaces!(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) && + loadfaces!(Symbol(fullname) => convert(Face, fspec)) + !isempty(fnest) && + loadfaces!(fnest, fullname) + end +end + +function convert(::Type{Face}, spec::Dict) + Face(if haskey(spec, "font") && spec["font"] isa String + spec["font"] end, + if haskey(spec, "height") && (spec["height"] isa Int || spec["height"] isa Float64) + spec["height"] + end, + if haskey(spec, "weight") && spec["weight"] isa String + Symbol(spec["weight"]) + elseif haskey(spec, "bold") && spec["bold"] isa Bool + ifelse(spec["bold"], :bold, :normal) + end, + if haskey(spec, "slant") && spec["slant"] isa String + Symbol(spec["slant"]) + elseif haskey(spec, "italic") && spec["italic"] isa Bool + ifelse(spec["italic"], :italic, :normal) + end, + if haskey(spec, "foreground") && spec["foreground"] isa String + tryparse(SimpleColor, spec["foreground"]) + elseif haskey(spec, "fg") && spec["fg"] isa String + tryparse(SimpleColor, spec["fg"]) + end, + if haskey(spec, "background") && spec["background"] isa String + tryparse(SimpleColor, spec["background"]) + elseif haskey(spec, "bg") && spec["bg"] isa String + tryparse(SimpleColor, spec["bg"]) + end, + if !haskey(spec, "underline") + elseif spec["underline"] isa Bool + spec["underline"] + elseif spec["underline"] isa String + tryparse(SimpleColor, spec["underline"]) + elseif spec["underline"] isa Vector && length(spec["underline"]) == 2 + color = tryparse(SimpleColor, spec["underline"][1]) + (color, Symbol(spec["underline"][2])) + end, + if !haskey(spec, "strikethrough") + elseif spec["strikethrough"] isa Bool + spec["strikethrough"] + elseif spec["strikethrough"] isa String + tryparse(SimpleColor, spec["strikethrough"]) + end, + if haskey(spec, "inverse") && spec["inverse"] isa Bool + spec["inverse"] end, + if !haskey(spec, "inherit") + Symbol[] + elseif spec["inherit"] isa String + [Symbol(spec["inherit"])] + elseif spec["inherit"] isa Vector{String} + Symbol.(spec["inherit"]) + else + Symbol[] + end) +end diff --git a/base/strings/strings.jl b/base/strings/strings.jl index 17f329bedb208..5e1e91b319b17 100644 --- a/base/strings/strings.jl +++ b/base/strings/strings.jl @@ -1,6 +1,7 @@ # This file is a part of Julia. License is MIT: https://julialang.org/license include("strings/styled.jl") +include("strings/faces.jl") include("strings/search.jl") include("strings/unicode.jl") From eada39b4e162b32454f967333b3557ece60293ee Mon Sep 17 00:00:00 2001 From: TEC Date: Tue, 2 May 2023 00:29:46 +0800 Subject: [PATCH 05/17] Introduce a styled string macro (@S_str) To make specifying StyledStrings easier, the @S_str macro is added to convert a minimalistic style markup to either a constant StyledString or a StyledString-generating expression. This macro was not easy to write, but seems to work well in practice. --- base/exports.jl | 1 + base/strings/faces.jl | 254 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+) diff --git a/base/exports.jl b/base/exports.jl index 93b7ee8181065..d2b6995318789 100644 --- a/base/exports.jl +++ b/base/exports.jl @@ -1018,6 +1018,7 @@ export @b_str, # byte vector @r_str, # regex @s_str, # regex substitution string + @S_str, # styled string @v_str, # version number @raw_str, # raw string with no interpolation/unescaping @NamedTuple, diff --git a/base/strings/faces.jl b/base/strings/faces.jl index c5b82bb7da1d7..69f5d43a6aa8e 100644 --- a/base/strings/faces.jl +++ b/base/strings/faces.jl @@ -559,3 +559,257 @@ function convert(::Type{Face}, spec::Dict) Symbol[] end) end + +## Style macro ## + +""" + @S_str -> StyledString + +Construct a styled string. Within the string, `{:}` structures +apply the formatting to ``, according to the list of comma-separated +specifications ``. Each spec can either take the form of a face name, +an inline face specification, or a `key=value` pair. The value must be wrapped +by `{...}` should it contain any of the characters `,=:{}`. + +String interpolation with `\$` functions in the same way as regular strings, +except quotes need to be escaped. Faces, keys, and values can also be +interpolated with `\$`. + +# Example + +```julia +S"The {bold:{italic:quick} {(foreground=#cd853f):brown} fox} jumped over \ +the {link={https://en.wikipedia.org/wiki/Laziness}:lazy} dog" +``` +""" +macro S_str(raw_content::String) + parts = Any[] + content = unescape_string(raw_content, ('{', '}', '$', '\n')) + content_bytes = Vector{UInt8}(content) + s = Iterators.Stateful(zip(eachindex(content), content)) + offset = 0 + point = 1 + escape = false + active_styles = Vector{Vector{Tuple{Int, Union{Symbol, Expr, Pair{Symbol, Any}}}}}() + pending_styles = Vector{Tuple{UnitRange{Int}, Union{Symbol, Expr, Pair{Symbol, Any}}}}() + interpolated = false + function addpart(stop::Int) + str = String(content_bytes[point:stop+offset+ncodeunits(content[stop])-1]) + push!(parts, + if isempty(pending_styles) && isempty(active_styles) + str + else + styles = Expr[] + relevant_styles = Iterators.filter( + (start, _)::Tuple -> start <= stop + offset + 1, + Iterators.flatten(active_styles)) + for (start, prop) in relevant_styles + range = (start - point):(stop - point + offset + 1) + push!(styles, Expr(:tuple, range, prop)) + end + for (range, prop) in pending_styles + if !isempty(range) + push!(styles, Expr(:tuple, range .- point, prop)) + end + end + empty!(pending_styles) + if isempty(styles) + str + else + :(StyledString($str, $(Expr(:vect, styles...)))) + end + end) + point = nextind(content, stop) + offset + end + function addpart(start::Int, expr, stop::Int) + if point < start + addpart(start) + end + if isempty(active_styles) + push!(parts, expr) + else + push!(parts, + :(StyledString(string($expr), + $(last.(Iterators.flatten(active_styles))...)))) + map!.((_, prop)::Tuple -> (nextind(content, stop + offset), prop), active_styles, active_styles) + end + end + for (i, char) in s + if char == '\\' + escape = true + elseif escape + if char in ('{', '}', '$') + deleteat!(content_bytes, i + offset - 1) + offset -= 1 + elseif char == '\n' + deleteat!(content_bytes, i+offset-1:i+offset) + offset -= 2 + end + escape = false + elseif char == '$' + # Interpolation + expr, nexti = Meta.parseatom(content, i + 1) + deleteat!(content_bytes, i + offset) + offset -= 1 + nchars = length(content[i:prevind(content, nexti)]) + for _ in 1:min(length(s), nchars-1) + popfirst!(s) + end + addpart(i, expr, nexti) + point = nexti + offset + interpolated = true + elseif char == '{' + # Property declaration parsing and application + properties = true + hasvalue = false + newstyles = Vector{Tuple{Int, Union{Symbol, Expr, Pair{Symbol, Any}}}}() + while properties + if !isnothing(peek(s)) && last(peek(s)) == '(' + # Inline face + popfirst!(s) + specstr = Iterators.takewhile(c -> last(c) != ')', s) |> + collect .|> last |> String + spec = map(split(specstr, ',')) do spec + spec = rstrip(spec) + kv = split(spec, '=', limit=2) + if length(kv) == 2 + kv[1] => @something(tryparse(Bool, kv[2]), + String(kv[2])) + else "" => "" end + end |> Dict + push!(newstyles, + (nextind(content, i + offset), + Pair{Symbol, Any}(:face, convert(Face, spec)))) + if isnothing(peek(s)) || last(popfirst!(s)) != ',' + properties = false + end + else + # Face symbol or key=value pair + key = if isempty(s) + break + elseif last(peek(s)) == '$' + interpolated = true + j, _ = popfirst!(s) + expr, nextj = Meta.parseatom(content, j + 1) + nchars = length(content[j:prevind(content, nextj)]) + for _ in 1:min(length(s), nchars-1) + popfirst!(s) + end + if !isempty(s) + _, c = popfirst!(s) + if c == ':' + properties = false + elseif c == '=' + hasvalue = true + end + end + expr + else + Iterators.takewhile( + function(c) + if last(c) == ':' # Start of content + properties = false + elseif last(c) == '=' # Start of value + hasvalue = true + false + elseif last(c) == ',' # Next key + false + else true end + end, s) |> collect .|> last |> String + end + if hasvalue + hasvalue = false + value = if !isnothing(peek(s)) + if last(peek(s)) == '{' + # Grab {}-wrapped value + popfirst!(s) + isescaped = false + val = Vector{Char}() + while (next = popfirst!(s)) |> !isnothing + (_, c) = next + if isescaped && c ∈ ('\\', '}') + push!(val, c) + elseif isescaped + push!(val, '\\', c) + elseif c == '}' + break + else + push!(val, c) + end + end + String(val) + elseif last(peek(s)) == '$' + j, _ = popfirst!(s) + expr, nextj = Meta.parseatom(content, j + 1) + nchars = length(content[j:prevind(content, nextj)]) + for _ in 1:min(length(s), nchars-1) + popfirst!(s) + end + interpolated = true + expr + else + # Grab up to next value, or start of content. + Iterators.takewhile( + function (c) + if last(c) == ':' + properties = false + elseif last(c) == ',' + false + else true end + end, s) |> collect .|> last |> String + end + end + push!(newstyles, + (nextind(content, i + offset), + if key isa String && !(value isa Symbol || value isa Expr) + Pair{Symbol, Any}(Symbol(key), value) + elseif key isa Expr || key isa Symbol + :(Pair{Symbol, Any}($key, $value)) + else + :(Pair{Symbol, Any}( + $(QuoteNode(Symbol(key))), $value)) + end)) + elseif key !== "" # No value, hence a Face property + push!(newstyles, + (nextind(content, i + offset), + if key isa Symbol || key isa Expr + :(Pair{Symbol, Any}(:face, $key)) + else # Face symbol + Pair{Symbol, Any}(:face, Symbol(key)) + end)) + end + end + end + push!(active_styles, newstyles) + # Adjust content_bytes/offset based on how much the index + # has been incremented in the processing of the + # style declaration(s). + if !isnothing(peek(s)) + nexti = first(peek(s)) + deleteat!(content_bytes, i+offset:nexti+offset-1) + offset -= nexti - i + end + elseif char == '}' && !isempty(active_styles) + # Close off most recent active style + for (start, prop) in pop!(active_styles) + push!(pending_styles, (start:i+offset, prop)) + end + deleteat!(content_bytes, i + offset) + offset -= 1 + end + end + # Ensure that any trailing unstyled content is added + if point <= lastindex(content) + offset + addpart(lastindex(content)) + end + if !isempty(active_styles) + println(stderr, "WARNING: Styled string macro in module ", __module__, + " at ", something(__source__.file, ""), ':', string(__source__.line), + " contains unterminated styled constructs.") + end + if interpolated + :(styledstring($(parts...))) |> esc + else + styledstring(map(eval, parts)...) + end +end From 13f32f1510d659abff9254c3f50876823cd7993d Mon Sep 17 00:00:00 2001 From: TEC Date: Tue, 2 May 2023 00:45:44 +0800 Subject: [PATCH 06/17] Implement styled printing of StyledStrings Printing StyledStrings is more complicated than using the printstyled function as a Face supports a much richer set of attributes, and StyledString allows for attributes to be nested and overlapping. With the aid of and the newly added terminfo, we can now print a StyledString in all it's glory, up to the capabilities of the current terminal, gracefully degrading italic to underline, and 24-bit colors to 8-bit. --- base/strings/io.jl | 269 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) diff --git a/base/strings/io.jl b/base/strings/io.jl index d15bcf412f121..57c1ab1c3dd4e 100644 --- a/base/strings/io.jl +++ b/base/strings/io.jl @@ -787,3 +787,272 @@ function StyledString(chars::AbstractVector{C}) where {C<:AbstractChar} end StyledString(str, props) end + +## Styled printing ## + +""" +A mapping between ANSI named colours and indices in the standard 256-color +table. The standard colors are 0-7, and high intensity colors 8-15. + +The high intensity colors are prefixed by "bright_". The "bright_black" color is +given two aliases: "grey" and "gray". +""" +const ANSI_4BIT_COLORS = Dict{Symbol, Int}( + :black => 0, + :red => 1, + :green => 2, + :yellow => 3, + :blue => 4, + :magenta => 5, + :cyan => 6, + :white => 7, + :bright_black => 8, + :grey => 8, + :gray => 8, + :bright_red => 9, + :bright_green => 10, + :bright_yellow => 11, + :bright_blue => 12, + :bright_magenta => 13, + :bright_cyan => 14, + :bright_white => 15) + +""" + ansi_4bit_color_code(color::Symbol, background::Bool=false) + +Provide the color code (30-37, 40-47, 90-97, 100-107) for `color`, as a string. +When `background` is set the background variant will be provided, otherwise +the provided code is for setting the foreground color. +""" +function ansi_4bit_color_code(color::Symbol, background::Bool=false) + if haskey(ANSI_4BIT_COLORS, color) + code = ANSI_4BIT_COLORS[color] + code >= 8 && (code += 52) + background && (code += 10) + string(code + 30) + else + ifelse(background, "49", "39") + end +end + +""" + termcolor8bit(io::IO, color::RGBTuple, category::Char) + +Print to `io` the best 8-bit SGR color code that sets the `category` color to +be close to `color`. +""" +function termcolor8bit(io::IO, (; r, g, b)::RGBTuple, category::Char) + # Magic numbers? Lots. + cdistsq(r1, g1, b1) = (r1 - r)^2 + (g1 - g)^2 + (b1 - b)^2 + to6cube(value) = if value < 48; 1 + elseif value < 114; 2 + else 1 + (value - 35) ÷ 40 end + r6cube, g6cube, b6cube = to6cube(r), to6cube(g), to6cube(b) + sixcube = (0, 95, 135, 175, 215, 255) + rnear, gnear, bnear = sixcube[r6cube], sixcube[g6cube], sixcube[b6cube] + colorcode = if r == rnear && g == gnear && b == bnear + 16 + 35 * r6cube + 6 * g6cube + b6cube + else + grey_avg = Int(r + g + b) ÷ 3 + grey_index = if grey_avg > 238 23 else (grey_avg - 3) ÷ 10 end + grey = 8 + 10 * grey_index + if cdistsq(grey, grey, grey) <= cdistsq(rnear, gnear, bnear) + 232 + grey + else + 16 + 35 * r6cube + 6 * g6cube + b6cube + end + end + print(io, "\e[", category, "8;5;", string(colorcode), 'm') +end + +""" + termcolor24bit(io::IO, color::RGBTuple, category::Char) + +Print to `io` the 24-bit SGR color code to set the `category`8 slot to `color`. +""" +function termcolor24bit(io::IO, color::RGBTuple, category::Char) + print(io, "\e[", category, "8;2;", + string(color.r), ';', + string(color.g), ';', + string(color.b), 'm') +end + +""" + termcolor(io::IO, color::SimpleColor, category::Char) + +Print to `io` the SGR code to set the `category`'s slot to `color`, +where `category` is set as follows: +- `'3'` sets the foreground color +- `'4'` sets the background color +- `'5'` sets the underline color + +If `color` is a `SimpleColor{Symbol}`, the value should be a a member of +`ANSI_4BIT_COLORS`. Any other value will cause the color to be reset. + +If `color` is a `SimpleColor{RGBTuple}` and `get_have_truecolor()` returns true, +24-bit color is used. Otherwise, an 8-bit approximation of `color` is used. +""" +function termcolor(io::IO, color::SimpleColor, category::Char) + if color.value isa RGBTuple + if get_have_truecolor() + termcolor24bit(io, color.value, category) + else + termcolor8bit(io, color.value, category) + end + elseif (fg = get(FACES.current[], color.value, getface()).foreground) != SimpleColor(color.value) + termcolor(io, fg, category) + else + print(io, "\e[", + if category == '3' || category == '4' + ansi_4bit_color_code(color.value, category == '4') + elseif category == '5' + if haskey(ANSI_4BIT_COLORS, color.value) + string("58;5;", ANSI_4BIT_COLORS[color.value]) + else "59" end + end, + 'm') + end +end + +""" + termcolor(io::IO, ::Nothing, category::Char) + +Print to `io` the SGR code to reset the color for `category`. +""" +termcolor(io::IO, ::Nothing, category::Char) = + print(io, "\e[", category, '9', 'm') + +const ANSI_STYLE_CODES = ( + bold_weight = "\e[1m", + dim_weight = "\e[2m", + normal_weight = "\e[22m", + start_italics = "\e[3m", + end_italics = "\e[23m", + start_underline = "\e[4m", + end_underline = "\e[24m", + start_reverse = "\e[7m", + end_reverse = "\e[27m", + start_strikethrough = "\e[9m", + end_strikethrough = "\e[29m" +) + +function termstyle(io::IO, face::Face, lastface::Face=getface()) + face.foreground == lastface.foreground || + termcolor(io, face.foreground, '3') + face.background == lastface.background || + termcolor(io, face.background, '4') + face.weight == lastface.weight || + print(io, if face.weight ∈ (:medium, :semibold, :bold, :extrabold, :black) + get(current_terminfo, :bold, "\e[1m") + elseif face.weight ∈ (:semilight, :light, :extralight, :thin) + get(current_terminfo, :dim, "") + else # :normal + ANSI_STYLE_CODES.normal_weight + end) + face.slant == lastface.slant || + if haskey(current_terminfo, :enter_italics_mode) + print(io, ifelse(face.slant ∈ (:italic, :oblique), + ANSI_STYLE_CODES.start_italics, + ANSI_STYLE_CODES.end_italics)) + elseif face.slant ∈ (:italic, :oblique) && face.underline ∈ (nothing, false) + print(io, ANSI_STYLE_CODES.start_underline) + elseif face.slant ∉ (:italic, :oblique) && lastface.underline ∈ (nothing, false) + print(io, ANSI_STYLE_CODES.end_underline) + end + # Kitty fancy underlines, see + # Supported in Kitty, VTE, iTerm2, Alacritty, and Wezterm. + face.underline == lastface.underline || + if get(current_terminfo, :Su, false) # Color/style capabilities + if face.underline isa Tuple # Color and style + color, style = face.underline + print(io, "\e[4:", + if style == :straight; '1' + elseif style == :double; '2' + elseif style == :curly; '3' + elseif style == :dotted; '4' + elseif style == :dashed; '5' + else '0' end, 'm') + !isnothing(color) && termcolor(io, color, '5') + elseif face.underline isa SimpleColor + if !(lastface.underline isa SimpleColor || lastface.underline == true) + print(io, ANSI_STYLE_CODES.start_underline) + end + termcolor(io, face.underline, '5') + else + if lastface.underline isa SimpleColor || lastface.underline isa Tuple && first(lastface.underline) isa SimpleColor + termcolor(io, SimpleColor(:none), '5') + end + print(io, ifelse(face.underline == true, + ANSI_STYLE_CODES.start_underline, + ANSI_STYLE_CODES.end_underline)) + end + else + print(io, ifelse(face.underline !== false, + ANSI_STYLE_CODES.start_underline, + ANSI_STYLE_CODES.end_underline)) + end + face.strikethrough == lastface.strikethrough || !haskey(current_terminfo, :smxx) || + print(io, ifelse(face.strikethrough === true, + ANSI_STYLE_CODES.start_strikethrough, + ANSI_STYLE_CODES.end_strikethrough)) + face.inverse == lastface.inverse || !haskey(current_terminfo, :enter_reverse_mode) || + print(io, ifelse(face.inverse === true, + ANSI_STYLE_CODES.start_reverse, + ANSI_STYLE_CODES.end_reverse)) +end + +function _ansi_writer(io::IO, s::Union{<:StyledString, SubString{<:StyledString}}, + string_writer::Function) + if get(io, :color, false)::Bool + for (str, styles) in eachstyle(s) + face = getface(styles) + link = let idx=findfirst(==(:link) ∘ first, styles) + if !isnothing(idx) + string(last(styles[idx]))::String + end end + !isnothing(link) && write(io, "\e]8;;", link, "\e\\") + termstyle(io, face, lastface) + string_writer(io, str) + !isnothing(link) && write(io, "\e]8;;\e\\") + lastface = face + end + termstyle(io, getface(), lastface) + elseif s isa StyledString + string_writer(io, s.string) + elseif s isa SubString + string_writer( + io, SubString(s.string.string, s.offset, s.ncodeunits, Val(:noshift))) + end +end + +write(io::IO, s::Union{<:StyledString, SubString{<:StyledString}}) = + _ansi_writer(io, s, write) + +print(io::IO, s::Union{<:StyledString, SubString{<:StyledString}}) = + (write(io, s); nothing) + +escape_string(io::IO, s::Union{<:StyledString, SubString{<:StyledString}}, + esc = ""; keep = ()) = + (_ansi_writer(io, s, (io, s) -> escape_string(io, s, esc; keep)); nothing) + +function write(io::IO, c::StyledChar) + if get(io, :color, false) == true + termstyle(io, getface(c), getface()) + print(io, c.char) + termstyle(io, getface(), getface(c)) + else + print(io, c.char) + end +end + +print(io::IO, c::StyledChar) = (write(io, c); nothing) + +function show(io::IO, c::StyledChar) + if get(io, :color, false) == true + out = IOBuffer() + show(out, c.char) + print(io, ''', StyledString(String(take!(out)[2:end-1]), c.properties), ''') + else + show(io, c.char) + end +end From ea24b5371368047dcaa11ebbcbdf64cd6eee6174 Mon Sep 17 00:00:00 2001 From: TEC Date: Tue, 2 May 2023 18:28:13 +0800 Subject: [PATCH 07/17] Buffer styled printing When printing directly to stdout, there is a non-negligible overhead compared to simply printing to an IOBuffer. Testing indicates 3 allocations per print argument, and benchmarks reveal a ~2x increase in allocations overall and much as a 10x increase in execution time. Thus, it seems worthwhile to use a temporary buffer in all cases. --- base/strings/io.jl | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/base/strings/io.jl b/base/strings/io.jl index 57c1ab1c3dd4e..446d44398128a 100644 --- a/base/strings/io.jl +++ b/base/strings/io.jl @@ -1004,19 +1004,22 @@ end function _ansi_writer(io::IO, s::Union{<:StyledString, SubString{<:StyledString}}, string_writer::Function) if get(io, :color, false)::Bool + buf = IOBuffer() # Avoid the overhead in repeatadly printing to `stdout` + lastface::Face = FACES.current[][:default] for (str, styles) in eachstyle(s) face = getface(styles) link = let idx=findfirst(==(:link) ∘ first, styles) if !isnothing(idx) string(last(styles[idx]))::String end end - !isnothing(link) && write(io, "\e]8;;", link, "\e\\") - termstyle(io, face, lastface) - string_writer(io, str) - !isnothing(link) && write(io, "\e]8;;\e\\") + !isnothing(link) && write(buf, "\e]8;;", link, "\e\\") + termstyle(buf, face, lastface) + string_writer(buf, str) + !isnothing(link) && write(buf, "\e]8;;\e\\") lastface = face end - termstyle(io, getface(), lastface) + termstyle(buf, getface(), lastface) + write(io, take!(buf)) elseif s isa StyledString string_writer(io, s.string) elseif s isa SubString From 98e9af49325ce27560fbb50c607e27b06d192f23 Mon Sep 17 00:00:00 2001 From: TEC Date: Thu, 18 May 2023 19:07:57 +0800 Subject: [PATCH 08/17] Add text/html show method for styled strings --- base/strings/io.jl | 175 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/base/strings/io.jl b/base/strings/io.jl index 446d44398128a..8f1ae565243dd 100644 --- a/base/strings/io.jl +++ b/base/strings/io.jl @@ -1059,3 +1059,178 @@ function show(io::IO, c::StyledChar) show(io, c.char) end end + +""" +A mapping between ANSI named colors and 8-bit colors for use in HTML +representations. +""" +const HTML_BASIC_COLORS = Dict{Symbol, SimpleColor}( + :black => SimpleColor(0x00, 0x00, 0x00), + :red => SimpleColor(0x80, 0x00, 0x00), + :green => SimpleColor(0x00, 0x80, 0x00), + :yellow => SimpleColor(0x80, 0x80, 0x00), + :blue => SimpleColor(0x00, 0x00, 0x80), + :magenta => SimpleColor(0x80, 0x00, 0x80), + :cyan => SimpleColor(0x00, 0x80, 0x80), + :white => SimpleColor(0xc0, 0xc0, 0xc0), + :bright_black => SimpleColor(0x80, 0x80, 0x80), + :grey => SimpleColor(0x80, 0x80, 0x80), + :gray => SimpleColor(0x80, 0x80, 0x80), + :bright_red => SimpleColor(0xff, 0x00, 0x00), + :bright_green => SimpleColor(0x00, 0xff, 0x00), + :bright_yellow => SimpleColor(0xff, 0xff, 0x00), + :bright_blue => SimpleColor(0x00, 0x00, 0xff), + :bright_magenta => SimpleColor(0xff, 0x00, 0xff), + :bright_cyan => SimpleColor(0x00, 0xff, 0xff), + :bright_white => SimpleColor(0xff, 0xff, 0xff)) + +function htmlcolor(io::IO, color::SimpleColor) + if color.value isa Symbol + if color.value === :default + print(io, "initial") + elseif (fg = get(FACES.current[], color.value, getface()).foreground) != SimpleColor(color.value) + htmlcolor(io, fg) + else + htmlcolor(io, get(HTML_BASIC_COLORS, color.value, SimpleColor(:default))) + end + 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)) + end +end + +const HTML_WEIGHT_MAP = Dict{Symbol, Int}( + :thin => 100, + :extralight => 200, + :light => 300, + :semilight => 300, + :normal => 400, + :medium => 500, + :semibold => 600, + :bold => 700, + :extrabold => 800, + :black => 900) + +function htmlstyle(io::IO, face::Face, lastface::Face=getface()) + print(io, " """, ''' => "'"), '"') + face.height == lastface.height || + print(io, "font-size: ", string(face.height ÷ 10), "pt;") + face.weight == lastface.weight || + print(io, "font-weight: ", get(HTML_WEIGHT_MAP, face.weight, 400), ';') + face.slant == lastface.slant || + print(io, "font-style: ", String(face.slant), ';') + foreground, background = + ifelse(face.inverse === true, + (face.background, face.foreground), + (face.foreground, face.background)) + lastforeground, lastbackground = + ifelse(lastface.inverse === true, + (lastface.background, lastface.foreground), + (lastface.foreground, lastface.background)) + if foreground != lastforeground + print(io, "color: ") + htmlcolor(io, foreground) + print(io, ';') + end + if background != lastbackground + print(io, "background-color: ") + htmlcolor(io, background) + print(io, ';') + end + face.underline == lastface.underline || + if face.underline isa Tuple # Color and style + color, style = face.underline + print(io, "text-decoration: ") + if !isnothing(color) + htmlcolor(io, color) + print(io, ' ') + end + print(io, if style == :straight "solid " + elseif style == :double "double " + elseif style == :curly "wavy " + elseif style == :dotted "dotted " + elseif style == :dashed "dashed " + else "" end) + print(io, "underline;") + elseif face.underline isa SimpleColor + print(io, "text-decoration: ") + htmlcolor(io, face.underline) + if lastface.underline isa Tuple && last(lastface.underline) != :straight + print(io, " solid") + end + print(io, " underline;") + else # must be a Bool + print(io, "text-decoration: ") + if lastface.underline isa SimpleColor + print(io, "currentcolor ") + elseif lastface.underline isa Tuple + first(lastface.underline) isa SimpleColor && + print(io, "currentcolor ") + last(lastface.underline) != :straight && + print(io, "straight ") + end + print(io, ifelse(face.underline, "underline;", "none;")) + end + face.strikethrough == lastface.strikethrough || + print(io, ifelse(face.strikethrough, + "text-decoration: line-through", + ifelse(face.underline === false, + "text-decoration: none", ""))) + print(io, "\">") +end + +function show(io::IO, ::MIME"text/html", s::Union{<:StyledString, SubString{<:StyledString}}; wrap::Symbol=:pre) + htmlescape(str) = replace(str, '&' => "&", '<' => "<", '>' => ">") + buf = IOBuffer() # Avoid potential overhead in repeatadly printing a more complex IO + wrap == :none || + print(buf, '<', String(wrap), '>') + lastface::Face = getface() + stylestackdepth = 0 + for (str, styles) in eachstyle(s) + face = getface(styles) + link = let idx=findfirst(==(:link) ∘ first, styles) + if !isnothing(idx) + string(last(styles[idx]))::String + end end + !isnothing(link) && print(buf, "") + if face == getface() + print(buf, "" ^ stylestackdepth) + stylestackdepth = 0 + elseif (lastface.inverse, lastface.foreground, lastface.background) != + (face.inverse, face.foreground, face.background) + # We can't un-inherit colors well, so we just need to reset and apply + print(buf, "" ^ stylestackdepth) + htmlstyle(buf, face, getface()) + stylestackdepth = 1 + else + htmlstyle(buf, face, lastface) + stylestackdepth += 1 + end + if wrap == :p + newpara = false + for para in eachsplit(str, "\n\n") + newpara && print(buf, "

\n

") + print(buf, htmlescape(para)) + newpara = true + end + else + print(buf, htmlescape(str)) + end + !isnothing(link) && print(buf, "") + lastface = face + end + print(buf, "" ^ stylestackdepth) + wrap == :none || + print(buf, "') + write(io, take!(buf)) + nothing +end From c214350944bfdb1e1dc9048cce8a3b1e393b28f4 Mon Sep 17 00:00:00 2001 From: TEC Date: Sun, 3 Sep 2023 17:19:03 +0800 Subject: [PATCH 09/17] Custom show methods for faces and simplecolor This is just nicer to look at in the REPL --- base/strings/faces.jl | 93 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/base/strings/faces.jl b/base/strings/faces.jl index 69f5d43a6aa8e..2ca44a05ec85e 100644 --- a/base/strings/faces.jl +++ b/base/strings/faces.jl @@ -164,6 +164,99 @@ end getfield.(Ref(a), fieldnames(Face)) == getfield.(Ref(b), fieldnames(Face)) +function show(io::IO, ::MIME"text/plain", color::SimpleColor) + skiptype = get(io, :typeinfo, nothing) === SimpleColor + skiptype || show(io, SimpleColor) + skiptype || print(io, '(') + if get(io, :color, false)::Bool + print(io, StyledString("■", :face => Face(foreground=color)), ' ') + end + if color.value isa Symbol + print(io, color.value) + else # rgb tuple + print(io, '#', join(lpad.(string.(values(color.value), base=16), 2, '0'))) + end + skiptype || print(io, ')') + nothing +end + +function show(io::IO, ::MIME"text/plain", face::Face) + if get(io, :compact, false)::Bool + show(io, Face) + if get(io, :color, false)::Bool + # Could do S"({$face:sample})", but S_str isn't defined yet + print(io, StyledString("(sample)", [(2:7, :face => face)])) + else + print(io, '(') + isfirst = true + for field in setdiff(fieldnames(Face), (:inherit,)) + if !isnothing(getfield(face, field)) + if isfirst; isfirst = false else print(io, ", ") end + print(io, field, '=') + show(io, getfield(face, field)) + end + end + if !isempty(face.inherit) + if isfirst; isfirst = false else print(io, ", ") end + print(io, "inherit=") + show(IOContext(io, :typeinfo => Vector{Symbol}), face.inherit) + end + print(io, ')') + end + else + show(io, Face) + print(io, StyledString(" (sample)", [(3:8, :face => face)])) + showcolor(io, color) = show(IOContext(io, :typeinfo => SimpleColor), + MIME("text/plain"), color) + setfields = Pair{Symbol, Any}[] + isempty(setfields) || print(io, ":") + fieldnamepad = 14 + for field in (:font, :height, :weight, :slant) + if !isnothing(getfield(face, field)) + print(io, '\n', lpad(String(field), fieldnamepad, ' '), ": ", + getfield(face, field)) + end + end + for field in (:foreground, :background) + if !isnothing(getfield(face, field)) + print(io, '\n', lpad(String(field), fieldnamepad, ' '), ": ") + showcolor(io, getfield(face, field)) + end + end + if !isnothing(face.underline) + print(io, '\n', lpad("underline", fieldnamepad, ' '), ": ") + if face.underline isa Bool + print(io, face.underline) + elseif face.underline isa SimpleColor + showcolor(io, face.underline) + elseif face.underline isa Tuple{Nothing, Symbol} + print(io, last(face.underline)) + elseif face.underline isa Tuple{SimpleColor, Symbol} + showcolor(io, first(face.underline)) + print(io, ", ", last(face.underline)) + end + end + for field in (:strikethrough, :inverse) + if !isnothing(getfield(face, field)) + print(io, '\n', lpad(String(field), fieldnamepad, ' '), ": ", + getfield(face, field)) + end + end + if !isempty(face.inherit) + print(io, '\n', lpad("inherit", fieldnamepad, ' '), ": ") + isfirst = true + for iface in face.inherit + if isfirst; isfirst = false else print(io, ", ") end + print(io, iface, '(', StyledString("*", :face => iface), ')') + end + end + end +end + +function show(io::IO, face::Face) + show(IOContext(io, :compact => true), MIME("text/plain"), face) +end + """ Globally named [`Face`](@ref)s. From 96e3d6bbab80eaaef8ec087d6b962bc522c2b5cc Mon Sep 17 00:00:00 2001 From: TEC Date: Mon, 4 Sep 2023 21:59:54 +0800 Subject: [PATCH 10/17] Load terminfo during exec_options This way should any styled printing occur, regardless of whether a REPL session is started, it will be handled correctly based on the current terminal. --- base/client.jl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/base/client.jl b/base/client.jl index ac7fabe19a458..aa02e48cd47d8 100644 --- a/base/client.jl +++ b/base/client.jl @@ -271,6 +271,10 @@ function exec_options(opts) interactiveinput = (repl || is_interactive::Bool) && isa(stdin, TTY) is_interactive::Bool |= interactiveinput + # load terminfo in for styled printing + term_env = get(ENV, "TERM", @static Sys.iswindows() ? "" : "dumb") + global current_terminfo = load_terminfo(term_env) + # load ~/.julia/config/startup.jl file if startup try @@ -435,7 +439,6 @@ function run_main_repl(interactive::Bool, quiet::Bool, banner::Symbol, history_f if !fallback_repl && interactive && isassigned(REPL_MODULE_REF) invokelatest(REPL_MODULE_REF[]) do REPL term_env = get(ENV, "TERM", @static Sys.iswindows() ? "" : "dumb") - global current_terminfo = load_terminfo(term_env) term = REPL.Terminals.TTYTerminal(term_env, stdin, stdout, stderr) banner == :no || Base.banner(term, short=banner==:short) if term.term_type == "dumb" From 4a9128d6b8db5fe4473d3d422e76f25068df5b4d Mon Sep 17 00:00:00 2001 From: TEC Date: Sat, 9 Sep 2023 20:25:44 +0800 Subject: [PATCH 11/17] Overhaul S"" macro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous S"" macro was essentially one giant for loop with a helper function. When adding support for inline face value interpolation, it was clear that that approach was unmaintainable. As a result, the implementation has been completely rewritten. The new S"" macro is more maintainable, featureful, and correct — now with a documented EBNF grammar and more validation during expansion. --- base/strings/faces.jl | 720 +++++++++++++++++++++++++++++++----------- 1 file changed, 528 insertions(+), 192 deletions(-) diff --git a/base/strings/faces.jl b/base/strings/faces.jl index 2ca44a05ec85e..c48fbca7f68fb 100644 --- a/base/strings/faces.jl +++ b/base/strings/faces.jl @@ -674,235 +674,571 @@ interpolated with `\$`. S"The {bold:{italic:quick} {(foreground=#cd853f):brown} fox} jumped over \ the {link={https://en.wikipedia.org/wiki/Laziness}:lazy} dog" ``` + +# Extended help + +This macro can be described by the following EBNF grammar: + +```ebnf +styledstring = { styled | interpolated | escaped | plain } ; + +specialchar = '{' | '}' | '\$' | '\\\"' ; +anychar = [\u0-\u1fffff] ; +plain = { anychar - specialchar } ; +escaped = '\\\\', specialchar ; + +interpolated = '\$', ? expr ? | '\$(', ? expr ?, ')' ; + +styled = '{', ws, properties, ':', content, '}' ; +content = { interpolated | escaped | plain | styled } ; +properties = property | properties, ws, ',', ws, property +property = face | inlineface | keyvalue ; +ws = { ' ' | '\\t' | '\\n' } ; (* whitespace *) + +face = facename | interpolated ; +facename = [A-Za-z0-9_]+ ; + +inlineface = '(', ws, [ faceprop ], { ws, ',', faceprop }, ws, ')' ; +faceprop = [a-z]+, ws, '=', ws, ( [^,)]+ | interpolated) ; + +keyvalue = key, ws, '=', ws, value ; +key = ( [^\${}=,:], [^=,:]* ) | interpolated ; +value = simplevalue | curlybraced | interpolated ; +curlybraced = '{' { escaped | plain } '}' ; +simplevalue = [^\${},:], [^,:]* ; +``` + +The above grammar for `inlineface` is simplified, as the actual implementation +is a bit more sophisticated. The full behaviour is given below. + +```ebnf +faceprop = ( 'face', ws, '=', ws, ( ? string ? | interpolated ) ) | + ( 'height', ws, '=', ws, ( ? number ? | interpolated ) ) | + ( 'weight', ws, '=', ws, ( symbol | interpolated ) ) | + ( 'slant', ws, '=', ws, ( symbol | interpolated ) ) | + ( ( 'foreground' | 'fg' | 'background' | 'bg' ), + ws, '=', ws, ( simplecolor | interpolated ) ) | + ( 'underline', ws, '=', ws, ( underline | interpolated ) ) | + ( 'strikethrough', ws, '=', ws, ( bool | interpolated ) ) | + ( 'inverse', ws, '=', ws, ( bool | interpolated ) ) | + ( 'inherit', ws, '=', ws, ( inherit | interpolated ) ) ; + +nothing = 'nothing' ; +bool = 'true' | 'false' ; +symbol = [^ ,)]+ ; +hexcolor = ('#' | '0x'), [0-9a-f]{6} ; +simplecolor = hexcolor | symbol | nothing ; + +underline = nothing | bool | simplecolor | underlinestyled; +underlinestyled = '(', whitespace, ('' | nothing | simplecolor), whitespace, + ',', whitespace, symbol, whitespace ')' ; + +inherit = ( '[', inheritval, { ',', inheritval }, ']' ) | inheritval; +inheritval = whitespace, ':'?, symbol ; +``` """ macro S_str(raw_content::String) - parts = Any[] - content = unescape_string(raw_content, ('{', '}', '$', '\n')) - content_bytes = Vector{UInt8}(content) - s = Iterators.Stateful(zip(eachindex(content), content)) - offset = 0 - point = 1 - escape = false - active_styles = Vector{Vector{Tuple{Int, Union{Symbol, Expr, Pair{Symbol, Any}}}}}() - pending_styles = Vector{Tuple{UnitRange{Int}, Union{Symbol, Expr, Pair{Symbol, Any}}}}() - interpolated = false - function addpart(stop::Int) - str = String(content_bytes[point:stop+offset+ncodeunits(content[stop])-1]) - push!(parts, - if isempty(pending_styles) && isempty(active_styles) + #------------------ + # Helper functions + #------------------ + + # If this were a module, I'd define the following struct. + + #= struct State + content::String # the (unescaped) input string + bytes::Vector{UInt8} # bytes of `content` + s::Iterators.Stateful # (index, char) interator of `content` + parts::Vector{Any} # the final result + active_styles::Vector{ # unterminated batches of styles + Vector{Tuple{Int, Union{Symbol, Expr, Pair{Symbol, Any}}}}} + pending_styles::Vector{ # terminated styles that have yet to be applied + Tuple{UnitRange{Int}, Union{Symbol, Expr, Pair{Symbol, Any}}}} + offset::Ref{Int} # drift in the `content` index as structures are absorbed + point::Ref{Int} # current index in `content` + escape::Ref{Bool} # whether the last char was an escape char + interpolated::Ref{Bool} # whether any string interpolation occurs + end =# + + # Instead we'll just use a `NamedTuple` + state = let content = unescape_string(raw_content, ('{', '}', '$', '\n')) + (; content, bytes = Vector{UInt8}(content), + s = Iterators.Stateful(zip(eachindex(content), content)), + parts = Any[], + active_styles = Vector{Tuple{Int, Union{Symbol, Expr, Pair{Symbol, Any}}}}[], + pending_styles = Tuple{UnitRange{Int}, Union{Symbol, Expr, Pair{Symbol, Any}}}[], + offset = Ref(0), point = Ref(1), escape = Ref(false), interpolated = Ref(false)) + end + + # Value restrictions we can check against + valid_weights = ("thin", "extralight", "light", "semilight", "normal", + "medium", "semibold", "bold", "extrabold", "black") + valid_slants = ("italic", "oblique", "normal") + valid_underline_styles = ("straight", "double", "curly", "dotted", "dashed") + valid_colornames = + ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", + "grey", "gray", "bright_black", "bright_red", "bright_green", "bright_yellow", + "bright_blue", "bright_magenta", "bright_cyan", "bright_white") + + function stywarn(state, message::String) + println(stderr, "WARNING: Styled string macro in module ", __module__, + " at ", something(__source__.file, ""), + ':', string(__source__.line), + something(if !isempty(state.s) + i, chr = peek(state.s) + " (just before '$chr' [$i])" + end, ""), + ", ", message, '.') + end + + function addpart!(state, stop::Int) + if state.point[] > stop+state.offset[]+ncodeunits(state.content[stop])-1 + return state.point[] = nextind(state.content, stop) + state.offset[] + end + str = String(state.bytes[ + state.point[]:stop+state.offset[]+ncodeunits(state.content[stop])-1]) + push!(state.parts, + if isempty(state.pending_styles) && isempty(state.active_styles) str else styles = Expr[] relevant_styles = Iterators.filter( - (start, _)::Tuple -> start <= stop + offset + 1, - Iterators.flatten(active_styles)) + (start, _)::Tuple -> start <= stop + state.offset[] + 1, + Iterators.flatten(state.active_styles)) for (start, prop) in relevant_styles - range = (start - point):(stop - point + offset + 1) + range = (start - state.point[]):(stop - state.point[] + state.offset[] + 1) push!(styles, Expr(:tuple, range, prop)) end - for (range, prop) in pending_styles + sort!(state.pending_styles, by = first) + for (range, prop) in state.pending_styles if !isempty(range) - push!(styles, Expr(:tuple, range .- point, prop)) + push!(styles, Expr(:tuple, range .- state.point[], prop)) end end - empty!(pending_styles) + empty!(state.pending_styles) if isempty(styles) str else - :(StyledString($str, $(Expr(:vect, styles...)))) + :($StyledString($str, $(Expr(:vect, styles...)))) end end) - point = nextind(content, stop) + offset + state.point[] = nextind(state.content, stop) + state.offset[] end - function addpart(start::Int, expr, stop::Int) - if point < start - addpart(start) + + function addpart!(state, start::Int, expr, stop::Int) + if state.point[] < start + addpart!(state, start) end - if isempty(active_styles) - push!(parts, expr) + if isempty(state.active_styles) + push!(state.parts, expr) else - push!(parts, - :(StyledString(string($expr), - $(last.(Iterators.flatten(active_styles))...)))) - map!.((_, prop)::Tuple -> (nextind(content, stop + offset), prop), active_styles, active_styles) + push!(state.parts, + :($StyledString(string($expr), + $(last.(Iterators.flatten(state.active_styles))...)))) + map!.((_, prop)::Tuple -> (stop + state.offset[] + 1, prop), + state.active_styles, state.active_styles) + end + end + + function escaped!(state, i, char) + if char in ('{', '}', '$', '\\') + deleteat!(state.bytes, i + state.offset[] - 1) + state.offset[] -= ncodeunits('\\') + elseif char == '\n' + deleteat!(state.bytes, i+state.offset[]-1:i+state.offset[]) + state.offset[] -= ncodeunits("\\\n") end + state.escape[] = false + end + + function interpolated!(state, i, _) + expr, nexti = readexpr!(state, i + ncodeunits('$')) + deleteat!(state.bytes, i + state.offset[]) + state.offset[] -= ncodeunits('$') + addpart!(state, i, expr, nexti) + state.point[] = nexti + state.offset[] + state.interpolated[] = true end - for (i, char) in s - if char == '\\' - escape = true - elseif escape - if char in ('{', '}', '$') - deleteat!(content_bytes, i + offset - 1) - offset -= 1 - elseif char == '\n' - deleteat!(content_bytes, i+offset-1:i+offset) - offset -= 2 + + function readexpr!(state, pos::Int) + expr, nextpos = Meta.parseatom(state.content, pos) + nchars = length(state.content[pos:prevind(state.content, nextpos)]) + for _ in 1:min(length(state.s), nchars) + popfirst!(state.s) + end + expr, nextpos + end + + readexpr!(state) = readexpr!(state, first(popfirst!(state.s)) + 1) + + function skipwhitespace!(state) + isempty(state.s) && return + while last(peek(state.s)) ∈ (' ', '\t', '\n') + popfirst!(state.s) + end + end + + function begin_style!(state, i, char) + hasvalue = false + newstyles = Vector{Tuple{Int, Union{Symbol, Expr, Pair{Symbol, Any}}}}() + while read_property!(state, i, char, newstyles) end + push!(state.active_styles, newstyles) + # Adjust bytes/offset based on how much the index + # has been incremented in the processing of the + # style declaration(s). + if !isempty(state.s) + nexti = first(peek(state.s)) + deleteat!(state.bytes, i+state.offset[]:nexti+state.offset[]-1) + state.offset[] -= nexti - i + end + end + + function end_style!(state, i, char) + # Close off most recent active style + for (start, prop) in pop!(state.active_styles) + push!(state.pending_styles, (start:i+state.offset[], prop)) + end + deleteat!(state.bytes, i + state.offset[]) + state.offset[] -= ncodeunits('}') + end + + function read_property!(state, i, char, newstyles) + skipwhitespace!(state) + isempty(state.s) && return false + nextchar = last(peek(state.s)) + if nextchar == ':' + popfirst!(state.s) + return false + elseif nextchar == '(' + read_inlineface!(state, i, char, newstyles) + else + read_face_or_keyval!(state, i, char, newstyles) + end + isempty(state.s) && return false + nextchar = last(peek(state.s)) + if nextchar == ',' + popfirst!(state.s) + true + elseif nextchar == ':' + true + elseif nextchar ∈ (' ', '\t', '\n') + skipwhitespace!(state) + true + else + stywarn(state, "malformed styled string construct") + false + end + end + + function read_inlineface!(state, i, char, newstyles) + # Substructure parsing helper functions + function readalph!(state, lastchar) + Iterators.takewhile( + c -> 'a' <= (lastchar = last(c)) <= 'z', state.s) |> + collect .|> last |> String, lastchar + end + function readsymbol!(state, lastchar) + Iterators.takewhile( + c -> (lastchar = last(c)) ∉ (' ', '\t', '\n', ',', ')'), state.s) |> + collect .|> last |> String, lastchar + end + function parsecolor(color::String) + if color == "nothing" + elseif startswith(color, '#') && length(color) == 7 + tryparse(SimpleColor, color) + elseif startswith(color, "0x") && length(color) == 8 + tryparse(SimpleColor, '#' * color[3:end]) + else + color ∈ valid_colornames || + stywarn(state, "unrecognised named color '$color' (should be $(join(valid_colornames, ", ", ", or ")))") + SimpleColor(Symbol(color)) end - escape = false - elseif char == '$' - # Interpolation - expr, nexti = Meta.parseatom(content, i + 1) - deleteat!(content_bytes, i + offset) - offset -= 1 - nchars = length(content[i:prevind(content, nexti)]) - for _ in 1:min(length(s), nchars-1) - popfirst!(s) + end + function nextnonwhitespace!(state, lastchar) + if lastchar ∈ (' ', '\t', '\n') + skipwhitespace!(state) + _, lastchar = popfirst!(state.s) end - addpart(i, expr, nexti) - point = nexti + offset - interpolated = true - elseif char == '{' - # Property declaration parsing and application - properties = true - hasvalue = false - newstyles = Vector{Tuple{Int, Union{Symbol, Expr, Pair{Symbol, Any}}}}() - while properties - if !isnothing(peek(s)) && last(peek(s)) == '(' - # Inline face - popfirst!(s) - specstr = Iterators.takewhile(c -> last(c) != ')', s) |> - collect .|> last |> String - spec = map(split(specstr, ',')) do spec - spec = rstrip(spec) - kv = split(spec, '=', limit=2) - if length(kv) == 2 - kv[1] => @something(tryparse(Bool, kv[2]), - String(kv[2])) - else "" => "" end - end |> Dict - push!(newstyles, - (nextind(content, i + offset), - Pair{Symbol, Any}(:face, convert(Face, spec)))) - if isnothing(peek(s)) || last(popfirst!(s)) != ',' - properties = false + lastchar + end + function read_underline!(state, lastchar) + if last(peek(state.s)) == '(' + popfirst!(state.s) + skipwhitespace!(state) + ucolor_str, ucolor = if last(peek(state.s)) == ',' + lastchar = last(popfirst!(state.s)) + "", nothing + else + word, lastchar = readsymbol!(state, lastchar) + word, parsecolor(word) + end + lastchar = nextnonwhitespace!(state, lastchar) + if !isempty(state) && lastchar == ',' + skipwhitespace!(state) + ustyle, lastchar = readalph!(state, lastchar) + lastchar = nextnonwhitespace!(state, lastchar) + if lastchar == ')' + lastchar = last(popfirst!(state.s)) + else + stywarn(state, "malformed underline value, should be (,