escriba_ui/splash.rs
1//! `Splash` — the start screen escriba paints when it opens with no file.
2//!
3//! ## Why this is a model and not three renderers
4//!
5//! Every editor in the category ships one of these (vim's `:intro`,
6//! alpha.nvim / startify, emacs' `*GNU Emacs*` buffer, the VSCode welcome
7//! tab) and every one of them re-implements the centering, the art, and
8//! the menu inside its own draw path. escriba has THREE faces — ratatui,
9//! GPU, and the one-shot ANSI dump — so a per-face start screen would be
10//! the same layout arithmetic written three times, drifting three ways.
11//!
12//! This module is the one model. It answers exactly one question —
13//! *"given a `width × height` canvas, what goes where, and in which
14//! ROLE?"* — and returns [`SplashRow`]s. It never touches a color, a
15//! terminal, or a GPU: a role becomes a color at the [`ChromePalette`]
16//! seam, which is the same seam the rest of the chrome resolves through,
17//! so the start screen tracks a theme change for free.
18//!
19//! ## Why the content is not here
20//!
21//! There is deliberately no `Splash::default()` carrying escriba's art
22//! and menu. The content is authored — `(defsplash …)` in
23//! `configs/blnvim-defaults.lisp` — and lowered into this type by
24//! `escriba_lisp::apply_plan_to_splash`. A hand-written default factory
25//! for something this configurable is exactly the shape this repo's
26//! conventions forbid ("if it's configurable, it's a def-form").
27
28use escriba_core::Action;
29use ishou_tokens::Rgb;
30use serde::{Deserialize, Serialize};
31
32use crate::chrome::ChromePalette;
33
34/// What a run of splash text MEANS. Roles, never hues — `MenuKey` is the
35/// accent on every theme, and no call site knows which accent that is.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37pub enum SplashRole {
38 /// The wordmark.
39 Art,
40 /// The one-line statement under the wordmark.
41 Tagline,
42 /// The divider rule.
43 Rule,
44 /// The key an operator presses to take a menu entry.
45 MenuKey,
46 /// What that key does.
47 MenuLabel,
48 /// The bottom fact strip (version, counts, theme).
49 Footer,
50}
51
52impl SplashRole {
53 /// Resolve this role against a chrome palette.
54 ///
55 /// Total over the enum — no wildcard arm — so adding a role fails
56 /// this to compile rather than silently painting it as body text.
57 #[must_use]
58 pub fn color(self, c: &ChromePalette) -> Rgb {
59 match self {
60 Self::Art => c.info,
61 // The tagline is a statement, not chrome — `text_dim` on Nord
62 // is the comment colour and sinks it into the background.
63 Self::Tagline | Self::MenuLabel => c.text,
64 Self::Rule | Self::Footer => c.text_dim,
65 Self::MenuKey => c.accent,
66 }
67 }
68}
69
70/// One styled run within a row.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct SplashSpan {
73 pub text: String,
74 pub role: SplashRole,
75}
76
77/// One laid-out row: which terminal row, which starting column, and the
78/// spans that fill it. Faces render this verbatim — there is no layout
79/// arithmetic left for them to do.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct SplashRow {
82 pub row: u16,
83 pub col: u16,
84 pub spans: Vec<SplashSpan>,
85}
86
87impl SplashRow {
88 /// The row's text with styling dropped — what the ANSI-free and
89 /// test paths compare against.
90 #[must_use]
91 pub fn plain(&self) -> String {
92 let mut s = String::new();
93 for span in &self.spans {
94 s.push_str(&span.text);
95 }
96 s
97 }
98
99 /// Printable width in cells.
100 #[must_use]
101 pub fn width(&self) -> usize {
102 self.spans.iter().map(|s| s.text.chars().count()).sum()
103 }
104}
105
106/// A menu entry — a key, what it does in words, and the typed action it
107/// dispatches. The action is already RESOLVED: the authoring layer turns
108/// `:action "quit"` into [`Action::Quit`] before it reaches here, so the
109/// runtime dispatches a menu entry through the same path a keybinding
110/// takes, and an entry can never be a string the editor cannot honour.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct SplashEntry {
113 pub key: char,
114 pub label: String,
115 pub action: Action,
116}
117
118/// The start screen, as data.
119#[derive(Debug, Clone, Default, PartialEq, Eq)]
120pub struct Splash {
121 /// Wordmark rows, top to bottom. Dropped wholesale when the canvas
122 /// is too small for them (see [`Splash::rows`]).
123 pub art: Vec<String>,
124 /// The line under the wordmark.
125 pub tagline: String,
126 /// The menu, in display order.
127 pub entries: Vec<SplashEntry>,
128 /// Footer facts — joined with `·` at render time. Filled by the
129 /// binary with live values (version, plugin count, theme), so the
130 /// strip states what this build actually is rather than what a
131 /// static string once claimed.
132 pub facts: Vec<String>,
133}
134
135/// Gap in cells between a menu key and its label.
136const KEY_GAP: usize = 3;
137/// Minimum side margin — the canvas must fit the block plus this on
138/// each side or the block degrades.
139const MARGIN: usize = 2;
140/// The compact wordmark used when the canvas is too narrow for the art.
141const COMPACT_ART: &str = "escriba";
142/// Separator between footer facts.
143const FACT_SEP: &str = " · ";
144
145impl Splash {
146 /// Nothing to paint — no art, no tagline, no entries, no facts.
147 #[must_use]
148 pub fn is_empty(&self) -> bool {
149 self.art.is_empty()
150 && self.tagline.is_empty()
151 && self.entries.is_empty()
152 && self.facts.is_empty()
153 }
154
155 /// The entry `key` selects, if any.
156 #[must_use]
157 pub fn entry_for(&self, key: char) -> Option<&SplashEntry> {
158 self.entries.iter().find(|e| e.key == key)
159 }
160
161 /// Lay the splash out on a `width × height` canvas.
162 ///
163 /// Degrades rather than overflowing, in a fixed order: the art drops
164 /// to a one-line wordmark when it does not fit the width, the whole
165 /// art block drops when it does not fit the height, and the menu
166 /// truncates from the bottom last. An empty result means the canvas
167 /// is too small for even the wordmark — the caller paints nothing,
168 /// never a mangled frame.
169 ///
170 /// Rows are guaranteed to sit inside the canvas: `row < height` and
171 /// `col + row_width <= width` for every row returned.
172 #[must_use]
173 pub fn rows(&self, width: u16, height: u16) -> Vec<SplashRow> {
174 let (w, h) = (width as usize, height as usize);
175 if self.is_empty() || w <= MARGIN * 2 || h == 0 {
176 return Vec::new();
177 }
178 let usable = w - MARGIN * 2;
179
180 let mut blocks = self.blocks(usable);
181 // Height degradation, worst-affordable-first: the art is the
182 // largest and least load-bearing block, so it goes before a
183 // single menu entry does.
184 if total_lines(&blocks) > h {
185 blocks.retain(|b| b.kind != BlockKind::Art);
186 }
187 while total_lines(&blocks) > h {
188 if !truncate_menu(&mut blocks) {
189 return Vec::new();
190 }
191 }
192
193 let total = total_lines(&blocks);
194 // Optical centre, not geometric: a block placed at exact centre
195 // reads as sitting low, so it is nudged to two-fifths down.
196 let top = (h - total) * 2 / 5;
197
198 let mut out = Vec::with_capacity(total);
199 let mut row = top;
200 for block in &blocks {
201 // Each block is centred as a UNIT on its own widest line, so
202 // menu entries stay column-aligned with each other instead of
203 // each entry drifting to its own centre.
204 let bw = block.width();
205 let left = MARGIN + (usable.saturating_sub(bw)) / 2;
206 for line in &block.lines {
207 if !line.is_empty() {
208 // Both fit: `row` is bounded by `height` and `left` by
209 // `width`, each of which arrived as a u16. The saturating
210 // conversion is belt-and-braces, never reached.
211 out.push(SplashRow {
212 row: u16::try_from(row).unwrap_or(u16::MAX),
213 col: u16::try_from(left).unwrap_or(u16::MAX),
214 spans: line.clone(),
215 });
216 }
217 row += 1;
218 }
219 }
220 out
221 }
222
223 /// The same layout, flattened into one screen-shaped stream of
224 /// `(text, role)` chunks whose concatenation IS the screen — padding
225 /// and newlines included.
226 ///
227 /// [`Self::rows`] suits a face that positions widgets (ratatui); this
228 /// suits the two that emit a character stream (the ANSI dump and the
229 /// GPU's rich-text buffer, which requires a coverage-complete
230 /// partition). Both derive from `rows`, so a face can pick the shape
231 /// it wants without a second layout ever existing.
232 ///
233 /// Trailing blank lines are not emitted — nothing is gained by
234 /// painting empty rows to the bottom of the canvas.
235 #[must_use]
236 pub fn screen_chunks(&self, width: u16, height: u16) -> Vec<SplashSpan> {
237 let rows = self.rows(width, height);
238 let mut out: Vec<SplashSpan> = Vec::new();
239 let mut cursor_row = 0u16;
240 for r in &rows {
241 for _ in cursor_row..r.row {
242 out.push(pad("\n"));
243 }
244 cursor_row = r.row + 1;
245 if r.col > 0 {
246 out.push(pad(&" ".repeat(r.col as usize)));
247 }
248 out.extend(r.spans.iter().cloned());
249 out.push(pad("\n"));
250 }
251 out
252 }
253
254 /// Compose the vertical blocks, already clipped to `usable` width.
255 fn blocks(&self, usable: usize) -> Vec<Block> {
256 let mut blocks = Vec::new();
257
258 if !self.art.is_empty() {
259 let fits = self.art.iter().all(|l| l.chars().count() <= usable);
260 let lines: Vec<Vec<SplashSpan>> = if fits {
261 self.art
262 .iter()
263 .map(|l| span_line(l, SplashRole::Art))
264 .collect()
265 } else {
266 vec![span_line(COMPACT_ART, SplashRole::Art)]
267 };
268 blocks.push(Block::new(BlockKind::Art, lines));
269 }
270
271 if !self.tagline.is_empty() {
272 let tagline = clip(&self.tagline, usable);
273 let rule_width = tagline.chars().count().min(usable);
274 blocks.push(Block::new(
275 BlockKind::Head,
276 vec![
277 Vec::new(),
278 span_line(&tagline, SplashRole::Tagline),
279 span_line(&"─".repeat(rule_width), SplashRole::Rule),
280 ],
281 ));
282 }
283
284 if !self.entries.is_empty() {
285 let label_room = usable.saturating_sub(1 + KEY_GAP);
286 let mut lines = vec![Vec::new()];
287 for e in &self.entries {
288 let mut gap = String::with_capacity(KEY_GAP);
289 for _ in 0..KEY_GAP {
290 gap.push(' ');
291 }
292 gap.push_str(&clip(&e.label, label_room));
293 lines.push(vec![
294 SplashSpan {
295 text: e.key.to_string(),
296 role: SplashRole::MenuKey,
297 },
298 SplashSpan {
299 text: gap,
300 role: SplashRole::MenuLabel,
301 },
302 ]);
303 }
304 blocks.push(Block::new(BlockKind::Menu, lines));
305 }
306
307 if !self.facts.is_empty() {
308 let strip = clip(&self.facts.join(FACT_SEP), usable);
309 blocks.push(Block::new(
310 BlockKind::Foot,
311 vec![Vec::new(), span_line(&strip, SplashRole::Footer)],
312 ));
313 }
314
315 blocks
316 }
317}
318
319/// Which vertical block a group of lines belongs to. Only the degrade
320/// order reads this, but naming the groups is what makes that order
321/// reviewable instead of an index into a vector.
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323enum BlockKind {
324 Art,
325 Head,
326 Menu,
327 Foot,
328}
329
330/// A vertical run of lines centred as one unit.
331struct Block {
332 kind: BlockKind,
333 lines: Vec<Vec<SplashSpan>>,
334}
335
336impl Block {
337 fn new(kind: BlockKind, lines: Vec<Vec<SplashSpan>>) -> Self {
338 Self { kind, lines }
339 }
340
341 /// The block's widest line — what it is centred on.
342 fn width(&self) -> usize {
343 self.lines
344 .iter()
345 .map(|l| l.iter().map(|s| s.text.chars().count()).sum::<usize>())
346 .max()
347 .unwrap_or(0)
348 }
349}
350
351fn total_lines(blocks: &[Block]) -> usize {
352 blocks.iter().map(|b| b.lines.len()).sum()
353}
354
355/// Drop the last menu entry. Returns `false` when there is nothing left
356/// to give up, which is the caller's signal to render nothing at all.
357fn truncate_menu(blocks: &mut Vec<Block>) -> bool {
358 let Some(menu) = blocks.iter_mut().find(|b| b.kind == BlockKind::Menu) else {
359 return false;
360 };
361 // `> 1` keeps the leading blank spacer honest: a menu block that is
362 // only its spacer is no menu, so it is removed outright below.
363 if menu.lines.len() > 1 {
364 menu.lines.pop();
365 if menu.lines.len() == 1 {
366 blocks.retain(|b| b.kind != BlockKind::Menu);
367 }
368 return true;
369 }
370 blocks.retain(|b| b.kind != BlockKind::Menu);
371 !blocks.is_empty()
372}
373
374/// Whitespace carrying no meaning. It needs SOME role to keep the stream a
375/// complete partition (glyphon's `set_rich_text` requires one); `Footer` is
376/// the quietest, and spaces render the same in any of them.
377fn pad(text: &str) -> SplashSpan {
378 SplashSpan {
379 text: text.to_string(),
380 role: SplashRole::Footer,
381 }
382}
383
384fn span_line(text: &str, role: SplashRole) -> Vec<SplashSpan> {
385 vec![SplashSpan {
386 text: text.to_string(),
387 role,
388 }]
389}
390
391/// Clip to `max` CHARACTERS (not bytes) so multibyte content never
392/// splits mid-glyph.
393fn clip(s: &str, max: usize) -> String {
394 if s.chars().count() <= max {
395 return s.to_string();
396 }
397 s.chars().take(max).collect()
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use escriba_core::Mode;
404
405 fn sample() -> Splash {
406 Splash {
407 art: vec![" ___ ".into(), " |___| ".into()],
408 tagline: "a modal editor".into(),
409 entries: vec![
410 SplashEntry {
411 key: 'e',
412 label: "start editing".into(),
413 action: Action::ChangeMode(Mode::Normal),
414 },
415 SplashEntry {
416 key: 'q',
417 label: "quit".into(),
418 action: Action::Quit,
419 },
420 ],
421 facts: vec!["v0.1.0".into(), "nord".into()],
422 }
423 }
424
425 #[test]
426 fn empty_splash_paints_nothing() {
427 assert!(Splash::default().rows(120, 40).is_empty());
428 }
429
430 #[test]
431 fn rows_stay_inside_the_canvas() {
432 // The invariant every face relies on: nothing this returns can
433 // draw outside the area it was given.
434 for (w, h) in [(120u16, 40u16), (80, 24), (40, 12), (20, 8), (10, 4)] {
435 for r in sample().rows(w, h) {
436 assert!(r.row < h, "row {} outside height {h}", r.row);
437 assert!(
438 r.col as usize + r.width() <= w as usize,
439 "row {} overflows width {w}: col={} width={}",
440 r.row,
441 r.col,
442 r.width(),
443 );
444 }
445 }
446 }
447
448 #[test]
449 fn a_roomy_canvas_shows_art_tagline_menu_and_facts() {
450 let text: Vec<String> = sample()
451 .rows(120, 40)
452 .iter()
453 .map(SplashRow::plain)
454 .collect();
455 let joined = text.join("\n");
456 assert!(joined.contains("|___|"), "art missing: {joined}");
457 assert!(joined.contains("a modal editor"), "tagline missing");
458 assert!(joined.contains("start editing"), "menu missing");
459 assert!(joined.contains("nord"), "facts missing");
460 }
461
462 #[test]
463 fn a_narrow_canvas_falls_back_to_the_compact_wordmark() {
464 let s = Splash {
465 art: vec!["#".repeat(60)],
466 ..sample()
467 };
468 let joined = s
469 .rows(30, 20)
470 .iter()
471 .map(SplashRow::plain)
472 .collect::<Vec<_>>()
473 .join("\n");
474 assert!(
475 joined.contains(COMPACT_ART),
476 "no compact wordmark: {joined}"
477 );
478 assert!(
479 !joined.contains("######"),
480 "wide art survived a narrow canvas"
481 );
482 }
483
484 #[test]
485 fn a_short_canvas_drops_the_art_before_the_menu() {
486 // 7 lines: not enough for art + head + menu + foot. The art goes
487 // first (largest, least actionable) and the menu truncates from the
488 // BOTTOM, so the first entries — the ones authored as most useful —
489 // are the ones that survive.
490 let joined = sample()
491 .rows(80, 7)
492 .iter()
493 .map(SplashRow::plain)
494 .collect::<Vec<_>>()
495 .join("\n");
496 assert!(
497 !joined.contains("|___|"),
498 "art should have dropped: {joined}"
499 );
500 assert!(
501 joined.contains("start editing"),
502 "the first menu entry must survive: {joined}",
503 );
504 }
505
506 #[test]
507 fn a_canvas_with_no_room_paints_nothing_rather_than_a_mangled_frame() {
508 assert!(sample().rows(120, 1).is_empty());
509 assert!(sample().rows(2, 40).is_empty());
510 }
511
512 #[test]
513 fn menu_entries_share_one_left_column() {
514 // Column alignment is the whole reason blocks are centred as a
515 // unit; if each entry centred itself the keys would zig-zag.
516 let rows = sample().rows(120, 40);
517 let cols: Vec<u16> = rows
518 .iter()
519 .filter(|r| {
520 r.spans
521 .first()
522 .is_some_and(|s| s.role == SplashRole::MenuKey)
523 })
524 .map(|r| r.col)
525 .collect();
526 assert_eq!(cols.len(), 2);
527 assert_eq!(cols[0], cols[1], "menu keys must share a column");
528 }
529
530 #[test]
531 fn entry_lookup_resolves_the_typed_action() {
532 let s = sample();
533 assert_eq!(s.entry_for('q').map(|e| &e.action), Some(&Action::Quit));
534 assert!(s.entry_for('z').is_none());
535 }
536
537 #[test]
538 fn roles_resolve_to_distinct_chrome_colors() {
539 // A start screen whose accent and body text resolve the same is
540 // one where the menu keys stop reading as keys.
541 let c = ChromePalette::prescribed();
542 assert_ne!(
543 SplashRole::MenuKey.color(&c).hex(),
544 SplashRole::MenuLabel.color(&c).hex(),
545 );
546 assert_ne!(
547 SplashRole::Art.color(&c).hex(),
548 SplashRole::Footer.color(&c).hex(),
549 );
550 }
551
552 #[test]
553 fn screen_chunks_reconstruct_the_same_screen_as_rows() {
554 // The two projections must never disagree — that would be two
555 // layouts again, which is the thing this module exists to prevent.
556 let s = sample();
557 let chunks: String = s
558 .screen_chunks(80, 24)
559 .iter()
560 .map(|c| c.text.as_str())
561 .collect();
562 for r in s.rows(80, 24) {
563 let line = chunks.lines().nth(r.row as usize).unwrap_or("");
564 assert_eq!(
565 line,
566 format!("{}{}", " ".repeat(r.col as usize), r.plain()),
567 "row {} differs between rows() and screen_chunks()",
568 r.row,
569 );
570 }
571 }
572
573 #[test]
574 fn screen_chunks_are_a_complete_partition() {
575 // The GPU face feeds this straight into glyphon's `set_rich_text`,
576 // which concatenates the chunks and requires that the result BE the
577 // text — every byte covered exactly once, no gaps, no overlaps. A
578 // gap here is a hole in the rendered screen, on the one face that
579 // cannot be tested headlessly.
580 let s = sample();
581 let chunks = s.screen_chunks(80, 24);
582 assert!(!chunks.is_empty());
583 assert!(
584 chunks.iter().all(|c| !c.text.is_empty()),
585 "an empty chunk is a wasted span",
586 );
587
588 // Byte-exact: the concatenation must reproduce the laid-out screen
589 // line for line, including the padding that positions each row.
590 let joined: String = chunks.iter().map(|c| c.text.as_str()).collect();
591 let mut expected = String::new();
592 let mut cursor_row = 0u16;
593 for r in s.rows(80, 24) {
594 for _ in cursor_row..r.row {
595 expected.push('\n');
596 }
597 cursor_row = r.row + 1;
598 for _ in 0..r.col {
599 expected.push(' ');
600 }
601 expected.push_str(&r.plain());
602 expected.push('\n');
603 }
604 assert_eq!(joined, expected, "chunk stream is not the laid-out screen");
605 }
606
607 #[test]
608 fn no_chunk_line_exceeds_the_canvas_width() {
609 // A line longer than the canvas wraps on the GPU face (glyphon has
610 // no clipping of its own), which would silently push every row below
611 // it out of place.
612 for (w, h) in [(120u16, 40u16), (80, 24), (40, 12), (24, 10)] {
613 let joined: String = sample()
614 .screen_chunks(w, h)
615 .iter()
616 .map(|c| c.text.as_str())
617 .collect();
618 for line in joined.lines() {
619 assert!(
620 line.chars().count() <= w as usize,
621 "line of {} cells on a {w}-wide canvas: {line:?}",
622 line.chars().count(),
623 );
624 }
625 }
626 }
627
628 #[test]
629 fn clip_never_splits_a_multibyte_glyph() {
630 assert_eq!(clip("─────", 3), "───");
631 assert_eq!(clip("abc", 10), "abc");
632 }
633}