Skip to main content

fresh/view/settings/
layout.rs

1//! Settings layout for hit testing
2//!
3//! Tracks the layout of rendered settings UI elements for mouse interaction.
4
5use super::render::ControlLayoutInfo;
6use crate::view::ui::point_in_rect;
7use ratatui::layout::Rect;
8
9/// Layout information for the entire settings UI
10#[derive(Debug, Clone, Default)]
11pub struct SettingsLayout {
12    /// The modal area
13    pub modal_area: Rect,
14    /// Category list items (index, area)
15    pub categories: Vec<(usize, Rect)>,
16    /// Setting items (index, path, area, control_layout)
17    pub items: Vec<ItemLayout>,
18    /// Search result items (page_index, item_index, area)
19    pub search_results: Vec<SearchResultLayout>,
20    /// Layer button area
21    pub layer_button: Option<Rect>,
22    /// Edit config file button area
23    pub edit_button: Option<Rect>,
24    /// Save button area
25    pub save_button: Option<Rect>,
26    /// Cancel button area
27    pub cancel_button: Option<Rect>,
28    /// Reset button area
29    pub reset_button: Option<Rect>,
30    /// Clear category button area (shown in page header for nullable categories)
31    pub clear_category_button: Option<Rect>,
32    /// Sections rendered under expanded categories: (cat_idx, section_idx, area).
33    pub sections: Vec<(usize, usize, Rect)>,
34    /// Disclosure-chevron areas for expandable categories: (cat_idx, area).
35    pub category_disclosures: Vec<(usize, Rect)>,
36    /// Categories panel area (for mouse-wheel detection).
37    pub categories_panel_area: Option<Rect>,
38    /// Categories panel scrollbar area (for drag).
39    pub categories_scrollbar_area: Option<Rect>,
40    /// Settings panel area (for scroll hit testing)
41    pub settings_panel_area: Option<Rect>,
42    /// Scrollbar area (for drag detection)
43    pub scrollbar_area: Option<Rect>,
44    /// Search results scrollbar area (for search results scrolling)
45    pub search_scrollbar_area: Option<Rect>,
46    /// Search results content area (for scroll wheel detection)
47    pub search_results_area: Option<Rect>,
48}
49
50/// Layout info for a search result
51#[derive(Debug, Clone)]
52pub struct SearchResultLayout {
53    /// Page index (category)
54    pub page_index: usize,
55    /// Item index within the page
56    pub item_index: usize,
57    /// Full area for this result
58    pub area: Rect,
59}
60
61/// Layout info for a setting item
62#[derive(Debug, Clone)]
63pub struct ItemLayout {
64    /// Item index within current page
65    pub index: usize,
66    /// JSON path for this setting
67    pub path: String,
68    /// Full item area (for selection)
69    pub area: Rect,
70    /// Control-specific layout info
71    pub control: ControlLayoutInfo,
72    /// Area of the [Inherit] button (only for nullable, explicitly-set items)
73    pub inherit_button: Option<Rect>,
74}
75
76impl SettingsLayout {
77    /// Create a new layout for the given modal area
78    pub fn new(modal_area: Rect) -> Self {
79        Self {
80            modal_area,
81            categories: Vec::new(),
82            items: Vec::new(),
83            search_results: Vec::new(),
84            layer_button: None,
85            edit_button: None,
86            save_button: None,
87            cancel_button: None,
88            reset_button: None,
89            clear_category_button: None,
90            sections: Vec::new(),
91            category_disclosures: Vec::new(),
92            categories_panel_area: None,
93            categories_scrollbar_area: None,
94            settings_panel_area: None,
95            scrollbar_area: None,
96            search_scrollbar_area: None,
97            search_results_area: None,
98        }
99    }
100
101    /// Add a category to the layout
102    pub fn add_category(&mut self, index: usize, area: Rect) {
103        self.categories.push((index, area));
104    }
105
106    /// Add a section row (under an expanded category) for hit-testing.
107    pub fn add_section(&mut self, cat_idx: usize, section_idx: usize, area: Rect) {
108        self.sections.push((cat_idx, section_idx, area));
109    }
110
111    /// Register the disclosure-chevron area for an expandable category.
112    pub fn add_category_disclosure(&mut self, cat_idx: usize, area: Rect) {
113        self.category_disclosures.push((cat_idx, area));
114    }
115
116    /// Add a setting item to the layout
117    pub fn add_item(
118        &mut self,
119        index: usize,
120        path: String,
121        area: Rect,
122        control: ControlLayoutInfo,
123        inherit_button: Option<Rect>,
124    ) {
125        self.items.push(ItemLayout {
126            index,
127            path,
128            area,
129            control,
130            inherit_button,
131        });
132    }
133
134    /// Add a search result to the layout
135    pub fn add_search_result(&mut self, page_index: usize, item_index: usize, area: Rect) {
136        self.search_results.push(SearchResultLayout {
137            page_index,
138            item_index,
139            area,
140        });
141    }
142
143    /// Hit test a position and return what was clicked
144    pub fn hit_test(&self, x: u16, y: u16) -> Option<SettingsHit> {
145        // Check if outside modal
146        if !point_in_rect(self.modal_area, x, y) {
147            return Some(SettingsHit::Outside);
148        }
149
150        // Check footer buttons
151        if let Some(ref layer) = self.layer_button {
152            if point_in_rect(*layer, x, y) {
153                return Some(SettingsHit::LayerButton);
154            }
155        }
156        if let Some(ref edit) = self.edit_button {
157            if point_in_rect(*edit, x, y) {
158                return Some(SettingsHit::EditButton);
159            }
160        }
161        if let Some(ref save) = self.save_button {
162            if point_in_rect(*save, x, y) {
163                return Some(SettingsHit::SaveButton);
164            }
165        }
166        if let Some(ref cancel) = self.cancel_button {
167            if point_in_rect(*cancel, x, y) {
168                return Some(SettingsHit::CancelButton);
169            }
170        }
171        if let Some(ref reset) = self.reset_button {
172            if point_in_rect(*reset, x, y) {
173                return Some(SettingsHit::ResetButton);
174            }
175        }
176        if let Some(ref clear_cat) = self.clear_category_button {
177            if point_in_rect(*clear_cat, x, y) {
178                return Some(SettingsHit::ClearCategoryButton);
179            }
180        }
181
182        // Check categories tree (chevrons → sections → category rows; chevron
183        // first so clicking just the disclosure toggles expansion without
184        // changing the body-panel selection).
185        for (cat_idx, area) in &self.category_disclosures {
186            if point_in_rect(*area, x, y) {
187                return Some(SettingsHit::CategoryDisclosure(*cat_idx));
188            }
189        }
190        for (cat_idx, section_idx, area) in &self.sections {
191            if point_in_rect(*area, x, y) {
192                return Some(SettingsHit::CategorySection(*cat_idx, *section_idx));
193            }
194        }
195        for (index, area) in &self.categories {
196            if point_in_rect(*area, x, y) {
197                return Some(SettingsHit::Category(*index));
198            }
199        }
200        if let Some(ref scrollbar) = self.categories_scrollbar_area {
201            if point_in_rect(*scrollbar, x, y) {
202                return Some(SettingsHit::CategoriesScrollbar);
203            }
204        }
205        if let Some(ref panel) = self.categories_panel_area {
206            if point_in_rect(*panel, x, y) {
207                return Some(SettingsHit::CategoriesPanel);
208            }
209        }
210
211        // Check search scrollbar (before search results, for click/drag priority)
212        if let Some(ref scrollbar) = self.search_scrollbar_area {
213            if point_in_rect(*scrollbar, x, y) {
214                return Some(SettingsHit::SearchScrollbar);
215            }
216        }
217
218        // Check search results (before regular items, since they replace the item list during search)
219        for (idx, result) in self.search_results.iter().enumerate() {
220            if point_in_rect(result.area, x, y) {
221                return Some(SettingsHit::SearchResult(idx));
222            }
223        }
224
225        // Check search results area (for scroll wheel when over the results area but not on a result)
226        if let Some(ref area) = self.search_results_area {
227            if point_in_rect(*area, x, y) {
228                return Some(SettingsHit::SearchResultsPanel);
229            }
230        }
231
232        // Check setting items
233        for item in &self.items {
234            if point_in_rect(item.area, x, y) {
235                // Check inherit button first (highest priority within item)
236                if let Some(ref inherit_area) = item.inherit_button {
237                    if point_in_rect(*inherit_area, x, y) {
238                        return Some(SettingsHit::ControlInherit(item.index));
239                    }
240                }
241
242                // Check specific control areas
243                match &item.control {
244                    ControlLayoutInfo::Toggle(toggle_area) => {
245                        if point_in_rect(*toggle_area, x, y) {
246                            return Some(SettingsHit::ControlToggle(item.index));
247                        }
248                    }
249                    ControlLayoutInfo::Number {
250                        decrement,
251                        increment,
252                        value,
253                    } => {
254                        if point_in_rect(*decrement, x, y) {
255                            return Some(SettingsHit::ControlDecrement(item.index));
256                        }
257                        if point_in_rect(*increment, x, y) {
258                            return Some(SettingsHit::ControlIncrement(item.index));
259                        }
260                        if point_in_rect(*value, x, y) {
261                            return Some(SettingsHit::ControlNumberValue(item.index));
262                        }
263                    }
264                    ControlLayoutInfo::Dropdown {
265                        button_area,
266                        option_areas,
267                        scroll_offset,
268                    } => {
269                        // Check option areas first (when dropdown is open)
270                        for (i, area) in option_areas.iter().enumerate() {
271                            if point_in_rect(*area, x, y) {
272                                return Some(SettingsHit::ControlDropdownOption(
273                                    item.index,
274                                    scroll_offset + i,
275                                ));
276                            }
277                        }
278                        if point_in_rect(*button_area, x, y) {
279                            return Some(SettingsHit::ControlDropdown(item.index));
280                        }
281                    }
282                    ControlLayoutInfo::Text(area) => {
283                        if point_in_rect(*area, x, y) {
284                            return Some(SettingsHit::ControlText(item.index));
285                        }
286                    }
287                    ControlLayoutInfo::TextList { rows } => {
288                        for &(data_idx, row_area) in rows.iter() {
289                            if point_in_rect(row_area, x, y) {
290                                return Some(SettingsHit::ControlTextListRow(
291                                    item.index,
292                                    data_idx.unwrap_or(usize::MAX),
293                                ));
294                            }
295                        }
296                    }
297                    ControlLayoutInfo::Map {
298                        entry_rows,
299                        add_row_area,
300                    } => {
301                        // Check click on add-new row first (so it has priority)
302                        if let Some(add_area) = add_row_area {
303                            if point_in_rect(*add_area, x, y) {
304                                return Some(SettingsHit::ControlMapAddNew(item.index));
305                            }
306                        }
307                        for &(data_idx, row_area) in entry_rows.iter() {
308                            if point_in_rect(row_area, x, y) {
309                                return Some(SettingsHit::ControlMapRow(item.index, data_idx));
310                            }
311                        }
312                    }
313                    ControlLayoutInfo::ObjectArray { entry_rows } => {
314                        for &(data_idx, row_area) in entry_rows.iter() {
315                            if point_in_rect(row_area, x, y) {
316                                return Some(SettingsHit::ControlMapRow(item.index, data_idx));
317                            }
318                        }
319                    }
320                    ControlLayoutInfo::DualList(dual_layout) => {
321                        use crate::view::controls::DualListHit;
322                        if let Some(hit) = dual_layout.hit_test(x, y) {
323                            return Some(match hit {
324                                DualListHit::AvailableRow(row) => {
325                                    SettingsHit::ControlDualListAvailable(item.index, row)
326                                }
327                                DualListHit::IncludedRow(row) => {
328                                    SettingsHit::ControlDualListIncluded(item.index, row)
329                                }
330                                DualListHit::AddButton => {
331                                    SettingsHit::ControlDualListAdd(item.index)
332                                }
333                                DualListHit::RemoveButton => {
334                                    SettingsHit::ControlDualListRemove(item.index)
335                                }
336                                DualListHit::MoveUpButton => {
337                                    SettingsHit::ControlDualListMoveUp(item.index)
338                                }
339                                DualListHit::MoveDownButton => {
340                                    SettingsHit::ControlDualListMoveDown(item.index)
341                                }
342                            });
343                        }
344                    }
345                    ControlLayoutInfo::Json { edit_area } => {
346                        if point_in_rect(*edit_area, x, y) {
347                            return Some(SettingsHit::ControlText(item.index));
348                        }
349                    }
350                    ControlLayoutInfo::Complex => {}
351                }
352
353                return Some(SettingsHit::Item(item.index));
354            }
355        }
356
357        // Check scrollbar area (for drag detection)
358        if let Some(ref scrollbar) = self.scrollbar_area {
359            if point_in_rect(*scrollbar, x, y) {
360                return Some(SettingsHit::Scrollbar);
361            }
362        }
363
364        // Check settings panel area (for scroll wheel)
365        if let Some(ref panel) = self.settings_panel_area {
366            if point_in_rect(*panel, x, y) {
367                return Some(SettingsHit::SettingsPanel);
368            }
369        }
370
371        Some(SettingsHit::Background)
372    }
373}
374
375/// Result of a hit test on the settings UI
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub enum SettingsHit {
378    /// Click outside the modal
379    Outside,
380    /// Click on modal background
381    Background,
382    /// Click on a category (index)
383    Category(usize),
384    /// Click on the chevron of an expandable category — toggles expansion
385    /// without changing the body-panel selection.
386    CategoryDisclosure(usize),
387    /// Click on a section row inside an expanded category in the tree view.
388    CategorySection(usize, usize),
389    /// Click on the categories panel itself (for mouse-wheel scroll).
390    CategoriesPanel,
391    /// Click on the categories panel scrollbar (for drag).
392    CategoriesScrollbar,
393    /// Click on a setting item (index)
394    Item(usize),
395    /// Click on a search result (index in search_results)
396    SearchResult(usize),
397    /// Click on toggle control
398    ControlToggle(usize),
399    /// Click on number decrement button
400    ControlDecrement(usize),
401    /// Click on number increment button
402    ControlIncrement(usize),
403    /// Click on the value area between the brackets of a number control —
404    /// should focus the item and enter inline editing mode.
405    ControlNumberValue(usize),
406    /// Click on dropdown button
407    ControlDropdown(usize),
408    /// Click on dropdown option (item_idx, option_idx)
409    ControlDropdownOption(usize, usize),
410    /// Click on text input
411    ControlText(usize),
412    /// Click on text list row (item_idx, row_idx)
413    ControlTextListRow(usize, usize),
414    /// Click on map row (item_idx, row_idx)
415    ControlMapRow(usize, usize),
416    /// Click on map add-new row (item_idx)
417    ControlMapAddNew(usize),
418    /// Click on inherit button (item_idx) - unset a nullable value
419    ControlInherit(usize),
420    /// Click on dual-list available row (item_idx, row_idx)
421    ControlDualListAvailable(usize, usize),
422    /// Click on dual-list included row (item_idx, row_idx)
423    ControlDualListIncluded(usize, usize),
424    /// Click on dual-list add button (item_idx)
425    ControlDualListAdd(usize),
426    /// Click on dual-list remove button (item_idx)
427    ControlDualListRemove(usize),
428    /// Click on dual-list move-up button (item_idx)
429    ControlDualListMoveUp(usize),
430    /// Click on dual-list move-down button (item_idx)
431    ControlDualListMoveDown(usize),
432    /// Click on layer button
433    LayerButton,
434    /// Click on edit config file button
435    EditButton,
436    /// Click on save button
437    SaveButton,
438    /// Click on cancel button
439    CancelButton,
440    /// Click on reset button
441    ResetButton,
442    /// Click on clear category button (for nullable categories)
443    ClearCategoryButton,
444    /// Click on settings panel scrollbar
445    Scrollbar,
446    /// Click on settings panel (scrollable area)
447    SettingsPanel,
448    /// Click on search results scrollbar
449    SearchScrollbar,
450    /// Click on search results area (for scroll wheel)
451    SearchResultsPanel,
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn test_layout_creation() {
460        let modal = Rect::new(10, 5, 80, 30);
461        let mut layout = SettingsLayout::new(modal);
462
463        layout.add_category(0, Rect::new(11, 6, 20, 1));
464        layout.add_category(1, Rect::new(11, 7, 20, 1));
465
466        assert_eq!(layout.categories.len(), 2);
467    }
468
469    #[test]
470    fn test_hit_test_outside() {
471        let modal = Rect::new(10, 5, 80, 30);
472        let layout = SettingsLayout::new(modal);
473
474        assert_eq!(layout.hit_test(0, 0), Some(SettingsHit::Outside));
475        assert_eq!(layout.hit_test(5, 5), Some(SettingsHit::Outside));
476    }
477
478    #[test]
479    fn test_hit_test_category() {
480        let modal = Rect::new(10, 5, 80, 30);
481        let mut layout = SettingsLayout::new(modal);
482
483        layout.add_category(0, Rect::new(11, 6, 20, 1));
484        layout.add_category(1, Rect::new(11, 7, 20, 1));
485
486        assert_eq!(layout.hit_test(15, 6), Some(SettingsHit::Category(0)));
487        assert_eq!(layout.hit_test(15, 7), Some(SettingsHit::Category(1)));
488    }
489
490    #[test]
491    fn test_hit_test_buttons() {
492        let modal = Rect::new(10, 5, 80, 30);
493        let mut layout = SettingsLayout::new(modal);
494
495        layout.save_button = Some(Rect::new(60, 32, 8, 1));
496        layout.cancel_button = Some(Rect::new(70, 32, 10, 1));
497
498        assert_eq!(layout.hit_test(62, 32), Some(SettingsHit::SaveButton));
499        assert_eq!(layout.hit_test(75, 32), Some(SettingsHit::CancelButton));
500    }
501
502    #[test]
503    fn test_hit_test_item_with_toggle() {
504        let modal = Rect::new(10, 5, 80, 30);
505        let mut layout = SettingsLayout::new(modal);
506
507        layout.add_item(
508            0,
509            "/test".to_string(),
510            Rect::new(35, 10, 50, 2),
511            ControlLayoutInfo::Toggle(Rect::new(37, 11, 15, 1)),
512            None,
513        );
514
515        // Click on toggle control
516        assert_eq!(layout.hit_test(40, 11), Some(SettingsHit::ControlToggle(0)));
517
518        // Click on item but not on toggle
519        assert_eq!(layout.hit_test(35, 10), Some(SettingsHit::Item(0)));
520    }
521
522    /// Reproducer for issue #1825: clicking on the value area between the
523    /// brackets of a Number control used to return `Item`, which only
524    /// changes selection. It must now return a dedicated hit that the mouse
525    /// handler can route to "start editing".
526    #[test]
527    fn test_hit_test_number_value_area() {
528        let modal = Rect::new(10, 5, 80, 30);
529        let mut layout = SettingsLayout::new(modal);
530
531        let item_area = Rect::new(35, 10, 50, 1);
532        let decrement = Rect::new(50, 10, 3, 1);
533        let increment = Rect::new(54, 10, 3, 1);
534        let value = Rect::new(42, 10, 7, 1);
535
536        layout.add_item(
537            0,
538            "/tab_size".to_string(),
539            item_area,
540            ControlLayoutInfo::Number {
541                decrement,
542                increment,
543                value,
544            },
545            None,
546        );
547
548        assert_eq!(
549            layout.hit_test(45, 10),
550            Some(SettingsHit::ControlNumberValue(0))
551        );
552        assert_eq!(
553            layout.hit_test(51, 10),
554            Some(SettingsHit::ControlDecrement(0))
555        );
556        assert_eq!(
557            layout.hit_test(55, 10),
558            Some(SettingsHit::ControlIncrement(0))
559        );
560    }
561}