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    /// Settings panel area (for scroll hit testing)
31    pub settings_panel_area: Option<Rect>,
32    /// Scrollbar area (for drag detection)
33    pub scrollbar_area: Option<Rect>,
34    /// Search results scrollbar area (for search results scrolling)
35    pub search_scrollbar_area: Option<Rect>,
36    /// Search results content area (for scroll wheel detection)
37    pub search_results_area: Option<Rect>,
38}
39
40/// Layout info for a search result
41#[derive(Debug, Clone)]
42pub struct SearchResultLayout {
43    /// Page index (category)
44    pub page_index: usize,
45    /// Item index within the page
46    pub item_index: usize,
47    /// Full area for this result
48    pub area: Rect,
49}
50
51/// Layout info for a setting item
52#[derive(Debug, Clone)]
53pub struct ItemLayout {
54    /// Item index within current page
55    pub index: usize,
56    /// JSON path for this setting
57    pub path: String,
58    /// Full item area (for selection)
59    pub area: Rect,
60    /// Control-specific layout info
61    pub control: ControlLayoutInfo,
62}
63
64impl SettingsLayout {
65    /// Create a new layout for the given modal area
66    pub fn new(modal_area: Rect) -> Self {
67        Self {
68            modal_area,
69            categories: Vec::new(),
70            items: Vec::new(),
71            search_results: Vec::new(),
72            layer_button: None,
73            edit_button: None,
74            save_button: None,
75            cancel_button: None,
76            reset_button: None,
77            settings_panel_area: None,
78            scrollbar_area: None,
79            search_scrollbar_area: None,
80            search_results_area: None,
81        }
82    }
83
84    /// Add a category to the layout
85    pub fn add_category(&mut self, index: usize, area: Rect) {
86        self.categories.push((index, area));
87    }
88
89    /// Add a setting item to the layout
90    pub fn add_item(&mut self, index: usize, path: String, area: Rect, control: ControlLayoutInfo) {
91        self.items.push(ItemLayout {
92            index,
93            path,
94            area,
95            control,
96        });
97    }
98
99    /// Add a search result to the layout
100    pub fn add_search_result(&mut self, page_index: usize, item_index: usize, area: Rect) {
101        self.search_results.push(SearchResultLayout {
102            page_index,
103            item_index,
104            area,
105        });
106    }
107
108    /// Hit test a position and return what was clicked
109    pub fn hit_test(&self, x: u16, y: u16) -> Option<SettingsHit> {
110        // Check if outside modal
111        if !point_in_rect(self.modal_area, x, y) {
112            return Some(SettingsHit::Outside);
113        }
114
115        // Check footer buttons
116        if let Some(ref layer) = self.layer_button {
117            if point_in_rect(*layer, x, y) {
118                return Some(SettingsHit::LayerButton);
119            }
120        }
121        if let Some(ref edit) = self.edit_button {
122            if point_in_rect(*edit, x, y) {
123                return Some(SettingsHit::EditButton);
124            }
125        }
126        if let Some(ref save) = self.save_button {
127            if point_in_rect(*save, x, y) {
128                return Some(SettingsHit::SaveButton);
129            }
130        }
131        if let Some(ref cancel) = self.cancel_button {
132            if point_in_rect(*cancel, x, y) {
133                return Some(SettingsHit::CancelButton);
134            }
135        }
136        if let Some(ref reset) = self.reset_button {
137            if point_in_rect(*reset, x, y) {
138                return Some(SettingsHit::ResetButton);
139            }
140        }
141
142        // Check categories
143        for (index, area) in &self.categories {
144            if point_in_rect(*area, x, y) {
145                return Some(SettingsHit::Category(*index));
146            }
147        }
148
149        // Check search scrollbar (before search results, for click/drag priority)
150        if let Some(ref scrollbar) = self.search_scrollbar_area {
151            if point_in_rect(*scrollbar, x, y) {
152                return Some(SettingsHit::SearchScrollbar);
153            }
154        }
155
156        // Check search results (before regular items, since they replace the item list during search)
157        for (idx, result) in self.search_results.iter().enumerate() {
158            if point_in_rect(result.area, x, y) {
159                return Some(SettingsHit::SearchResult(idx));
160            }
161        }
162
163        // Check search results area (for scroll wheel when over the results area but not on a result)
164        if let Some(ref area) = self.search_results_area {
165            if point_in_rect(*area, x, y) {
166                return Some(SettingsHit::SearchResultsPanel);
167            }
168        }
169
170        // Check setting items
171        for item in &self.items {
172            if point_in_rect(item.area, x, y) {
173                // Check specific control areas
174                match &item.control {
175                    ControlLayoutInfo::Toggle(toggle_area) => {
176                        if point_in_rect(*toggle_area, x, y) {
177                            return Some(SettingsHit::ControlToggle(item.index));
178                        }
179                    }
180                    ControlLayoutInfo::Number {
181                        decrement,
182                        increment,
183                        value,
184                    } => {
185                        if point_in_rect(*decrement, x, y) {
186                            return Some(SettingsHit::ControlDecrement(item.index));
187                        }
188                        if point_in_rect(*increment, x, y) {
189                            return Some(SettingsHit::ControlIncrement(item.index));
190                        }
191                        if point_in_rect(*value, x, y) {
192                            return Some(SettingsHit::Item(item.index));
193                        }
194                    }
195                    ControlLayoutInfo::Dropdown {
196                        button_area,
197                        option_areas,
198                        scroll_offset,
199                    } => {
200                        // Check option areas first (when dropdown is open)
201                        for (i, area) in option_areas.iter().enumerate() {
202                            if point_in_rect(*area, x, y) {
203                                return Some(SettingsHit::ControlDropdownOption(
204                                    item.index,
205                                    scroll_offset + i,
206                                ));
207                            }
208                        }
209                        if point_in_rect(*button_area, x, y) {
210                            return Some(SettingsHit::ControlDropdown(item.index));
211                        }
212                    }
213                    ControlLayoutInfo::Text(area) => {
214                        if point_in_rect(*area, x, y) {
215                            return Some(SettingsHit::ControlText(item.index));
216                        }
217                    }
218                    ControlLayoutInfo::TextList { rows } => {
219                        for &(data_idx, row_area) in rows.iter() {
220                            if point_in_rect(row_area, x, y) {
221                                return Some(SettingsHit::ControlTextListRow(
222                                    item.index,
223                                    data_idx.unwrap_or(usize::MAX),
224                                ));
225                            }
226                        }
227                    }
228                    ControlLayoutInfo::Map {
229                        entry_rows,
230                        add_row_area,
231                    } => {
232                        // Check click on add-new row first (so it has priority)
233                        if let Some(add_area) = add_row_area {
234                            if point_in_rect(*add_area, x, y) {
235                                return Some(SettingsHit::ControlMapAddNew(item.index));
236                            }
237                        }
238                        for &(data_idx, row_area) in entry_rows.iter() {
239                            if point_in_rect(row_area, x, y) {
240                                return Some(SettingsHit::ControlMapRow(item.index, data_idx));
241                            }
242                        }
243                    }
244                    ControlLayoutInfo::ObjectArray { entry_rows } => {
245                        for &(data_idx, row_area) in entry_rows.iter() {
246                            if point_in_rect(row_area, x, y) {
247                                return Some(SettingsHit::ControlMapRow(item.index, data_idx));
248                            }
249                        }
250                    }
251                    ControlLayoutInfo::Json { edit_area } => {
252                        if point_in_rect(*edit_area, x, y) {
253                            return Some(SettingsHit::ControlText(item.index));
254                        }
255                    }
256                    ControlLayoutInfo::Complex => {}
257                }
258
259                return Some(SettingsHit::Item(item.index));
260            }
261        }
262
263        // Check scrollbar area (for drag detection)
264        if let Some(ref scrollbar) = self.scrollbar_area {
265            if point_in_rect(*scrollbar, x, y) {
266                return Some(SettingsHit::Scrollbar);
267            }
268        }
269
270        // Check settings panel area (for scroll wheel)
271        if let Some(ref panel) = self.settings_panel_area {
272            if point_in_rect(*panel, x, y) {
273                return Some(SettingsHit::SettingsPanel);
274            }
275        }
276
277        Some(SettingsHit::Background)
278    }
279}
280
281/// Result of a hit test on the settings UI
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum SettingsHit {
284    /// Click outside the modal
285    Outside,
286    /// Click on modal background
287    Background,
288    /// Click on a category (index)
289    Category(usize),
290    /// Click on a setting item (index)
291    Item(usize),
292    /// Click on a search result (index in search_results)
293    SearchResult(usize),
294    /// Click on toggle control
295    ControlToggle(usize),
296    /// Click on number decrement button
297    ControlDecrement(usize),
298    /// Click on number increment button
299    ControlIncrement(usize),
300    /// Click on dropdown button
301    ControlDropdown(usize),
302    /// Click on dropdown option (item_idx, option_idx)
303    ControlDropdownOption(usize, usize),
304    /// Click on text input
305    ControlText(usize),
306    /// Click on text list row (item_idx, row_idx)
307    ControlTextListRow(usize, usize),
308    /// Click on map row (item_idx, row_idx)
309    ControlMapRow(usize, usize),
310    /// Click on map add-new row (item_idx)
311    ControlMapAddNew(usize),
312    /// Click on layer button
313    LayerButton,
314    /// Click on edit config file button
315    EditButton,
316    /// Click on save button
317    SaveButton,
318    /// Click on cancel button
319    CancelButton,
320    /// Click on reset button
321    ResetButton,
322    /// Click on settings panel scrollbar
323    Scrollbar,
324    /// Click on settings panel (scrollable area)
325    SettingsPanel,
326    /// Click on search results scrollbar
327    SearchScrollbar,
328    /// Click on search results area (for scroll wheel)
329    SearchResultsPanel,
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_layout_creation() {
338        let modal = Rect::new(10, 5, 80, 30);
339        let mut layout = SettingsLayout::new(modal);
340
341        layout.add_category(0, Rect::new(11, 6, 20, 1));
342        layout.add_category(1, Rect::new(11, 7, 20, 1));
343
344        assert_eq!(layout.categories.len(), 2);
345    }
346
347    #[test]
348    fn test_hit_test_outside() {
349        let modal = Rect::new(10, 5, 80, 30);
350        let layout = SettingsLayout::new(modal);
351
352        assert_eq!(layout.hit_test(0, 0), Some(SettingsHit::Outside));
353        assert_eq!(layout.hit_test(5, 5), Some(SettingsHit::Outside));
354    }
355
356    #[test]
357    fn test_hit_test_category() {
358        let modal = Rect::new(10, 5, 80, 30);
359        let mut layout = SettingsLayout::new(modal);
360
361        layout.add_category(0, Rect::new(11, 6, 20, 1));
362        layout.add_category(1, Rect::new(11, 7, 20, 1));
363
364        assert_eq!(layout.hit_test(15, 6), Some(SettingsHit::Category(0)));
365        assert_eq!(layout.hit_test(15, 7), Some(SettingsHit::Category(1)));
366    }
367
368    #[test]
369    fn test_hit_test_buttons() {
370        let modal = Rect::new(10, 5, 80, 30);
371        let mut layout = SettingsLayout::new(modal);
372
373        layout.save_button = Some(Rect::new(60, 32, 8, 1));
374        layout.cancel_button = Some(Rect::new(70, 32, 10, 1));
375
376        assert_eq!(layout.hit_test(62, 32), Some(SettingsHit::SaveButton));
377        assert_eq!(layout.hit_test(75, 32), Some(SettingsHit::CancelButton));
378    }
379
380    #[test]
381    fn test_hit_test_item_with_toggle() {
382        let modal = Rect::new(10, 5, 80, 30);
383        let mut layout = SettingsLayout::new(modal);
384
385        layout.add_item(
386            0,
387            "/test".to_string(),
388            Rect::new(35, 10, 50, 2),
389            ControlLayoutInfo::Toggle(Rect::new(37, 11, 15, 1)),
390        );
391
392        // Click on toggle control
393        assert_eq!(layout.hit_test(40, 11), Some(SettingsHit::ControlToggle(0)));
394
395        // Click on item but not on toggle
396        assert_eq!(layout.hit_test(35, 10), Some(SettingsHit::Item(0)));
397    }
398}