返回 CodeWhale
mark.rs
根目录 / crates / tui / src / tui / mark.rs
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
369 lines RUST