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 // Frameless windows draw no drop shadow, so they need no outset halo
152 // around the FBO — a shadow-sized border would just waste texture and
153 // offset the field from the pill it must sit exactly over.
154 let (outset_left, outset_bottom, outset_right, outset_top) = if self.chrome {
155 Self::layer_outsets()
156 } else {
157 (0.0, 0.0, 0.0, 0.0)
158 };
159 let rounded_clip = if self.chrome {
160 Some(CORNER_R)
161 } else {
162 Some(3.0)
163 };
164 BackbufferSpec {
165 kind: BackbufferKind::GlFbo,
166 cached: true,
167 alpha,
168 outsets: Insets {
169 left: outset_left,
170 right: outset_right,
171 top: outset_top,
172 bottom: outset_bottom,
173 },
174 rounded_clip,
175 }
176 }
177
178 fn backbuffer_state_mut(&mut self) -> Option<&mut BackbufferState> {
179 Some(&mut self.backbuffer)
180 }
181
182 /// Clip child painting to the content area (below the title bar).
183 /// When collapsed bounds.height == TITLE_H so the content rect has zero height,
184 /// preventing any child from drawing outside the visible title-bar strip.
185 fn clip_children_rect(&self) -> Option<(f64, f64, f64, f64)> {
186 if !self.is_visible() {
187 return None;
188 }
189 let w = self.bounds.width;
190 let content_h = (self.bounds.height - self.chrome_h()).max(0.0);
191 // Clip to content area: y=0 (bottom) up to content_h, full width.
192 Some((0.0, 0.0, w, content_h))
193 }
194
195 fn hit_test(&self, local_pos: Point) -> bool {
196 if !self.requested_visible() {
197 return false;
198 }
199 if self.drag_mode != DragMode::None {
200 return true;
201 }
202 let b = self.bounds();
203 local_pos.x >= 0.0
204 && local_pos.x <= b.width
205 && local_pos.y >= 0.0
206 && local_pos.y <= b.height
207 }
208
209 fn claims_pointer_exclusively(&self, local_pos: Point) -> bool {
210 self.requested_visible()
211 && (self.drag_mode != DragMode::None || self.resize_dir(local_pos).is_some())
212 }
213
214 /// Opt-in modal windows (e.g. the floating colour-wheel picker dialog)
215 /// grab all pointer/keyboard routing while visible. The `App` walks to
216 /// this subtree before normal hit-testing, so clicks over the window —
217 /// including the chrome close button and any point that would otherwise
218 /// fall through an ancestor whose bounds don't cover the window (the
219 /// `Rebuilder`/`Stack` overlay slot) — are delivered here and never leak
220 /// to widgets painted beneath. Gated on `requested_visible()` so a closing
221 /// dialog releases the modal grab in the same frame it hides, letting the
222 /// captured press/release pair complete against this window.
223 fn has_active_modal(&self) -> bool {
224 self.modal && self.requested_visible()
225 }
226
227 fn layout(&mut self, available: Size) -> Size {
228 // Pull runtime-driven flag cells (demo checkboxes → live window
229 // behaviour). Read before anything else so this frame's collapse /
230 // resize / auto-size / title decisions all see the current values.
231 if let Some(ref cell) = self.resizable_cell {
232 self.resizable = cell.get();
233 }
234 if let Some(ref cell) = self.auto_size_cell {
235 self.auto_size = cell.get();
236 }
237 if let Some(ref cell) = self.collapsible_cell {
238 self.collapsible = cell.get();
239 }
240 self.title_bar.set_collapsible(self.collapsible);
241 if let Some(ref cell) = self.title_cell {
242 let wanted = cell.borrow();
243 if *wanted != *self.last_applied_title.borrow() {
244 self.title_bar.set_title(&wanted);
245 *self.last_applied_title.borrow_mut() = wanted.clone();
246 }
247 }
248
249 // Drain the title-bar chevron's click flag — the chevron is a
250 // real child widget that flips this `Rc<Cell<bool>>` when the
251 // framework dispatches its MouseDown. Acting on the flag here
252 // (rather than in our own `on_event`) lets the child consume
253 // the event normally instead of forcing the parent to manual
254 // hit-test the chevron's coordinates. Gate on `collapsible` so a
255 // stale click can't fold a window whose affordance was just
256 // disabled.
257 if self.title_bar.take_chevron_click() && self.collapsible {
258 self.toggle_collapse();
259 self.last_title_click = None;
260 crate::animation::request_draw();
261 }
262 // Rising-edge visibility detection requests a parent raise.
263 let now_visible = self.requested_visible();
264 // First-layout fit (visibility-cell-managed windows only):
265 // a window restored as already-visible via `visible_cell` misses
266 // the rising-edge branch below (last_visible was seeded to match
267 // the cell), so without this its persisted bounds can land
268 // outside the live viewport — the user sees the sidebar pill
269 // highlighted but no window. Gating on `visible_cell.is_some()`
270 // keeps the auto-save invariant for plain `with_bounds(...)`
271 // windows whose layout must never mutate persisted state.
272 if now_visible && self.needs_initial_fit.get() && self.visible_cell.is_some() {
273 self.fit_fully_to_canvas(available);
274 }
275 self.needs_initial_fit.set(false);
276 if now_visible && !self.last_visible.get() {
277 self.raise_request.set(true);
278 if let Some(cb) = self.on_raised.as_mut() {
279 cb(&self.title);
280 }
281 // Un-maximize on reopen. Clicking a sidebar checkbox is "open
282 // this window for use" — the user expects the window to come
283 // up at its normal size, not still stretched to fill the canvas
284 // from the last session's maximise. Restore `pre_maximize_bounds`
285 // which `toggle_maximize` saved when the user maximised.
286 if self.maximized {
287 self.bounds = self.pre_maximize_bounds;
288 self.maximized = false;
289 }
290 self.fit_fully_to_canvas(available);
291 }
292 if now_visible {
293 self.fade_out_active.set(false);
294 self.visibility_anim.set_target(1.0);
295 } else {
296 self.visibility_anim.set_target(0.0);
297 if self.visibility_anim.tick() <= 0.001 {
298 self.fade_out_active.set(false);
299 }
300 }
301 self.last_visible.set(now_visible);
302
303 if !self.is_visible() {
304 return Size::new(self.bounds.width, self.bounds.height);
305 }
306
307 if self.maximized && available.width > 0.0 && available.height > 0.0 {
308 self.bounds = snap(Rect::new(0.0, 0.0, available.width, available.height));
309 self.pre_collapse_h = self.bounds.height;
310 }
311
312 // Auto-size: measure the child's preferred size, then adopt it as the
313 // new window size (pinning the top edge — Y-up → adjust `bounds.y` so
314 // the title bar stays put when the height changes). Skip while
315 // collapsed: the user toggled a fixed TITLE_H height.
316 //
317 // We cap the measurement request by `child.max_size()` when finite
318 // (otherwise by the canvas size): flex containers return their given
319 // `available.width` rather than an intrinsic natural width, so without
320 // a cap we'd produce an infinite/canvas-wide window. Callers wanting
321 // a content-fitted window set `with_max_size(Size::new(w, f64::MAX))`
322 // on their root widget.
323 if self.auto_size && !self.collapsed && !self.maximized {
324 if let Some(child) = self.children.first_mut() {
325 let max_sz = child.max_size();
326 // `Size::MAX` uses `f64::MAX / 2.0` as its sentinel so
327 // widgets can add-without-overflow (see `geometry.rs`).
328 // That value is *technically* finite, so a plain
329 // `.is_finite()` check wrongly treats it as a real cap
330 // and cascades an ~`f64::MAX/2` width down to wrapped
331 // Labels, whose bounds then blow up LCD-backbuffer
332 // allocators to hundreds of GB. Guard with a sane
333 // threshold: anything ≥ `CAP_SENTINEL` means "no cap,
334 // fall back to viewport-provided bounds".
335 const CAP_SENTINEL: f64 = 1.0e18;
336 // WIDTH is PINNED to the current bounds.width (seeded
337 // by `with_bounds` and preserved across frames).
338 // Why: wrapping Labels inside the content claim their
339 // full available width — if we pass the viewport
340 // width here, the window grows to the canvas on the
341 // first frame and never shrinks back. egui's
342 // equivalent is `default_width`, which also pins.
343 let cap_w = self.bounds.width.max(MIN_W);
344 let cap_h = if max_sz.height.is_finite() && max_sz.height < CAP_SENTINEL {
345 max_sz.height
346 } else {
347 available.height.max(MIN_H)
348 };
349 let pref = child.layout(Size::new(cap_w, cap_h));
350 // Auto-size follows content in BOTH directions — so
351 // the window can also shrink back down when the
352 // inner Resize (or any other sizing widget) narrows.
353 // Lower bound: `MIN_W`. Upper bound: the parent-
354 // provided `available.width` (main_area / canvas).
355 // Matches egui where auto_sized tracks content size
356 // symmetrically.
357 let new_w = pref.width.max(MIN_W).min(available.width.max(MIN_W));
358 let new_h = (pref.height + TITLE_H).min(cap_h + TITLE_H).max(MIN_H);
359 let top = self.bounds.y + self.bounds.height;
360 self.bounds.width = new_w;
361 self.bounds.height = new_h;
362 self.bounds.y = top - new_h;
363 self.pre_collapse_h = new_h;
364 }
365 }
366
367 // ── Tight-fit pre-pass ───────────────────────────────────
368 //
369 // When `with_tight_content_fit(true)` is set (and we're not
370 // already in the auto_size block above, which handles both
371 // axes), ask the content tree what minimum height it needs
372 // at our current width and SNAP `bounds.height` to that.
373 //
374 // Uses `Widget::measure_min_height` rather than `layout` so
375 // the result is independent of flex distribution — a
376 // flex-fill widget like `TextArea` reports its true wrapped-
377 // content height through `measure_min_height` even though
378 // its `layout` returns the full slot. This is what makes
379 // egui's "no scroll, no clip, no whitespace" contract work
380 // for windows whose content includes a flex-fill child.
381 if self.tight_content_fit && !self.auto_size && !self.collapsed && !self.maximized {
382 if let Some(child) = self.children.first() {
383 let needed = child.measure_min_height(self.bounds.width);
384 let new_h = (needed + TITLE_H).max(MIN_H);
385 let top = self.bounds.y + self.bounds.height;
386 self.bounds.height = new_h;
387 self.bounds.y = top - new_h;
388 self.last_content_natural_h.set(needed);
389 }
390 }
391
392 // When collapsed, bounds.height == TITLE_H (set during toggle).
393 // Frameless windows reserve no title strip, so content fills bounds.
394 let content_h = (self.bounds.height - self.chrome_h()).max(0.0);
395
396 if let Some(child) = self.children.first_mut() {
397 if !self.collapsed {
398 let desired = child.layout(Size::new(self.bounds.width, content_h));
399 let child_h = if child.v_anchor().is_stretch() {
400 content_h
401 } else {
402 desired.height.clamp(
403 child.min_size().height,
404 child.max_size().height.min(content_h),
405 )
406 };
407 let child_y = if child.v_anchor().contains(VAnchor::BOTTOM) {
408 0.0
409 } else if child.v_anchor().contains(VAnchor::CENTER) {
410 ((content_h - child_h) * 0.5).max(0.0)
411 } else {
412 (content_h - child_h).max(0.0)
413 };
414 if (child_h - content_h).abs() > f64::EPSILON {
415 child.layout(Size::new(self.bounds.width, child_h));
416 }
417 child.set_bounds(Rect::new(0.0, child_y, self.bounds.width, child_h));
418 }
419 // When collapsed the child keeps its last bounds but is not visible
420 // because hit_test returns false for the content area.
421 }
422
423 // Cache the child's required height via `measure_min_height`
424 // so `apply_resize` and the tight-fit floor see a current
425 // value EVEN when the content's `layout` returns the slot
426 // size (the flex-fill case). `Widget::measure_min_height`
427 // walks the content tree and returns the actual content
428 // requirement at the supplied width.
429 if (self.tight_content_fit || self.floor_content_height) && !self.collapsed {
430 if let Some(child) = self.children.first() {
431 self.last_content_natural_h
432 .set(child.measure_min_height(self.bounds.width));
433 }
434 }
435
436 // Position the title-bar strip at the top of the window and
437 // give it a layout pass so the title label knows its size.
438 let tb_y = self.bounds.height - TITLE_H;
439 self.title_bar
440 .set_bounds(Rect::new(0.0, tb_y, self.bounds.width, TITLE_H));
441 self.title_bar.layout(Size::new(self.bounds.width, TITLE_H));
442
443 // Record the canvas size — used by drag / resize / collapse clamp
444 // paths that fire on USER ACTION. We deliberately do NOT clamp
445 // passively at layout time: platforms fire a Resized event with a
446 // transient smaller size during fullscreen/maximize EXIT (Windows
447 // notably), and if we clamped on shrink the auto-save would persist
448 // those transient clamped bounds — the "all windows pushed down to
449 // the same Y on next startup" bug. Clamping only on user actions
450 // (dragging a window, resize-handle, collapse toggle) keeps saved
451 // state pinned to what the user actually chose.
452 //
453 // If a later OS shrink genuinely leaves a window's title bar out of
454 // reach, the user can drag it back, use "Organize windows" to
455 // retile, or a dedicated "reset positions" command.
456 self.canvas_size = available;
457 if let Some(ref cell) = self.position_cell {
458 // When maximised, persist the UNDERLYING pre-maximise bounds,
459 // not the stretched-to-canvas ones. The maximized flag itself is
460 // persisted separately so reloads restore the interaction state
461 // without losing the user's last normal-size bounds.
462 let save_bounds = if self.maximized {
463 self.pre_maximize_bounds
464 } else {
465 self.bounds
466 };
467 cell.set(save_bounds);
468 }
469 if let Some(ref cell) = self.maximized_cell {
470 cell.set(self.maximized);
471 }
472
473 // Snap-layout registration — every laid-out window declares
474 // itself as a snap target so peers dragging nearby can pull
475 // toward its edges. Hidden / maximised windows opt out via
476 // `Snappable::is_snap_target` and are removed from the
477 // thread-local registry so their stale bounds don't yank
478 // anyone around.
479 {
480 use crate::snap::Snappable;
481 if self.is_snap_target() {
482 // Register in canvas-absolute space so peers (whose bounds are
483 // canvas-absolute) snap against our TRUE on-screen edges. A
484 // nested modal dialog's `bounds` are slot-local; lift them by the
485 // cached slot offset (zero for a top-level window). The offset is
486 // captured at modal-paint time and is at worst one frame stale.
487 let (ox, oy) = self.world_offset.get();
488 let abs = Rect::new(
489 self.bounds.x + ox,
490 self.bounds.y + oy,
491 self.bounds.width,
492 self.bounds.height,
493 );
494 crate::snap::register_target(self.snap_id, abs);
495 } else {
496 crate::snap::unregister_target(self.snap_id);
497 }
498 }
499
500 Size::new(self.bounds.width, self.bounds.height)
501 }
502
503 fn paint(&mut self, ctx: &mut dyn DrawCtx) {
504 super::paint::paint_window(self, ctx);
505 }
506
507 fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
508 super::paint::paint_overlay(self, ctx);
509 }
510
511 fn finish_paint(&mut self, ctx: &mut dyn DrawCtx) {
512 super::paint::finish_paint(self, ctx);
513 }
514
515 /// Modal windows paint via the global-overlay pass (see
516 /// [`paint_global_overlay`](Self::paint_global_overlay)) so an ancestor's
517 /// clip — a scrolled container, the host window's content region — can't
518 /// truncate the dialog. Non-modal windows keep the ordinary inline paint.
519 fn defer_paint_to_overlay(&self) -> bool {
520 self.modal
521 }
522
523 /// Paint a modal window during the clip-free global overlay walk. The walk
524 /// has already translated `ctx` to this window's local origin without
525 /// pushing any ancestor clip, so re-entering the normal paint path here via
526 /// [`paint_subtree_forced`] renders the whole dialog (chrome + content)
527 /// above and outside everything that would otherwise clip it. We first
528 /// clamp the window into the viewport so it can't open partially off-screen.
529 fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
530 if !self.modal || !self.is_visible() {
531 return;
532 }
533 let (dx, dy) = super::paint::clamp_modal_into_viewport(self, ctx);
534 ctx.save();
535 if dx != 0.0 || dy != 0.0 {
536 ctx.translate(dx, dy);
537 }
538 crate::widget::paint_subtree_forced(self, ctx);
539 ctx.restore();
540 }
541
542 fn on_event(&mut self, event: &Event) -> EventResult {
543 super::events::on_event(self, event)
544 }
545}