Skip to main content

buffr_ui/
input_bar.rs

1//! Single-line text-input widget for the command line + omnibar.
2//!
3//! Phase 3 chrome: a 28-px-tall strip docked to the **top** of the
4//! window, drawn with a softbuffer-style `&mut [u32]` blit. Same
5//! protocol as the statusline at the bottom of the window — caller
6//! owns surface lifecycle, we just paint pixels.
7//!
8//! # Layout
9//!
10//! ```text
11//! +---------------------------------------------------+
12//! | : | open https://example.com                     | <- input row (28px)
13//! +---------------------------------------------------+
14//! | open  open <url>                                  | <- suggestion 1 (24px)
15//! | back  history back                                | <- suggestion 2 (24px)
16//! | …                                                 |
17//! +---------------------------------------------------+
18//! ```
19//!
20//! # Cursor blink
21//!
22//! The widget exposes a [`InputBar::cursor_visible`] flag and the
23//! caller is expected to flip it on a 500ms timer. We stay clock-free
24//! so unit tests stay deterministic.
25//!
26//! # Glyph coverage
27//!
28//! The system font covers full Unicode; ASCII URLs work out of the box.
29//! A bitmap fallback activates when no suitable system font is found.
30
31use crate::font;
32use crate::{STATUSLINE_HEIGHT, fill_rect};
33
34/// Input strip height in pixels. Slightly taller than the statusline
35/// so it reads as a separate UI affordance — and so the glyph row
36/// has room for a 1-px focus border above and below.
37pub const INPUT_HEIGHT: u32 = 28;
38
39/// Suggestion row height. Matches the statusline so a stack of rows
40/// reads as one cohesive overlay.
41pub const SUGGESTION_ROW_HEIGHT: u32 = STATUSLINE_HEIGHT;
42
43/// Maximum suggestions rendered. Past this, callers should truncate
44/// their result set to avoid spilling off-screen.
45pub const MAX_SUGGESTIONS: usize = 8;
46
47/// What kind of suggestion is being rendered. Drives both the badge
48/// colour and the relative ordering (history < bookmark < command <
49/// search-engine fallback in the omnibar).
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum SuggestionKind {
52    History,
53    Bookmark,
54    Command,
55    /// Synthesised "search the web for {query}" entry. Always the
56    /// last row when present.
57    SearchSuggestion,
58}
59
60/// One suggestion row. `display` is what's shown to the user;
61/// `value` is what gets substituted into the buffer when the user
62/// confirms a selection (Enter or Tab).
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Suggestion {
65    pub display: String,
66    pub value: String,
67    pub kind: SuggestionKind,
68}
69
70/// Colour palette for the input bar. Mirrors the statusline palette
71/// shape so a future theme system can wire both at once.
72#[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/// Single-line text input + dropdown.
104#[derive(Debug, Clone)]
105pub struct InputBar {
106    pub prefix: String,
107    pub buffer: String,
108    /// Byte index into [`Self::buffer`] where the next character would
109    /// be inserted. `0..=buffer.len()` always; never points into the
110    /// middle of a UTF-8 codepoint.
111    pub cursor: usize,
112    pub suggestions: Vec<Suggestion>,
113    /// Index into [`Self::suggestions`]. `None` means "no selection,
114    /// Enter uses the typed buffer verbatim".
115    pub selected: Option<usize>,
116    pub palette: Palette,
117    /// Caller-managed cursor blink. Flip every 500ms or so.
118    pub cursor_visible: bool,
119}
120
121impl Default for InputBar {
122    fn default() -> Self {
123        Self::with_prefix(":")
124    }
125}
126
127impl InputBar {
128    /// Build an empty input bar with the given prefix string.
129    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    /// What the caller should treat as the "confirmed value" on Enter.
142    /// If a suggestion is selected, returns its `value`; else returns
143    /// the raw buffer.
144    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    /// Reset to "empty + prefix only" state. Doesn't clear the
154    /// prefix itself — callers swap the widget for one with a new
155    /// prefix when transitioning between command line / omnibar.
156    pub fn clear(&mut self) {
157        self.buffer.clear();
158        self.cursor = 0;
159        self.suggestions.clear();
160        self.selected = None;
161    }
162
163    /// Insert `ch` at the cursor and advance.
164    pub fn handle_text(&mut self, ch: char) {
165        // Reject control characters; they're handled by dedicated
166        // arrow / backspace methods.
167        if ch.is_control() {
168            return;
169        }
170        self.buffer.insert(self.cursor, ch);
171        self.cursor += ch.len_utf8();
172        // Editing always invalidates the suggestion selection; caller
173        // re-runs `compute_suggestions` after this.
174        self.selected = None;
175    }
176
177    /// Backspace — delete the codepoint before the cursor.
178    pub fn handle_back(&mut self) {
179        if self.cursor == 0 {
180            return;
181        }
182        // Step back to the previous char boundary.
183        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    /// Delete the word before the cursor (`<C-w>`). A "word" is a run
193    /// of non-whitespace, optionally preceded by whitespace.
194    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        // Skip trailing whitespace.
201        while end > 0 && bytes[end - 1].is_ascii_whitespace() {
202            end -= 1;
203        }
204        // Skip the word itself.
205        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    /// Clear the buffer entirely (`<C-u>`).
214    pub fn handle_clear_line(&mut self) {
215        self.buffer.clear();
216        self.cursor = 0;
217        self.selected = None;
218    }
219
220    /// Move cursor one codepoint left.
221    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    /// Move cursor one codepoint right.
233    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    /// Move suggestion selection one row up. Wraps from 0 → no-op.
245    /// `<Up>` arrow / `<S-Tab>`.
246    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    /// Move suggestion selection one row down. `<Down>` / `<Tab>`.
259    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    /// Replace the suggestion list. Resets `selected` to `None`.
273    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    /// Total pixel height when fully expanded with `n` visible
282    /// suggestions, where `n = min(suggestions.len(), MAX_SUGGESTIONS)`.
283    /// Used by the host to compute the CEF child rect.
284    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    /// Paint the input bar into the *top* `INPUT_HEIGHT` rows of
290    /// `buffer`, then any visible suggestion rows below that. Rows
291    /// below `total_height()` are left untouched — the caller has
292    /// either reserved that space or has nothing else to draw there.
293    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    /// Paint into a sub-rectangle of the surface buffer. `x`, `y`, `w`, `h`
298    /// are pixel positions in the full surface (stride = `buf_w`). The bar
299    /// draws at the top of the rect; suggestions extend downward within it.
300    #[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        // Background — fills the input row within the sub-rect.
322        fill_rect(buffer, buf_w, buf_h, x as i32, y as i32, w, bar_h, p.bg);
323
324        // Bottom 1-px border of the input row.
325        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        // Prefix in accent.
339        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        // Compute available pixel width for the buffer text and the
353        // char-based scroll offset that keeps the cursor visible.
354        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        // Don't scroll past the end — keep the trailing edge of the
365        // text within view when the buffer is shorter than scroll.
366        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        // Visible substring.
371        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        // Cursor: 2-px-wide vertical bar at `cursor` char position
380        // (relative to the scrolled substring).
381        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        // Dropdown.
398        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            // Kind pip.
414            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        // Initially no selection.
566        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(); // clamp
574        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(); // back to none
580        assert_eq!(b.selected, None);
581        b.handle_up(); // clamp
582        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        // Input row painted with bar bg.
634        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}