From 561584579f25ce784882d1d09649a90d75cb97a3 Mon Sep 17 00:00:00 2001 From: Cody Tapscott Date: Wed, 16 Oct 2024 09:07:39 -0400 Subject: [PATCH] Implement "display protocol" for `AnnotatedString` This is required for downstream libraries (esp. StyledStrings) to be able to influence the display of an AnnotatedString without type-piracy. The idea here is to provide an `iterate`-like display protocol that passes style information into the implementing library, which takes that information (and a "state" from the last display update), and decides how it needs to update the output to render the string. Since AnnotatedStrings don't have a distinguished "owner" or "type" (only their annotations have types), this leads to corner cases where the "laststate" you receive might be from another library. In that case, it's the responsibility of the downstream renderer to discard the laststate information and "reset" the display state as needed. --- base/strings/annotated_render.jl | 185 +++++++++++++++++++++++++++++++ base/strings/strings.jl | 1 + 2 files changed, 186 insertions(+) create mode 100644 base/strings/annotated_render.jl diff --git a/base/strings/annotated_render.jl b/base/strings/annotated_render.jl new file mode 100644 index 0000000000000..4c6183cdc8db7 --- /dev/null +++ b/base/strings/annotated_render.jl @@ -0,0 +1,185 @@ +module AnnotatedDisplay + +using ..Base: IO, SubString, IOBuffer, AnnotatedString, AnnotatedChar, AnnotatedIOBuffer +using ..Base: eachregion, invoke_in_world, tls_world_age + +import ..Base: write, print, show, escape_string # implemented methods + +# This is the "display interface" for printing an AnnotatedString +termreset(io::IO, ::Nothing) = nothing # no-op +termstyle(io::IO, ::Nothing, laststyle::Any) = termreset(io, laststyle) +# termstyle(io::IO, face::Symbol, laststyle::Any) = error("Unresolved `:face` annotation encountered: $face") + +htmlreset(io::IO, ::Nothing) = nothing # no-op +htmlstyle(io::IO, ::Nothing, laststyle::Any) = htmlreset(io, laststyle) +# htmlstyle(io::IO, face::Symbol, laststyle::Any) = error("Unresolved `:face` annotation encountered: $face") + +mergestyle(::Nothing, @nospecialize(style::Any)) = (style, true) +# mergestyle(style::Symbol, @nospecialize(::Any)) = (style, false) + +# call mergestyle(...) w/ invalidation barrier +mergestyle_(@nospecialize(merged::Any), @nospecialize(style::Any)) = + invoke_in_world(tls_world_age(), mergestyle, merged, style) +# call termreset(...) w/ invalidation barrier +termreset_(io::IO, @nospecialize(laststyle::Any)) = + invoke_in_world(tls_world_age(), termreset, io, laststyle) +# call termstyle(...) w/ invalidation barrier +termstyle_(io::IO, @nospecialize(style::Any), @nospecialize(laststyle::Any)) = + invoke_in_world(tls_world_age(), termstyle, io, style, laststyle) +# call htmlreset(...) w/ invalidation barrier +htmlreset_(io::IO, @nospecialize(laststyle::Any)) = + invoke_in_world(tls_world_age(), htmlreset, io, laststyle) +# call htmlstyle(...) w/ invalidation barrier +htmlstyle_(io::IO, @nospecialize(style::Any), @nospecialize(laststyle::Any)) = + invoke_in_world(tls_world_age(), htmlstyle, io, style, laststyle) + +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. + if get(io, :color, false)::Bool + buf = IOBuffer() # Avoid the overhead in repeatedly printing to `stdout` + active_style::Any = nothing # Represents the currently-applied style + for (str, styles) in eachregion(s) + link = nothing + merged_style = nothing + for (key, style) in reverse(styles) + if key === :link + link = style::String + end + key !== :face && continue + # Merge as many of these as we can into a single style before + # we print to the terminal + (merged_style, successful) = mergestyle_(merged_style, style) + if !successful + active_style = termstyle_(buf, merged_style, active_style) + merged_style = style + end + end + + active_style = termstyle_(buf, merged_style, active_style) + !isnothing(link) && write(buf, "\e]8;;", link, "\e\\") + string_writer(buf, str) + !isnothing(link) && write(buf, "\e]8;;\e\\") + end + # Reset the terminal state (whoever last wrote has the responsibility) + termreset_(buf, active_style) + 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 + +write(io::IO, s::Union{<:AnnotatedString, SubString{<:AnnotatedString}}) = + _ansi_writer(io, s, write)::Int + +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. +print(io::AnnotatedIOBuffer, s::Union{<:AnnotatedString, SubString{<:AnnotatedString}}) = + (write(io, s); nothing) + +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 write(io::IO, c::AnnotatedChar) + if get(io, :color, false) == true + active_style::Any = nothing # Represents the currently-applied style + + # TODO: re-factor into separate (shared) function + link = nothing + merged_style = nothing + for (key, style) in reverse(c.annotations) + if key === :link + link = style::String + end + key !== :face && continue + # Merge as many of these as we can into a single style before + # we print to the terminal + (merged_style, successful) = mergestyle_(merged_style, style) + if !successful + active_style = termstyle_(buf, merged_style, active_style) + merged_style = style + end + end + + active_style = termstyle_(io, merged_style, active_style) + bytes = write(io, c.char) + termreset_(io, active_style) + bytes + else + write(io, c.char) + end +end + +print(io::IO, c::AnnotatedChar) = (write(io, c); nothing) + +function 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 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 +end + +function show(io::IO, ::MIME"text/html", s::Union{<:AnnotatedString, SubString{<:AnnotatedString}}) + htmlescape(str) = replace(str, '&' => "&", '<' => "<", '>' => ">") + + buf = IOBuffer() # Avoid potential overhead in repeatedly printing a more complex IO + active_style::Any = nothing # Represents the currently-applied style + for (str, styles) in eachregion(s) + link = nothing + merged_style = nothing + for (key, style) in reverse(styles) + if key === :link + link = style::String + end + key !== :face && continue + + # Merge as many of these as we can into a single style before + # we print to the terminal + (merged_style, successful) = mergestyle_(merged_style, style) + if !successful + active_style = htmlstyle_(buf, merged_style, active_style) + merged_style = style + end + end + + active_style = htmlstyle_(buf, merged_style, active_style) + !isnothing(link) && print(buf, "") + print(buf, htmlescape(str)) + !isnothing(link) && print(buf, "") + end + + # Reset the terminal state (whoever last wrote has the responsibility) + htmlreset_(buf, active_style) + write(io, take!(buf)) + nothing +end + +end diff --git a/base/strings/strings.jl b/base/strings/strings.jl index 32975b6ea3fc7..e084363bb4c1e 100644 --- a/base/strings/strings.jl +++ b/base/strings/strings.jl @@ -12,3 +12,4 @@ import .Iterators: PartitionIterator include("strings/util.jl") include("strings/io.jl") include("strings/annotated_io.jl") +include("strings/annotated_render.jl")