Skip to main content

fresh/view/controls/number_input/
mod.rs

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