Skip to content

Commit c89eef3

Browse files
committed
Read the terminal colour scheme on REPL startup
1 parent cd2ebf9 commit c89eef3

File tree

10 files changed

+235
-4
lines changed

10 files changed

+235
-4
lines changed

deps/checksums/StyledStrings-68bf7b1f83f334391dc05fda34f48267e04e2bd0.tar.gz/md5

Lines changed: 0 additions & 1 deletion
This file was deleted.

deps/checksums/StyledStrings-68bf7b1f83f334391dc05fda34f48267e04e2bd0.tar.gz/sha512

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
7c70e191ca335599b80a5432b58b8153
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ae51d4541d98c524f6b3985df1bc021a0a0695d7f94f79ab4fb6d281e3d6777c2c5533aea559ad843c68a4ae550c68aebde71123632c83bdda21dc5b278c39ac

stdlib/REPL/src/LineEdit.jl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ module LineEdit
55
import ..REPL
66
using ..REPL: AbstractREPL, Options
77
using ..REPL.StylingPasses: StylingPass, SyntaxHighlightPass, RegionHighlightPass, EnclosingParenHighlightPass, StylingContext, apply_styling_passes, merge_annotations
8+
using ..REPL: TermOSC.receive_osc
89
using ..REPL: histsearch
910

1011
using ..Terminals
@@ -2670,7 +2671,9 @@ AnyDict(
26702671
"\el" => (s::MIState,o...)->edit_lower_case(s),
26712672
"\ec" => (s::MIState,o...)->edit_title_case(s),
26722673
"\ee" => (s::MIState,o...) -> edit_input(s),
2673-
"\em" => (s::MIState, o...) -> activate_module(s)
2674+
"\em" => (s::MIState, o...) -> activate_module(s),
2675+
# Read OSC responses
2676+
"\e]" => (s::MIState, o...) -> receive_osc(terminal(s))
26742677
)
26752678

26762679
const history_keymap = AnyDict(

stdlib/REPL/src/REPL.jl

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ using .StylingPasses
7272

7373
function histsearch end # To work around circular dependency
7474

75+
include("term_osc.jl")
76+
using .TermOSC
77+
7578
include("LineEdit.jl")
7679
using .LineEdit
7780
import .LineEdit:
@@ -1658,6 +1661,9 @@ function run_frontend(repl::LineEditREPL, backend::REPLBackendRef)
16581661
else
16591662
interface = repl.interface
16601663
end
1664+
# Collect color state
1665+
TermOSC.get_all_colors(StyledStrings.setcolors!, terminal(repl))
1666+
# Do it
16611667
repl.backendref = backend
16621668
repl.mistate = LineEdit.init_state(terminal(repl), interface)
16631669
run_interface(terminal(repl), interface, repl.mistate)

stdlib/REPL/src/precompile.jl

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,12 @@ let
237237
precompile(Tuple{typeof(Base.setindex!), Base.Dict{Tuple{Symbol, Any}, Int64}, Int64, Tuple{Symbol, String}})
238238
precompile(Tuple{typeof(Base.setindex!), Base.Dict{Tuple{Symbol, Any}, Int64}, Int64, Tuple{Symbol, Symbol}})
239239
precompile(Tuple{typeof(REPL.banner), Base.TTY})
240+
# Since TermOSC uses a keymap-triggered task, it's not hit in the workload.
241+
precompile(Tuple{typeof(REPL.TermOSC.receive_osc), Base.Terminals.TTYTerminal})
242+
precompile(Tuple{typeof(REPL.TermOSC.read_osc_response), Base.TTY})
243+
precompile(Tuple{REPL.TermOSC.ColorCallbackWrapper{typeof(REPL.StyledStrings.setcolors!)}, Array{REPL.TermOSC.OSCResponse, 1}})
244+
# Unknown source, but seemingly needed
245+
precompile(Tuple{typeof(Base.print), Base.TTY, String})
240246
finally
241247
ccall(:jl_tag_newly_inferred_disable, Cvoid, ())
242248
end

stdlib/REPL/src/term_osc.jl

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
module TermOSC
2+
3+
using Base.Threads
4+
using Base.Terminals: UnixTerminal, raw!
5+
6+
public receive_osc, query, get_all_colors
7+
8+
struct OSCResponse
9+
opcode::Int
10+
parts::Vector{SubString{String}}
11+
end
12+
13+
const PENDING_RESPONSES = OSCResponse[]
14+
const RESPONSE_POLLERS = Function[]
15+
16+
const OSC_LOCK = ReentrantLock()
17+
18+
const OSC_TIMEOUT = 2.0 # seconds
19+
20+
"""
21+
query(callback::Function, terminal::UnixTerminal, query::String)
22+
23+
Send an OSC `query` to the `terminal` and register a `callback` to handle the response.
24+
25+
The `callback` function is called with the vector of all `OSCResponse`s received so far.
26+
27+
It is responsible for determining which responses it wants to handle, and deleting them from the vector.
28+
29+
The callback should return `true` if it has finished processing and can be removed from the pollers list,
30+
or `false` if it wants to be called again when more responses arrive.
31+
"""
32+
function query(callback::Function, term::UnixTerminal, query::String)
33+
raw!(term, true)
34+
@lock OSC_LOCK begin
35+
print(term.out_stream, query)
36+
flush(term.out_stream)
37+
push!(RESPONSE_POLLERS, callback)
38+
end
39+
nothing
40+
end
41+
42+
function read_osc_response(in::IO)
43+
data = Base.StringVector(0)
44+
while true
45+
c = read(in, UInt8)
46+
c == UInt8('\3') && break # ^C
47+
c == 0x9c && break # ST
48+
c == UInt8('\a') && break
49+
if c == UInt8('\e')
50+
c = read(in, UInt8)
51+
c == UInt8('\\') && break
52+
push!(data, UInt8('\e'))
53+
end
54+
push!(data, c)
55+
end
56+
String(data)
57+
end
58+
59+
function receive_osc(term::UnixTerminal)
60+
osc = read_osc_response(term.in_stream)
61+
parts = split(osc, ';')
62+
opcode = tryparse(Int, first(parts))
63+
isnothing(opcode) && return
64+
popfirst!(parts)
65+
@lock OSC_LOCK push!(PENDING_RESPONSES, OSCResponse(opcode, parts))
66+
run_pollers()
67+
end
68+
69+
function run_pollers()
70+
@lock OSC_LOCK begin
71+
finished = Int[]
72+
for (i, poller) in enumerate(RESPONSE_POLLERS)
73+
try
74+
poller(PENDING_RESPONSES)::Bool &&
75+
push!(finished, i)
76+
catch ex
77+
showerror(stderr, ex)
78+
Base.show_backtrace(stderr, catch_backtrace())
79+
println(stderr)
80+
push!(finished, i)
81+
end
82+
end
83+
isempty(finished) || deleteat!(RESPONSE_POLLERS, finished)
84+
isempty(RESPONSE_POLLERS) && empty!(PENDING_RESPONSES)
85+
end
86+
nothing
87+
end
88+
89+
# Helper functions
90+
91+
function interpret_color(data::AbstractString)
92+
if startswith(data, "rgb:")
93+
data = @view data[ncodeunits("rgb:")+1:end]
94+
components = split(data, '/')
95+
length(components) == 3 || return
96+
elseif startswith(data, "rgba:")
97+
data = @view data[ncodeunits("rgba:")+1:end]
98+
components = split(data, '/')
99+
length(components) == 4 || return
100+
else
101+
return
102+
end
103+
function tryparsecolor(chex)
104+
cnum = tryparse(UInt16, chex, base=16)
105+
isnothing(cnum) && return nothing
106+
UInt8(cnum ÷ 16^(ncodeunits(chex) - 2))
107+
end
108+
r = tryparsecolor(components[1])
109+
isnothing(r) && return
110+
g = tryparsecolor(components[2])
111+
isnothing(g) && return
112+
b = tryparsecolor(components[3])
113+
isnothing(b) && return
114+
(; r, g, b)
115+
end
116+
117+
const ANSI_COLOR_ORDER = (
118+
:black, :red, :green, :yellow, :blue, :magenta, :cyan, :white,
119+
:bright_black, :bright_red, :bright_green, :bright_yellow,
120+
:bright_blue, :bright_magenta, :bright_cyan, :bright_white)
121+
122+
const RGBTuple = @NamedTuple{r::UInt8, g::UInt8, b::UInt8}
123+
124+
struct ColorCallbackWrapper{F <: Function} <: Function
125+
callback::F
126+
end
127+
128+
function (o::ColorCallbackWrapper{F})(oscs::Vector{OSCResponse}) where {F}
129+
colors = read_all_colors!(oscs)
130+
isnothing(colors) && return false
131+
o.callback(colors)
132+
true
133+
end
134+
135+
"""
136+
get_all_colors(term::UnixTerminal)
137+
138+
Query the terminal for all colors (foreground, background, and ANSI 0-15).
139+
140+
!!! warning
141+
To catch the responses, you must be asynchronously reading from `stdin`
142+
and calling `TermOSC.receive_osc(term)` whenever an OSC response is detected.
143+
"""
144+
function get_all_colors(callback::Function, term::UnixTerminal)
145+
fgbg_query = "\e]10;?\e\\\e]11;?\e\\"
146+
# NOTE: In theory, as per <https://www.xfree86.org/current/ctlseqs.html>
147+
# 'Operating System Controls' > 'P s = 4', multiple queries may be provided:
148+
#
149+
# "Because more than one pair of color number and specification can be given
150+
# in one control sequence, xterm can make more than one reply."
151+
#
152+
# However, in practice, while some terminals are good and support this
153+
# (e.g. Kitty, Wezterm, and Foot) others, even those with good reputations
154+
# for being faithful VTs, do not (e.g. Ghostty, Alacritty, Konsole).
155+
#
156+
# So, we resort to sending 16 individual OSC queries instead of
157+
# one large one 🥲.
158+
ansi_query = join(("\e]4;$n;?\e\\" for n in 0:15))
159+
callback_wrapper = ColorCallbackWrapper(callback)
160+
query(callback_wrapper, term, fgbg_query * ansi_query)
161+
end
162+
163+
function read_all_colors!(oscs::Vector{OSCResponse})
164+
hasfg = false
165+
hasbg = false
166+
hasansi = zeros(Bool, 16)
167+
for (; opcode, parts) in oscs
168+
if opcode == 10 && length(parts) == 1
169+
hasfg = true
170+
elseif opcode == 11 && length(parts) == 1
171+
hasbg = true
172+
elseif opcode == 4
173+
for part in parts
174+
all(isdigit, part) || continue
175+
opc = tryparse(Int, part)
176+
isnothing(opc) && break
177+
hasansi[clamp(opc, 0, 15)+1] = true
178+
end
179+
end
180+
end
181+
(hasfg && hasbg && all(hasansi)) || return
182+
colors = Pair{Symbol, RGBTuple}[]
183+
consumed = Int[]
184+
for (i, (; opcode, parts)) in enumerate(oscs)
185+
if opcode == 10 && length(parts) == 1
186+
col = interpret_color(first(parts))
187+
push!(consumed, i)
188+
isnothing(col) && continue
189+
push!(colors, :foreground => col)
190+
elseif opcode == 11 && length(parts) == 1
191+
col = interpret_color(first(parts))
192+
push!(consumed, i)
193+
isnothing(col) && continue
194+
push!(colors, :background => col)
195+
elseif opcode == 4
196+
colornum = -1
197+
for part in parts
198+
if all(isdigit, part)
199+
colornum = something(tryparse(Int, part), -1)
200+
elseif colornum 0:15
201+
col = interpret_color(part)
202+
isnothing(col) && continue
203+
push!(colors, ANSI_COLOR_ORDER[colornum+1] => col)
204+
colornum = -1
205+
end
206+
end
207+
push!(consumed, i)
208+
end
209+
end
210+
deleteat!(oscs, consumed)
211+
colors
212+
end
213+
214+
end

stdlib/REPL/test/precompilation.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ if !Sys.iswindows()
3737

3838
n_precompiles = count(r"precompile\(", tracecompile_out)
3939

40+
@show tracecompile_out
41+
4042
@test n_precompiles <= expected_precompiles
4143

4244
if n_precompiles == 0

stdlib/StyledStrings.version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
STYLEDSTRINGS_BRANCH = main
2-
STYLEDSTRINGS_SHA1 = 68bf7b1f83f334391dc05fda34f48267e04e2bd0
2+
STYLEDSTRINGS_SHA1 = e235bd7f264de7fa47fc4d9706af742ee4d80cc6
33
STYLEDSTRINGS_GIT_URL := https://github.com/JuliaLang/StyledStrings.jl.git
44
STYLEDSTRINGS_TAR_URL = https://api.github.com/repos/JuliaLang/StyledStrings.jl/tarball/$1

0 commit comments

Comments
 (0)