Skip to main content

agg_gui/widgets/window/
widget_impl.rs

1use super::*;
2
3impl Window {
4    /// Whether `local` (window-local, Y-up) lies within this window's bounds.
5    /// Used by the click-away path to tell an inside press from an outside one
6    /// once the modal grab has routed it here regardless of position.
7    fn point_in_local_bounds(&self, local: Point) -> bool {
8        local.x >= 0.0
9            && local.x <= self.bounds.width
10            && local.y >= 0.0
11            && local.y <= self.bounds.height
12    }
13}
14
15impl Widget for Window {
16    fn type_name(&self) -> &'static str {
17        "Window"
18    }
19    /// External identity for z-order persistence, inspector lookup, etc.
20    fn id(&self) -> Option<&str> {
21        Some(&self.title)
22    }
23
24    fn is_visible(&self) -> bool {
25        self.requested_visible() || self.fade_out_active.get()
26    }
27
28    /// Collapsed or closed windows should not keep the host loop awake.
29    fn needs_draw(&self) -> bool {
30        if !self.is_visible() || self.collapsed {
31            return false;
32        }
33        self.children().iter().any(|c| c.needs_draw())
34    }
35
36    fn next_draw_deadline(&self) -> Option<web_time::Instant> {
37        if !self.is_visible() || self.collapsed {
38            return None;
39        }
40        let mut best: Option<web_time::Instant> = None;
41        for c in self.children() {
42            if let Some(t) = c.next_draw_deadline() {
43                best = Some(match best {
44                    Some(b) if b <= t => b,
45                    _ => t,
46                });
47            }
48        }
49        best
50    }
51
52    fn bounds(&self) -> Rect {
53        self.bounds
54    }
55
56    fn margin(&self) -> Insets {
57        self.base.margin
58    }
59    fn widget_base(&self) -> Option<&WidgetBase> {
60        Some(&self.base)
61    }
62    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
63        Some(&mut self.base)
64    }
65    fn h_anchor(&self) -> HAnchor {
66        self.base.h_anchor
67    }
68    fn v_anchor(&self) -> VAnchor {
69        self.base.v_anchor
70    }
71    fn min_size(&self) -> Size {
72        self.base.min_size
73    }
74    fn max_size(&self) -> Size {
75        self.base.max_size
76    }
77
78    fn properties(&self) -> Vec<(&'static str, String)> {
79        vec![
80            (
81                "backbuffer_kind",
82                if self.use_gl_backbuffer {
83                    "GlFbo".to_string()
84                } else {
85                    "None".to_string()
86                },
87            ),
88            ("backbuffer_dirty", self.backbuffer.dirty.to_string()),
89            (
90                "backbuffer_repaints",
91                self.backbuffer.repaint_count.to_string(),
92            ),
93            (
94                "backbuffer_composites",
95                self.backbuffer.composite_count.to_string(),
96            ),
97            (
98                "backbuffer_size",
99                format!("{}x{}", self.backbuffer.width, self.backbuffer.height),
100            ),
101        ]
102    }
103
104    /// Pop this window to the top of the parent `Stack` when the
105    /// false→true visibility edge fires (see `layout`).
106    fn take_raise_request(&mut self) -> bool {
107        let pending = self.raise_request.get();
108        self.raise_request.set(false);
109        pending
110    }
111
112    fn set_bounds(&mut self, b: Rect) {
113        if let Some(ref cell) = self.reset_to {
114            if let Some(new_b) = cell.get() {
115                self.bounds = new_b;
116                self.pre_collapse_h = new_b.height;
117                self.collapsed = false;
118                cell.set(None);
119                return;
120            }
121        }
122        if self.bounds.width == 0.0 || self.bounds.height == 0.0 {
123            self.bounds = b;
124            self.pre_collapse_h = b.height;
125        }
126    }
127
128    fn children(&self) -> &[Box<dyn Widget>] {
129        &self.children
130    }
131    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
132        &mut self.children
133    }
134
135    fn backbuffer_spec(&mut self) -> BackbufferSpec {
136        if !self.use_gl_backbuffer {
137            return BackbufferSpec::none();
138        }
139        if !self.is_visible() {
140            let alpha = self.visibility_anim.value();
141            if self.requested_visible() || alpha <= 0.001 {
142                return BackbufferSpec::none();
143            }
144        }
145
146        // Live-content windows self-invalidate every frame, except when
147        // collapsed or hidden — no wasted work behind a folded title bar.
148        if self.live_content && !self.collapsed && self.requested_visible() {
149            self.backbuffer.invalidate();
150        }
151
152        let requested_visible = self.requested_visible();
153        self.visibility_anim
154            .set_target(if requested_visible { 1.0 } else { 0.0 });
155        let alpha = self.visibility_anim.tick();
156        if !requested_visible && alpha > 0.001 {
157            self.fade_out_active.set(true);
158        }
159        if !requested_visible && alpha <= 0.001 {
160            self.fade_out_active.set(false);
161        }
162
163        let (outset_left, outset_bottom, outset_right, outset_top) = Self::layer_outsets();
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: Some(CORNER_R),
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 - TITLE_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        let content_h = (self.bounds.height - TITLE_H).max(0.0);
394
395        if let Some(child) = self.children.first_mut() {
396            if !self.collapsed {
397                let desired = child.layout(Size::new(self.bounds.width, content_h));
398                let child_h = if child.v_anchor().is_stretch() {
399                    content_h
400                } else {
401                    desired.height.clamp(
402                        child.min_size().height,
403                        child.max_size().height.min(content_h),
404                    )
405                };
406                let child_y = if child.v_anchor().contains(VAnchor::BOTTOM) {
407                    0.0
408                } else if child.v_anchor().contains(VAnchor::CENTER) {
409                    ((content_h - child_h) * 0.5).max(0.0)
410                } else {
411                    (content_h - child_h).max(0.0)
412                };
413                if (child_h - content_h).abs() > f64::EPSILON {
414                    child.layout(Size::new(self.bounds.width, child_h));
415                }
416                child.set_bounds(Rect::new(0.0, child_y, self.bounds.width, child_h));
417            }
418            // When collapsed the child keeps its last bounds but is not visible
419            // because hit_test returns false for the content area.
420        }
421
422        // Cache the child's required height via `measure_min_height`
423        // so `apply_resize` and the tight-fit floor see a current
424        // value EVEN when the content's `layout` returns the slot
425        // size (the flex-fill case).  `Widget::measure_min_height`
426        // walks the content tree and returns the actual content
427        // requirement at the supplied width.
428        if (self.tight_content_fit || self.floor_content_height) && !self.collapsed {
429            if let Some(child) = self.children.first() {
430                self.last_content_natural_h
431                    .set(child.measure_min_height(self.bounds.width));
432            }
433        }
434
435        // Position the title-bar strip at the top of the window and
436        // give it a layout pass so the title label knows its size.
437        let tb_y = self.bounds.height - TITLE_H;
438        self.title_bar
439            .set_bounds(Rect::new(0.0, tb_y, self.bounds.width, TITLE_H));
440        self.title_bar.layout(Size::new(self.bounds.width, TITLE_H));
441
442        // Record the canvas size — used by drag / resize / collapse clamp
443        // paths that fire on USER ACTION.  We deliberately do NOT clamp
444        // passively at layout time: platforms fire a Resized event with a
445        // transient smaller size during fullscreen/maximize EXIT (Windows
446        // notably), and if we clamped on shrink the auto-save would persist
447        // those transient clamped bounds — the "all windows pushed down to
448        // the same Y on next startup" bug.  Clamping only on user actions
449        // (dragging a window, resize-handle, collapse toggle) keeps saved
450        // state pinned to what the user actually chose.
451        //
452        // If a later OS shrink genuinely leaves a window's title bar out of
453        // reach, the user can drag it back, use "Organize windows" to
454        // retile, or a dedicated "reset positions" command.
455        self.canvas_size = available;
456        if let Some(ref cell) = self.position_cell {
457            // When maximised, persist the UNDERLYING pre-maximise bounds,
458            // not the stretched-to-canvas ones.  The maximized flag itself is
459            // persisted separately so reloads restore the interaction state
460            // without losing the user's last normal-size bounds.
461            let save_bounds = if self.maximized {
462                self.pre_maximize_bounds
463            } else {
464                self.bounds
465            };
466            cell.set(save_bounds);
467        }
468        if let Some(ref cell) = self.maximized_cell {
469            cell.set(self.maximized);
470        }
471
472        // Snap-layout registration — every laid-out window declares
473        // itself as a snap target so peers dragging nearby can pull
474        // toward its edges.  Hidden / maximised windows opt out via
475        // `Snappable::is_snap_target` and are removed from the
476        // thread-local registry so their stale bounds don't yank
477        // anyone around.
478        {
479            use crate::snap::Snappable;
480            if self.is_snap_target() {
481                // Register in canvas-absolute space so peers (whose bounds are
482                // canvas-absolute) snap against our TRUE on-screen edges. A
483                // nested modal dialog's `bounds` are slot-local; lift them by the
484                // cached slot offset (zero for a top-level window). The offset is
485                // captured at modal-paint time and is at worst one frame stale.
486                let (ox, oy) = self.world_offset.get();
487                let abs = Rect::new(
488                    self.bounds.x + ox,
489                    self.bounds.y + oy,
490                    self.bounds.width,
491                    self.bounds.height,
492                );
493                crate::snap::register_target(self.snap_id, abs);
494            } else {
495                crate::snap::unregister_target(self.snap_id);
496            }
497        }
498
499        Size::new(self.bounds.width, self.bounds.height)
500    }
501
502    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
503        super::paint::paint_window(self, ctx);
504    }
505
506    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
507        super::paint::paint_overlay(self, ctx);
508    }
509
510    fn finish_paint(&mut self, ctx: &mut dyn DrawCtx) {
511        super::paint::finish_paint(self, ctx);
512    }
513
514    /// Modal windows paint via the global-overlay pass (see
515    /// [`paint_global_overlay`](Self::paint_global_overlay)) so an ancestor's
516    /// clip — a scrolled container, the host window's content region — can't
517    /// truncate the dialog. Non-modal windows keep the ordinary inline paint.
518    fn defer_paint_to_overlay(&self) -> bool {
519        self.modal
520    }
521
522    /// Paint a modal window during the clip-free global overlay walk. The walk
523    /// has already translated `ctx` to this window's local origin without
524    /// pushing any ancestor clip, so re-entering the normal paint path here via
525    /// [`paint_subtree_forced`] renders the whole dialog (chrome + content)
526    /// above and outside everything that would otherwise clip it. We first
527    /// clamp the window into the viewport so it can't open partially off-screen.
528    fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
529        if !self.modal || !self.is_visible() {
530            return;
531        }
532        let (dx, dy) = super::paint::clamp_modal_into_viewport(self, ctx);
533        ctx.save();
534        if dx != 0.0 || dy != 0.0 {
535            ctx.translate(dx, dy);
536        }
537        crate::widget::paint_subtree_forced(self, ctx);
538        ctx.restore();
539    }
540
541    fn on_event(&mut self, event: &Event) -> EventResult {
542        if !self.requested_visible() {
543            return EventResult::Ignored;
544        }
545
546        match event {
547            Event::MouseMove { pos } => {
548                let was_close = self.close_hovered;
549                let was_max = self.maximize_hovered;
550                let was_dir = self.hover_dir;
551                self.close_hovered = self.in_close_button(*pos);
552                self.maximize_hovered = self.in_maximize_button(*pos);
553
554                match self.drag_mode {
555                    DragMode::Move => {
556                        let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
557                        let dx = world.x - self.drag_start_world.x;
558                        let dy = world.y - self.drag_start_world.y;
559                        self.bounds.x = (self.drag_start_bounds.x + dx).round();
560                        self.bounds.y = (self.drag_start_bounds.y + dy).round();
561                        // Snap pass — runs only when the global flag
562                        // is on.  Reads the thread-local target list
563                        // populated by every other window's `layout`
564                        // and writes the resulting visual guides for
565                        // `SnapOverlay` to render.
566                        self.apply_move_snap();
567                        self.clamp_to_canvas();
568                        self.hover_dir = None;
569                        set_cursor_icon(CursorIcon::Grabbing);
570                        crate::animation::request_draw_without_invalidation();
571                        return EventResult::Ignored;
572                    }
573                    DragMode::Resize(dir) => {
574                        let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
575                        self.apply_resize(world);
576                        self.apply_resize_snap(dir);
577                        set_cursor_icon(resize_cursor(dir));
578                        crate::animation::request_draw();
579                        return EventResult::Consumed;
580                    }
581                    DragMode::None => {
582                        // Track which edge/corner the cursor is hovering over so
583                        // paint_overlay can draw the appropriate highlight.
584                        self.hover_dir = self.resize_dir(*pos);
585                        if let Some(dir) = self.hover_dir {
586                            set_cursor_icon(resize_cursor(dir));
587                        }
588                    }
589                }
590                if was_close != self.close_hovered
591                    || was_max != self.maximize_hovered
592                    || was_dir != self.hover_dir
593                {
594                    crate::animation::request_draw();
595                }
596                EventResult::Ignored
597            }
598
599            // Click-away dismissal: while this window holds the modal grab, the
600            // App routes EVERY press to our subtree — including presses OUTSIDE
601            // our bounds (they arrive here with an out-of-range local `pos`). If
602            // click-away is enabled, ANY button pressed outside closes us through
603            // the unified `close()` path with `ClickAway` and swallows the press
604            // so it never activates whatever sat underneath. This arm precedes
605            // the button-specific handlers so a right/middle press-away dismisses
606            // too (a right-click must not, e.g., start a title drag). Wheel
607            // events are `MouseWheel`, not `MouseDown`, so scrolling outside
608            // stays inert.
609            Event::MouseDown { pos, .. }
610                if self.modal
611                    && self.click_away == ClickAwayAction::Close
612                    && !self.point_in_local_bounds(*pos) =>
613            {
614                self.close(CloseReason::ClickAway);
615                EventResult::Consumed
616            }
617
618            Event::MouseDown { button, pos, .. }
619                if matches!(*button, MouseButton::Left | MouseButton::Middle) =>
620            {
621                let is_left_click = *button == MouseButton::Left;
622
623                // Press-to-raise: any direct press on this window brings it forward.
624                self.raise_request.set(true);
625                // Z-order changes are visible; repaint.
626                crate::animation::request_draw();
627                if let Some(cb) = self.on_raised.as_mut() {
628                    cb(&self.title);
629                }
630
631                // Close button — highest priority.
632                if is_left_click && self.in_close_button(*pos) {
633                    self.close(CloseReason::CloseButton);
634                    return EventResult::Consumed;
635                }
636
637                // Maximize / Restore button.
638                if is_left_click && self.in_maximize_button(*pos) {
639                    self.toggle_maximize();
640                    crate::animation::request_draw();
641                    return EventResult::Consumed;
642                }
643
644                // Route the click into the title-bar sub-tree FIRST so
645                // any child widget there (currently the chevron) gets a
646                // chance to consume it.  `WindowTitleBar` lives outside
647                // `Window.children` because the body content owns that
648                // slot, so the framework's normal hit-test pass never
649                // descends into it — we run the framework's hit-test
650                // + dispatch helpers manually on the sub-tree instead.
651                if is_left_click && self.in_title_bar(*pos) {
652                    let tb_bounds = self.title_bar.bounds();
653                    let tb_local = Point::new(pos.x - tb_bounds.x, pos.y - tb_bounds.y);
654                    if let Some(path) = crate::widget::hit_test_subtree(&self.title_bar, tb_local) {
655                        // Path could be empty (clicked the bar itself
656                        // but not a child) — skip in that case so the
657                        // title-drag handling further down still runs.
658                        if !path.is_empty() {
659                            // Preserve modifiers from the original event.
660                            let mods = match event {
661                                Event::MouseDown { modifiers, .. } => *modifiers,
662                                _ => Default::default(),
663                            };
664                            let translated = Event::MouseDown {
665                                pos: tb_local,
666                                button: *button,
667                                modifiers: mods,
668                            };
669                            let result = crate::widget::dispatch_event_dyn(
670                                &mut self.title_bar,
671                                &path,
672                                &translated,
673                                tb_local,
674                            );
675                            if result.is_consumed() {
676                                // Chevron flag is drained in `layout`,
677                                // but we also want this frame to redraw
678                                // before that.
679                                crate::animation::request_draw();
680                                return EventResult::Consumed;
681                            }
682                        }
683                    }
684                }
685
686                // Resize edge — check before title bar to handle corner overlap.
687                if let Some(dir) = self.resize_dir(*pos) {
688                    // Only start resize if not in the close button area and not a pure title bar drag.
689                    // The N edge overlaps the title bar — prefer resize over drag from the top N px.
690                    let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
691                    self.drag_mode = DragMode::Resize(dir);
692                    self.drag_start_world = world;
693                    self.drag_start_bounds = self.bounds;
694                    return EventResult::Consumed;
695                }
696
697                // Title bar drag + double-click maximize.
698                if self.in_title_bar(*pos) {
699                    // Double-click detection.
700                    let is_double = if is_left_click {
701                        let now = Instant::now();
702                        self.last_title_click
703                            .map(|t| now.duration_since(t).as_millis() < DBL_CLICK_MS)
704                            .unwrap_or(false)
705                    } else {
706                        false
707                    };
708
709                    if is_double {
710                        // Windows convention: double-click title bar toggles
711                        // maximize / restore.  Collapse/expand lives on the
712                        // chevron button to the left.
713                        self.toggle_maximize();
714                        self.last_title_click = None;
715                        crate::animation::request_draw();
716                    } else {
717                        if is_left_click {
718                            self.last_title_click = Some(Instant::now());
719                        }
720                        let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
721                        self.drag_mode = DragMode::Move;
722                        self.drag_start_world = world;
723                        self.drag_start_bounds = self.bounds;
724                    }
725                    return EventResult::Consumed;
726                }
727
728                // Click on content area: consume so it doesn't fall through.
729                if is_left_click && !self.collapsed {
730                    EventResult::Consumed
731                } else {
732                    EventResult::Ignored
733                }
734            }
735
736            Event::MouseUp {
737                button: MouseButton::Left | MouseButton::Middle,
738                ..
739            } => {
740                let was_dragging = self.drag_mode != DragMode::None;
741                self.drag_mode = DragMode::None;
742                if was_dragging {
743                    // Drag ended — wipe the snap guides so the
744                    // overlay clears.  Cheap no-op when snapping was
745                    // off (guide buffer was already empty).
746                    crate::snap::clear_guides();
747                    crate::animation::request_draw();
748                    EventResult::Consumed
749                } else {
750                    EventResult::Ignored
751                }
752            }
753
754            // Escape closes a modal window (standard dialog convention),
755            // running the same teardown as the × button so `on_close` fires.
756            // Modal key routing bubbles Escape up to us when no inner field
757            // consumed it (see `App::on_key_down`); non-modal windows ignore it
758            // so app-level shortcuts still see the key.
759            Event::KeyDown {
760                key: Key::Escape, ..
761            } if self.modal => {
762                self.close(CloseReason::Escape);
763                EventResult::Consumed
764            }
765
766            _ => EventResult::Ignored,
767        }
768    }
769}