| 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 |