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}