Skip to main content

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}