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 count: MatchCount,
82 pub message: Option<&'a str>,
87}
88
89impl StatusModel<'_> {
90 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 #[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
133fn 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 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}