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