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 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 #[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
139fn 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 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}