Skip to main content

armas_basic/layout/
resizable.rs

1//! Resizable Panel Component (shadcn/ui style)
2//!
3//! Resizable panel groups with draggable handles between panels.
4//!
5//! ```rust,no_run
6//! # use egui::Ui;
7//! # fn example(ui: &mut Ui) {
8//! use armas_basic::prelude::*;
9//!
10//! let panels = vec![
11//!     ResizablePanel::new(0.25),
12//!     ResizablePanel::new(0.75),
13//! ];
14//! let mut resizable = Resizable::new("split", ResizableDirection::Horizontal);
15//! resizable.show(ui, &panels, |ui, index| {
16//!     ui.label(format!("Panel {}", index + 1));
17//! });
18//! # }
19//! ```
20
21use egui::{vec2, CursorIcon, Id, Pos2, Rect, Sense, Ui};
22
23// Constants
24const HANDLE_SIZE: f32 = 4.0;
25const HANDLE_HIT_SIZE: f32 = 8.0; // Larger hit area for easier grabbing
26const GRIP_DOT_SIZE: f32 = 2.0;
27const GRIP_DOT_GAP: f32 = 3.0;
28
29/// Direction of the resizable panel group.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ResizableDirection {
32    /// Panels are arranged left to right, handle is vertical.
33    Horizontal,
34    /// Panels are arranged top to bottom, handle is horizontal.
35    Vertical,
36}
37
38/// Configuration for a single panel in a resizable group.
39#[derive(Debug, Clone, Copy)]
40pub struct ResizablePanel {
41    /// Default size as a fraction (0.0..1.0).
42    pub default_size: f32,
43    /// Minimum size fraction.
44    pub min_size: Option<f32>,
45    /// Maximum size fraction.
46    pub max_size: Option<f32>,
47}
48
49impl ResizablePanel {
50    /// Create a new panel with a default size fraction.
51    #[must_use]
52    pub const fn new(default_size: f32) -> Self {
53        Self {
54            default_size,
55            min_size: None,
56            max_size: None,
57        }
58    }
59
60    /// Set the minimum size fraction.
61    #[must_use]
62    pub const fn min_size(mut self, min: f32) -> Self {
63        self.min_size = Some(min);
64        self
65    }
66
67    /// Set the maximum size fraction.
68    #[must_use]
69    pub const fn max_size(mut self, max: f32) -> Self {
70        self.max_size = Some(max);
71        self
72    }
73}
74
75/// Resizable panel group — panels separated by draggable handles.
76pub struct Resizable {
77    id: Id,
78    direction: ResizableDirection,
79}
80
81/// Response from a resizable panel group.
82pub struct ResizableResponse {
83    /// The UI response.
84    pub response: egui::Response,
85    /// Current panel sizes as fractions.
86    pub sizes: Vec<f32>,
87    /// Whether any handle was dragged this frame.
88    pub changed: bool,
89}
90
91impl Resizable {
92    /// Create a new resizable panel group.
93    pub fn new(id: impl Into<Id>, direction: ResizableDirection) -> Self {
94        Self {
95            id: id.into(),
96            direction,
97        }
98    }
99
100    /// Show the resizable panel group.
101    pub fn show(
102        &mut self,
103        ui: &mut Ui,
104        panels: &[ResizablePanel],
105        mut content: impl FnMut(&mut Ui, usize),
106    ) -> ResizableResponse {
107        let theme = crate::ext::ArmasContextExt::armas_theme(ui.ctx());
108
109        if panels.is_empty() {
110            let (_, response) = ui.allocate_exact_size(vec2(0.0, 0.0), Sense::hover());
111            return ResizableResponse {
112                response,
113                sizes: vec![],
114                changed: false,
115            };
116        }
117
118        let is_horizontal = self.direction == ResizableDirection::Horizontal;
119        let available = ui.available_rect_before_wrap();
120
121        // Load persisted sizes
122        let sizes_id = self.id.with("sizes");
123        let mut sizes: Vec<f32> =
124            ui.ctx()
125                .data_mut(|d| d.get_temp(sizes_id))
126                .unwrap_or_else(|| {
127                    let defaults: Vec<f32> = panels.iter().map(|p| p.default_size).collect();
128                    normalize_sizes(&defaults)
129                });
130
131        // Ensure sizes array matches panel count
132        if sizes.len() != panels.len() {
133            sizes = normalize_sizes(&panels.iter().map(|p| p.default_size).collect::<Vec<_>>());
134        }
135
136        let total_main = if is_horizontal {
137            available.width()
138        } else {
139            available.height()
140        };
141        let handle_count = panels.len().saturating_sub(1);
142        let usable_main = total_main - (handle_count as f32 * HANDLE_SIZE);
143
144        // Allocate the full rect
145        let (full_rect, full_response) = ui.allocate_exact_size(available.size(), Sense::hover());
146
147        let mut changed = false;
148
149        // Draw panels and handles
150        let mut offset = 0.0;
151        for i in 0..panels.len() {
152            let panel_extent = sizes[i] * usable_main;
153
154            // Panel rect
155            let panel_rect = if is_horizontal {
156                Rect::from_min_size(
157                    Pos2::new(full_rect.left() + offset, full_rect.top()),
158                    vec2(panel_extent, full_rect.height()),
159                )
160            } else {
161                Rect::from_min_size(
162                    Pos2::new(full_rect.left(), full_rect.top() + offset),
163                    vec2(full_rect.width(), panel_extent),
164                )
165            };
166
167            // Create child UI for panel content
168            let mut child_ui = ui.new_child(
169                egui::UiBuilder::new()
170                    .max_rect(panel_rect)
171                    .layout(egui::Layout::top_down(egui::Align::LEFT)),
172            );
173            child_ui.set_clip_rect(panel_rect);
174            content(&mut child_ui, i);
175
176            offset += panel_extent;
177
178            // Draw handle between panels (not after the last one)
179            if i < panels.len() - 1 {
180                let handle_rect = if is_horizontal {
181                    Rect::from_min_size(
182                        Pos2::new(full_rect.left() + offset, full_rect.top()),
183                        vec2(HANDLE_SIZE, full_rect.height()),
184                    )
185                } else {
186                    Rect::from_min_size(
187                        Pos2::new(full_rect.left(), full_rect.top() + offset),
188                        vec2(full_rect.width(), HANDLE_SIZE),
189                    )
190                };
191
192                // Larger hit area
193                let hit_rect = if is_horizontal {
194                    handle_rect.expand2(vec2((HANDLE_HIT_SIZE - HANDLE_SIZE) / 2.0, 0.0))
195                } else {
196                    handle_rect.expand2(vec2(0.0, (HANDLE_HIT_SIZE - HANDLE_SIZE) / 2.0))
197                };
198
199                let handle_id = self.id.with(("handle", i));
200                let handle_response = ui.interact(hit_rect, handle_id, Sense::drag());
201
202                // Cursor change
203                if handle_response.hovered() || handle_response.dragged() {
204                    ui.ctx().set_cursor_icon(if is_horizontal {
205                        CursorIcon::ResizeHorizontal
206                    } else {
207                        CursorIcon::ResizeVertical
208                    });
209                }
210
211                // Handle visual
212                let handle_color = if handle_response.dragged() || handle_response.hovered() {
213                    theme.ring()
214                } else {
215                    theme.border()
216                };
217
218                ui.painter().rect_filled(handle_rect, 0.0, handle_color);
219
220                // Grip dots in center
221                let center = handle_rect.center();
222                if is_horizontal {
223                    for dy in [-GRIP_DOT_GAP, 0.0, GRIP_DOT_GAP] {
224                        ui.painter().circle_filled(
225                            Pos2::new(center.x, center.y + dy),
226                            GRIP_DOT_SIZE / 2.0,
227                            theme.muted_foreground(),
228                        );
229                    }
230                } else {
231                    for dx in [-GRIP_DOT_GAP, 0.0, GRIP_DOT_GAP] {
232                        ui.painter().circle_filled(
233                            Pos2::new(center.x + dx, center.y),
234                            GRIP_DOT_SIZE / 2.0,
235                            theme.muted_foreground(),
236                        );
237                    }
238                }
239
240                // Handle drag
241                if handle_response.dragged() {
242                    let delta = if is_horizontal {
243                        handle_response.drag_delta().x
244                    } else {
245                        handle_response.drag_delta().y
246                    };
247
248                    let delta_frac = delta / usable_main;
249
250                    // Redistribute between adjacent panels
251                    let new_left = sizes[i] + delta_frac;
252                    let new_right = sizes[i + 1] - delta_frac;
253
254                    // Apply min/max constraints
255                    let min_left = panels[i].min_size.unwrap_or(0.05);
256                    let max_left = panels[i].max_size.unwrap_or(0.95);
257                    let min_right = panels[i + 1].min_size.unwrap_or(0.05);
258                    let max_right = panels[i + 1].max_size.unwrap_or(0.95);
259
260                    if new_left >= min_left
261                        && new_left <= max_left
262                        && new_right >= min_right
263                        && new_right <= max_right
264                    {
265                        sizes[i] = new_left;
266                        sizes[i + 1] = new_right;
267                        changed = true;
268                    }
269                }
270
271                offset += HANDLE_SIZE;
272            }
273        }
274
275        // Save state
276        let sizes_clone = sizes.clone();
277        ui.ctx().data_mut(|d| d.insert_temp(sizes_id, sizes_clone));
278
279        ResizableResponse {
280            response: full_response,
281            sizes,
282            changed,
283        }
284    }
285}
286
287/// Normalize sizes so they sum to 1.0.
288fn normalize_sizes(sizes: &[f32]) -> Vec<f32> {
289    let sum: f32 = sizes.iter().sum();
290    if sum <= 0.0 {
291        let n = sizes.len();
292        return vec![1.0 / n as f32; n];
293    }
294    sizes.iter().map(|s| s / sum).collect()
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_normalize_sizes() {
303        let sizes = normalize_sizes(&[0.25, 0.75]);
304        assert!((sizes[0] - 0.25).abs() < f32::EPSILON);
305        assert!((sizes[1] - 0.75).abs() < f32::EPSILON);
306    }
307
308    #[test]
309    fn test_normalize_sizes_unequal() {
310        let sizes = normalize_sizes(&[1.0, 2.0, 1.0]);
311        assert!((sizes[0] - 0.25).abs() < f32::EPSILON);
312        assert!((sizes[1] - 0.5).abs() < f32::EPSILON);
313        assert!((sizes[2] - 0.25).abs() < f32::EPSILON);
314    }
315
316    #[test]
317    fn test_resizable_panel_builder() {
318        let panel = ResizablePanel::new(0.5).min_size(0.2).max_size(0.8);
319        assert_eq!(panel.default_size, 0.5);
320        assert_eq!(panel.min_size, Some(0.2));
321        assert_eq!(panel.max_size, Some(0.8));
322    }
323}