返回 CodeWhale
whale.rs
1 //! The whale, drawn in Braille dots.
2 //!
3 //! The C-shaped whale from the Codewhale mark, as the v2 pet
4 //! (`whale-character-v2`), is the one character on every surface. The desktop
5 //! app draws it with the v2 rig; the terminal draws the same contours as
6 //! Braille dots. Its states carry meaning: the kit's 17 actions, from resting
7 //! and listening through reading, editing and running to needs you and done,
8 //! and a pod, where calves swim with it while agents work in parallel. The
9 //! art has room for three calves; the words always carry the real count, and
10 //! a pod of none is drawn as plain work, never with an invented calf.
11 //!
12 //! `assets/whale-v2.scenes` holds the poster pose of each state as exact
13 //! cubic contours, exported from the v2 kit's Director by
14 //! `tools/export-whale.cjs`. [`rasterize`] is a port of the kit's pure
15 //! vector-to-dot renderer (`braille.js`): flatten each cubic into ten
16 //! segments, fill scanlines by even-odd crossings at dot centres (so the
17 //! throat and eye holes stay open), and pack each 2x4 block into one
18 //! `U+2800` cell. It reproduces the kit's 32x16 and 20x10 stills exactly
19 //! (`tests/whale.rs`).
20 //!
21 //! The art is decoration; the state lives in the words beside it. Screen
22 //! readers get the label, never a stream of dot names, and ASCII-safe
23 //! terminals get the words alone.
24 //!
25 //! [`Whale::paint_frame`] accepts a packed frame from the host's shared
26 //! Director. This renderer owns no clock, animation or state selection;
27 //! [`Paint::paint`] keeps drawing the existing poster pose.
28
29 use std::borrow::Cow;
30 use std::sync::OnceLock;
31
32 use ratatui::{
33 buffer::Buffer,
34 layout::Rect,
35 style::{Color, Style},
36 text::Line,
37 widgets::Widget,
38 };
39
40 use crate::{
41 Paint, Role, State, StatusMark, Theme,
42 color::{ColorDepth, blend, rgb, rgb_to_ansi256},
43 theme::{LOGO_BOTTOM, LOGO_TOP},
44 };
45
46 /// Dot bits per cell, row-major: `01 08`, `02 10`, `04 20`, `40 80`.
47 pub const BITS: [[u8; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
48
49 /// What the whale is doing: the v2 kit's 17 actions (`catalogue.js`).
50 ///
51 /// The host classifies; the whale only draws. Pick the action from the
52 /// engine's own presence and activity (the kit's `PORTING.md`), never by
53 /// guessing from a command, URL or text.
54 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
55 pub enum WhaleState {
56 /// Idle: nothing to do.
57 Rest,
58 /// The person is typing.
59 Listen,
60 /// First output pending, or reasoning reported.
61 Think,
62 /// Working, with no finer activity reported.
63 Busy,
64 /// Reading files.
65 Read,
66 /// Searching.
67 Search,
68 /// Editing files.
69 Write,
70 /// Running a command.
71 Run,
72 /// Browsing the web.
73 Browse,
74 /// Writing the reply.
75 Talk,
76 /// Working with `calves` agents in parallel. The art draws at most three
77 /// calves (zero draws the working pose); the words use the real count.
78 Pod { calves: u8 },
79 /// Waiting on the person.
80 NeedsYou,
81 /// The turn finished.
82 Done,
83 /// The turn genuinely failed moments ago. The kit marks this state a
84 /// proposal: the host shows it only for a failed turn and expires it
85 /// (the kit uses three seconds).
86 Stuck,
87 /// The engine is offline.
88 Asleep,
89 /// Using the computer (computer use).
90 Computer,
91 /// Calling a connected app. Says the call happened, not that it worked.
92 Connect,
93 }
94
95 impl WhaleState {
96 /// Every action once, in the kit's catalogue order (the pod with three
97 /// calves).
98 pub const ALL: [WhaleState; 17] = [
99 WhaleState::Rest,
100 WhaleState::Listen,
101 WhaleState::Think,
102 WhaleState::Busy,
103 WhaleState::Read,
104 WhaleState::Search,
105 WhaleState::Write,
106 WhaleState::Run,
107 WhaleState::Browse,
108 WhaleState::Talk,
109 WhaleState::Pod { calves: 3 },
110 WhaleState::NeedsYou,
111 WhaleState::Done,
112 WhaleState::Stuck,
113 WhaleState::Asleep,
114 WhaleState::Computer,
115 WhaleState::Connect,
116 ];
117
118 /// The action's name in the v2 kit (`catalogue.js`, `braille/<key>-*.txt`).
119 #[must_use]
120 pub const fn key(self) -> &'static str {
121 match self {
122 WhaleState::Rest => "rest",
123 WhaleState::Listen => "listen",
124 WhaleState::Think => "think",
125 WhaleState::Busy => "busy",
126 WhaleState::Read => "read",
127 WhaleState::Search => "search",
128 WhaleState::Write => "write",
129 WhaleState::Run => "run",
130 WhaleState::Browse => "browse",
131 WhaleState::Talk => "talk",
132 WhaleState::Pod { .. } => "pod",
133 WhaleState::NeedsYou => "needs",
134 WhaleState::Done => "done",
135 WhaleState::Stuck => "hmm",
136 WhaleState::Asleep => "sleep",
137 WhaleState::Computer => "computer",
138 WhaleState::Connect => "connect",
139 }
140 }
141
142 /// The action named `key` in the v2 kit. The pod comes back with three
143 /// calves; set the real count on it.
144 #[must_use]
145 pub fn from_key(key: &str) -> Option<Self> {
146 Self::ALL.into_iter().find(|s| s.key() == key)
147 }
148
149 /// The key and calf count in `assets/whale-v2.scenes`.
150 fn scene_key(self) -> (&'static str, u8) {
151 match self {
152 WhaleState::Pod { calves: 0 } => ("busy", 0),
153 WhaleState::Pod { calves } => ("pod", calves.min(3)),
154 other => (other.key(), 0),
155 }
156 }
157
158 /// The status this pose stands for: its mark and hue beside the words.
159 #[must_use]
160 pub const fn state(self) -> State {
161 match self {
162 WhaleState::Rest | WhaleState::Listen => State::Ready,
163 WhaleState::Think
164 | WhaleState::Busy
165 | WhaleState::Read
166 | WhaleState::Search
167 | WhaleState::Write
168 | WhaleState::Run
169 | WhaleState::Browse
170 | WhaleState::Talk
171 | WhaleState::Pod { .. }
172 | WhaleState::Computer
173 | WhaleState::Connect => State::Working,
174 WhaleState::NeedsYou => State::NeedsYou,
175 WhaleState::Done => State::Done,
176 WhaleState::Stuck => State::Failed,
177 WhaleState::Asleep => State::Stopped,
178 }
179 }
180
181 /// The English words beside the art. Hosts pass localized words with
182 /// [`Whale::words`].
183 #[must_use]
184 pub fn words(self) -> Cow<'static, str> {
185 match self {
186 WhaleState::Rest => "Resting".into(),
187 WhaleState::Listen => "Listening".into(),
188 WhaleState::Think => "Thinking".into(),
189 WhaleState::Busy | WhaleState::Pod { calves: 0 } => "Working".into(),
190 WhaleState::Read => "Reading".into(),
191 WhaleState::Search => "Searching".into(),
192 WhaleState::Write => "Editing".into(),
193 WhaleState::Run => "Running a command".into(),
194 WhaleState::Browse => "Browsing".into(),
195 WhaleState::Talk => "Replying".into(),
196 WhaleState::Pod { calves: 1 } => "Working with 1 agent".into(),
197 WhaleState::Pod { calves } => format!("Working with {calves} agents").into(),
198 WhaleState::NeedsYou => "Needs you".into(),
199 WhaleState::Done => "Done".into(),
200 WhaleState::Stuck => "Stuck".into(),
201 WhaleState::Asleep => "Asleep".into(),
202 WhaleState::Computer => "Using the computer".into(),
203 WhaleState::Connect => "Calling a connected app".into(),
204 }
205 }
206 }
207
208 /// A closed contour: a start point and cubic segments `(c1, c2, end)`.
209 #[derive(Clone, Debug, PartialEq)]
210 pub struct Contour {
211 pub start: [f64; 2],
212 pub cubics: Vec<[f64; 6]>,
213 }
214
215 /// One filled shape and its compound holes, filled even-odd together.
216 #[derive(Clone, Debug, PartialEq)]
217 pub struct Shape {
218 pub id: String,
219 pub role: String,
220 pub contours: Vec<Contour>,
221 }
222
223 /// A posed whale at one optical size.
224 #[derive(Clone, Debug, PartialEq)]
225 pub struct Scene {
226 pub state: String,
227 pub calves: u8,
228 pub size: u32,
229 pub shapes: Vec<Shape>,
230 }
231
232 const SCENES_SOURCE: &str = include_str!("../assets/whale-v2.scenes");
233
234 /// Every exported scene, parsed once.
235 ///
236 /// # Panics
237 /// Only if the checked-in asset is malformed, which `tests/whale.rs` rules
238 /// out.
239 pub fn scenes() -> &'static [Scene] {
240 static SCENES: OnceLock<Vec<Scene>> = OnceLock::new();
241 SCENES.get_or_init(|| parse_scenes(SCENES_SOURCE).expect("whale-v2.scenes parses"))
242 }
243
244 fn parse_scenes(src: &str) -> Result<Vec<Scene>, String> {
245 let mut scenes: Vec<Scene> = Vec::new();
246 for (n, line) in src.lines().enumerate() {
247 let err = |what: &str| format!("line {}: {what}", n + 1);
248 let mut words = line.split_ascii_whitespace();
249 match words.next() {
250 None | Some("#") => {}
251 Some(w) if w.starts_with('#') => {}
252 Some("scene") => {
253 let state = words.next().ok_or_else(|| err("scene state"))?.to_string();
254 let calves = words
255 .next()
256 .and_then(|w| w.parse().ok())
257 .ok_or_else(|| err("calves"))?;
258 let size = words
259 .next()
260 .and_then(|w| w.parse().ok())
261 .ok_or_else(|| err("size"))?;
262 scenes.push(Scene {
263 state,
264 calves,
265 size,
266 shapes: Vec::new(),
267 });
268 }
269 Some("shape") => {
270 let scene = scenes.last_mut().ok_or_else(|| err("shape before scene"))?;
271 scene.shapes.push(Shape {
272 id: words.next().ok_or_else(|| err("shape id"))?.to_string(),
273 role: words.next().ok_or_else(|| err("shape role"))?.to_string(),
274 contours: Vec::new(),
275 });
276 }
277 Some("path") => {
278 let shape = scenes
279 .last_mut()
280 .and_then(|s| s.shapes.last_mut())
281 .ok_or_else(|| err("path before shape"))?;
282 let nums: Vec<f64> = words
283 .map(|w| w.parse::<f64>().map_err(|_| err("number")))
284 .collect::<Result<_, _>>()?;
285 if nums.len() < 2 || !(nums.len() - 2).is_multiple_of(6) {
286 return Err(err("path length"));
287 }
288 shape.contours.push(Contour {
289 start: [nums[0], nums[1]],
290 cubics: nums[2..].as_chunks::<6>().0.to_vec(),
291 });
292 }
293 Some(other) => return Err(err(&format!("unknown record {other}"))),
294 }
295 }
296 Ok(scenes)
297 }
298
299 /// The scene for `state` whose optical size is nearest `size`.
300 #[must_use]
301 pub fn scene(state: WhaleState, size: u32) -> Option<&'static Scene> {
302 let (key, calves) = state.scene_key();
303 scenes()
304 .iter()
305 .filter(|s| s.state == key && s.calves == calves)
306 .min_by_key(|s| s.size.abs_diff(size))
307 }
308
309 /// Packed Braille cells, row-major; `0` is an empty cell.
310 #[derive(Clone, Debug, PartialEq, Eq)]
311 pub struct Grid {
312 pub cols: u16,
313 pub rows: u16,
314 pub cells: Vec<u8>,
315 }
316
317 impl Grid {
318 #[must_use]
319 pub fn cell(&self, col: u16, row: u16) -> u8 {
320 self.cells[usize::from(row) * usize::from(self.cols) + usize::from(col)]
321 }
322
323 /// The cell as text: `U+2800 + bits`, or a space.
324 #[must_use]
325 pub fn char_at(&self, col: u16, row: u16) -> char {
326 match self.cell(col, row) {
327 0 => ' ',
328 bits => char::from_u32(0x2800 + u32::from(bits)).unwrap_or(' '),
329 }
330 }
331
332 /// Rows of text, joined by newlines, as the kit's stills are written.
333 #[must_use]
334 pub fn text(&self) -> String {
335 (0..self.rows)
336 .map(|r| {
337 (0..self.cols)
338 .map(|c| self.char_at(c, r))
339 .collect::<String>()
340 })
341 .collect::<Vec<_>>()
342 .join("\n")
343 }
344 }
345
346 /// Flatten one contour into straight edges, exactly as `braille.js` does:
347 /// ten segments per cubic, then close back to the start.
348 fn edges(contour: &Contour, out: &mut Vec<[f64; 4]>) {
349 let mut pen = contour.start;
350 for c in &contour.cubics {
351 let p = pen;
352 for i in 1..=10 {
353 let t = f64::from(i) / 10.0;
354 let u = 1.0 - t;
355 let q = [
356 u * u * u * p[0]
357 + 3.0 * u * u * t * c[0]
358 + 3.0 * u * t * t * c[2]
359 + t * t * t * c[4],
360 u * u * u * p[1]
361 + 3.0 * u * u * t * c[1]
362 + 3.0 * u * t * t * c[3]
363 + t * t * t * c[5],
364 ];
365 out.push([pen[0], pen[1], q[0], q[1]]);
366 pen = q;
367 }
368 }
369 out.push([pen[0], pen[1], contour.start[0], contour.start[1]]);
370 }
371
372 /// Rasterize a scene into `cols` x `rows` Braille cells. `cell_aspect` is a
373 /// cell's width over its height (0.5 for a typical monospace font).
374 #[must_use]
375 pub fn rasterize(scene: &Scene, cols: u16, rows: u16, cell_aspect: f64) -> Grid {
376 let width = usize::from(cols) * 2;
377 let height = usize::from(rows) * 4;
378 let (w, h) = (width as f64, height as f64);
379 let mut dots = vec![false; width * height];
380 let scale = (w * cell_aspect * 2.0).min(h) / 124.0;
381 let sx = scale / (cell_aspect * 2.0);
382 let sy = scale;
383 let mut shape_edges = Vec::new();
384 let mut cross = Vec::new();
385 for shape in &scene.shapes {
386 shape_edges.clear();
387 for contour in &shape.contours {
388 edges(contour, &mut shape_edges);
389 }
390 for e in &mut shape_edges {
391 *e = [
392 w / 2.0 + e[0] * sx,
393 h / 2.0 + e[1] * sy,
394 w / 2.0 + e[2] * sx,
395 h / 2.0 + e[3] * sy,
396 ];
397 }
398 for y in 0..height {
399 let cy = y as f64 + 0.5;
400 cross.clear();
401 for &[x1, y1, x2, y2] in &shape_edges {
402 if (y1 > cy) != (y2 > cy) {
403 cross.push(x1 + (cy - y1) * (x2 - x1) / (y2 - y1));
404 }
405 }
406 cross.sort_by(f64::total_cmp);
407 for pair in cross.as_chunks::<2>().0 {
408 let left = (pair[0] - 0.5).ceil().max(0.0);
409 let right = (pair[1] - 0.5).ceil().min(w);
410 if right <= left {
411 continue;
412 }
413 for x in left as usize..right as usize {
414 dots[y * width + x] = true;
415 }
416 }
417 }
418 }
419 let mut cells = vec![0u8; usize::from(cols) * usize::from(rows)];
420 for y in 0..height {
421 for x in 0..width {
422 if dots[y * width + x] {
423 cells[(y / 4) * usize::from(cols) + x / 2] |= BITS[y % 4][x % 2];
424 }
425 }
426 }
427 Grid { cols, rows, cells }
428 }
429
430 /// The whale for `state` in a `cols` x `rows` viewport, as the kit's `frame`
431 /// sizes it, or `None` below 16x8 cells, where only the words fit.
432 #[must_use]
433 pub fn frame(state: WhaleState, cols: u16, rows: u16) -> Option<Grid> {
434 if cols < 16 || rows < 8 {
435 return None;
436 }
437 let size = (u32::from(cols) * 2).min(u32::from(rows) * 4);
438 Some(rasterize(scene(state, size)?, cols, rows, 0.5))
439 }
440
441 /// The whale widget: the art, centred, with its words on the row below.
442 #[derive(Clone, Debug, PartialEq, Eq)]
443 pub struct Whale {
444 pub state: WhaleState,
445 /// Localized words; `None` uses [`WhaleState::words`].
446 pub words: Option<Cow<'static, str>>,
447 }
448
449 impl Whale {
450 #[must_use]
451 pub fn new(state: WhaleState) -> Self {
452 Self { state, words: None }
453 }
454
455 #[must_use]
456 pub fn words(mut self, words: impl Into<Cow<'static, str>>) -> Self {
457 self.words = Some(words.into());
458 self
459 }
460
461 /// Paint packed Braille cells evaluated by the host's shared Director.
462 ///
463 /// `grid.cells` must contain exactly `grid.cols * grid.rows` row-major
464 /// bytes. The complete grid, at least 16×8 cells, must fit the intersection
465 /// of `area` and the buffer with one additional row for the state words.
466 /// Invalid or non-fitting grids and ASCII-safe terminals show only those
467 /// words; a supplied frame is never resized or replaced with a poster.
468 ///
469 /// The host owns timing, reduced motion and hidden-surface scheduling.
470 /// It can call [`Paint::paint`] for the static poster instead. Zero bits
471 /// are transparent, preserving the existing ground: repaint the surface
472 /// before each frame, as with other ratatui components. No grid is copied
473 /// or allocated by this method.
474 pub fn paint_frame(&self, area: Rect, buf: &mut Buffer, theme: &Theme, grid: &Grid) {
475 self.paint_grid(area, buf, theme, Some(grid));
476 }
477
478 /// The largest standard viewport (32x16, 20x10, 16x8) that fits.
479 fn viewport(area: Rect) -> Option<(u16, u16)> {
480 let rows = area.height.saturating_sub(1);
481 [(32, 16), (20, 10), (16, 8)]
482 .into_iter()
483 .find(|&(c, r)| area.width >= c && rows >= r)
484 }
485
486 /// Ink for one row of art: the logo ombre in truecolor, `Primary`
487 /// elsewhere, the terminal's own ink without color.
488 fn ink(theme: &Theme, row: u16, rows: u16) -> Style {
489 if !theme.paints_grounds() {
490 return theme.fg(Role::Primary);
491 }
492 let (top, bottom) = match theme.caps().appearance {
493 crate::detect::Appearance::Light => (rgb(LOGO_TOP), rgb(LOGO_BOTTOM)),
494 _ => (theme.token(Role::Primary), rgb(LOGO_TOP)),
495 };
496 let t = f32::from(row) / f32::from(rows.saturating_sub(1).max(1));
497 let c = blend(bottom, top, t);
498 match (theme.depth(), c) {
499 (ColorDepth::TrueColor, c) => Style::default().fg(c),
500 (_, Color::Rgb(r, g, b)) => {
501 Style::default().fg(Color::Indexed(rgb_to_ansi256(r, g, b)))
502 }
503 _ => theme.fg(Role::Primary),
504 }
505 }
506
507 fn paint_label(label: Line<'_>, area: Rect, buf: &mut Buffer) {
508 if area.right() != u16::MAX || label.width() <= usize::from(area.width) {
509 label.render(area, buf);
510 return;
511 }
512 // ratatui 0.30 saturates a truncated Span's cursor at u16::MAX,
513 // then tries to write that exclusive edge. Local coordinates preserve
514 // its ordinary centered clipping without reaching the invalid index.
515 let mut row = Buffer::empty(Rect::new(0, 0, area.width, 1));
516 for x in 0..area.width {
517 row[(x, 0)] = buf[(area.x + x, area.y)].clone();
518 }
519 label.render(row.area, &mut row);
520 for x in 0..area.width {
521 buf[(area.x + x, area.y)] = row[(x, 0)].clone();
522 }
523 }
524
525 fn paint_grid(&self, area: Rect, buf: &mut Buffer, theme: &Theme, grid: Option<&Grid>) {
526 let area = area.intersection(buf.area);
527 if area.is_empty() {
528 return;
529 }
530 let mark = StatusMark::new(self.state.state())
531 .word(self.words.clone().unwrap_or_else(|| self.state.words()));
532 let label = Line::from(mark.spans(theme)).centered();
533 // Braille has no honest ASCII form: dot-count shading turns the whale
534 // into noise. ASCII-safe terminals get the words alone.
535 let grid = grid.filter(|grid| {
536 !theme.ascii()
537 && grid.cols >= 16
538 && grid.rows >= 8
539 && grid.cols <= area.width
540 && grid.rows < area.height
541 && usize::from(grid.cols).checked_mul(usize::from(grid.rows))
542 == Some(grid.cells.len())
543 });
544 let Some(grid) = grid else {
545 Self::paint_label(label, Rect { height: 1, ..area }, buf);
546 return;
547 };
548 let (cols, rows) = (grid.cols, grid.rows);
549 let x0 = area.x + (area.width - cols) / 2;
550 let y0 = area.y + (area.height - rows - 1) / 2;
551 crate::components::paint_braille_cells(
552 Rect::new(x0, y0, cols, rows),
553 buf,
554 &grid.cells,
555 |row| Self::ink(theme, row, rows),
556 );
557 Self::paint_label(
558 label,
559 Rect {
560 y: y0 + rows,
561 height: 1,
562 ..area
563 },
564 buf,
565 );
566 }
567 }
568
569 impl Paint for Whale {
570 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
571 let area = area.intersection(buf.area);
572 let grid = if theme.ascii() {
573 None
574 } else {
575 Self::viewport(area).and_then(|(cols, rows)| frame(self.state, cols, rows))
576 };
577 self.paint_grid(area, buf, theme, grid.as_ref());
578 }
579
580 fn height(&self, _width: u16, _theme: &Theme) -> u16 {
581 17
582 }
583 }
584
584 lines RUST