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