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