agg_gui/widgets/resize.rs
1//! `Resize` — a nested, user-draggable resizable container.
2//!
3//! Egui-parity port of `egui::Resize`. Wraps a single child widget
4//! and exposes a bottom-right grip the user can drag to resize the
5//! subregion independently of its surrounding layout. Used inside
6//! the Window Resize Test's "↔ auto-sized" window to show a resize-
7//! within-auto-size behaviour (the outer window fits its content; the
8//! inner `Resize` has its own draggable handle).
9//!
10//! # Coordinate conventions
11//!
12//! The widget is pure Y-up: local `(0, 0)` is bottom-left. The SE
13//! grip sits at `(w - HANDLE, 0) .. (w, HANDLE)` — bottom-right in
14//! screen space.
15//!
16//! # Drag bookkeeping
17//!
18//! A drag captures the mouse position in **parent-relative** coords
19//! (`local + bounds.xy`) rather than widget-local. That keeps the
20//! drag stable even when the parent's layout shifts the widget's
21//! `bounds.y` because the widget's own height just changed — a
22//! common situation inside a `FlexColumn`, where stacking widgets
23//! push later siblings down when earlier ones grow.
24
25use std::cell::Cell;
26use std::rc::Rc;
27
28use crate::cursor::{set_cursor_icon, CursorIcon};
29use crate::draw_ctx::DrawCtx;
30use crate::event::{Event, EventResult, MouseButton};
31use crate::geometry::{Point, Rect, Size};
32use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
33use crate::widget::Widget;
34
35/// Width (and height) of the bottom-right drag grip, in logical pixels.
36const HANDLE: f64 = 14.0;
37
38pub struct Resize {
39 bounds: Rect,
40 /// Always exactly one child — the wrapped content.
41 children: Vec<Box<dyn Widget>>,
42 base: WidgetBase,
43
44 /// Current size the user has dragged to. `None` before the first
45 /// drag → layout uses `default_size` clamped against available.
46 current_size: Option<Size>,
47 min_size: Size,
48 max_size: Size,
49 default_size: Size,
50
51 /// Optional external cell that mirrors `current_size` for
52 /// persistence / inspection. Written each time the user drags.
53 size_cell: Option<Rc<Cell<Size>>>,
54
55 // ── drag state ────────────────────────────────────────────────
56 dragging: bool,
57 hover_handle: bool,
58 /// Mouse position in APP-LEVEL world coords at drag start. Using
59 /// world (not widget-local or parent-relative) coords is required
60 /// because a nested `Resize` inside an auto-sized `Window` has
61 /// ancestor bounds that shift each frame as layout ripples — so
62 /// widget-local event positions shift even when the user's cursor
63 /// is stationary. World coords are the only invariant reference.
64 drag_start_world: Point,
65 drag_start_size: Size,
66}
67
68impl Resize {
69 /// Wrap `child` in a user-resizable container. Defaults: 200×150
70 /// initial size, 80×40 min, 1000×800 max — override with the
71 /// builder methods below. The defaults are deliberately "sane
72 /// demo-friendly" values; match egui's `Resize::default().show(...)`.
73 pub fn new(child: Box<dyn Widget>) -> Self {
74 Self {
75 bounds: Rect::default(),
76 children: vec![child],
77 base: WidgetBase::new(),
78 current_size: None,
79 min_size: Size::new(80.0, 40.0),
80 // Generous default max — we want `Resize` to be able to
81 // grow up to whatever the surrounding layout allows.
82 // Override via `with_max_size_hint` to impose a tighter
83 // cap; this value is way beyond any realistic screen.
84 max_size: Size::new(8000.0, 6000.0),
85 default_size: Size::new(200.0, 150.0),
86 size_cell: None,
87 dragging: false,
88 hover_handle: false,
89 drag_start_world: Point::ORIGIN,
90 drag_start_size: Size::new(0.0, 0.0),
91 }
92 }
93
94 pub fn with_default_size(mut self, s: Size) -> Self {
95 self.default_size = s;
96 self
97 }
98 pub fn with_min_size_hint(mut self, s: Size) -> Self {
99 self.min_size = s;
100 self
101 }
102 pub fn with_max_size_hint(mut self, s: Size) -> Self {
103 self.max_size = s;
104 self
105 }
106
107 /// Bind the current size to a shared `Cell<Size>`. Reads during
108 /// layout (so callers can programmatically drive size), writes
109 /// during drag (so callers can persist user-chosen geometry).
110 pub fn with_size_cell(mut self, cell: Rc<Cell<Size>>) -> Self {
111 // Seed so the first layout picks up any persisted value.
112 self.current_size = Some(cell.get());
113 self.size_cell = Some(cell);
114 self
115 }
116
117 pub fn with_margin(mut self, m: Insets) -> Self {
118 self.base.margin = m;
119 self
120 }
121 pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
122 self.base.h_anchor = h;
123 self
124 }
125 pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
126 self.base.v_anchor = v;
127 self
128 }
129
130 /// Public accessor for tests and inspector integrations.
131 pub fn current_size(&self) -> Size {
132 self.current_size.unwrap_or(self.default_size)
133 }
134
135 /// Widget-local rect of the SE drag grip (Y-up: bottom-right).
136 fn handle_rect(&self) -> Rect {
137 Rect::new(
138 (self.bounds.width - HANDLE).max(0.0),
139 0.0,
140 HANDLE.min(self.bounds.width),
141 HANDLE.min(self.bounds.height),
142 )
143 }
144
145 fn in_handle(&self, p: Point) -> bool {
146 let h = self.handle_rect();
147 p.x >= h.x && p.x <= h.x + h.width && p.y >= h.y && p.y <= h.y + h.height
148 }
149}
150
151impl Widget for Resize {
152 fn type_name(&self) -> &'static str {
153 "Resize"
154 }
155 fn bounds(&self) -> Rect {
156 self.bounds
157 }
158 fn set_bounds(&mut self, b: Rect) {
159 self.bounds = b;
160 }
161 fn children(&self) -> &[Box<dyn Widget>] {
162 &self.children
163 }
164 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
165 &mut self.children
166 }
167
168 fn margin(&self) -> Insets {
169 self.base.margin
170 }
171 fn h_anchor(&self) -> HAnchor {
172 self.base.h_anchor
173 }
174 fn v_anchor(&self) -> VAnchor {
175 self.base.v_anchor
176 }
177 fn min_size(&self) -> Size {
178 self.min_size
179 }
180 fn max_size(&self) -> Size {
181 self.max_size
182 }
183
184 fn layout(&mut self, available: Size) -> Size {
185 let _ = available; // Intentionally ignored — see below.
186 // Pick up the latest cell value each frame so external writes
187 // (e.g. persistence restore) propagate into layout.
188 if let Some(c) = &self.size_cell {
189 self.current_size = Some(c.get());
190 }
191 let target = self.current_size.unwrap_or(self.default_size);
192 // Clamp only to the explicit min_size / max_size hints — NOT
193 // to `available`. A `Resize` widget is the user's "I want
194 // exactly this much space" contract: if the user drags it
195 // bigger than its parent's current slot, the widget still
196 // reports the bigger size so an auto-sized ancestor can grow
197 // to fit on the next layout pass. Matches egui, where the
198 // surrounding Window expands when the inner Resize demands
199 // more width or height.
200 let w_target = target.width.clamp(self.min_size.width, self.max_size.width);
201 let h_target = target
202 .height
203 .clamp(self.min_size.height, self.max_size.height);
204
205 // Content-bound floor: measure the child at the requested
206 // target, and if its natural size is larger (e.g. wrapped
207 // text at a narrower width produces taller content), enforce
208 // content-natural as the minimum. The user can never drag
209 // the Resize smaller than its content fits — matches egui.
210 let natural = if let Some(child) = self.children.first_mut() {
211 child.layout(Size::new(w_target, h_target))
212 } else {
213 Size::new(0.0, 0.0)
214 };
215 let w = w_target.max(natural.width);
216 let h = h_target.max(natural.height);
217 let size = Size::new(w, h);
218 self.current_size = Some(size);
219 self.bounds = Rect::new(0.0, 0.0, w, h);
220
221 if let Some(child) = self.children.first_mut() {
222 // Re-layout if enforcement inflated either axis.
223 if (w - w_target).abs() > 0.5 || (h - h_target).abs() > 0.5 {
224 child.layout(size);
225 }
226 child.set_bounds(Rect::new(0.0, 0.0, w, h));
227 }
228 size
229 }
230
231 fn paint(&mut self, ctx: &mut dyn DrawCtx) {
232 let v = ctx.visuals();
233 let w = self.bounds.width;
234 let h = self.bounds.height;
235
236 // 1-px outline so users see the resizable region even before
237 // they grab the handle. Matches egui's `Resize` subtle frame.
238 ctx.set_stroke_color(v.widget_stroke);
239 ctx.set_line_width(1.0);
240 ctx.begin_path();
241 ctx.rounded_rect(0.5, 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0), 3.0);
242 ctx.stroke();
243
244 // SE grip — three stacked diagonals in the bottom-right corner
245 // (Y-up: y = 0 is the bottom edge). Highlight when hovered or
246 // actively dragging so the interaction is obvious.
247 let grip_color = if self.dragging {
248 v.window_resize_active
249 } else if self.hover_handle {
250 v.window_resize_hover
251 } else {
252 v.widget_stroke
253 };
254 ctx.set_stroke_color(grip_color);
255 ctx.set_line_width(1.5);
256 let m = 3.0_f64;
257 for i in 1..=3_i32 {
258 let off = i as f64 * 4.0 + m;
259 ctx.begin_path();
260 ctx.move_to(w - off, m);
261 ctx.line_to(w - m, off);
262 ctx.stroke();
263 }
264 // `h` used only by the stroke above; mark silenced for any
265 // future refactor that comments out the outline.
266 let _ = h;
267 }
268
269 fn on_event(&mut self, event: &Event) -> EventResult {
270 match event {
271 Event::MouseMove { pos } => {
272 if self.dragging {
273 // Use APP-LEVEL world coords. Widget-local and
274 // parent-relative positions both shift between
275 // events here because we're typically nested
276 // inside an auto-sized `Window` whose layout
277 // ripples each frame as our size changes, moving
278 // every ancestor frame in the tree. World coords
279 // come from `App` via a thread-local set by the
280 // same entry point that dispatched this event, so
281 // they're stable against ancestor reshuffling.
282 let world = crate::widget::current_mouse_world().unwrap_or_else(|| {
283 Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y)
284 });
285 let dx = world.x - self.drag_start_world.x;
286 let dy = world.y - self.drag_start_world.y;
287 // SE handle semantics:
288 // cursor right → width grows (+dx)
289 // cursor down → height grows (in Y-up, down = dy<0 → -dy)
290 let new_w = (self.drag_start_size.width + dx)
291 .clamp(self.min_size.width, self.max_size.width);
292 let new_h = (self.drag_start_size.height - dy)
293 .clamp(self.min_size.height, self.max_size.height);
294 let new_sz = Size::new(new_w, new_h);
295 self.current_size = Some(new_sz);
296 if let Some(c) = &self.size_cell {
297 c.set(new_sz);
298 }
299 set_cursor_icon(CursorIcon::ResizeNwSe);
300 crate::animation::request_draw();
301 return EventResult::Consumed;
302 }
303 let was = self.hover_handle;
304 self.hover_handle = self.in_handle(*pos);
305 if self.hover_handle {
306 set_cursor_icon(CursorIcon::ResizeNwSe);
307 }
308 if was != self.hover_handle {
309 crate::animation::request_draw();
310 return EventResult::Consumed;
311 }
312 EventResult::Ignored
313 }
314 Event::MouseDown {
315 pos,
316 button: MouseButton::Left | MouseButton::Middle,
317 ..
318 } if self.in_handle(*pos) => {
319 self.dragging = true;
320 // Snapshot the world cursor pos at drag start. If
321 // unavailable (a unit test dispatching events directly
322 // without going through `App`), fall back to parent-
323 // relative — widget-local drag semantics work when no
324 // ancestor layout ripple is happening.
325 self.drag_start_world = crate::widget::current_mouse_world()
326 .unwrap_or_else(|| Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y));
327 self.drag_start_size = Size::new(self.bounds.width, self.bounds.height);
328 set_cursor_icon(CursorIcon::ResizeNwSe);
329 crate::animation::request_draw();
330 EventResult::Consumed
331 }
332 Event::MouseUp { .. } if self.dragging => {
333 self.dragging = false;
334 crate::animation::request_draw();
335 EventResult::Consumed
336 }
337 _ => EventResult::Ignored,
338 }
339 }
340
341 fn hit_test(&self, local_pos: Point) -> bool {
342 local_pos.x >= 0.0
343 && local_pos.x <= self.bounds.width
344 && local_pos.y >= 0.0
345 && local_pos.y <= self.bounds.height
346 }
347
348 fn properties(&self) -> Vec<(&'static str, String)> {
349 let s = self.current_size();
350 vec![
351 ("current_w", format!("{:.1}", s.width)),
352 ("current_h", format!("{:.1}", s.height)),
353 ("min_w", format!("{:.1}", self.min_size.width)),
354 ("max_w", format!("{:.1}", self.max_size.width)),
355 ("dragging", self.dragging.to_string()),
356 ]
357 }
358}