1use crate::font;
32use crate::{STATUSLINE_HEIGHT, fill_rect};
33
34pub const INPUT_HEIGHT: u32 = 28;
38
39pub const SUGGESTION_ROW_HEIGHT: u32 = STATUSLINE_HEIGHT;
42
43pub const MAX_SUGGESTIONS: usize = 8;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum SuggestionKind {
52 History,
53 Bookmark,
54 Command,
55 SearchSuggestion,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Suggestion {
65 pub display: String,
66 pub value: String,
67 pub kind: SuggestionKind,
68}
69
70#[derive(Debug, Clone, Copy)]
73pub struct Palette {
74 pub bg: u32,
75 pub fg: u32,
76 pub accent: u32,
77 pub border: u32,
78 pub dropdown_bg: u32,
79 pub dropdown_selected_bg: u32,
80 pub dropdown_kind_history: u32,
81 pub dropdown_kind_bookmark: u32,
82 pub dropdown_kind_command: u32,
83 pub dropdown_kind_search: u32,
84}
85
86impl Default for Palette {
87 fn default() -> Self {
88 Self {
89 bg: 0xFF_1A_1F_2E,
90 fg: 0xFF_EE_EE_EE,
91 accent: 0xFF_55_88_FF,
92 border: 0xFF_33_3D_55,
93 dropdown_bg: 0xFF_14_18_24,
94 dropdown_selected_bg: 0xFF_22_2D_45,
95 dropdown_kind_history: 0xFF_88_AA_FF,
96 dropdown_kind_bookmark: 0xFF_E0_C8_5A,
97 dropdown_kind_command: 0xFF_4A_C9_5C,
98 dropdown_kind_search: 0xFF_C8_5A_E0,
99 }
100 }
101}
102
103#[derive(Debug, Clone)]
105pub struct InputBar {
106 pub prefix: String,
107 pub buffer: String,
108 pub cursor: usize,
112 pub suggestions: Vec<Suggestion>,
113 pub selected: Option<usize>,
116 pub palette: Palette,
117 pub cursor_visible: bool,
119}
120
121impl Default for InputBar {
122 fn default() -> Self {
123 Self::with_prefix(":")
124 }
125}
126
127impl InputBar {
128 pub fn with_prefix(prefix: impl Into<String>) -> Self {
130 Self {
131 prefix: prefix.into(),
132 buffer: String::new(),
133 cursor: 0,
134 suggestions: Vec::new(),
135 selected: None,
136 palette: Palette::default(),
137 cursor_visible: true,
138 }
139 }
140
141 pub fn current_value(&self) -> &str {
145 if let Some(idx) = self.selected
146 && let Some(s) = self.suggestions.get(idx)
147 {
148 return s.value.as_str();
149 }
150 &self.buffer
151 }
152
153 pub fn clear(&mut self) {
157 self.buffer.clear();
158 self.cursor = 0;
159 self.suggestions.clear();
160 self.selected = None;
161 }
162
163 pub fn handle_text(&mut self, ch: char) {
165 if ch.is_control() {
168 return;
169 }
170 self.buffer.insert(self.cursor, ch);
171 self.cursor += ch.len_utf8();
172 self.selected = None;
175 }
176
177 pub fn handle_back(&mut self) {
179 if self.cursor == 0 {
180 return;
181 }
182 let mut prev = self.cursor - 1;
184 while !self.buffer.is_char_boundary(prev) && prev > 0 {
185 prev -= 1;
186 }
187 self.buffer.replace_range(prev..self.cursor, "");
188 self.cursor = prev;
189 self.selected = None;
190 }
191
192 pub fn handle_delete_word(&mut self) {
195 if self.cursor == 0 {
196 return;
197 }
198 let bytes = self.buffer.as_bytes();
199 let mut end = self.cursor;
200 while end > 0 && bytes[end - 1].is_ascii_whitespace() {
202 end -= 1;
203 }
204 while end > 0 && !bytes[end - 1].is_ascii_whitespace() {
206 end -= 1;
207 }
208 self.buffer.replace_range(end..self.cursor, "");
209 self.cursor = end;
210 self.selected = None;
211 }
212
213 pub fn handle_clear_line(&mut self) {
215 self.buffer.clear();
216 self.cursor = 0;
217 self.selected = None;
218 }
219
220 pub fn handle_left(&mut self) {
222 if self.cursor == 0 {
223 return;
224 }
225 let mut prev = self.cursor - 1;
226 while !self.buffer.is_char_boundary(prev) && prev > 0 {
227 prev -= 1;
228 }
229 self.cursor = prev;
230 }
231
232 pub fn handle_right(&mut self) {
234 if self.cursor >= self.buffer.len() {
235 return;
236 }
237 let mut next = self.cursor + 1;
238 while next < self.buffer.len() && !self.buffer.is_char_boundary(next) {
239 next += 1;
240 }
241 self.cursor = next;
242 }
243
244 pub fn handle_up(&mut self) {
247 if self.suggestions.is_empty() {
248 self.selected = None;
249 return;
250 }
251 self.selected = match self.selected {
252 None => None,
253 Some(0) => None,
254 Some(n) => Some(n - 1),
255 };
256 }
257
258 pub fn handle_down(&mut self) {
260 if self.suggestions.is_empty() {
261 self.selected = None;
262 return;
263 }
264 let max = self.suggestions.len() - 1;
265 self.selected = match self.selected {
266 None => Some(0),
267 Some(n) if n >= max => Some(max),
268 Some(n) => Some(n + 1),
269 };
270 }
271
272 pub fn set_suggestions(&mut self, suggestions: Vec<Suggestion>) {
274 self.suggestions = suggestions;
275 if self.suggestions.len() > MAX_SUGGESTIONS {
276 self.suggestions.truncate(MAX_SUGGESTIONS);
277 }
278 self.selected = None;
279 }
280
281 pub fn total_height(&self) -> u32 {
285 let rows = self.suggestions.len().min(MAX_SUGGESTIONS) as u32;
286 INPUT_HEIGHT + rows * SUGGESTION_ROW_HEIGHT
287 }
288
289 pub fn paint(&self, buffer: &mut [u32], width: usize, height: usize) {
294 self.paint_at(buffer, width, height, 0, 0, width, height);
295 }
296
297 #[allow(clippy::too_many_arguments)]
301 pub fn paint_at(
302 &self,
303 buffer: &mut [u32],
304 buf_w: usize,
305 buf_h: usize,
306 x: usize,
307 y: usize,
308 w: usize,
309 h: usize,
310 ) {
311 if w == 0 || h < INPUT_HEIGHT as usize {
312 return;
313 }
314 if buffer.len() < buf_w * buf_h {
315 return;
316 }
317
318 let p = &self.palette;
319 let bar_h = INPUT_HEIGHT as usize;
320
321 fill_rect(buffer, buf_w, buf_h, x as i32, y as i32, w, bar_h, p.bg);
323
324 fill_rect(
326 buffer,
327 buf_w,
328 buf_h,
329 x as i32,
330 (y + bar_h) as i32 - 1,
331 w,
332 1,
333 p.border,
334 );
335
336 let text_y = y as i32 + ((bar_h as i32) - font::glyph_h() as i32) / 2;
337
338 let prefix_x = x as i32 + 6;
340 font::draw_text(
341 buffer,
342 buf_w,
343 buf_h,
344 prefix_x,
345 text_y,
346 &self.prefix,
347 p.accent,
348 );
349 let prefix_w = font::text_width(&self.prefix) as i32;
350 let buffer_x = prefix_x + prefix_w + 6;
351
352 let glyph_advance = font::glyph_w() + 1;
355 let inner_w = (x as i32 + w as i32 - 6 - buffer_x).max(0) as usize;
356 let chars_visible = (inner_w / glyph_advance).max(1);
357 let cursor_chars = self.buffer[..self.cursor].chars().count();
358 let total_chars = self.buffer.chars().count();
359 let mut scroll_chars: usize = if cursor_chars >= chars_visible {
360 cursor_chars + 1 - chars_visible
361 } else {
362 0
363 };
364 let max_scroll = total_chars.saturating_sub(chars_visible.saturating_sub(1));
367 if scroll_chars > max_scroll {
368 scroll_chars = max_scroll;
369 }
370 let visible: String = self
372 .buffer
373 .chars()
374 .skip(scroll_chars)
375 .take(chars_visible)
376 .collect();
377 font::draw_text(buffer, buf_w, buf_h, buffer_x, text_y, &visible, p.fg);
378
379 if self.cursor_visible && self.selected.is_none() {
382 let cursor_offset = cursor_chars.saturating_sub(scroll_chars);
383 let cursor_px = cursor_offset * glyph_advance;
384 let cursor_x = buffer_x + cursor_px as i32;
385 fill_rect(
386 buffer,
387 buf_w,
388 buf_h,
389 cursor_x,
390 text_y - 1,
391 2,
392 font::glyph_h() + 2,
393 p.fg,
394 );
395 }
396
397 if self.suggestions.is_empty() {
399 return;
400 }
401 let row_h = SUGGESTION_ROW_HEIGHT as usize;
402 for (i, sug) in self.suggestions.iter().take(MAX_SUGGESTIONS).enumerate() {
403 let row_y = y + bar_h + i * row_h;
404 if row_y + row_h > y + h {
405 break;
406 }
407 let bg = if Some(i) == self.selected {
408 p.dropdown_selected_bg
409 } else {
410 p.dropdown_bg
411 };
412 fill_rect(buffer, buf_w, buf_h, x as i32, row_y as i32, w, row_h, bg);
413 let pip_colour = match sug.kind {
415 SuggestionKind::History => p.dropdown_kind_history,
416 SuggestionKind::Bookmark => p.dropdown_kind_bookmark,
417 SuggestionKind::Command => p.dropdown_kind_command,
418 SuggestionKind::SearchSuggestion => p.dropdown_kind_search,
419 };
420 fill_rect(
421 buffer,
422 buf_w,
423 buf_h,
424 x as i32 + 6,
425 row_y as i32 + 8,
426 3,
427 font::glyph_h(),
428 pip_colour,
429 );
430 let row_text_y = row_y as i32 + ((row_h as i32 - font::glyph_h() as i32) / 2);
431 let text_left = x + 16;
432 let text_max_px = (x + w).saturating_sub(text_left + 8);
433 let display = crate::truncate_to_width(&sug.display, text_max_px);
434 font::draw_text(
435 buffer,
436 buf_w,
437 buf_h,
438 text_left as i32,
439 row_text_y,
440 display,
441 p.fg,
442 );
443 }
444 }
445}
446
447#[cfg(test)]
448#[allow(clippy::field_reassign_with_default)]
449mod tests {
450 use super::*;
451
452 fn s(d: &str) -> Suggestion {
453 Suggestion {
454 display: d.into(),
455 value: d.into(),
456 kind: SuggestionKind::History,
457 }
458 }
459
460 #[test]
461 fn handle_text_appends_at_cursor() {
462 let mut b = InputBar::default();
463 b.handle_text('h');
464 b.handle_text('i');
465 assert_eq!(b.buffer, "hi");
466 assert_eq!(b.cursor, 2);
467 }
468
469 #[test]
470 fn handle_text_inserts_in_middle() {
471 let mut b = InputBar::default();
472 b.buffer = "hi".into();
473 b.cursor = 1;
474 b.handle_text('e');
475 assert_eq!(b.buffer, "hei");
476 assert_eq!(b.cursor, 2);
477 }
478
479 #[test]
480 fn handle_text_rejects_control_chars() {
481 let mut b = InputBar::default();
482 b.handle_text('\n');
483 b.handle_text('\t');
484 b.handle_text('\x07');
485 assert_eq!(b.buffer, "");
486 }
487
488 #[test]
489 fn handle_back_at_zero_is_noop() {
490 let mut b = InputBar::default();
491 b.handle_back();
492 assert_eq!(b.buffer, "");
493 assert_eq!(b.cursor, 0);
494 }
495
496 #[test]
497 fn handle_back_deletes_codepoint() {
498 let mut b = InputBar::default();
499 b.buffer = "hi".into();
500 b.cursor = 2;
501 b.handle_back();
502 assert_eq!(b.buffer, "h");
503 assert_eq!(b.cursor, 1);
504 }
505
506 #[test]
507 fn handle_delete_word_word_only() {
508 let mut b = InputBar::default();
509 b.buffer = "hello world".into();
510 b.cursor = 11;
511 b.handle_delete_word();
512 assert_eq!(b.buffer, "hello ");
513 assert_eq!(b.cursor, 6);
514 }
515
516 #[test]
517 fn handle_delete_word_with_trailing_space() {
518 let mut b = InputBar::default();
519 b.buffer = "hello world ".into();
520 b.cursor = 13;
521 b.handle_delete_word();
522 assert_eq!(b.buffer, "hello ");
523 }
524
525 #[test]
526 fn handle_clear_line_empties() {
527 let mut b = InputBar::default();
528 b.buffer = "stuff".into();
529 b.cursor = 5;
530 b.handle_clear_line();
531 assert_eq!(b.buffer, "");
532 assert_eq!(b.cursor, 0);
533 }
534
535 #[test]
536 fn handle_left_clamps_at_zero() {
537 let mut b = InputBar::default();
538 b.handle_left();
539 assert_eq!(b.cursor, 0);
540 b.buffer = "hi".into();
541 b.cursor = 1;
542 b.handle_left();
543 assert_eq!(b.cursor, 0);
544 b.handle_left();
545 assert_eq!(b.cursor, 0);
546 }
547
548 #[test]
549 fn handle_right_clamps_at_end() {
550 let mut b = InputBar::default();
551 b.buffer = "hi".into();
552 b.cursor = 0;
553 b.handle_right();
554 assert_eq!(b.cursor, 1);
555 b.handle_right();
556 assert_eq!(b.cursor, 2);
557 b.handle_right();
558 assert_eq!(b.cursor, 2);
559 }
560
561 #[test]
562 fn up_down_clamp_at_boundaries() {
563 let mut b = InputBar::default();
564 b.set_suggestions(vec![s("a"), s("b"), s("c")]);
565 assert_eq!(b.selected, None);
567 b.handle_down();
568 assert_eq!(b.selected, Some(0));
569 b.handle_down();
570 assert_eq!(b.selected, Some(1));
571 b.handle_down();
572 assert_eq!(b.selected, Some(2));
573 b.handle_down(); assert_eq!(b.selected, Some(2));
575 b.handle_up();
576 assert_eq!(b.selected, Some(1));
577 b.handle_up();
578 assert_eq!(b.selected, Some(0));
579 b.handle_up(); assert_eq!(b.selected, None);
581 b.handle_up(); assert_eq!(b.selected, None);
583 }
584
585 #[test]
586 fn current_value_uses_selection_when_set() {
587 let mut b = InputBar::default();
588 b.buffer = "typed".into();
589 b.set_suggestions(vec![Suggestion {
590 display: "first".into(),
591 value: "first-value".into(),
592 kind: SuggestionKind::History,
593 }]);
594 assert_eq!(b.current_value(), "typed");
595 b.handle_down();
596 assert_eq!(b.current_value(), "first-value");
597 }
598
599 #[test]
600 fn current_value_falls_back_when_no_suggestions() {
601 let mut b = InputBar::default();
602 b.buffer = "raw".into();
603 assert_eq!(b.current_value(), "raw");
604 }
605
606 #[test]
607 fn set_suggestions_truncates_to_max() {
608 let mut b = InputBar::default();
609 let many: Vec<_> = (0..20).map(|i| s(&format!("row{i}"))).collect();
610 b.set_suggestions(many);
611 assert_eq!(b.suggestions.len(), MAX_SUGGESTIONS);
612 }
613
614 #[test]
615 fn total_height_grows_with_suggestion_count() {
616 let mut b = InputBar::default();
617 assert_eq!(b.total_height(), INPUT_HEIGHT);
618 b.set_suggestions(vec![s("a"), s("b")]);
619 assert_eq!(b.total_height(), INPUT_HEIGHT + 2 * SUGGESTION_ROW_HEIGHT);
620 }
621
622 #[test]
623 fn paint_smoke_no_crash_with_dropdown() {
624 let w = 400;
625 let h = 200;
626 let mut buf = vec![0u32; w * h];
627 let mut b = InputBar::default();
628 b.buffer = "hello".into();
629 b.cursor = 5;
630 b.set_suggestions(vec![s("first"), s("second")]);
631 b.handle_down();
632 b.paint(&mut buf, w, h);
633 assert_eq!(buf[0], b.palette.bg);
635 }
636
637 #[test]
638 fn editing_resets_selection() {
639 let mut b = InputBar::default();
640 b.set_suggestions(vec![s("a"), s("b")]);
641 b.handle_down();
642 assert_eq!(b.selected, Some(0));
643 b.handle_text('x');
644 assert_eq!(b.selected, None);
645 }
646
647 #[test]
648 fn clear_resets_state() {
649 let mut b = InputBar::default();
650 b.buffer = "stuff".into();
651 b.cursor = 5;
652 b.set_suggestions(vec![s("a")]);
653 b.handle_down();
654 b.clear();
655 assert_eq!(b.buffer, "");
656 assert_eq!(b.cursor, 0);
657 assert_eq!(b.selected, None);
658 assert!(b.suggestions.is_empty());
659 }
660}