Skip to main content

escriba_runtime/
status.rs

1//! The status line, as DATA — one model, rendered by every face.
2//!
3//! ## Why this exists
4//!
5//! The two faces had drifted, and not cosmetically. The ratatui face drew the
6//! search prompt; the GPU face — escriba's *default* renderer — built its
7//! status line from a fixed `format!()` carrying mode, line, column and
8//! version, and read neither `modal.minibuffer()` nor `search.prompt()`.
9//!
10//! The consequence is worth stating plainly, because it is the whole reason
11//! this module exists: on the default face, typing `/foo` moved the cursor
12//! with **no prompt, no pattern, and no message on screen**. Search was fully
13//! implemented and completely invisible, which is indistinguishable from
14//! search not existing.
15//!
16//! Two faces each deciding independently what a status line contains is a
17//! divergence generator. A shared model makes them disagree only about
18//! *styling*, which is what a face is actually for.
19//!
20//! `format!()` is not used here — the fleet's ★★ TYPED EMISSION rule. Every
21//! rendering path is `push_str`/`push` into a caller-owned buffer.
22
23use escriba_core::Mode;
24use escriba_search::MatchCount;
25
26/// What the command line is currently for.
27///
28/// A search prompt and an ex-command share `Mode::Command` (as they share
29/// vim's cmdline), so the mode alone cannot tell a face which sigil to draw.
30/// This is that discriminator, derived from the same typed `Option<Prompt>`
31/// the runtime already routes `<CR>` on — never a second flag to keep in sync.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum PromptKind {
34    /// Not in the command line at all.
35    None,
36    /// `/` — a forward search.
37    SearchForward,
38    /// `?` — a backward search.
39    SearchBackward,
40    /// `:` — an ex-command.
41    Ex,
42}
43
44impl PromptKind {
45    /// The leading character a face draws for this prompt, if any.
46    ///
47    /// Total over the enum — a new prompt kind is a compile error here rather
48    /// than a prompt that silently renders with no sigil.
49    #[must_use]
50    pub const fn sigil(self) -> Option<char> {
51        match self {
52            Self::None => None,
53            Self::SearchForward => Some('/'),
54            Self::SearchBackward => Some('?'),
55            Self::Ex => Some(':'),
56        }
57    }
58
59    /// Is this a search prompt (either direction)?
60    #[must_use]
61    pub const fn is_search(self) -> bool {
62        matches!(self, Self::SearchForward | Self::SearchBackward)
63    }
64}
65
66/// Everything a status line needs, borrowed from the editor state.
67///
68/// Borrowed rather than owned so building it costs nothing per frame — a face
69/// may call this every redraw.
70#[derive(Debug, Clone, Copy)]
71pub struct StatusModel<'a> {
72    pub mode: Mode,
73    /// 1-based, display-ready.
74    pub line: usize,
75    /// 1-based, display-ready.
76    pub column: usize,
77    pub prompt: PromptKind,
78    /// What has been typed into the prompt, without the sigil.
79    pub prompt_text: &'a str,
80    /// Where the caret sits inside `prompt_text`, in CHARS.
81    ///
82    /// Chars, not bytes, because a face positions a cursor by column and a
83    /// byte index is the wrong number the moment the pattern contains `é`.
84    /// A face draws its cursor at `sigil_width + prompt_caret`.
85    pub prompt_caret: usize,
86    /// `[3/17]`. [`MatchCount::Idle`] when there is nothing to say.
87    pub count: MatchCount,
88    /// The newest message — vim's `E486` and friends. These were written to
89    /// `EditorState::messages` from the start and had no reader outside the
90    /// `eval` CLI's `println!`, so a failed search was indistinguishable from
91    /// a dropped keystroke on both interactive faces.
92    pub message: Option<&'a str>,
93}
94
95impl StatusModel<'_> {
96    /// What the mode indicator should SAY — which is not always the mode.
97    ///
98    /// vim's `/` reuses the command line, so escriba's search runs in
99    /// `Mode::Command`. Reporting the raw mode therefore labelled a search
100    /// `COMMAND` and drew it with the `:` glyph: pressing `/` produced a
101    /// status line indistinguishable from having pressed `:`. The mode was
102    /// right and the *report* was wrong — an implementation detail (which
103    /// mode hosts the prompt) leaking into what the operator is told.
104    ///
105    /// Derived from the same typed `PromptKind` the sigil comes from, so the
106    /// label and the sigil cannot disagree.
107    #[must_use]
108    pub fn mode_label(self) -> &'static str {
109        match self.prompt {
110            PromptKind::SearchForward | PromptKind::SearchBackward => "SEARCH",
111            PromptKind::None | PromptKind::Ex => self.mode.as_str(),
112        }
113    }
114
115    /// The character the mode indicator leads with, when a prompt is open.
116    ///
117    /// `None` means "no prompt — use your own mode glyph". A face that draws
118    /// a pill takes this over its mode glyph so `/` and `?` read as
119    /// themselves instead of as the `:` of an ex-command.
120    #[must_use]
121    pub const fn pill_sigil(self) -> Option<char> {
122        self.prompt.sigil()
123    }
124
125    /// Render the prompt segment (`/foo`) into `out`. Empty when no prompt is
126    /// open.
127    pub fn render_prompt_into(self, out: &mut String) {
128        if let Some(sigil) = self.prompt.sigil() {
129            out.push(sigil);
130            out.push_str(self.prompt_text);
131        }
132    }
133
134    /// Where the caret sits INSIDE the string [`Self::render_prompt_into`]
135    /// writes, in columns. `None` when no prompt is open.
136    ///
137    /// This was a sentence in `prompt_caret`'s doc comment — "a face draws its
138    /// cursor at `sigil_width + prompt_caret`" — for as long as no face did.
139    /// The caret moved, `<C-w>` fired, `Home` jumped, and the screen showed
140    /// none of it, so mid-pattern editing was blind: the model was fully
141    /// correct and the operator could not see any of it. A sentence cannot be
142    /// called; a method can.
143    #[must_use]
144    pub const fn prompt_caret_offset(self) -> Option<usize> {
145        // Every sigil is one column wide (`/`, `?`, `:`), and `sigil()` is
146        // total over `PromptKind` — a wide one cannot arrive unnoticed.
147        match self.prompt.sigil() {
148            Some(_) => Some(1 + self.prompt_caret),
149            None => None,
150        }
151    }
152
153    /// Render the whole line the way a plain-text face wants it:
154    /// `-- NORMAL --  3:1  [2/7]  /foo` with the message last.
155    ///
156    /// A face with real layout (the GPU one) reads the fields instead; this is
157    /// for the text/TUI paths and for tests, which want one string to assert
158    /// against.
159    #[must_use]
160    pub fn render(self) -> String {
161        let mut out = String::with_capacity(64);
162        out.push_str(self.mode_label());
163        out.push_str("  ");
164        push_usize(&mut out, self.line);
165        out.push(':');
166        push_usize(&mut out, self.column);
167
168        if !self.count.is_idle() {
169            out.push_str("  ");
170            self.count.render_into(&mut out);
171        }
172
173        if self.prompt.sigil().is_some() {
174            out.push_str("  ");
175            self.render_prompt_into(&mut out);
176        }
177
178        if let Some(msg) = self.message {
179            out.push_str("  ");
180            out.push_str(msg);
181        }
182
183        out
184    }
185}
186
187/// Decimal-append without `format!`.
188fn push_usize(out: &mut String, mut n: usize) {
189    if n == 0 {
190        out.push('0');
191        return;
192    }
193    let mut buf = [0u8; 20];
194    let mut i = buf.len();
195    while n > 0 {
196        i -= 1;
197        buf[i] = b'0' + u8::try_from(n % 10).unwrap_or(0);
198        n /= 10;
199    }
200    out.push_str(core::str::from_utf8(&buf[i..]).unwrap_or("?"));
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn model<'a>(
208        prompt: PromptKind,
209        text: &'a str,
210        count: MatchCount,
211        msg: Option<&'a str>,
212    ) -> StatusModel<'a> {
213        StatusModel {
214            mode: Mode::Normal,
215            line: 3,
216            column: 1,
217            prompt,
218            prompt_text: text,
219            prompt_caret: text.chars().count(),
220            count,
221            message: msg,
222        }
223    }
224
225    #[test]
226    fn every_prompt_kind_has_the_sigil_a_user_expects() {
227        assert_eq!(PromptKind::None.sigil(), None);
228        assert_eq!(PromptKind::SearchForward.sigil(), Some('/'));
229        assert_eq!(PromptKind::SearchBackward.sigil(), Some('?'));
230        assert_eq!(PromptKind::Ex.sigil(), Some(':'));
231        assert!(PromptKind::SearchForward.is_search());
232        assert!(PromptKind::SearchBackward.is_search());
233        assert!(!PromptKind::Ex.is_search());
234    }
235
236    #[test]
237    fn a_search_prompt_renders_its_pattern() {
238        // The regression this module exists for: the prompt must appear.
239        let s = model(PromptKind::SearchForward, "foo", MatchCount::Idle, None).render();
240        assert!(s.contains("/foo"), "prompt missing from {s:?}");
241
242        let mut only = String::new();
243        model(PromptKind::SearchBackward, "bar", MatchCount::Idle, None)
244            .render_prompt_into(&mut only);
245        assert_eq!(only, "?bar");
246    }
247
248    #[test]
249    fn no_prompt_renders_no_sigil() {
250        let mut out = String::new();
251        model(PromptKind::None, "", MatchCount::Idle, None).render_prompt_into(&mut out);
252        assert!(out.is_empty(), "got {out:?}");
253    }
254
255    #[test]
256    fn the_count_is_shown_and_idle_is_silent() {
257        let s = model(
258            PromptKind::None,
259            "",
260            MatchCount::Exact {
261                current: 2,
262                total: 7,
263            },
264            None,
265        )
266        .render();
267        assert!(s.contains("[2/7]"), "{s}");
268
269        let idle = model(PromptKind::None, "", MatchCount::Idle, None).render();
270        assert!(!idle.contains('['), "idle must draw nothing: {idle}");
271    }
272
273    #[test]
274    fn zero_matches_says_so_rather_than_going_quiet() {
275        let s = model(PromptKind::SearchForward, "zzz", MatchCount::None, None).render();
276        assert!(s.contains("[0/0]"), "{s}");
277    }
278
279    #[test]
280    fn a_capped_count_is_marked_as_capped() {
281        let s = model(
282            PromptKind::None,
283            "",
284            MatchCount::Capped { current: 5 },
285            None,
286        )
287        .render();
288        assert!(s.contains("[5/>99]"), "{s}");
289    }
290
291    #[test]
292    fn the_newest_message_reaches_the_line() {
293        let s = model(
294            PromptKind::None,
295            "",
296            MatchCount::Idle,
297            Some("E486: Pattern not found: zzz"),
298        )
299        .render();
300        assert!(s.contains("E486"), "{s}");
301    }
302
303    /// The reported defect: `/foo` produced a status line that read
304    /// `: COMMAND`, which is what pressing `:` produces. A search must
305    /// announce itself as a search, on every face, from one decision.
306    #[test]
307    fn an_open_search_reports_as_search_not_command() {
308        let searching = |dir| StatusModel {
309            mode: Mode::Command, // search genuinely runs in Command mode…
310            prompt: dir,
311            ..model(PromptKind::None, "foo", MatchCount::Idle, None)
312        };
313        for dir in [PromptKind::SearchForward, PromptKind::SearchBackward] {
314            let m = searching(dir);
315            assert_eq!(m.mode_label(), "SEARCH", "{dir:?}"); // …and says SEARCH
316            assert_ne!(m.pill_sigil(), Some(':'), "{dir:?}");
317            assert!(m.render().starts_with("SEARCH"), "{}", m.render());
318        }
319        assert_eq!(m_ex().mode_label(), "COMMAND");
320        assert_eq!(m_ex().pill_sigil(), Some(':'));
321    }
322
323    fn m_ex<'a>() -> StatusModel<'a> {
324        StatusModel {
325            mode: Mode::Command,
326            prompt: PromptKind::Ex,
327            ..model(PromptKind::None, "w", MatchCount::Idle, None)
328        }
329    }
330
331    #[test]
332    fn the_pill_sigil_and_the_label_never_disagree() {
333        // Both derive from PromptKind; this pins that they keep doing so.
334        for (kind, sigil, label) in [
335            (PromptKind::SearchForward, Some('/'), "SEARCH"),
336            (PromptKind::SearchBackward, Some('?'), "SEARCH"),
337            (PromptKind::Ex, Some(':'), "COMMAND"),
338        ] {
339            let m = StatusModel {
340                mode: Mode::Command,
341                prompt: kind,
342                ..model(PromptKind::None, "", MatchCount::Idle, None)
343            };
344            assert_eq!(m.pill_sigil(), sigil, "{kind:?}");
345            assert_eq!(m.mode_label(), label, "{kind:?}");
346        }
347    }
348
349    #[test]
350    fn mode_and_position_are_one_based() {
351        let s = model(PromptKind::None, "", MatchCount::Idle, None).render();
352        assert!(s.starts_with("NORMAL"), "{s}");
353        assert!(s.contains("3:1"), "{s}");
354    }
355
356    #[test]
357    fn large_numbers_render_correctly_without_format() {
358        let m = StatusModel {
359            mode: Mode::Insert,
360            line: 12_345,
361            column: 678,
362            prompt: PromptKind::None,
363            prompt_text: "",
364            prompt_caret: 0,
365            count: MatchCount::Exact {
366                current: 10,
367                total: 99,
368            },
369            message: None,
370        };
371        let s = m.render();
372        assert!(s.contains("12345:678"), "{s}");
373        assert!(s.contains("[10/99]"), "{s}");
374    }
375}