1use egui::{vec2, CursorIcon, Id, Pos2, Rect, Sense, Ui};
22
23const HANDLE_SIZE: f32 = 4.0;
25const HANDLE_HIT_SIZE: f32 = 8.0; const GRIP_DOT_SIZE: f32 = 2.0;
27const GRIP_DOT_GAP: f32 = 3.0;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ResizableDirection {
32 Horizontal,
34 Vertical,
36}
37
38#[derive(Debug, Clone, Copy)]
40pub struct ResizablePanel {
41 pub default_size: f32,
43 pub min_size: Option<f32>,
45 pub max_size: Option<f32>,
47}
48
49impl ResizablePanel {
50 #[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 #[must_use]
62 pub const fn min_size(mut self, min: f32) -> Self {
63 self.min_size = Some(min);
64 self
65 }
66
67 #[must_use]
69 pub const fn max_size(mut self, max: f32) -> Self {
70 self.max_size = Some(max);
71 self
72 }
73}
74
75pub struct Resizable {
77 id: Id,
78 direction: ResizableDirection,
79}
80
81pub struct ResizableResponse {
83 pub response: egui::Response,
85 pub sizes: Vec<f32>,
87 pub changed: bool,
89}
90
91impl Resizable {
92 pub fn new(id: impl Into<Id>, direction: ResizableDirection) -> Self {
94 Self {
95 id: id.into(),
96 direction,
97 }
98 }
99
100 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 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 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 let (full_rect, full_response) = ui.allocate_exact_size(available.size(), Sense::hover());
146
147 let mut changed = false;
148
149 let mut offset = 0.0;
151 for i in 0..panels.len() {
152 let panel_extent = sizes[i] * usable_main;
153
154 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 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 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 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 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 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 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 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 let new_left = sizes[i] + delta_frac;
252 let new_right = sizes[i + 1] - delta_frac;
253
254 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 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
287fn 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}