Skip to content

Commit

Permalink
Implement "display protocol" for AnnotatedString
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
topolarity committed Oct 16, 2024
1 parent e72c867 commit d485bb7
Show file tree
Hide file tree
Showing 2 changed files with 186 additions and 0 deletions.
185 changes: 185 additions & 0 deletions base/strings/annotated_render.jl
Original file line number Diff line number Diff line change
@@ -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, '&' => "&amp;", '<' => "&lt;", '>' => "&gt;")

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, "<a href=\"", link, "\">")
print(buf, htmlescape(str))
!isnothing(link) && print(buf, "</a>")
end

# Reset the terminal state (whoever last wrote has the responsibility)
htmlreset_(buf, active_style)
write(io, take!(buf))
nothing
end

end
1 change: 1 addition & 0 deletions base/strings/strings.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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")

0 comments on commit d485bb7

Please sign in to comment.