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    /// Render the whole line the way a plain-text face wants it:
135    /// `-- NORMAL --  3:1  [2/7]  /foo` with the message last.
136    ///
137    /// A face with real layout (the GPU one) reads the fields instead; this is
138    /// for the text/TUI paths and for tests, which want one string to assert
139    /// against.
140    #[must_use]
141    pub fn render(self) -> String {
142        let mut out = String::with_capacity(64);
143        out.push_str(self.mode_label());
144        out.push_str("  ");
145        push_usize(&mut out, self.line);
146        out.push(':');
147        push_usize(&mut out, self.column);
148
149        if !self.count.is_idle() {
150            out.push_str("  ");
151            self.count.render_into(&mut out);
152        }
153
154        if self.prompt.sigil().is_some() {
155            out.push_str("  ");
156            self.render_prompt_into(&mut out);
157        }
158
159        if let Some(msg) = self.message {
160            out.push_str("  ");
161            out.push_str(msg);
162        }
163
164        out
165    }
166}
167
168/// Decimal-append without `format!`.
169fn push_usize(out: &mut String, mut n: usize) {
170    if n == 0 {
171        out.push('0');
172        return;
173    }
174    let mut buf = [0u8; 20];
175    let mut i = buf.len();
176    while n > 0 {
177        i -= 1;
178        buf[i] = b'0' + u8::try_from(n % 10).unwrap_or(0);
179        n /= 10;
180    }
181    out.push_str(core::str::from_utf8(&buf[i..]).unwrap_or("?"));
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn model<'a>(
189        prompt: PromptKind,
190        text: &'a str,
191        count: MatchCount,
192        msg: Option<&'a str>,
193    ) -> StatusModel<'a> {
194        StatusModel {
195            mode: Mode::Normal,
196            line: 3,
197            column: 1,
198            prompt,
199            prompt_text: text,
200            prompt_caret: text.chars().count(),
201            count,
202            message: msg,
203        }
204    }
205
206    #[test]
207    fn every_prompt_kind_has_the_sigil_a_user_expects() {
208        assert_eq!(PromptKind::None.sigil(), None);
209        assert_eq!(PromptKind::SearchForward.sigil(), Some('/'));
210        assert_eq!(PromptKind::SearchBackward.sigil(), Some('?'));
211        assert_eq!(PromptKind::Ex.sigil(), Some(':'));
212        assert!(PromptKind::SearchForward.is_search());
213        assert!(PromptKind::SearchBackward.is_search());
214        assert!(!PromptKind::Ex.is_search());
215    }
216
217    #[test]
218    fn a_search_prompt_renders_its_pattern() {
219        // The regression this module exists for: the prompt must appear.
220        let s = model(PromptKind::SearchForward, "foo", MatchCount::Idle, None).render();
221        assert!(s.contains("/foo"), "prompt missing from {s:?}");
222
223        let mut only = String::new();
224        model(PromptKind::SearchBackward, "bar", MatchCount::Idle, None)
225            .render_prompt_into(&mut only);
226        assert_eq!(only, "?bar");
227    }
228
229    #[test]
230    fn no_prompt_renders_no_sigil() {
231        let mut out = String::new();
232        model(PromptKind::None, "", MatchCount::Idle, None).render_prompt_into(&mut out);
233        assert!(out.is_empty(), "got {out:?}");
234    }
235
236    #[test]
237    fn the_count_is_shown_and_idle_is_silent() {
238        let s = model(
239            PromptKind::None,
240            "",
241            MatchCount::Exact {
242                current: 2,
243                total: 7,
244            },
245            None,
246        )
247        .render();
248        assert!(s.contains("[2/7]"), "{s}");
249
250        let idle = model(PromptKind::None, "", MatchCount::Idle, None).render();
251        assert!(!idle.contains('['), "idle must draw nothing: {idle}");
252    }
253
254    #[test]
255    fn zero_matches_says_so_rather_than_going_quiet() {
256        let s = model(PromptKind::SearchForward, "zzz", MatchCount::None, None).render();
257        assert!(s.contains("[0/0]"), "{s}");
258    }
259
260    #[test]
261    fn a_capped_count_is_marked_as_capped() {
262        let s = model(
263            PromptKind::None,
264            "",
265            MatchCount::Capped { current: 5 },
266            None,
267        )
268        .render();
269        assert!(s.contains("[5/>99]"), "{s}");
270    }
271
272    #[test]
273    fn the_newest_message_reaches_the_line() {
274        let s = model(
275            PromptKind::None,
276            "",
277            MatchCount::Idle,
278            Some("E486: Pattern not found: zzz"),
279        )
280        .render();
281        assert!(s.contains("E486"), "{s}");
282    }
283
284    /// The reported defect: `/foo` produced a status line that read
285    /// `: COMMAND`, which is what pressing `:` produces. A search must
286    /// announce itself as a search, on every face, from one decision.
287    #[test]
288    fn an_open_search_reports_as_search_not_command() {
289        let searching = |dir| StatusModel {
290            mode: Mode::Command, // search genuinely runs in Command mode…
291            prompt: dir,
292            ..model(PromptKind::None, "foo", MatchCount::Idle, None)
293        };
294        for dir in [PromptKind::SearchForward, PromptKind::SearchBackward] {
295            let m = searching(dir);
296            assert_eq!(m.mode_label(), "SEARCH", "{dir:?}"); // …and says SEARCH
297            assert_ne!(m.pill_sigil(), Some(':'), "{dir:?}");
298            assert!(m.render().starts_with("SEARCH"), "{}", m.render());
299        }
300        assert_eq!(m_ex().mode_label(), "COMMAND");
301        assert_eq!(m_ex().pill_sigil(), Some(':'));
302    }
303
304    fn m_ex<'a>() -> StatusModel<'a> {
305        StatusModel {
306            mode: Mode::Command,
307            prompt: PromptKind::Ex,
308            ..model(PromptKind::None, "w", MatchCount::Idle, None)
309        }
310    }
311
312    #[test]
313    fn the_pill_sigil_and_the_label_never_disagree() {
314        // Both derive from PromptKind; this pins that they keep doing so.
315        for (kind, sigil, label) in [
316            (PromptKind::SearchForward, Some('/'), "SEARCH"),
317            (PromptKind::SearchBackward, Some('?'), "SEARCH"),
318            (PromptKind::Ex, Some(':'), "COMMAND"),
319        ] {
320            let m = StatusModel {
321                mode: Mode::Command,
322                prompt: kind,
323                ..model(PromptKind::None, "", MatchCount::Idle, None)
324            };
325            assert_eq!(m.pill_sigil(), sigil, "{kind:?}");
326            assert_eq!(m.mode_label(), label, "{kind:?}");
327        }
328    }
329
330    #[test]
331    fn mode_and_position_are_one_based() {
332        let s = model(PromptKind::None, "", MatchCount::Idle, None).render();
333        assert!(s.starts_with("NORMAL"), "{s}");
334        assert!(s.contains("3:1"), "{s}");
335    }
336
337    #[test]
338    fn large_numbers_render_correctly_without_format() {
339        let m = StatusModel {
340            mode: Mode::Insert,
341            line: 12_345,
342            column: 678,
343            prompt: PromptKind::None,
344            prompt_text: "",
345            prompt_caret: 0,
346            count: MatchCount::Exact {
347                current: 10,
348                total: 99,
349            },
350            message: None,
351        };
352        let s = m.render();
353        assert!(s.contains("12345:678"), "{s}");
354        assert!(s.contains("[10/99]"), "{s}");
355    }
356}