1use escriba_core::Mode;
24use escriba_search::MatchCount;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum PromptKind {
34 None,
36 SearchForward,
38 SearchBackward,
40 Ex,
42}
43
44impl PromptKind {
45 #[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 #[must_use]
61 pub const fn is_search(self) -> bool {
62 matches!(self, Self::SearchForward | Self::SearchBackward)
63 }
64}
65
66#[derive(Debug, Clone, Copy)]
71pub struct StatusModel<'a> {
72 pub mode: Mode,
73 pub line: usize,
75 pub column: usize,
77 pub prompt: PromptKind,
78 pub prompt_text: &'a str,
80 pub prompt_caret: usize,
86 pub count: MatchCount,
88 pub message: Option<&'a str>,
93}
94
95impl StatusModel<'_> {
96 #[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 #[must_use]
121 pub const fn pill_sigil(self) -> Option<char> {
122 self.prompt.sigil()
123 }
124
125 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 #[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
168fn 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 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 #[test]
288 fn an_open_search_reports_as_search_not_command() {
289 let searching = |dir| StatusModel {
290 mode: Mode::Command, 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:?}"); 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 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}