Skip to main content

fresh/view/controls/number_input/
mod.rs

1//! Number input control with increment/decrement
2//!
3//! Renders as: `Label: [  42  ] [-] [+]`
4//!
5//! This module provides a complete number input component with:
6//! - State management (`NumberInputState`)
7//! - Rendering (`render_number_input`, `render_number_input_aligned`)
8//! - Input handling (`NumberInputState::handle_mouse`, `handle_key`)
9//! - Layout/hit testing (`NumberInputLayout`)
10
11mod input;
12mod render;
13
14use ratatui::layout::Rect;
15use ratatui::style::Color;
16
17pub use input::NumberInputEvent;
18pub use render::{render_number_input, render_number_input_aligned};
19
20use super::FocusState;
21use crate::view::ui::text_edit::TextEdit;
22
23/// State for a number input control
24#[derive(Debug, Clone)]
25pub struct NumberInputState {
26    /// Current value
27    pub value: i64,
28    /// Minimum allowed value
29    pub min: Option<i64>,
30    /// Maximum allowed value
31    pub max: Option<i64>,
32    /// Step amount for increment/decrement
33    pub step: i64,
34    /// Label displayed before the input
35    pub label: String,
36    /// Focus state
37    pub focus: FocusState,
38    /// Text editor for editing mode (None when not editing)
39    pub editor: Option<TextEdit>,
40    /// Whether this value is a percentage (float value * 100 for display)
41    /// When true, the value should be divided by 100 when converting back to JSON
42    pub is_percentage: bool,
43}
44
45impl NumberInputState {
46    /// Create a new number input state
47    pub fn new(value: i64, label: impl Into<String>) -> Self {
48        Self {
49            value,
50            min: None,
51            max: None,
52            step: 1,
53            label: label.into(),
54            focus: FocusState::Normal,
55            editor: None,
56            is_percentage: false,
57        }
58    }
59
60    /// Check if currently editing
61    pub fn editing(&self) -> bool {
62        self.editor.is_some()
63    }
64
65    /// Set the minimum value
66    pub fn with_min(mut self, min: i64) -> Self {
67        self.min = Some(min);
68        self
69    }
70
71    /// Set the maximum value
72    pub fn with_max(mut self, max: i64) -> Self {
73        self.max = Some(max);
74        self
75    }
76
77    /// Set the step amount
78    pub fn with_step(mut self, step: i64) -> Self {
79        self.step = step;
80        self
81    }
82
83    /// Set the focus state
84    pub fn with_focus(mut self, focus: FocusState) -> Self {
85        self.focus = focus;
86        self
87    }
88
89    /// Mark this value as a percentage (float * 100 for display)
90    pub fn with_percentage(mut self) -> Self {
91        self.is_percentage = true;
92        self
93    }
94
95    /// Check if the control is enabled
96    pub fn is_enabled(&self) -> bool {
97        self.focus != FocusState::Disabled
98    }
99
100    /// Increment the value by step
101    pub fn increment(&mut self) {
102        if !self.is_enabled() {
103            return;
104        }
105        let new_value = self.value.saturating_add(self.step);
106        self.value = match self.max {
107            Some(max) => new_value.min(max),
108            None => new_value,
109        };
110    }
111
112    /// Decrement the value by step
113    pub fn decrement(&mut self) {
114        if !self.is_enabled() {
115            return;
116        }
117        let new_value = self.value.saturating_sub(self.step);
118        self.value = match self.min {
119            Some(min) => new_value.max(min),
120            None => new_value,
121        };
122    }
123
124    /// Set the value directly, respecting min/max
125    pub fn set_value(&mut self, value: i64) {
126        if !self.is_enabled() {
127            return;
128        }
129        let mut v = value;
130        if let Some(min) = self.min {
131            v = v.max(min);
132        }
133        if let Some(max) = self.max {
134            v = v.min(max);
135        }
136        self.value = v;
137    }
138
139    /// Start editing mode
140    pub fn start_editing(&mut self) {
141        if !self.is_enabled() {
142            return;
143        }
144        let mut editor = TextEdit::single_line();
145        editor.set_value(&self.value.to_string());
146        // Select all text so typing replaces the value
147        editor.select_all();
148        self.editor = Some(editor);
149    }
150
151    /// Cancel editing and restore original value
152    pub fn cancel_editing(&mut self) {
153        self.editor = None;
154    }
155
156    /// Confirm editing and apply the new value
157    pub fn confirm_editing(&mut self) {
158        if let Some(editor) = self.editor.take() {
159            if let Ok(new_value) = editor.value().parse::<i64>() {
160                self.set_value(new_value);
161            }
162        }
163    }
164
165    /// Insert a character while editing
166    /// Allows digits, minus sign, and decimal point for number input
167    pub fn insert_char(&mut self, c: char) {
168        if let Some(editor) = &mut self.editor {
169            // Allow digits, minus sign, and decimal point
170            if c.is_ascii_digit() || c == '-' || c == '.' {
171                editor.insert_char(c);
172            }
173        }
174    }
175
176    /// Backspace while editing
177    pub fn backspace(&mut self) {
178        if let Some(editor) = &mut self.editor {
179            editor.backspace();
180        }
181    }
182
183    /// Delete character at cursor
184    pub fn delete(&mut self) {
185        if let Some(editor) = &mut self.editor {
186            editor.delete();
187        }
188    }
189
190    /// Move cursor left
191    pub fn move_left(&mut self) {
192        if let Some(editor) = &mut self.editor {
193            editor.move_left();
194        }
195    }
196
197    /// Move cursor right
198    pub fn move_right(&mut self) {
199        if let Some(editor) = &mut self.editor {
200            editor.move_right();
201        }
202    }
203
204    /// Move cursor to start of text
205    pub fn move_home(&mut self) {
206        if let Some(editor) = &mut self.editor {
207            editor.move_home();
208        }
209    }
210
211    /// Move cursor to end of text
212    pub fn move_end(&mut self) {
213        if let Some(editor) = &mut self.editor {
214            editor.move_end();
215        }
216    }
217
218    /// Move cursor left by word (Ctrl+Left)
219    pub fn move_word_left(&mut self) {
220        if let Some(editor) = &mut self.editor {
221            editor.move_word_left();
222        }
223    }
224
225    /// Move cursor right by word (Ctrl+Right)
226    pub fn move_word_right(&mut self) {
227        if let Some(editor) = &mut self.editor {
228            editor.move_word_right();
229        }
230    }
231
232    /// Move cursor left with selection (Shift+Left)
233    pub fn move_left_selecting(&mut self) {
234        if let Some(editor) = &mut self.editor {
235            editor.move_left_selecting();
236        }
237    }
238
239    /// Move cursor right with selection (Shift+Right)
240    pub fn move_right_selecting(&mut self) {
241        if let Some(editor) = &mut self.editor {
242            editor.move_right_selecting();
243        }
244    }
245
246    /// Move to start with selection (Shift+Home)
247    pub fn move_home_selecting(&mut self) {
248        if let Some(editor) = &mut self.editor {
249            editor.move_home_selecting();
250        }
251    }
252
253    /// Move to end with selection (Shift+End)
254    pub fn move_end_selecting(&mut self) {
255        if let Some(editor) = &mut self.editor {
256            editor.move_end_selecting();
257        }
258    }
259
260    /// Move word left with selection (Ctrl+Shift+Left)
261    pub fn move_word_left_selecting(&mut self) {
262        if let Some(editor) = &mut self.editor {
263            editor.move_word_left_selecting();
264        }
265    }
266
267    /// Move word right with selection (Ctrl+Shift+Right)
268    pub fn move_word_right_selecting(&mut self) {
269        if let Some(editor) = &mut self.editor {
270            editor.move_word_right_selecting();
271        }
272    }
273
274    /// Select all text (Ctrl+A)
275    pub fn select_all(&mut self) {
276        if let Some(editor) = &mut self.editor {
277            editor.select_all();
278        }
279    }
280
281    /// Delete from cursor to end of word (Ctrl+Delete)
282    pub fn delete_word_forward(&mut self) {
283        if let Some(editor) = &mut self.editor {
284            editor.delete_word_forward();
285        }
286    }
287
288    /// Delete from start of word to cursor (Ctrl+Backspace)
289    pub fn delete_word_backward(&mut self) {
290        if let Some(editor) = &mut self.editor {
291            editor.delete_word_backward();
292        }
293    }
294
295    /// Get selected text for copy
296    pub fn selected_text(&self) -> Option<String> {
297        self.editor.as_ref().and_then(|e| e.selected_text())
298    }
299
300    /// Delete selection and return deleted text (for cut)
301    pub fn delete_selection(&mut self) -> Option<String> {
302        self.editor.as_mut().and_then(|e| e.delete_selection())
303    }
304
305    /// Insert string at cursor (for paste)
306    pub fn insert_str(&mut self, text: &str) {
307        if let Some(editor) = &mut self.editor {
308            // Filter to only allow valid number characters
309            let filtered: String = text
310                .chars()
311                .filter(|c| c.is_ascii_digit() || *c == '-' || *c == '.')
312                .collect();
313            editor.insert_str(&filtered);
314        }
315    }
316
317    /// Get the display text (edit text when editing, value otherwise)
318    pub fn display_text(&self) -> String {
319        if let Some(editor) = &self.editor {
320            editor.value()
321        } else {
322            self.value.to_string()
323        }
324    }
325
326    /// Get cursor position when editing (column in single-line text)
327    pub fn cursor_col(&self) -> usize {
328        self.editor.as_ref().map(|e| e.cursor_col).unwrap_or(0)
329    }
330
331    /// Check if there's an active selection
332    pub fn has_selection(&self) -> bool {
333        self.editor
334            .as_ref()
335            .map(|e| e.has_selection())
336            .unwrap_or(false)
337    }
338
339    /// Get selection range as (start, end) column positions
340    pub fn selection_range(&self) -> Option<(usize, usize)> {
341        self.editor.as_ref().and_then(|e| {
342            e.selection_range()
343                .map(|((_, start_col), (_, end_col))| (start_col, end_col))
344        })
345    }
346}
347
348/// Colors for the number input control
349#[derive(Debug, Clone, Copy)]
350pub struct NumberInputColors {
351    /// Label color
352    pub label: Color,
353    /// Value text color
354    pub value: Color,
355    /// Border/bracket color
356    pub border: Color,
357    /// Button color (increment/decrement)
358    pub button: Color,
359    /// Focused highlight background color
360    pub focused: Color,
361    /// Focused highlight foreground color (text on focused background)
362    pub focused_fg: Color,
363    /// Background colour for the in-edit selection range. Must contrast
364    /// with `focused`, otherwise the selection is invisible whenever the
365    /// row is also the focused (selected) row.
366    pub selection_bg: Color,
367    /// Disabled color
368    pub disabled: Color,
369}
370
371impl Default for NumberInputColors {
372    fn default() -> Self {
373        Self {
374            label: Color::White,
375            value: Color::Yellow,
376            border: Color::Gray,
377            button: Color::Cyan,
378            focused: Color::Cyan,
379            focused_fg: Color::Black,
380            selection_bg: Color::Blue,
381            disabled: Color::DarkGray,
382        }
383    }
384}
385
386impl NumberInputColors {
387    /// Create colors from theme
388    pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
389        Self {
390            label: theme.editor_fg,
391            value: theme.help_key_fg,
392            border: theme.line_number_fg,
393            button: theme.menu_active_fg,
394            focused: theme.settings_selected_bg,
395            focused_fg: theme.settings_selected_fg,
396            // Use the editor's text-selection bg so the in-edit selection
397            // is visible against the row's focus highlight (`focused`).
398            selection_bg: theme.selection_bg,
399            disabled: theme.line_number_fg,
400        }
401    }
402}
403
404/// Layout information returned after rendering for hit testing
405#[derive(Debug, Clone, Copy, Default)]
406pub struct NumberInputLayout {
407    /// The value display area
408    pub value_area: Rect,
409    /// The decrement button area
410    pub decrement_area: Rect,
411    /// The increment button area
412    pub increment_area: Rect,
413    /// The full control area
414    pub full_area: Rect,
415}
416
417impl NumberInputLayout {
418    /// Check if a point is on the decrement button
419    pub fn is_decrement(&self, x: u16, y: u16) -> bool {
420        x >= self.decrement_area.x
421            && x < self.decrement_area.x + self.decrement_area.width
422            && y >= self.decrement_area.y
423            && y < self.decrement_area.y + self.decrement_area.height
424    }
425
426    /// Check if a point is on the increment button
427    pub fn is_increment(&self, x: u16, y: u16) -> bool {
428        x >= self.increment_area.x
429            && x < self.increment_area.x + self.increment_area.width
430            && y >= self.increment_area.y
431            && y < self.increment_area.y + self.increment_area.height
432    }
433
434    /// Check if a point is on the value area
435    pub fn is_value(&self, x: u16, y: u16) -> bool {
436        x >= self.value_area.x
437            && x < self.value_area.x + self.value_area.width
438            && y >= self.value_area.y
439            && y < self.value_area.y + self.value_area.height
440    }
441
442    /// Check if a point is within any part of the control
443    pub fn contains(&self, x: u16, y: u16) -> bool {
444        x >= self.full_area.x
445            && x < self.full_area.x + self.full_area.width
446            && y >= self.full_area.y
447            && y < self.full_area.y + self.full_area.height
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use ratatui::backend::TestBackend;
455    use ratatui::Terminal;
456
457    fn test_frame<F>(width: u16, height: u16, f: F)
458    where
459        F: FnOnce(&mut ratatui::Frame, Rect),
460    {
461        let backend = TestBackend::new(width, height);
462        let mut terminal = Terminal::new(backend).unwrap();
463        terminal
464            .draw(|frame| {
465                let area = Rect::new(0, 0, width, height);
466                f(frame, area);
467            })
468            .unwrap();
469    }
470
471    #[test]
472    fn test_number_input_renders() {
473        test_frame(40, 1, |frame, area| {
474            let state = NumberInputState::new(42, "Count");
475            let colors = NumberInputColors::default();
476            let layout = render_number_input(frame, area, &state, &colors);
477
478            assert!(layout.value_area.width > 0);
479            assert!(layout.decrement_area.width > 0);
480            assert!(layout.increment_area.width > 0);
481        });
482    }
483
484    #[test]
485    fn test_number_input_increment() {
486        let mut state = NumberInputState::new(5, "Value");
487        state.increment();
488        assert_eq!(state.value, 6);
489    }
490
491    #[test]
492    fn test_number_input_decrement() {
493        let mut state = NumberInputState::new(5, "Value");
494        state.decrement();
495        assert_eq!(state.value, 4);
496    }
497
498    #[test]
499    fn test_number_input_min_max() {
500        let mut state = NumberInputState::new(5, "Value").with_min(0).with_max(10);
501
502        state.set_value(-5);
503        assert_eq!(state.value, 0);
504
505        state.set_value(20);
506        assert_eq!(state.value, 10);
507    }
508
509    #[test]
510    fn test_number_input_step() {
511        let mut state = NumberInputState::new(0, "Value").with_step(5);
512        state.increment();
513        assert_eq!(state.value, 5);
514        state.increment();
515        assert_eq!(state.value, 10);
516    }
517
518    #[test]
519    fn test_number_input_disabled() {
520        let mut state = NumberInputState::new(5, "Value").with_focus(FocusState::Disabled);
521        state.increment();
522        assert_eq!(state.value, 5);
523    }
524
525    #[test]
526    fn test_number_input_hit_detection() {
527        test_frame(40, 1, |frame, area| {
528            let state = NumberInputState::new(42, "Count");
529            let colors = NumberInputColors::default();
530            let layout = render_number_input(frame, area, &state, &colors);
531
532            let dec_x = layout.decrement_area.x;
533            assert!(layout.is_decrement(dec_x, 0));
534            assert!(!layout.is_increment(dec_x, 0));
535
536            let inc_x = layout.increment_area.x;
537            assert!(layout.is_increment(inc_x, 0));
538            assert!(!layout.is_decrement(inc_x, 0));
539        });
540    }
541
542    #[test]
543    fn test_number_input_start_editing() {
544        let mut state = NumberInputState::new(42, "Value");
545        assert!(!state.editing());
546        assert_eq!(state.display_text(), "42");
547
548        state.start_editing();
549        assert!(state.editing());
550        assert_eq!(state.display_text(), "42");
551    }
552
553    #[test]
554    fn test_number_input_cancel_editing() {
555        let mut state = NumberInputState::new(42, "Value");
556        state.start_editing();
557        // After start_editing, text is selected so typing replaces it
558        state.insert_char('1');
559        state.insert_char('0');
560        state.insert_char('0');
561        assert_eq!(state.display_text(), "100");
562
563        state.cancel_editing();
564        assert!(!state.editing());
565        assert_eq!(state.display_text(), "42");
566        assert_eq!(state.value, 42);
567    }
568
569    #[test]
570    fn test_number_input_confirm_editing() {
571        let mut state = NumberInputState::new(42, "Value");
572        state.start_editing();
573        // Clear and type new value
574        state.select_all();
575        state.insert_str("100");
576
577        state.confirm_editing();
578        assert!(!state.editing());
579        assert_eq!(state.value, 100);
580    }
581
582    #[test]
583    fn test_number_input_confirm_invalid_resets() {
584        let mut state = NumberInputState::new(42, "Value");
585        state.start_editing();
586        // Type invalid text - only valid chars will be inserted
587        state.select_all();
588        state.insert_str("abc"); // This will be filtered to empty
589
590        state.confirm_editing();
591        assert!(!state.editing());
592        // Value remains unchanged since empty string can't be parsed
593        assert_eq!(state.value, 42);
594    }
595
596    #[test]
597    fn test_number_input_insert_char() {
598        let mut state = NumberInputState::new(0, "Value");
599        state.start_editing();
600        // Clear and insert new chars
601        state.select_all();
602        state.insert_char('1');
603        state.insert_char('2');
604        state.insert_char('3');
605        assert_eq!(state.display_text(), "123");
606
607        let mut state2 = NumberInputState::new(0, "Value");
608        state2.start_editing();
609        state2.select_all();
610        state2.insert_char('-');
611        assert_eq!(state2.display_text(), "-");
612        state2.insert_char('-'); // Multiple minus signs allowed by TextEdit
613        state2.insert_char('5');
614        assert_eq!(state2.display_text(), "--5");
615    }
616
617    #[test]
618    fn test_number_input_backspace() {
619        let mut state = NumberInputState::new(123, "Value");
620        state.start_editing();
621        assert_eq!(state.display_text(), "123");
622
623        // After start_editing, text is selected. Move to end to deselect.
624        state.move_end();
625
626        state.backspace();
627        assert_eq!(state.display_text(), "12");
628        state.backspace();
629        assert_eq!(state.display_text(), "1");
630        state.backspace();
631        assert_eq!(state.display_text(), "");
632        state.backspace();
633        assert_eq!(state.display_text(), "");
634    }
635
636    #[test]
637    fn test_number_input_display_text() {
638        let mut state = NumberInputState::new(42, "Value");
639
640        assert_eq!(state.display_text(), "42");
641
642        state.start_editing();
643        assert_eq!(state.display_text(), "42");
644        // After start_editing, text is selected. Move to end to append.
645        state.move_end();
646        state.insert_char('0');
647        assert_eq!(state.display_text(), "420");
648    }
649
650    #[test]
651    fn test_number_input_editing_respects_minmax() {
652        let mut state = NumberInputState::new(50, "Value").with_min(0).with_max(100);
653        state.start_editing();
654        state.select_all();
655        state.insert_str("200");
656
657        state.confirm_editing();
658        assert_eq!(state.value, 100);
659    }
660
661    #[test]
662    fn test_number_input_disabled_no_editing() {
663        let mut state = NumberInputState::new(42, "Value").with_focus(FocusState::Disabled);
664        state.start_editing();
665        assert!(!state.editing());
666    }
667
668    #[test]
669    fn test_number_input_decimal_point() {
670        let mut state = NumberInputState::new(0, "Value");
671        state.start_editing();
672        state.select_all();
673        state.insert_str("0.25");
674        assert_eq!(state.display_text(), "0.25");
675
676        // Confirm won't parse as i64, so value stays at 0
677        state.confirm_editing();
678        assert_eq!(state.value, 0);
679    }
680
681    #[test]
682    fn test_number_input_selection() {
683        let mut state = NumberInputState::new(12345, "Value");
684        state.start_editing();
685        assert_eq!(state.display_text(), "12345");
686
687        // Select all and replace
688        state.select_all();
689        assert!(state.has_selection());
690        state.insert_char('9');
691        assert_eq!(state.display_text(), "9");
692    }
693
694    #[test]
695    fn test_number_input_cursor_navigation() {
696        let mut state = NumberInputState::new(123, "Value");
697        state.start_editing();
698        // Cursor starts at end
699        assert_eq!(state.cursor_col(), 3);
700
701        state.move_left();
702        assert_eq!(state.cursor_col(), 2);
703
704        state.move_home();
705        assert_eq!(state.cursor_col(), 0);
706
707        state.move_end();
708        assert_eq!(state.cursor_col(), 3);
709    }
710
711    /// Regression: entering edit mode used to shrink `[  4  ]` (value cell
712    /// rendered as `format!("{:^5}", "4")`) down to `[4]`, shifting the
713    /// `[-]` / `[+]` buttons left. Both states must render the value cell
714    /// at the same width.
715    #[test]
716    fn test_value_cell_width_stable_between_edit_and_view() {
717        fn bracket_columns(state: &NumberInputState) -> (u16, u16) {
718            let backend = TestBackend::new(40, 1);
719            let mut terminal = Terminal::new(backend).unwrap();
720            terminal
721                .draw(|frame| {
722                    let area = Rect::new(0, 0, 40, 1);
723                    let colors = NumberInputColors::default();
724                    render_number_input(frame, area, state, &colors);
725                })
726                .unwrap();
727            let buffer = terminal.backend().buffer().clone();
728            let mut open = None;
729            let mut close = None;
730            for x in 0..40 {
731                let symbol = buffer.cell((x, 0)).map(|c| c.symbol()).unwrap_or("");
732                if symbol == "[" && open.is_none() {
733                    open = Some(x);
734                } else if symbol == "]" && open.is_some() && close.is_none() {
735                    close = Some(x);
736                }
737            }
738            (
739                open.expect("missing opening bracket"),
740                close.expect("missing closing bracket"),
741            )
742        }
743
744        let view_state = NumberInputState::new(4, "Tab Size");
745        let mut edit_state = NumberInputState::new(4, "Tab Size");
746        edit_state.start_editing();
747
748        let view_brackets = bracket_columns(&view_state);
749        let edit_brackets = bracket_columns(&edit_state);
750        assert_eq!(
751            view_brackets, edit_brackets,
752            "value cell brackets must stay at the same columns when entering edit mode"
753        );
754    }
755
756    /// Regression: the digit's column used to shift left as soon as the
757    /// user started typing — the cursor block claimed the last cell and
758    /// the right-aligned digit slid one column inward. The trailing
759    /// reserved cell now keeps the digit pinned to the same column in
760    /// view mode, while-selected, and while-typing.
761    #[test]
762    fn test_digit_column_stable_across_view_select_and_typing() {
763        fn digit_column(state: &NumberInputState, digit: char) -> u16 {
764            let backend = TestBackend::new(40, 1);
765            let mut terminal = Terminal::new(backend).unwrap();
766            terminal
767                .draw(|frame| {
768                    let area = Rect::new(0, 0, 40, 1);
769                    let colors = NumberInputColors::default();
770                    render_number_input(frame, area, state, &colors);
771                })
772                .unwrap();
773            let buffer = terminal.backend().buffer().clone();
774            let needle = digit.to_string();
775            for x in 0..40 {
776                let symbol = buffer.cell((x, 0)).map(|c| c.symbol()).unwrap_or("");
777                if symbol == needle {
778                    return x;
779                }
780            }
781            panic!("digit {digit:?} not found on rendered line");
782        }
783
784        // View mode: "4" is rendered right-aligned with the trailing
785        // reserved cell.
786        let view_state = NumberInputState::new(4, "Tab Size");
787
788        // Edit mode, select-all (cursor at end, value still "4").
789        let mut select_state = NumberInputState::new(4, "Tab Size");
790        select_state.start_editing();
791
792        // Edit mode, after typing replaces selection with "1" (cursor at
793        // end of the new value).
794        let mut typed_state = NumberInputState::new(4, "Tab Size");
795        typed_state.start_editing();
796        typed_state.insert_char('1');
797
798        let view_col = digit_column(&view_state, '4');
799        let select_col = digit_column(&select_state, '4');
800        let typed_col = digit_column(&typed_state, '1');
801
802        assert_eq!(
803            view_col, select_col,
804            "digit must stay at the same column when entering edit mode"
805        );
806        assert_eq!(
807            view_col, typed_col,
808            "digit must stay at the same column after typing replaces the selection"
809        );
810    }
811
812    /// Regression: the in-edit selection used to share its bg colour with
813    /// the row's focus highlight, so `select_all()` (called on entering
814    /// edit mode) rendered as bg-on-bg and was invisible. The two colours
815    /// are now decoupled.
816    #[test]
817    fn test_selection_bg_distinct_from_focus_bg() {
818        let theme = crate::view::theme::Theme::load_builtin("dark")
819            .or_else(|| crate::view::theme::Theme::load_builtin("default"))
820            .expect("expected a builtin theme to load");
821        let colors = NumberInputColors::from_theme(&theme);
822        assert_ne!(
823            colors.selection_bg, colors.focused,
824            "selection bg must differ from focus bg, otherwise the in-edit selection is invisible \
825             when the row is focused"
826        );
827    }
828}