diff --git a/docs/src/internals.md b/docs/src/internals.md index 6cfca87..c137a1c 100644 --- a/docs/src/internals.md +++ b/docs/src/internals.md @@ -19,6 +19,7 @@ StyledStrings.eachregion StyledStrings.annotation_events StyledStrings.face! StyledStrings.getface +StyledStrings.resolve StyledStrings.loadface! StyledStrings.loaduserfaces! StyledStrings.resetfaces! diff --git a/src/StyledStrings.jl b/src/StyledStrings.jl index 69adaf1..dc7e246 100644 --- a/src/StyledStrings.jl +++ b/src/StyledStrings.jl @@ -2,7 +2,7 @@ module StyledStrings -using Base: AnnotatedString, AnnotatedChar, AnnotatedIOBuffer, annotations, annotate!, annotatedstring +using Base: AnnotatedString, AnnotatedChar, AnnotatedIOBuffer, annotations, annotate!, annotatedstring, eachregion using Base.ScopedValues: ScopedValue, with, @with # While these are imported from Base, we claim them as part of the `StyledStrings` API. @@ -12,7 +12,6 @@ export @styled_str public Face, addface!, withfaces, styled, SimpleColor include("faces.jl") -include("regioniterator.jl") include("io.jl") include("styledmarkup.jl") include("legacy.jl") diff --git a/src/faces.jl b/src/faces.jl index 327a871..7c4a3d9 100644 --- a/src/faces.jl +++ b/src/faces.jl @@ -500,96 +500,70 @@ Merge the properties of the `initial` face and `others`, with later faces taking priority. """ function Base.merge(a::Face, b::Face) - if isempty(b.inherit) - # Extract the heights to help type inference a bit to be able - # to narrow the types in e.g. `aheight * bheight` - aheight = a.height - bheight = b.height - abheight = if isnothing(bheight) aheight - elseif isnothing(aheight) bheight - elseif bheight isa Int bheight - elseif aheight isa Int round(Int, aheight * bheight) - else aheight * bheight end - Face(if isnothing(b.font) a.font else b.font end, - abheight, - if isnothing(b.weight) a.weight else b.weight end, - if isnothing(b.slant) a.slant else b.slant end, - if isnothing(b.foreground) a.foreground else b.foreground end, - if isnothing(b.background) a.background else b.background end, - if isnothing(b.underline) a.underline else b.underline end, - if isnothing(b.strikethrough) a.strikethrough else b.strikethrough end, - if isnothing(b.inverse) a.inverse else b.inverse end, - a.inherit) - else - b_noinherit = Face( - b.font, b.height, b.weight, b.slant, b.foreground, b.background, - b.underline, b.strikethrough, b.inverse, Symbol[]) - b_inheritance = map(fname -> get(Face, FACES.current[], fname), Iterators.reverse(b.inherit)) - b_resolved = merge(foldl(merge, b_inheritance), b_noinherit) - merge(a, b_resolved) - end -end - -Base.merge(a::Face, b::Face, others::Face...) = merge(merge(a, b), others...) - -## Getting the combined face from a set of properties ## - -# Putting these inside `getface` causes the julia compiler to box it -_mergedface(face::Face) = face -_mergedface(face::Symbol) = get(Face, FACES.current[], face) -_mergedface(faces::Vector) = mapfoldl(_mergedface, merge, Iterators.reverse(faces)) - -""" - getface(faces) - -Obtain the final merged face from `faces`, an iterator of -[`Face`](@ref)s, face name `Symbol`s, and lists thereof. -""" -function getface(faces) - isempty(faces) && return FACES.current[][:default] - combined = mapfoldl(_mergedface, merge, faces)::Face - if !isempty(combined.inherit) - combined = merge(Face(), combined) - end - merge(FACES.current[][:default], combined) + # We cannot merge unresolved Faces and resolving a face makes a difference + # to the user so we shouldn't do it automatically either (especially when + # inserting Faces via `addface!`) + # Here we require that a Face be resolved if you're going to merge it. + @assert isempty(a.inherit) && isempty(b.inherit) + return _merge(a, b) end -""" - getface(annotations::Vector{@NamedTuple{label::Symbol, value::Any}}) - -Combine all of the `:face` annotations with `getfaces`. -""" -function getface(annotations::Vector{@NamedTuple{label::Symbol, value::Any}}) - faces = (ann.value for ann in annotations if ann.label === :face) - getface(faces) +# Merge assuming that `a` and `b` are resolved Faces. +function _merge(a::Face, b::Face) + # Extract the heights to help type inference a bit to be able + # to narrow the types in e.g. `aheight * bheight` + aheight = a.height + bheight = b.height + abheight = if isnothing(bheight) aheight + elseif isnothing(aheight) bheight + elseif bheight isa Int bheight + elseif aheight isa Int round(Int, aheight * bheight) + else aheight * bheight end + Face(if isnothing(b.font) a.font else b.font end, + abheight, + if isnothing(b.weight) a.weight else b.weight end, + if isnothing(b.slant) a.slant else b.slant end, + if isnothing(b.foreground) a.foreground else b.foreground end, + if isnothing(b.background) a.background else b.background end, + if isnothing(b.underline) a.underline else b.underline end, + if isnothing(b.strikethrough) a.strikethrough else b.strikethrough end, + if isnothing(b.inverse) a.inverse else b.inverse end, + Symbol[]) end -getface(face::Face) = merge(FACES.current[][:default], merge(Face(), face)) -getface(face::Symbol) = getface(get(Face, FACES.current[], face)) +Base.merge(a::Face, b::Face, others::Face...) = merge(merge(a, b), others...) -""" - getface() +## Resolving Faces that are still 'lazy' ## -Obtain the default face. """ -getface() = FACES.current[][:default] + getface(face::Symbol; resolve::Bool)::Face -## Face/AnnotatedString integration ## +Obtain a [`Face`](@ref) from the active FACES mapping. -""" - getface(s::AnnotatedString, i::Integer) +If `resolve` is set to `false`, the Face is returned exactly +as it is present in the FACES mapping. -Get the merged [`Face`](@ref) that applies to `s` at index `i`. +If `resolve` is set to `true`, any inherited features are +resolved and a merged Face with no inheritance is returned. """ -getface(s::AnnotatedString, i::Integer) = - getface(map(last, annotations(s, i))) +function getface(face::Symbol; resolve::Bool=true) + face = get(Face, FACES.current[], face) + return resolve ? StyledStrings.resolve(face) : face +end -""" - getface(c::AnnotatedChar) +function resolve(face::Face) + for parent in face.inherit + # We use `_merge` here to bypass the "resolved check" since + # we just want this to merge ignoring the inherited faces + # of `face` (we are currently resolving them) + face = _merge(getface(parent; resolve=true), face) + end + return face +end -Get the merged [`Face`](@ref) that applies to `c`. -""" -getface(c::AnnotatedChar) = getface(c.annotations) +function resolve(face::Symbol) + return resolve(get(Face, FACES.current[], face)) +end """ face!(str::Union{<:AnnotatedString, <:SubString{<:AnnotatedString}}, @@ -598,12 +572,12 @@ 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) + range::UnitRange{Int}, face::Union{Symbol, Face}) = + annotate!(s, range, :face, resolve(face)) face!(s::Union{<:AnnotatedString, <:SubString{<:AnnotatedString}}, - face::Union{Symbol, Face, <:Vector{<:Union{Symbol, Face}}}) = - annotate!(s, firstindex(s):lastindex(s), :face, face) + face::Union{Symbol, Face}) = + annotate!(s, firstindex(s):lastindex(s), :face, resolve(face)) ## Reading face definitions from a dictionary ## diff --git a/src/io.jl b/src/io.jl index a88c312..b1e7ea1 100644 --- a/src/io.jl +++ b/src/io.jl @@ -1,7 +1,5 @@ # This file is a part of Julia. License is MIT: https://julialang.org/license -# This isn't so much type piracy as type privateering 😉 - """ 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. @@ -120,7 +118,7 @@ function termcolor(io::IO, color::SimpleColor, category::Char) end elseif color.value === :default print(io, "\e[", category, "9m") - elseif (fg = get(FACES.current[], color.value, getface()).foreground) != SimpleColor(color.value) + elseif (fg = getface(color.value; resolve=true).foreground) != SimpleColor(color.value) termcolor(io, fg, category) else print(io, "\e[") @@ -159,12 +157,35 @@ const ANSI_STYLE_CODES = ( end_strikethrough = "\e[29m" ) -function termstyle(io::IO, face::Face, lastface::Face=getface()) - face.foreground == lastface.foreground || +# TODO: Remove this type-pirating method once we fully-resolve all Faces in any StyledStrings +function Base.AnnotatedDisplay.mergestyle(merged::Symbol, @nospecialize(style::Any)) + return Base.AnnotatedDisplay.mergestyle(getface(merged), style === nothing ? nothing : getface(style)) +end + +function Base.AnnotatedDisplay.mergestyle(merged::Face, @nospecialize(style::Any)) + if isa(style, Face) + return (merge(style, merged), true) + else + return (merged, false) + end +end + +# TODO: Remove this type-pirating method once we fully-resolve all Faces in any StyledStrings +function Base.AnnotatedDisplay.termstyle(io::IO, face::Symbol, @nospecialize(lastface::Any)) + return Base.AnnotatedDisplay.termstyle(io, getface(face), lastface) +end + +function Base.AnnotatedDisplay.termstyle(io::IO, face::Face, @nospecialize(lastface::Any)) + if !isa(lastface, Face) + # We don't understand what the last style was (type from + # another library?) so assume nothing. + lastface = nothing + end + (lastface !== nothing && face.foreground == lastface.foreground) || termcolor(io, face.foreground, '3') - face.background == lastface.background || + (lastface !== nothing && face.background == lastface.background) || termcolor(io, face.background, '4') - face.weight == lastface.weight || + (lastface !== nothing && face.weight == lastface.weight) || print(io, if face.weight ∈ (:medium, :semibold, :bold, :extrabold, :black) ANSI_STYLE_CODES.bold_weight elseif face.weight ∈ (:semilight, :light, :extralight, :thin) @@ -172,7 +193,7 @@ function termstyle(io::IO, face::Face, lastface::Face=getface()) else # :normal ANSI_STYLE_CODES.normal_weight end) - face.slant == lastface.slant || + (lastface !== nothing && face.slant == lastface.slant) || if haskey(Base.current_terminfo, :enter_italics_mode) print(io, ifelse(face.slant ∈ (:italic, :oblique), ANSI_STYLE_CODES.start_italics, @@ -184,7 +205,7 @@ function termstyle(io::IO, face::Face, lastface::Face=getface()) end # Kitty fancy underlines, see # Supported in Kitty, VTE, iTerm2, Alacritty, and Wezterm. - face.underline == lastface.underline || + (lastface !== nothing && face.underline == lastface.underline) || if haskey(Base.current_terminfo, :set_underline_style) || get(Base.current_terminfo, :can_style_underline, false) if face.underline isa Tuple # Color and style @@ -198,12 +219,12 @@ function termstyle(io::IO, face::Face, lastface::Face=getface()) else '0' end, 'm') !isnothing(color) && termcolor(io, color, '5') elseif face.underline isa SimpleColor - if !(lastface.underline isa SimpleColor || lastface.underline == true) + if lastface === nothing || !(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 + if lastface === nothing || 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, @@ -215,99 +236,19 @@ function termstyle(io::IO, face::Face, lastface::Face=getface()) ANSI_STYLE_CODES.start_underline, ANSI_STYLE_CODES.end_underline)) end - face.strikethrough == lastface.strikethrough || !haskey(Base.current_terminfo, :smxx) || + (lastface !== nothing && face.strikethrough == lastface.strikethrough) || !haskey(Base.current_terminfo, :smxx) || print(io, ifelse(face.strikethrough === true, ANSI_STYLE_CODES.start_strikethrough, ANSI_STYLE_CODES.end_strikethrough)) - face.inverse == lastface.inverse || !haskey(Base.current_terminfo, :enter_reverse_mode) || + (lastface !== nothing && face.inverse == lastface.inverse) || !haskey(Base.current_terminfo, :enter_reverse_mode) || print(io, ifelse(face.inverse === true, ANSI_STYLE_CODES.start_reverse, ANSI_STYLE_CODES.end_reverse)) + return face end -function _ansi_writer(io::IO, s::Union{<:AnnotatedString, SubString{<:AnnotatedString}}, - string_writer::F) where {F <: Function} - # We need to make sure that the customisations are loaded - # before we start outputting any styled content. - load_customisations!() - if get(io, :color, false)::Bool - buf = IOBuffer() # Avoid the overhead in repeatadly printing to `stdout` - lastface::Face = FACES.default[:default] - for (str, styles) in eachregion(s) - face = getface(styles) - link = let idx=findfirst(==(:link) ∘ first, styles) - if !isnothing(idx) - string(last(styles[idx]))::String - end end - !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(buf, FACES.default[:default], lastface) - write(io, take!(buf)) - elseif s isa AnnotatedString - string_writer(io, s.string) - elseif s isa SubString - string_writer( - io, SubString(s.string.string, s.offset, s.ncodeunits, Val(:noshift))) - end -end - -Base.write(io::IO, s::Union{<:AnnotatedString, SubString{<:AnnotatedString}}) = - _ansi_writer(io, s, write)::Int - -Base.print(io::IO, s::Union{<:AnnotatedString, SubString{<:AnnotatedString}}) = - (_ansi_writer(io, s, print); nothing) - -# We need to make sure that printing to an `AnnotatedIOBuffer` calls `write` not `print` -# so we get the specialised handling that `_ansi_writer` doesn't provide. -Base.print(io::AnnotatedIOBuffer, s::Union{<:AnnotatedString, SubString{<:AnnotatedString}}) = - (write(io, s); nothing) - -Base.escape_string(io::IO, s::Union{<:AnnotatedString, SubString{<:AnnotatedString}}, - esc = ""; keep = ()) = - (_ansi_writer(io, s, (io, s) -> escape_string(io, s, esc; keep)); nothing) - -function Base.write(io::IO, c::AnnotatedChar) - if get(io, :color, false) == true - termstyle(io, getface(c), getface()) - bytes = write(io, c.char) - termstyle(io, getface(), getface(c)) - bytes - else - write(io, c.char) - end -end - -Base.print(io::IO, c::AnnotatedChar) = (write(io, c); nothing) - -function Base.show(io::IO, c::AnnotatedChar) - if get(io, :color, false) == true - out = IOBuffer() - show(out, c.char) - cstr = AnnotatedString( - String(take!(out)[2:end-1]), - [(1:ncodeunits(c), a...) for a in c.annotations]) - print(io, ''', cstr, ''') - else - show(io, c.char) - end -end - -function Base.write(io::IO, aio::AnnotatedIOBuffer) - if get(io, :color, false) == true - # This does introduce an overhead that technically - # could be avoided, but I'm not sure that it's currently - # worth the effort to implement an efficient version of - # writing from a AnnotatedIOBuffer with style. - # In the meantime, by converting to an `AnnotatedString` we can just - # reuse all the work done to make that work. - write(io, read(aio, AnnotatedString)) - else - write(io, aio.io) - end +function Base.AnnotatedDisplay.termreset(io::IO, lastface::Face) + return Base.AnnotatedDisplay.termstyle(io, FACES.default[:default], lastface) end """ @@ -338,7 +279,7 @@ 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) + elseif (fg = get(FACES.current[], color.value, getface(:default; resolve=true)).foreground) != SimpleColor(color.value) htmlcolor(io, fg) else htmlcolor(io, get(HTML_BASIC_COLORS, color.value, SimpleColor(:default))) @@ -367,7 +308,8 @@ const HTML_WEIGHT_MAP = Dict{Symbol, Int}( :extrabold => 800, :black => 900) -function cssattrs(io::IO, face::Face, lastface::Face=getface(), escapequotes::Bool=true) +# TODO: Let's switch this to proper scoping, like it should be +function cssattrs(io::IO, face::Face, lastface::Face, escapequotes::Bool=true) priorattr = false function printattr(io, attr, valparts...) if priorattr @@ -441,45 +383,49 @@ function cssattrs(io::IO, face::Face, lastface::Face=getface(), escapequotes::Bo printattr(io, "text-decoration", ifelse(face.strikethrough, "line-through", "none")) end -function htmlstyle(io::IO, face::Face, lastface::Face=getface()) - print(io, "") +mutable struct HTMLStyleState + face::Union{Nothing,Face} + depth::Int end -function Base.show(io::IO, ::MIME"text/html", s::Union{<:AnnotatedString, SubString{<:AnnotatedString}}) - # We need to make sure that the customisations are loaded - # before we start outputting any styled content. - load_customisations!() - htmlescape(str) = replace(str, '&' => "&", '<' => "<", '>' => ">") - buf = IOBuffer() # Avoid potential overhead in repeatadly printing a more complex IO - lastface::Face = getface() - stylestackdepth = 0 - for (str, styles) in eachregion(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) +# TODO: Remove this type-pirating method once we fully-resolve all Faces in any StyledStrings +function Base.AnnotatedDisplay.htmlstyle(io::IO, face::Symbol, @nospecialize(lastface::Any)) + return Base.AnnotatedDisplay.htmlstyle(io, getface(face), lastface) +end + +function Base.AnnotatedDisplay.htmlstyle(io::IO, face::Face, @nospecialize(laststate::Any)) + if !isa(laststate, HTMLStyleState) + # We don't understand what the last style was (type from + # another library?) so assume nothing. + laststate = HTMLStyleState(nothing, 0) + end + lastface = laststate.face + if lastface !== nothing + last_color_attributes = (lastface.inverse, lastface.foreground, lastface.background) + color_attributes = (face.inverse, face.foreground, face.background) + if color_attributes != last_color_attributes # 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 + Base.AnnotatedDisplay.htmlreset(io, laststate) end - print(buf, htmlescape(str)) - !isnothing(link) && print(buf, "") - lastface = face end - print(buf, "" ^ stylestackdepth) - write(io, take!(buf)) - nothing + + print(io, "") + + laststate.depth += 1 + laststate.face = face + return laststate +end + +function Base.AnnotatedDisplay.htmlreset(io::IO, laststate::HTMLStyleState) + print(io, "" ^ laststate.depth) + laststate.face = nothing + laststate.depth = 0 + return laststate end diff --git a/src/legacy.jl b/src/legacy.jl index e4c92e6..e7aedaa 100644 --- a/src/legacy.jl +++ b/src/legacy.jl @@ -123,18 +123,19 @@ function load_env_colors!() end end -function Base.printstyled(io::AnnotatedIOBuffer, msg...; - bold::Bool=false, italic::Bool=false, underline::Bool=false, - blink::Bool=false, reverse::Bool=false, hidden::Bool=false, - color::Union{Symbol, Int}=:normal) - str = annotatedstring(msg...) - bold && face!(str, :bold) - italic && face!(str, :italic) - underline && face!(str, :underline) - reverse && face!(str, :inverse) - color !== :normal && face!(str, Face(foreground=legacy_color(color))) - write(io, str) - nothing -end +# This is type-piracy -> not allowed +# function printstyled(io::AnnotatedIOBuffer, msg...; + # bold::Bool=false, italic::Bool=false, underline::Bool=false, + # blink::Bool=false, reverse::Bool=false, hidden::Bool=false, + # color::Union{Symbol, Int}=:normal) + # str = annotatedstring(msg...) + # bold && face!(str, :bold) + # italic && face!(str, :italic) + # underline && face!(str, :underline) + # reverse && face!(str, :inverse) + # color !== :normal && face!(str, Face(foreground=legacy_color(color))) + # write(io, str) + # nothing +# end end diff --git a/src/precompile.jl b/src/precompile.jl index 0ae30d1..6f858e7 100644 --- a/src/precompile.jl +++ b/src/precompile.jl @@ -19,15 +19,15 @@ parse(StyledStrings.SimpleColor, "#010203") StyledStrings.Face(nothing, nothing, nothing, nothing, nothing, nothing, nothing, nothing, nothing, [:default]) StyledStrings.Face(height=2) -merge(StyledStrings.Face(inherit=:blue), StyledStrings.Face(foreground=:white)) +merge(StyledStrings.resolve(StyledStrings.Face(inherit=:blue)), StyledStrings.Face(foreground=:white)) StyledStrings.Face(height=2) == StyledStrings.Face(height=3) show(colorio, MIME("text/plain"), StyledStrings.Face(foreground=:green)) show(colorio, StyledStrings.Face(foreground=:green)) -StyledStrings.getface() StyledStrings.getface(:red) -StyledStrings.getface(styled"{red:red}", 1) +StyledStrings.getface(:red; resolve=false) +StyledStrings.getface(:red; resolve=true) StyledStrings.addface!(:_precompile => Face(font="precompile")) StyledStrings.loadface!(:_precompile => Face(inverse=true))