agg_gui/widgets/window/widget_impl.rs
1use super::*;
2
3impl Widget for Window {
4 fn type_name(&self) -> &'static str {
5 "Window"
6 }
7 /// External identity for z-order persistence, inspector lookup, etc.
8 fn id(&self) -> Option<&str> {
9 Some(&self.title)
10 }
11
12 fn is_visible(&self) -> bool {
13 self.requested_visible() || self.fade_out_active.get()
14 }
15
16 /// Collapsed or closed windows should not keep the host loop awake.
17 fn needs_draw(&self) -> bool {
18 if !self.is_visible() || self.collapsed {
19 return false;
20 }
21 self.children().iter().any(|c| c.needs_draw())
22 }
23
24 fn next_draw_deadline(&self) -> Option<web_time::Instant> {
25 if !self.is_visible() || self.collapsed {
26 return None;
27 }
28 let mut best: Option<web_time::Instant> = None;
29 for c in self.children() {
30 if let Some(t) = c.next_draw_deadline() {
31 best = Some(match best {
32 Some(b) if b <= t => b,
33 _ => t,
34 });
35 }
36 }
37 best
38 }
39
40 fn bounds(&self) -> Rect {
41 self.bounds
42 }
43
44 fn margin(&self) -> Insets {
45 self.base.margin
46 }
47 fn widget_base(&self) -> Option<&WidgetBase> {
48 Some(&self.base)
49 }
50 fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
51 Some(&mut self.base)
52 }
53 fn h_anchor(&self) -> HAnchor {
54 self.base.h_anchor
55 }
56 fn v_anchor(&self) -> VAnchor {
57 self.base.v_anchor
58 }
59 fn min_size(&self) -> Size {
60 self.base.min_size
61 }
62 fn max_size(&self) -> Size {
63 self.base.max_size
64 }
65
66 fn properties(&self) -> Vec<(&'static str, String)> {
67 vec![
68 (
69 "backbuffer_kind",
70 if self.use_gl_backbuffer {
71 "GlFbo".to_string()
72 } else {
73 "None".to_string()
74 },
75 ),
76 ("backbuffer_dirty", self.backbuffer.dirty.to_string()),
77 (
78 "backbuffer_repaints",
79 self.backbuffer.repaint_count.to_string(),
80 ),
81 (
82 "backbuffer_composites",
83 self.backbuffer.composite_count.to_string(),
84 ),
85 (
86 "backbuffer_size",
87 format!("{}x{}", self.backbuffer.width, self.backbuffer.height),
88 ),
89 ]
90 }
91
92 /// Pop this window to the top of the parent `Stack` when the
93 /// false→true visibility edge fires (see `layout`).
94 fn take_raise_request(&mut self) -> bool {
95 let pending = self.raise_request.get();
96 self.raise_request.set(false);
97 pending
98 }
99
100 fn set_bounds(&mut self, b: Rect) {
101 if let Some(ref cell) = self.reset_to {
102 if let Some(new_b) = cell.get() {
103 self.bounds = new_b;
104 self.pre_collapse_h = new_b.height;
105 self.collapsed = false;
106 cell.set(None);
107 return;
108 }
109 }
110 if self.bounds.width == 0.0 || self.bounds.height == 0.0 {
111 self.bounds = b;
112 self.pre_collapse_h = b.height;
113 }
114 }
115
116 fn children(&self) -> &[Box<dyn Widget>] {
117 &self.children
118 }
119 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
120 &mut self.children
121 }
122
123 fn backbuffer_spec(&mut self) -> BackbufferSpec {
124 if !self.use_gl_backbuffer {
125 return BackbufferSpec::none();
126 }
127 if !self.is_visible() {
128 let alpha = self.visibility_anim.value();
129 if self.requested_visible() || alpha <= 0.001 {
130 return BackbufferSpec::none();
131 }
132 }
133
134 // Live-content windows self-invalidate every frame, except when
135 // collapsed or hidden — no wasted work behind a folded title bar.
136 if self.live_content && !self.collapsed && self.requested_visible() {
137 self.backbuffer.invalidate();
138 }
139
140 let requested_visible = self.requested_visible();
141 self.visibility_anim
142 .set_target(if requested_visible { 1.0 } else { 0.0 });
143 let alpha = self.visibility_anim.tick();
144 if !requested_visible && alpha > 0.001 {
145 self.fade_out_active.set(true);
146 }
147 if !requested_visible && alpha <= 0.001 {
148 self.fade_out_active.set(false);
149 }
150
151 let (outset_left, outset_bottom, outset_right, outset_top) = Self::layer_outsets();
152 BackbufferSpec {
153 kind: BackbufferKind::GlFbo,
154 cached: true,
155 alpha,
156 outsets: Insets {
157 left: outset_left,
158 right: outset_right,
159 top: outset_top,
160 bottom: outset_bottom,
161 },
162 rounded_clip: Some(CORNER_R),
163 }
164 }
165
166 fn backbuffer_state_mut(&mut self) -> Option<&mut BackbufferState> {
167 Some(&mut self.backbuffer)
168 }
169
170 /// Clip child painting to the content area (below the title bar).
171 /// When collapsed bounds.height == TITLE_H so the content rect has zero height,
172 /// preventing any child from drawing outside the visible title-bar strip.
173 fn clip_children_rect(&self) -> Option<(f64, f64, f64, f64)> {
174 if !self.is_visible() {
175 return None;
176 }
177 let w = self.bounds.width;
178 let content_h = (self.bounds.height - TITLE_H).max(0.0);
179 // Clip to content area: y=0 (bottom) up to content_h, full width.
180 Some((0.0, 0.0, w, content_h))
181 }
182
183 fn hit_test(&self, local_pos: Point) -> bool {
184 if !self.requested_visible() {
185 return false;
186 }
187 if self.drag_mode != DragMode::None {
188 return true;
189 }
190 let b = self.bounds();
191 local_pos.x >= 0.0
192 && local_pos.x <= b.width
193 && local_pos.y >= 0.0
194 && local_pos.y <= b.height
195 }
196
197 fn claims_pointer_exclusively(&self, local_pos: Point) -> bool {
198 self.requested_visible()
199 && (self.drag_mode != DragMode::None || self.resize_dir(local_pos).is_some())
200 }
201
202 fn layout(&mut self, available: Size) -> Size {
203 // Drain the title-bar chevron's click flag — the chevron is a
204 // real child widget that flips this `Rc<Cell<bool>>` when the
205 // framework dispatches its MouseDown. Acting on the flag here
206 // (rather than in our own `on_event`) lets the child consume
207 // the event normally instead of forcing the parent to manual
208 // hit-test the chevron's coordinates.
209 if self.title_bar.take_chevron_click() {
210 self.toggle_collapse();
211 self.last_title_click = None;
212 crate::animation::request_draw();
213 }
214 // Rising-edge visibility detection requests a parent raise.
215 let now_visible = self.requested_visible();
216 // First-layout fit (visibility-cell-managed windows only):
217 // a window restored as already-visible via `visible_cell` misses
218 // the rising-edge branch below (last_visible was seeded to match
219 // the cell), so without this its persisted bounds can land
220 // outside the live viewport — the user sees the sidebar pill
221 // highlighted but no window. Gating on `visible_cell.is_some()`
222 // keeps the auto-save invariant for plain `with_bounds(...)`
223 // windows whose layout must never mutate persisted state.
224 if now_visible && self.needs_initial_fit.get() && self.visible_cell.is_some() {
225 self.fit_fully_to_canvas(available);
226 }
227 self.needs_initial_fit.set(false);
228 if now_visible && !self.last_visible.get() {
229 self.raise_request.set(true);
230 if let Some(cb) = self.on_raised.as_mut() {
231 cb(&self.title);
232 }
233 // Un-maximize on reopen. Clicking a sidebar checkbox is "open
234 // this window for use" — the user expects the window to come
235 // up at its normal size, not still stretched to fill the canvas
236 // from the last session's maximise. Restore `pre_maximize_bounds`
237 // which `toggle_maximize` saved when the user maximised.
238 if self.maximized {
239 self.bounds = self.pre_maximize_bounds;
240 self.maximized = false;
241 }
242 self.fit_fully_to_canvas(available);
243 }
244 if now_visible {
245 self.fade_out_active.set(false);
246 self.visibility_anim.set_target(1.0);
247 } else {
248 self.visibility_anim.set_target(0.0);
249 if self.visibility_anim.tick() <= 0.001 {
250 self.fade_out_active.set(false);
251 }
252 }
253 self.last_visible.set(now_visible);
254
255 if !self.is_visible() {
256 return Size::new(self.bounds.width, self.bounds.height);
257 }
258
259 if self.maximized && available.width > 0.0 && available.height > 0.0 {
260 self.bounds = snap(Rect::new(0.0, 0.0, available.width, available.height));
261 self.pre_collapse_h = self.bounds.height;
262 }
263
264 // Auto-size: measure the child's preferred size, then adopt it as the
265 // new window size (pinning the top edge — Y-up → adjust `bounds.y` so
266 // the title bar stays put when the height changes). Skip while
267 // collapsed: the user toggled a fixed TITLE_H height.
268 //
269 // We cap the measurement request by `child.max_size()` when finite
270 // (otherwise by the canvas size): flex containers return their given
271 // `available.width` rather than an intrinsic natural width, so without
272 // a cap we'd produce an infinite/canvas-wide window. Callers wanting
273 // a content-fitted window set `with_max_size(Size::new(w, f64::MAX))`
274 // on their root widget.
275 if self.auto_size && !self.collapsed && !self.maximized {
276 if let Some(child) = self.children.first_mut() {
277 let max_sz = child.max_size();
278 // `Size::MAX` uses `f64::MAX / 2.0` as its sentinel so
279 // widgets can add-without-overflow (see `geometry.rs`).
280 // That value is *technically* finite, so a plain
281 // `.is_finite()` check wrongly treats it as a real cap
282 // and cascades an ~`f64::MAX/2` width down to wrapped
283 // Labels, whose bounds then blow up LCD-backbuffer
284 // allocators to hundreds of GB. Guard with a sane
285 // threshold: anything ≥ `CAP_SENTINEL` means "no cap,
286 // fall back to viewport-provided bounds".
287 const CAP_SENTINEL: f64 = 1.0e18;
288 // WIDTH is PINNED to the current bounds.width (seeded
289 // by `with_bounds` and preserved across frames).
290 // Why: wrapping Labels inside the content claim their
291 // full available width — if we pass the viewport
292 // width here, the window grows to the canvas on the
293 // first frame and never shrinks back. egui's
294 // equivalent is `default_width`, which also pins.
295 let cap_w = self.bounds.width.max(MIN_W);
296 let cap_h = if max_sz.height.is_finite() && max_sz.height < CAP_SENTINEL {
297 max_sz.height
298 } else {
299 available.height.max(MIN_H)
300 };
301 let pref = child.layout(Size::new(cap_w, cap_h));
302 // Auto-size follows content in BOTH directions — so
303 // the window can also shrink back down when the
304 // inner Resize (or any other sizing widget) narrows.
305 // Lower bound: `MIN_W`. Upper bound: the parent-
306 // provided `available.width` (main_area / canvas).
307 // Matches egui where auto_sized tracks content size
308 // symmetrically.
309 let new_w = pref.width.max(MIN_W).min(available.width.max(MIN_W));
310 let new_h = (pref.height + TITLE_H).min(cap_h + TITLE_H).max(MIN_H);
311 let top = self.bounds.y + self.bounds.height;
312 self.bounds.width = new_w;
313 self.bounds.height = new_h;
314 self.bounds.y = top - new_h;
315 self.pre_collapse_h = new_h;
316 }
317 }
318
319 // ── Tight-fit pre-pass ───────────────────────────────────
320 //
321 // When `with_tight_content_fit(true)` is set (and we're not
322 // already in the auto_size block above, which handles both
323 // axes), ask the content tree what minimum height it needs
324 // at our current width and SNAP `bounds.height` to that.
325 //
326 // Uses `Widget::measure_min_height` rather than `layout` so
327 // the result is independent of flex distribution — a
328 // flex-fill widget like `TextArea` reports its true wrapped-
329 // content height through `measure_min_height` even though
330 // its `layout` returns the full slot. This is what makes
331 // egui's "no scroll, no clip, no whitespace" contract work
332 // for windows whose content includes a flex-fill child.
333 if self.tight_content_fit && !self.auto_size && !self.collapsed && !self.maximized {
334 if let Some(child) = self.children.first() {
335 let needed = child.measure_min_height(self.bounds.width);
336 let new_h = (needed + TITLE_H).max(MIN_H);
337 let top = self.bounds.y + self.bounds.height;
338 self.bounds.height = new_h;
339 self.bounds.y = top - new_h;
340 self.last_content_natural_h.set(needed);
341 }
342 }
343
344 // When collapsed, bounds.height == TITLE_H (set during toggle).
345 let content_h = (self.bounds.height - TITLE_H).max(0.0);
346
347 if let Some(child) = self.children.first_mut() {
348 if !self.collapsed {
349 let desired = child.layout(Size::new(self.bounds.width, content_h));
350 let child_h = if child.v_anchor().is_stretch() {
351 content_h
352 } else {
353 desired.height.clamp(
354 child.min_size().height,
355 child.max_size().height.min(content_h),
356 )
357 };
358 let child_y = if child.v_anchor().contains(VAnchor::BOTTOM) {
359 0.0
360 } else if child.v_anchor().contains(VAnchor::CENTER) {
361 ((content_h - child_h) * 0.5).max(0.0)
362 } else {
363 (content_h - child_h).max(0.0)
364 };
365 if (child_h - content_h).abs() > f64::EPSILON {
366 child.layout(Size::new(self.bounds.width, child_h));
367 }
368 child.set_bounds(Rect::new(0.0, child_y, self.bounds.width, child_h));
369 }
370 // When collapsed the child keeps its last bounds but is not visible
371 // because hit_test returns false for the content area.
372 }
373
374 // Cache the child's required height via `measure_min_height`
375 // so `apply_resize` and the tight-fit floor see a current
376 // value EVEN when the content's `layout` returns the slot
377 // size (the flex-fill case). `Widget::measure_min_height`
378 // walks the content tree and returns the actual content
379 // requirement at the supplied width.
380 if (self.tight_content_fit || self.floor_content_height) && !self.collapsed {
381 if let Some(child) = self.children.first() {
382 self.last_content_natural_h
383 .set(child.measure_min_height(self.bounds.width));
384 }
385 }
386
387 // Position the title-bar strip at the top of the window and
388 // give it a layout pass so the title label knows its size.
389 let tb_y = self.bounds.height - TITLE_H;
390 self.title_bar
391 .set_bounds(Rect::new(0.0, tb_y, self.bounds.width, TITLE_H));
392 self.title_bar.layout(Size::new(self.bounds.width, TITLE_H));
393
394 // Record the canvas size — used by drag / resize / collapse clamp
395 // paths that fire on USER ACTION. We deliberately do NOT clamp
396 // passively at layout time: platforms fire a Resized event with a
397 // transient smaller size during fullscreen/maximize EXIT (Windows
398 // notably), and if we clamped on shrink the auto-save would persist
399 // those transient clamped bounds — the "all windows pushed down to
400 // the same Y on next startup" bug. Clamping only on user actions
401 // (dragging a window, resize-handle, collapse toggle) keeps saved
402 // state pinned to what the user actually chose.
403 //
404 // If a later OS shrink genuinely leaves a window's title bar out of
405 // reach, the user can drag it back, use "Organize windows" to
406 // retile, or a dedicated "reset positions" command.
407 self.canvas_size = available;
408 if let Some(ref cell) = self.position_cell {
409 // When maximised, persist the UNDERLYING pre-maximise bounds,
410 // not the stretched-to-canvas ones. The maximized flag itself is
411 // persisted separately so reloads restore the interaction state
412 // without losing the user's last normal-size bounds.
413 let save_bounds = if self.maximized {
414 self.pre_maximize_bounds
415 } else {
416 self.bounds
417 };
418 cell.set(save_bounds);
419 }
420 if let Some(ref cell) = self.maximized_cell {
421 cell.set(self.maximized);
422 }
423
424 // Snap-layout registration — every laid-out window declares
425 // itself as a snap target so peers dragging nearby can pull
426 // toward its edges. Hidden / maximised windows opt out via
427 // `Snappable::is_snap_target` and are removed from the
428 // thread-local registry so their stale bounds don't yank
429 // anyone around.
430 {
431 use crate::snap::Snappable;
432 if self.is_snap_target() {
433 crate::snap::register_target(self.snap_id, self.bounds);
434 } else {
435 crate::snap::unregister_target(self.snap_id);
436 }
437 }
438
439 Size::new(self.bounds.width, self.bounds.height)
440 }
441
442 fn paint(&mut self, ctx: &mut dyn DrawCtx) {
443 super::paint::paint_window(self, ctx);
444 }
445
446 fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
447 super::paint::paint_overlay(self, ctx);
448 }
449
450 fn finish_paint(&mut self, ctx: &mut dyn DrawCtx) {
451 super::paint::finish_paint(self, ctx);
452 }
453
454 fn on_event(&mut self, event: &Event) -> EventResult {
455 if !self.requested_visible() {
456 return EventResult::Ignored;
457 }
458
459 match event {
460 Event::MouseMove { pos } => {
461 let was_close = self.close_hovered;
462 let was_max = self.maximize_hovered;
463 let was_dir = self.hover_dir;
464 self.close_hovered = self.in_close_button(*pos);
465 self.maximize_hovered = self.in_maximize_button(*pos);
466
467 match self.drag_mode {
468 DragMode::Move => {
469 let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
470 let dx = world.x - self.drag_start_world.x;
471 let dy = world.y - self.drag_start_world.y;
472 self.bounds.x = (self.drag_start_bounds.x + dx).round();
473 self.bounds.y = (self.drag_start_bounds.y + dy).round();
474 // Snap pass — runs only when the global flag
475 // is on. Reads the thread-local target list
476 // populated by every other window's `layout`
477 // and writes the resulting visual guides for
478 // `SnapOverlay` to render.
479 self.apply_move_snap();
480 self.clamp_to_canvas();
481 self.hover_dir = None;
482 set_cursor_icon(CursorIcon::Grabbing);
483 crate::animation::request_draw_without_invalidation();
484 return EventResult::Ignored;
485 }
486 DragMode::Resize(dir) => {
487 let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
488 self.apply_resize(world);
489 self.apply_resize_snap(dir);
490 set_cursor_icon(resize_cursor(dir));
491 crate::animation::request_draw();
492 return EventResult::Consumed;
493 }
494 DragMode::None => {
495 // Track which edge/corner the cursor is hovering over so
496 // paint_overlay can draw the appropriate highlight.
497 self.hover_dir = self.resize_dir(*pos);
498 if let Some(dir) = self.hover_dir {
499 set_cursor_icon(resize_cursor(dir));
500 }
501 }
502 }
503 if was_close != self.close_hovered
504 || was_max != self.maximize_hovered
505 || was_dir != self.hover_dir
506 {
507 crate::animation::request_draw();
508 }
509 EventResult::Ignored
510 }
511
512 Event::MouseDown { button, pos, .. }
513 if matches!(*button, MouseButton::Left | MouseButton::Middle) =>
514 {
515 let is_left_click = *button == MouseButton::Left;
516 // Press-to-raise: any direct press on this window brings it forward.
517 self.raise_request.set(true);
518 // Z-order changes are visible; repaint.
519 crate::animation::request_draw();
520 if let Some(cb) = self.on_raised.as_mut() {
521 cb(&self.title);
522 }
523
524 // Close button — highest priority.
525 if is_left_click && self.in_close_button(*pos) {
526 self.visible = false;
527 self.visibility_anim.set_target(0.0);
528 if let Some(ref cell) = self.visible_cell {
529 cell.set(false);
530 }
531 if let Some(cb) = self.on_close.as_mut() {
532 cb();
533 }
534 crate::animation::request_draw();
535 return EventResult::Consumed;
536 }
537
538 // Maximize / Restore button.
539 if is_left_click && self.in_maximize_button(*pos) {
540 self.toggle_maximize();
541 crate::animation::request_draw();
542 return EventResult::Consumed;
543 }
544
545 // Route the click into the title-bar sub-tree FIRST so
546 // any child widget there (currently the chevron) gets a
547 // chance to consume it. `WindowTitleBar` lives outside
548 // `Window.children` because the body content owns that
549 // slot, so the framework's normal hit-test pass never
550 // descends into it — we run the framework's hit-test
551 // + dispatch helpers manually on the sub-tree instead.
552 if is_left_click && self.in_title_bar(*pos) {
553 let tb_bounds = self.title_bar.bounds();
554 let tb_local = Point::new(pos.x - tb_bounds.x, pos.y - tb_bounds.y);
555 if let Some(path) = crate::widget::hit_test_subtree(&self.title_bar, tb_local) {
556 // Path could be empty (clicked the bar itself
557 // but not a child) — skip in that case so the
558 // title-drag handling further down still runs.
559 if !path.is_empty() {
560 // Preserve modifiers from the original event.
561 let mods = match event {
562 Event::MouseDown { modifiers, .. } => *modifiers,
563 _ => Default::default(),
564 };
565 let translated = Event::MouseDown {
566 pos: tb_local,
567 button: *button,
568 modifiers: mods,
569 };
570 let result = crate::widget::dispatch_event_dyn(
571 &mut self.title_bar,
572 &path,
573 &translated,
574 tb_local,
575 );
576 if result == EventResult::Consumed {
577 // Chevron flag is drained in `layout`,
578 // but we also want this frame to redraw
579 // before that.
580 crate::animation::request_draw();
581 return EventResult::Consumed;
582 }
583 }
584 }
585 }
586
587 // Resize edge — check before title bar to handle corner overlap.
588 if let Some(dir) = self.resize_dir(*pos) {
589 // Only start resize if not in the close button area and not a pure title bar drag.
590 // The N edge overlaps the title bar — prefer resize over drag from the top N px.
591 let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
592 self.drag_mode = DragMode::Resize(dir);
593 self.drag_start_world = world;
594 self.drag_start_bounds = self.bounds;
595 return EventResult::Consumed;
596 }
597
598 // Title bar drag + double-click maximize.
599 if self.in_title_bar(*pos) {
600 // Double-click detection.
601 let is_double = if is_left_click {
602 let now = Instant::now();
603 self.last_title_click
604 .map(|t| now.duration_since(t).as_millis() < DBL_CLICK_MS)
605 .unwrap_or(false)
606 } else {
607 false
608 };
609
610 if is_double {
611 // Windows convention: double-click title bar toggles
612 // maximize / restore. Collapse/expand lives on the
613 // chevron button to the left.
614 self.toggle_maximize();
615 self.last_title_click = None;
616 crate::animation::request_draw();
617 } else {
618 if is_left_click {
619 self.last_title_click = Some(Instant::now());
620 }
621 let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
622 self.drag_mode = DragMode::Move;
623 self.drag_start_world = world;
624 self.drag_start_bounds = self.bounds;
625 }
626 return EventResult::Consumed;
627 }
628
629 // Click on content area: consume so it doesn't fall through.
630 if is_left_click && !self.collapsed {
631 EventResult::Consumed
632 } else {
633 EventResult::Ignored
634 }
635 }
636
637 Event::MouseUp {
638 button: MouseButton::Left | MouseButton::Middle,
639 ..
640 } => {
641 let was_dragging = self.drag_mode != DragMode::None;
642 self.drag_mode = DragMode::None;
643 if was_dragging {
644 // Drag ended — wipe the snap guides so the
645 // overlay clears. Cheap no-op when snapping was
646 // off (guide buffer was already empty).
647 crate::snap::clear_guides();
648 crate::animation::request_draw();
649 EventResult::Consumed
650 } else {
651 EventResult::Ignored
652 }
653 }
654
655 _ => EventResult::Ignored,
656 }
657 }
658}