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    /// Render the prompt segment (`/foo`) into `out`. Empty when no prompt is
97    /// open.
98    pub fn render_prompt_into(self, out: &mut String) {
99        if let Some(sigil) = self.prompt.sigil() {
100            out.push(sigil);
101            out.push_str(self.prompt_text);
102        }
103    }
104
105    /// Render the whole line the way a plain-text face wants it:
106    /// `-- NORMAL --  3:1  [2/7]  /foo` with the message last.
107    ///
108    /// A face with real layout (the GPU one) reads the fields instead; this is
109    /// for the text/TUI paths and for tests, which want one string to assert
110    /// against.
111    #[must_use]
112    pub fn render(self) -> String {
113        let mut out = String::with_capacity(64);
114        out.push_str(self.mode.as_str());
115        out.push_str("  ");
116        push_usize(&mut out, self.line);
117        out.push(':');
118        push_usize(&mut out, self.column);
119
120        if !self.count.is_idle() {
121            out.push_str("  ");
122            self.count.render_into(&mut out);
123        }
124
125        if self.prompt.sigil().is_some() {
126            out.push_str("  ");
127            self.render_prompt_into(&mut out);
128        }
129
130        if let Some(msg) = self.message {
131            out.push_str("  ");
132            out.push_str(msg);
133        }
134
135        out
136    }
137}
138
139/// Decimal-append without `format!`.
140fn push_usize(out: &mut String, mut n: usize) {
141    if n == 0 {
142        out.push('0');
143        return;
144    }
145    let mut buf = [0u8; 20];
146    let mut i = buf.len();
147    while n > 0 {
148        i -= 1;
149        buf[i] = b'0' + u8::try_from(n % 10).unwrap_or(0);
150        n /= 10;
151    }
152    out.push_str(core::str::from_utf8(&buf[i..]).unwrap_or("?"));
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn model<'a>(
160        prompt: PromptKind,
161        text: &'a str,
162        count: MatchCount,
163        msg: Option<&'a str>,
164    ) -> StatusModel<'a> {
165        StatusModel {
166            mode: Mode::Normal,
167            line: 3,
168            column: 1,
169            prompt,
170            prompt_text: text,
171            prompt_caret: text.chars().count(),
172            count,
173            message: msg,
174        }
175    }
176
177    #[test]
178    fn every_prompt_kind_has_the_sigil_a_user_expects() {
179        assert_eq!(PromptKind::None.sigil(), None);
180        assert_eq!(PromptKind::SearchForward.sigil(), Some('/'));
181        assert_eq!(PromptKind::SearchBackward.sigil(), Some('?'));
182        assert_eq!(PromptKind::Ex.sigil(), Some(':'));
183        assert!(PromptKind::SearchForward.is_search());
184        assert!(PromptKind::SearchBackward.is_search());
185        assert!(!PromptKind::Ex.is_search());
186    }
187
188    #[test]
189    fn a_search_prompt_renders_its_pattern() {
190        // The regression this module exists for: the prompt must appear.
191        let s = model(PromptKind::SearchForward, "foo", MatchCount::Idle, None).render();
192        assert!(s.contains("/foo"), "prompt missing from {s:?}");
193
194        let mut only = String::new();
195        model(PromptKind::SearchBackward, "bar", MatchCount::Idle, None)
196            .render_prompt_into(&mut only);
197        assert_eq!(only, "?bar");
198    }
199
200    #[test]
201    fn no_prompt_renders_no_sigil() {
202        let mut out = String::new();
203        model(PromptKind::None, "", MatchCount::Idle, None).render_prompt_into(&mut out);
204        assert!(out.is_empty(), "got {out:?}");
205    }
206
207    #[test]
208    fn the_count_is_shown_and_idle_is_silent() {
209        let s = model(
210            PromptKind::None,
211            "",
212            MatchCount::Exact {
213                current: 2,
214                total: 7,
215            },
216            None,
217        )
218        .render();
219        assert!(s.contains("[2/7]"), "{s}");
220
221        let idle = model(PromptKind::None, "", MatchCount::Idle, None).render();
222        assert!(!idle.contains('['), "idle must draw nothing: {idle}");
223    }
224
225    #[test]
226    fn zero_matches_says_so_rather_than_going_quiet() {
227        let s = model(PromptKind::SearchForward, "zzz", MatchCount::None, None).render();
228        assert!(s.contains("[0/0]"), "{s}");
229    }
230
231    #[test]
232    fn a_capped_count_is_marked_as_capped() {
233        let s = model(
234            PromptKind::None,
235            "",
236            MatchCount::Capped { current: 5 },
237            None,
238        )
239        .render();
240        assert!(s.contains("[5/>99]"), "{s}");
241    }
242
243    #[test]
244    fn the_newest_message_reaches_the_line() {
245        let s = model(
246            PromptKind::None,
247            "",
248            MatchCount::Idle,
249            Some("E486: Pattern not found: zzz"),
250        )
251        .render();
252        assert!(s.contains("E486"), "{s}");
253    }
254
255    #[test]
256    fn mode_and_position_are_one_based() {
257        let s = model(PromptKind::None, "", MatchCount::Idle, None).render();
258        assert!(s.starts_with("NORMAL"), "{s}");
259        assert!(s.contains("3:1"), "{s}");
260    }
261
262    #[test]
263    fn large_numbers_render_correctly_without_format() {
264        let m = StatusModel {
265            mode: Mode::Insert,
266            line: 12_345,
267            column: 678,
268            prompt: PromptKind::None,
269            prompt_text: "",
270            prompt_caret: 0,
271            count: MatchCount::Exact {
272                current: 10,
273                total: 99,
274            },
275            message: None,
276        };
277        let s = m.render();
278        assert!(s.contains("12345:678"), "{s}");
279        assert!(s.contains("[10/99]"), "{s}");
280    }
281}