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