返回 CodeWhale
cursor_accent.rs
根目录 / crates / tui / src / tui / cursor_accent.rs
1 //! Capability-gated OSC 12 cursor accent support.
2 //!
3 //! OSC 12 changes the terminal cursor color and OSC 112 restores the terminal
4 //! default. The guard is deliberately conservative: an explicit supported
5 //! terminal marker is required, while `TERM=dumb`, `NO_COLOR` (monochrome
6 //! color depth), and reduced-motion policy suppress the decorative escape
7 //! entirely.
8
9 use std::io::{self, Write};
10 use std::sync::atomic::{AtomicBool, Ordering};
11
12 use codewhale_palette::{ColorDepth, WHALE_ACTION_RGB};
13 use ratatui::style::Color;
14
15 const OSC12_RESET: &[u8] = b"\x1b]112\x07";
16 static ACTIVE: AtomicBool = AtomicBool::new(false);
17
18 /// RAII handle for one process-wide cursor accent installation.
19 pub(crate) struct CursorAccentGuard {
20 active: bool,
21 }
22
23 impl CursorAccentGuard {
24 /// Install the accent only when the resolved settings and environment make
25 /// decorative terminal control safe and explicit.
26 pub(crate) fn install(reduced_motion: bool, accent: Color) -> Self {
27 if reduced_motion || !environment_allows_cursor_accent() {
28 return Self { active: false };
29 }
30
31 let mut stdout = io::stdout();
32 if write_cursor_accent(&mut stdout, color_rgb(accent)).is_ok() {
33 ACTIVE.store(true, Ordering::SeqCst);
34 Self { active: true }
35 } else {
36 Self { active: false }
37 }
38 }
39 }
40
41 fn color_rgb(color: Color) -> (u8, u8, u8) {
42 match color {
43 Color::Rgb(red, green, blue) => (red, green, blue),
44 _ => WHALE_ACTION_RGB,
45 }
46 }
47
48 impl Drop for CursorAccentGuard {
49 fn drop(&mut self) {
50 if self.active {
51 restore_cursor_accent();
52 }
53 }
54 }
55
56 /// Restore the terminal's default cursor color once. Safe from normal,
57 /// panic, and signal cleanup paths; repeated calls are no-ops.
58 pub(crate) fn restore_cursor_accent() {
59 if !ACTIVE.swap(false, Ordering::SeqCst) {
60 return;
61 }
62 let mut stdout = io::stdout();
63 let _ = stdout.write_all(OSC12_RESET).and_then(|()| stdout.flush());
64 }
65
66 fn write_cursor_accent<W: Write>(
67 writer: &mut W,
68 (red, green, blue): (u8, u8, u8),
69 ) -> io::Result<()> {
70 write!(writer, "\x1b]12;#{red:02X}{green:02X}{blue:02X}\x07")?;
71 writer.flush()
72 }
73
74 fn environment_allows_cursor_accent() -> bool {
75 let term = std::env::var("TERM").unwrap_or_default();
76 let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
77 let color_term = std::env::var("COLORTERM").unwrap_or_default();
78 let reduced_motion = std::env::var("NO_ANIMATIONS")
79 .ok()
80 .is_some_and(|value| env_truthy(&value));
81 let no_color = ColorDepth::detect() == ColorDepth::Monochrome;
82 cursor_accent_supported(
83 Some(&term_program),
84 Some(&term),
85 Some(&color_term),
86 reduced_motion,
87 no_color,
88 )
89 }
90
91 fn cursor_accent_supported(
92 term_program: Option<&str>,
93 term: Option<&str>,
94 color_term: Option<&str>,
95 reduced_motion: bool,
96 no_color: bool,
97 ) -> bool {
98 if reduced_motion || no_color || term == Some("dumb") {
99 return false;
100 }
101
102 let program = term_program.unwrap_or_default().to_ascii_lowercase();
103 let known_terminal = matches!(
104 program.as_str(),
105 "alacritty"
106 | "apple_terminal"
107 | "contour"
108 | "ghostty"
109 | "iterm.app"
110 | "kitty"
111 | "konsole"
112 | "rio"
113 | "vscode"
114 | "wezterm"
115 | "windows_terminal"
116 );
117 known_terminal && (color_term.is_some_and(|value| !value.is_empty()) || term.is_some())
118 }
119
120 fn env_truthy(value: &str) -> bool {
121 matches!(
122 value.trim().to_ascii_lowercase().as_str(),
123 "1" | "true" | "yes" | "on"
124 )
125 }
126
127 #[cfg(test)]
128 mod tests {
129 use super::*;
130
131 #[test]
132 fn supported_terminals_are_explicitly_allowlisted() {
133 assert!(cursor_accent_supported(
134 Some("Ghostty"),
135 Some("xterm-256color"),
136 Some("truecolor"),
137 false,
138 false
139 ));
140 assert!(cursor_accent_supported(
141 Some("kitty"),
142 Some("xterm-kitty"),
143 Some("truecolor"),
144 false,
145 false
146 ));
147 assert!(!cursor_accent_supported(
148 Some("unknown-terminal"),
149 Some("xterm-256color"),
150 Some("truecolor"),
151 false,
152 false
153 ));
154 }
155
156 #[test]
157 fn plain_and_reduced_motion_terminals_are_suppressed() {
158 assert!(!cursor_accent_supported(
159 Some("Ghostty"),
160 Some("dumb"),
161 Some("truecolor"),
162 false,
163 false
164 ));
165 assert!(!cursor_accent_supported(
166 Some("Ghostty"),
167 Some("xterm-256color"),
168 Some("truecolor"),
169 true,
170 false
171 ));
172 }
173
174 #[test]
175 fn no_color_suppresses_the_accent_on_supported_terminals() {
176 assert!(!cursor_accent_supported(
177 Some("Ghostty"),
178 Some("xterm-256color"),
179 Some("truecolor"),
180 false,
181 true
182 ));
183 assert!(!cursor_accent_supported(
184 Some("kitty"),
185 Some("xterm-kitty"),
186 None,
187 false,
188 true
189 ));
190 }
191
192 #[test]
193 fn cursor_sequences_set_and_restore_the_default() {
194 let mut output = Vec::new();
195 write_cursor_accent(&mut output, (0x12, 0xab, 0xf0)).unwrap();
196 assert_eq!(
197 output, b"\x1b]12;#12ABF0\x07",
198 "OSC 12 must use the existing accent as an RGB cursor color"
199 );
200 assert_eq!(OSC12_RESET, b"\x1b]112\x07");
201 }
202
203 #[test]
204 fn non_rgb_themes_fall_back_to_the_existing_accent() {
205 assert_eq!(color_rgb(Color::Blue), WHALE_ACTION_RGB);
206 assert_eq!(color_rgb(Color::Rgb(1, 2, 3)), (1, 2, 3));
207 }
208
209 #[test]
210 fn truthy_environment_values_are_conservative() {
211 assert!(env_truthy("1"));
212 assert!(env_truthy(" TRUE "));
213 assert!(!env_truthy("0"));
214 assert!(!env_truthy("false"));
215 }
216 }
217
217 lines RUST