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