| 1 | //! The Codewhale mark, derived from the canonical founder raster by |
| 2 | //! `scripts/brand/braille-mark.py`, and terminal color/capability helpers. |
| 3 | //! The launch header paints the mark as terminal cells; no image is transmitted. |
| 4 | |
| 5 | use std::sync::OnceLock; |
| 6 | |
| 7 | use ratatui::style::Color; |
| 8 | |
| 9 | /// The launch mark resolves once; controls and text never wait for it. |
| 10 | pub(crate) const REVEAL_MS: u128 = 360; |
| 11 | |
| 12 | pub(crate) fn reveal_row(row: &str, elapsed_ms: u128, animated: bool) -> String { |
| 13 | let mask = if !animated || elapsed_ms >= REVEAL_MS { |
| 14 | 0xff |
| 15 | } else if elapsed_ms < 60 { |
| 16 | 0x09 |
| 17 | } else if elapsed_ms < 140 { |
| 18 | 0x1b |
| 19 | } else if elapsed_ms < 240 { |
| 20 | 0x3f |
| 21 | } else { |
| 22 | 0x7f |
| 23 | }; |
| 24 | row.chars() |
| 25 | .map(|ch| { |
| 26 | let code = u32::from(ch); |
| 27 | if (0x2800..=0x28ff).contains(&code) { |
| 28 | char::from_u32(0x2800 + ((code - 0x2800) & mask)).unwrap_or(ch) |
| 29 | } else { |
| 30 | ch |
| 31 | } |
| 32 | }) |
| 33 | .collect() |
| 34 | } |
| 35 | |
| 36 | /// Rungs of the mark's scale ladder, each generated at its own box. |
| 37 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 38 | pub enum MarkSize { |
| 39 | /// Box 22×6, ink 14×6 — the launch screen's hero mark. Generated from the |
| 40 | /// same founder raster as the smaller rungs, at a size where the whale |
| 41 | /// reads as a whale. |
| 42 | Large, |
| 43 | /// Box 11×3, ink 7×3 — the launch header's mark. |
| 44 | Small, |
| 45 | /// Box 8×2, ink 5×2 — for stages too narrow for the header lines beside |
| 46 | /// the small rung. |
| 47 | Tiny, |
| 48 | } |
| 49 | |
| 50 | // generated by scripts/brand/braille-mark.py from brand/codewhalemarkfinal.png |
| 51 | // (founder hero whale 504x453px, threshold 0.3, aspect preserved, |
| 52 | // edge columns trimmed, eye carved) |
| 53 | |
| 54 | // LARGE: box 22x6 -> ink 14x6 |
| 55 | const LARGE_ROWS: [&str; 6] = [ |
| 56 | " ⣀⣴⣶⣾⣶⣆ ", |
| 57 | "⢀⣼⣿⠛⠉⠉⠙⠻⠟⠃ ", |
| 58 | "⢸⣿⡇ ⢀⣀⣤⣤⣤⣤⡀", |
| 59 | "⢸⣿⣷⣤⣤⣶⣿⣿⣿⡿⠟⠉⣹⠁", |
| 60 | "⠈⢻⣿⣿⣿⣿⣿⣷⠟ ⢀⡴⠁ ", |
| 61 | " ⠈⠉⠽⠿⣿⠥⠤⠒⠉ ", |
| 62 | ]; |
| 63 | |
| 64 | // SMALL: box 11x3 -> ink 7x3 |
| 65 | const SMALL_ROWS: [&str; 3] = [ |
| 66 | "⢠⡶⠛⠧⠄ ", // |
| 67 | "⣿⣄⣠⣤⣶⠶⠆", |
| 68 | "⠘⠻⣿⣗⠡⠊ ", |
| 69 | ]; |
| 70 | |
| 71 | // TINY: box 8x2 -> ink 5x2 |
| 72 | const TINY_ROWS: [&str; 2] = [ |
| 73 | "⢠⡞⠛⢂⣀", // |
| 74 | "⠘⠿⣿⠍⠉", |
| 75 | ]; |
| 76 | |
| 77 | impl MarkSize { |
| 78 | /// Cell footprint as `(cols, rows)` — the ink's, not the design box's. |
| 79 | #[must_use] |
| 80 | pub const fn cells(self) -> (u16, u16) { |
| 81 | match self { |
| 82 | Self::Large => (14, 6), |
| 83 | Self::Small => (7, 3), |
| 84 | Self::Tiny => (5, 2), |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | /// The braille rows for this rung. Blank cells are spaces so the |
| 89 | /// renderer leaves the field behind them untouched. |
| 90 | #[must_use] |
| 91 | pub const fn rows(self) -> &'static [&'static str] { |
| 92 | match self { |
| 93 | Self::Large => &LARGE_ROWS, |
| 94 | Self::Small => &SMALL_ROWS, |
| 95 | Self::Tiny => &TINY_ROWS, |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | /// Blend `from` toward `to` (0.0 → `from`, 1.0 → `to`). At 0 the mark is |
| 101 | /// exactly the field colour, so it rises out of the water rather than over it. |
| 102 | #[must_use] |
| 103 | pub fn lerp_color(from: Color, to: Color, amount: f32) -> Color { |
| 104 | let amount = amount.clamp(0.0, 1.0); |
| 105 | match (from, to) { |
| 106 | (Color::Rgb(fr, fg, fb), Color::Rgb(tr, tg, tb)) => Color::Rgb( |
| 107 | lerp_channel(fr, tr, amount), |
| 108 | lerp_channel(fg, tg, amount), |
| 109 | lerp_channel(fb, tb, amount), |
| 110 | ), |
| 111 | // Indexed colours have no channels to interpolate; snap at midpoint. |
| 112 | _ => { |
| 113 | if amount >= 0.5 { |
| 114 | to |
| 115 | } else { |
| 116 | from |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | fn lerp_channel(from: u8, to: u8, amount: f32) -> u8 { |
| 123 | let from = f32::from(from); |
| 124 | let to = f32::from(to); |
| 125 | (from + (to - from) * amount).round().clamp(0.0, 255.0) as u8 |
| 126 | } |
| 127 | |
| 128 | // --------------------------------------------------------------------------- |
| 129 | // Kitty graphics capability receipt. No launch image is transmitted. |
| 130 | // --------------------------------------------------------------------------- |
| 131 | |
| 132 | /// Capability query: a 1×1 RGB image sent with `a=q` is validated but never |
| 133 | /// stored; a supporting terminal answers `ESC _ G i=31;OK ESC \`. |
| 134 | const KITTY_QUERY: &[u8] = b"\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\"; |
| 135 | /// A terminal that supports the protocol answers within a millisecond. |
| 136 | const KITTY_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(120); |
| 137 | |
| 138 | static KITTY_GRAPHICS: OnceLock<bool> = OnceLock::new(); |
| 139 | |
| 140 | /// Environments worth asking. The query is only issued where the terminal is |
| 141 | /// one that could answer, so terminals that will never reply do not pay the |
| 142 | /// timeout at startup. tmux is excluded: image data needs a passthrough |
| 143 | /// wrapper there, which this tier does not do. |
| 144 | fn kitty_candidate_env(env: impl Fn(&str) -> Option<String>) -> bool { |
| 145 | if env("TMUX").is_some() { |
| 146 | return false; |
| 147 | } |
| 148 | let term = env("TERM").unwrap_or_default().to_ascii_lowercase(); |
| 149 | let program = env("TERM_PROGRAM").unwrap_or_default().to_ascii_lowercase(); |
| 150 | term.contains("kitty") |
| 151 | || term.contains("ghostty") |
| 152 | || matches!( |
| 153 | program.as_str(), |
| 154 | "kitty" | "wezterm" | "ghostty" | "konsole" |
| 155 | ) |
| 156 | || env("KITTY_WINDOW_ID").is_some() |
| 157 | || env("KONSOLE_VERSION").is_some() |
| 158 | } |
| 159 | |
| 160 | /// The terminal's answer to the capability query, decided: `OK` means it |
| 161 | /// validated our image; anything else (an error, no reply) does not. |
| 162 | fn kitty_query_accepted(reply: Option<&[u8]>) -> bool { |
| 163 | reply.is_some_and(|reply| { |
| 164 | let text = String::from_utf8_lossy(reply); |
| 165 | text.contains("_G") && text.contains(";OK") |
| 166 | }) |
| 167 | } |
| 168 | |
| 169 | /// Ask the terminal once whether it draws kitty graphics, and cache the |
| 170 | /// answer for the process. Call from the TUI entry point in the same window |
| 171 | /// as `codewhale_palette::probe_terminal_background` — raw mode on, event loop not yet |
| 172 | /// reading stdin — since the reply comes back on stdin. |
| 173 | pub fn probe_kitty_graphics() -> bool { |
| 174 | *KITTY_GRAPHICS.get_or_init(|| { |
| 175 | kitty_candidate_env(|key| std::env::var(key).ok()) |
| 176 | && kitty_query_accepted( |
| 177 | codewhale_palette::osc11::query_terminal(KITTY_QUERY, KITTY_QUERY_TIMEOUT) |
| 178 | .as_deref(), |
| 179 | ) |
| 180 | }) |
| 181 | } |
| 182 | |
| 183 | /// Whether the terminal reported graphics support; false before the probe runs. |
| 184 | #[must_use] |
| 185 | pub fn kitty_graphics_supported() -> bool { |
| 186 | KITTY_GRAPHICS.get().copied().unwrap_or(false) |
| 187 | } |
| 188 | |
| 189 | // --------------------------------------------------------------------------- |
| 190 | // Sixel graphics tier. |
| 191 | // |
| 192 | // For terminals that draw sixel (foot, mlterm, contour, sixel-enabled xterm, |
| 193 | // WezTerm with kitty graphics off) but never answered the kitty query. |
| 194 | // Keep the capability probe for the terminal receipt; launch has no sixel renderer. |
| 195 | // --------------------------------------------------------------------------- |
| 196 | |
| 197 | /// Primary device-attributes request. A sixel terminal answers with its |
| 198 | /// capability parameters, e.g. `ESC [ ? 62 ; 4 c` (foot) or |
| 199 | /// `ESC [ ? 63 ; 1 ; 2 ; 4 ; 6 ; 7 ; 15 ; 18 c` (xterm-sixel); parameter 4 is |
| 200 | /// sixel graphics. Read with `query_terminal_csi`: a DA reply ends at its |
| 201 | /// `c` final byte, not at BEL/ST, so the shared OSC-11 reader would swallow |
| 202 | /// following input waiting for a terminator that never comes. |
| 203 | const SIXEL_QUERY: &[u8] = b"\x1b[c"; |
| 204 | /// Same budget as the kitty query: answering terminals reply at once. |
| 205 | const SIXEL_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(120); |
| 206 | static SIXEL_GRAPHICS: OnceLock<bool> = OnceLock::new(); |
| 207 | |
| 208 | /// Environments worth asking. Same contract as the kitty gate: only |
| 209 | /// terminals that answer a DA query promptly, never tmux (image data needs |
| 210 | /// a passthrough wrapper there, which this tier does not do). The DA reply |
| 211 | /// itself decides — `xterm-256color` is listed because Terminal.app, iTerm2 |
| 212 | /// and xterm.js answer it at once without parameter 4, which is a fast no. |
| 213 | fn sixel_candidate_env(env: impl Fn(&str) -> Option<String>) -> bool { |
| 214 | if env("TMUX").is_some() { |
| 215 | return false; |
| 216 | } |
| 217 | let term = env("TERM").unwrap_or_default().to_ascii_lowercase(); |
| 218 | let program = env("TERM_PROGRAM").unwrap_or_default().to_ascii_lowercase(); |
| 219 | term.contains("foot") |
| 220 | || term.contains("mlterm") |
| 221 | || term.contains("contour") |
| 222 | || term == "xterm" |
| 223 | || term.starts_with("xterm-") |
| 224 | || term == "st" |
| 225 | || term.starts_with("st-") |
| 226 | || program.as_str() == "wezterm" |
| 227 | } |
| 228 | |
| 229 | /// The terminal's answer to the primary-DA query, decided: parameter `4` |
| 230 | /// (sixel graphics) present means it draws sixel; anything else does not. |
| 231 | fn da_reports_sixel(reply: Option<&[u8]>) -> bool { |
| 232 | let Some(reply) = reply else { |
| 233 | return false; |
| 234 | }; |
| 235 | let text = String::from_utf8_lossy(reply); |
| 236 | text.split('\x1b').any(|chunk| { |
| 237 | let body = chunk.strip_prefix("[?").or_else(|| chunk.strip_prefix("[")); |
| 238 | body.is_some_and(|body| { |
| 239 | body.strip_suffix('c') |
| 240 | .is_some_and(|params| params.split(';').any(|param| param == "4")) |
| 241 | }) |
| 242 | }) |
| 243 | } |
| 244 | |
| 245 | /// Ask the terminal once whether it draws sixel, and cache the answer for |
| 246 | /// the process. Call after [`probe_kitty_graphics`] in the same pre-loop |
| 247 | /// window (raw mode on, event loop not yet reading stdin). A positive kitty |
| 248 | /// result skips this fallback diagnostic probe; neither probe paints an image. |
| 249 | pub fn probe_sixel_graphics() -> bool { |
| 250 | *SIXEL_GRAPHICS.get_or_init(|| { |
| 251 | !kitty_graphics_supported() |
| 252 | && sixel_candidate_env(|key| std::env::var(key).ok()) |
| 253 | && da_reports_sixel( |
| 254 | codewhale_palette::osc11::query_terminal_csi(SIXEL_QUERY, SIXEL_QUERY_TIMEOUT) |
| 255 | .as_deref(), |
| 256 | ) |
| 257 | }) |
| 258 | } |
| 259 | |
| 260 | #[cfg(test)] |
| 261 | mod tests { |
| 262 | #[test] |
| 263 | fn launch_reveal_preserves_width_and_only_adds_canonical_dots() { |
| 264 | use unicode_width::UnicodeWidthStr; |
| 265 | for size in [ |
| 266 | super::MarkSize::Large, |
| 267 | super::MarkSize::Small, |
| 268 | super::MarkSize::Tiny, |
| 269 | ] { |
| 270 | for row in size.rows() { |
| 271 | let mut previous = super::reveal_row(row, 0, true); |
| 272 | for elapsed in [60, 140, 240, 360, 1000] { |
| 273 | let next = super::reveal_row(row, elapsed, true); |
| 274 | assert_eq!(next.width(), row.width()); |
| 275 | for ((old, new), canonical) in |
| 276 | previous.chars().zip(next.chars()).zip(row.chars()) |
| 277 | { |
| 278 | if ('\u{2800}'..='\u{28ff}').contains(&canonical) { |
| 279 | let old = u32::from(old) - 0x2800; |
| 280 | let new = u32::from(new) - 0x2800; |
| 281 | let target = u32::from(canonical) - 0x2800; |
| 282 | assert_eq!(old & new, old); |
| 283 | assert_eq!(new & target, new); |
| 284 | } |
| 285 | } |
| 286 | previous = next; |
| 287 | } |
| 288 | assert_eq!(previous, *row); |
| 289 | assert_eq!(super::reveal_row(row, 0, false), *row); |
| 290 | } |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | use super::*; |
| 295 | |
| 296 | #[test] |
| 297 | fn kitty_candidates_are_the_terminals_that_can_answer_and_never_tmux() { |
| 298 | let env = |vars: &[(&str, &str)]| { |
| 299 | let vars: Vec<(String, String)> = vars |
| 300 | .iter() |
| 301 | .map(|(k, v)| (k.to_string(), v.to_string())) |
| 302 | .collect(); |
| 303 | move |key: &str| vars.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()) |
| 304 | }; |
| 305 | assert!(kitty_candidate_env(env(&[("TERM", "xterm-kitty")]))); |
| 306 | assert!(kitty_candidate_env(env(&[("TERM_PROGRAM", "WezTerm")]))); |
| 307 | assert!(kitty_candidate_env(env(&[("TERM", "xterm-ghostty")]))); |
| 308 | assert!(kitty_candidate_env(env(&[("KONSOLE_VERSION", "230800")]))); |
| 309 | assert!(!kitty_candidate_env(env(&[ |
| 310 | ("TERM", "xterm-256color"), |
| 311 | ("TERM_PROGRAM", "Apple_Terminal") |
| 312 | ]))); |
| 313 | assert!(!kitty_candidate_env(env(&[ |
| 314 | ("TERM", "xterm-kitty"), |
| 315 | ("TMUX", "/tmp/tmux-501/default,1,0") |
| 316 | ]))); |
| 317 | } |
| 318 | |
| 319 | #[test] |
| 320 | fn the_query_is_accepted_only_on_an_ok_reply() { |
| 321 | assert!(kitty_query_accepted(Some(b"\x1b_Gi=31;OK"))); |
| 322 | assert!(!kitty_query_accepted(Some(b"\x1b_Gi=31;EINVAL:bad"))); |
| 323 | assert!(!kitty_query_accepted(Some(b"\x1b[?1;2c"))); |
| 324 | assert!(!kitty_query_accepted(None)); |
| 325 | } |
| 326 | |
| 327 | #[test] |
| 328 | fn sixel_candidates_are_the_terminals_that_answer_da_and_never_tmux() { |
| 329 | let env = |vars: &[(&str, &str)]| { |
| 330 | let vars: Vec<(String, String)> = vars |
| 331 | .iter() |
| 332 | .map(|(k, v)| (k.to_string(), v.to_string())) |
| 333 | .collect(); |
| 334 | move |key: &str| vars.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()) |
| 335 | }; |
| 336 | assert!(sixel_candidate_env(env(&[("TERM", "foot")]))); |
| 337 | assert!(sixel_candidate_env(env(&[("TERM", "foot-extra")]))); |
| 338 | assert!(sixel_candidate_env(env(&[("TERM", "mlterm")]))); |
| 339 | assert!(sixel_candidate_env(env(&[("TERM", "contour")]))); |
| 340 | // `xterm-256color` is a candidate even though most of its owners |
| 341 | // decline: Terminal.app, iTerm2 and xterm.js answer DA at once |
| 342 | // without parameter 4, which is a fast no rather than a timeout. |
| 343 | assert!(sixel_candidate_env(env(&[("TERM", "xterm-256color")]))); |
| 344 | assert!(sixel_candidate_env(env(&[("TERM", "st-256color")]))); |
| 345 | assert!(sixel_candidate_env(env(&[("TERM_PROGRAM", "WezTerm")]))); |
| 346 | assert!(!sixel_candidate_env(env(&[]))); |
| 347 | assert!(!sixel_candidate_env(env(&[("TERM", "dumb")]))); |
| 348 | assert!(!sixel_candidate_env(env(&[ |
| 349 | ("TERM", "foot"), |
| 350 | ("TMUX", "/tmp/tmux-501/default,1,0") |
| 351 | ]))); |
| 352 | } |
| 353 | |
| 354 | #[test] |
| 355 | fn the_da_reply_decides_on_parameter_four_only() { |
| 356 | // foot: sixel present. |
| 357 | assert!(da_reports_sixel(Some(b"\x1b[?62;4c"))); |
| 358 | // xterm-sixel: sixel among other capabilities. |
| 359 | assert!(da_reports_sixel(Some(b"\x1b[?63;1;2;4;6;7;15;18c"))); |
| 360 | // Plain xterm: answers, no sixel. |
| 361 | assert!(!da_reports_sixel(Some(b"\x1b[?62;1;2;6;7c"))); |
| 362 | // A 4 inside another parameter is not sixel. |
| 363 | assert!(!da_reports_sixel(Some(b"\x1b[?62;44c"))); |
| 364 | // A kitty reply is not a DA reply. |
| 365 | assert!(!da_reports_sixel(Some(b"\x1b_Gi=31;OK\x1b\\"))); |
| 366 | assert!(!da_reports_sixel(None)); |
| 367 | } |
| 368 | } |
| 369 |