Skip to main content

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    /// A collapsed window paints only its title bar — nothing inside the
17    /// content area is visible, so no child can legitimately request a
18    /// repaint.  Closing (`is_visible` false) also short-circuits, matching
19    /// the default trait impl.  Without these overrides a cursor blink or
20    /// hover tween inside a collapsed/closed window would keep the host
21    /// loop awake despite being invisible.
22    fn needs_draw(&self) -> bool {
23        if !self.is_visible() || self.collapsed {
24            return false;
25        }
26        self.children().iter().any(|c| c.needs_draw())
27    }
28
29    fn next_draw_deadline(&self) -> Option<web_time::Instant> {
30        if !self.is_visible() || self.collapsed {
31            return None;
32        }
33        let mut best: Option<web_time::Instant> = None;
34        for c in self.children() {
35            if let Some(t) = c.next_draw_deadline() {
36                best = Some(match best {
37                    Some(b) if b <= t => b,
38                    _ => t,
39                });
40            }
41        }
42        best
43    }
44
45    fn bounds(&self) -> Rect {
46        self.bounds
47    }
48
49    fn margin(&self) -> Insets {
50        self.base.margin
51    }
52    fn widget_base(&self) -> Option<&WidgetBase> {
53        Some(&self.base)
54    }
55    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
56        Some(&mut self.base)
57    }
58    fn h_anchor(&self) -> HAnchor {
59        self.base.h_anchor
60    }
61    fn v_anchor(&self) -> VAnchor {
62        self.base.v_anchor
63    }
64    fn min_size(&self) -> Size {
65        self.base.min_size
66    }
67    fn max_size(&self) -> Size {
68        self.base.max_size
69    }
70
71    fn properties(&self) -> Vec<(&'static str, String)> {
72        vec![
73            (
74                "backbuffer_kind",
75                if self.use_gl_backbuffer {
76                    "GlFbo".to_string()
77                } else {
78                    "None".to_string()
79                },
80            ),
81            ("backbuffer_dirty", self.backbuffer.dirty.to_string()),
82            (
83                "backbuffer_repaints",
84                self.backbuffer.repaint_count.to_string(),
85            ),
86            (
87                "backbuffer_composites",
88                self.backbuffer.composite_count.to_string(),
89            ),
90            (
91                "backbuffer_size",
92                format!("{}x{}", self.backbuffer.width, self.backbuffer.height),
93            ),
94        ]
95    }
96
97    /// Pop this window to the top of the parent `Stack` when the
98    /// false→true visibility edge fires (see `layout`).
99    fn take_raise_request(&mut self) -> bool {
100        let pending = self.raise_request.get();
101        self.raise_request.set(false);
102        pending
103    }
104
105    fn set_bounds(&mut self, b: Rect) {
106        if let Some(ref cell) = self.reset_to {
107            if let Some(new_b) = cell.get() {
108                self.bounds = new_b;
109                self.pre_collapse_h = new_b.height;
110                self.collapsed = false;
111                cell.set(None);
112                return;
113            }
114        }
115        if self.bounds.width == 0.0 || self.bounds.height == 0.0 {
116            self.bounds = b;
117            self.pre_collapse_h = b.height;
118        }
119    }
120
121    fn children(&self) -> &[Box<dyn Widget>] {
122        &self.children
123    }
124    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
125        &mut self.children
126    }
127
128    fn backbuffer_spec(&mut self) -> BackbufferSpec {
129        if !self.use_gl_backbuffer {
130            return BackbufferSpec::none();
131        }
132        if !self.is_visible() {
133            let alpha = self.visibility_anim.value();
134            if self.requested_visible() || alpha <= 0.001 {
135                return BackbufferSpec::none();
136            }
137        }
138
139        let requested_visible = self.requested_visible();
140        self.visibility_anim
141            .set_target(if requested_visible { 1.0 } else { 0.0 });
142        let alpha = self.visibility_anim.tick();
143        if !requested_visible && alpha > 0.001 {
144            self.fade_out_active.set(true);
145        }
146        if !requested_visible && alpha <= 0.001 {
147            self.fade_out_active.set(false);
148        }
149
150        let (outset_left, outset_bottom, outset_right, outset_top) = Self::layer_outsets();
151        BackbufferSpec {
152            kind: BackbufferKind::GlFbo,
153            cached: true,
154            alpha,
155            outsets: Insets {
156                left: outset_left,
157                right: outset_right,
158                top: outset_top,
159                bottom: outset_bottom,
160            },
161            rounded_clip: Some(CORNER_R),
162        }
163    }
164
165    fn backbuffer_state_mut(&mut self) -> Option<&mut BackbufferState> {
166        Some(&mut self.backbuffer)
167    }
168
169    /// Clip child painting to the content area (below the title bar).
170    /// When collapsed bounds.height == TITLE_H so the content rect has zero height,
171    /// preventing any child from drawing outside the visible title-bar strip.
172    fn clip_children_rect(&self) -> Option<(f64, f64, f64, f64)> {
173        if !self.is_visible() {
174            return None;
175        }
176        let w = self.bounds.width;
177        let content_h = (self.bounds.height - TITLE_H).max(0.0);
178        // Clip to content area: y=0 (bottom) up to content_h, full width.
179        Some((0.0, 0.0, w, content_h))
180    }
181
182    fn hit_test(&self, local_pos: Point) -> bool {
183        if !self.requested_visible() {
184            return false;
185        }
186        if self.drag_mode != DragMode::None {
187            return true;
188        }
189        let b = self.bounds();
190        local_pos.x >= 0.0
191            && local_pos.x <= b.width
192            && local_pos.y >= 0.0
193            && local_pos.y <= b.height
194    }
195
196    fn claims_pointer_exclusively(&self, local_pos: Point) -> bool {
197        self.requested_visible()
198            && (self.drag_mode != DragMode::None || self.resize_dir(local_pos).is_some())
199    }
200
201    fn layout(&mut self, available: Size) -> Size {
202        // Rising-edge visibility detection → request parent raise.  The
203        // sidebar toggles `visible_cell`; we observe the transition here
204        // and set `raise_request`, which the parent `Stack` drains on its
205        // next layout (one-frame delay, invisible to the user).
206        let now_visible = self.requested_visible();
207        // First-layout fit (visibility-cell-managed windows only):
208        // a window restored as already-visible via `visible_cell` misses
209        // the rising-edge branch below (last_visible was seeded to match
210        // the cell), so without this its persisted bounds can land
211        // outside the live viewport — the user sees the sidebar pill
212        // highlighted but no window.  Gating on `visible_cell.is_some()`
213        // keeps the auto-save invariant for plain `with_bounds(...)`
214        // windows whose layout must never mutate persisted state.
215        if now_visible && self.needs_initial_fit.get() && self.visible_cell.is_some() {
216            self.fit_fully_to_canvas(available);
217        }
218        self.needs_initial_fit.set(false);
219        if now_visible && !self.last_visible.get() {
220            self.raise_request.set(true);
221            if let Some(cb) = self.on_raised.as_mut() {
222                cb(&self.title);
223            }
224            // Un-maximize on reopen.  Clicking a sidebar checkbox is "open
225            // this window for use" — the user expects the window to come
226            // up at its normal size, not still stretched to fill the canvas
227            // from the last session's maximise.  Restore `pre_maximize_bounds`
228            // which `toggle_maximize` saved when the user maximised.
229            if self.maximized {
230                self.bounds = self.pre_maximize_bounds;
231                self.maximized = false;
232            }
233            self.fit_fully_to_canvas(available);
234        }
235        if now_visible {
236            self.fade_out_active.set(false);
237            self.visibility_anim.set_target(1.0);
238        } else {
239            self.visibility_anim.set_target(0.0);
240            if self.visibility_anim.tick() <= 0.001 {
241                self.fade_out_active.set(false);
242            }
243        }
244        self.last_visible.set(now_visible);
245
246        if !self.is_visible() {
247            return Size::new(self.bounds.width, self.bounds.height);
248        }
249
250        if self.maximized && available.width > 0.0 && available.height > 0.0 {
251            self.bounds = snap(Rect::new(0.0, 0.0, available.width, available.height));
252            self.pre_collapse_h = self.bounds.height;
253        }
254
255        // Auto-size: measure the child's preferred size, then adopt it as the
256        // new window size (pinning the top edge — Y-up → adjust `bounds.y` so
257        // the title bar stays put when the height changes).  Skip while
258        // collapsed: the user toggled a fixed TITLE_H height.
259        //
260        // We cap the measurement request by `child.max_size()` when finite
261        // (otherwise by the canvas size): flex containers return their given
262        // `available.width` rather than an intrinsic natural width, so without
263        // a cap we'd produce an infinite/canvas-wide window.  Callers wanting
264        // a content-fitted window set `with_max_size(Size::new(w, f64::MAX))`
265        // on their root widget.
266        if self.auto_size && !self.collapsed && !self.maximized {
267            if let Some(child) = self.children.first_mut() {
268                let max_sz = child.max_size();
269                // `Size::MAX` uses `f64::MAX / 2.0` as its sentinel so
270                // widgets can add-without-overflow (see `geometry.rs`).
271                // That value is *technically* finite, so a plain
272                // `.is_finite()` check wrongly treats it as a real cap
273                // and cascades an ~`f64::MAX/2` width down to wrapped
274                // Labels, whose bounds then blow up LCD-backbuffer
275                // allocators to hundreds of GB.  Guard with a sane
276                // threshold: anything ≥ `CAP_SENTINEL` means "no cap,
277                // fall back to viewport-provided bounds".
278                const CAP_SENTINEL: f64 = 1.0e18;
279                // WIDTH is PINNED to the current bounds.width (seeded
280                // by `with_bounds` and preserved across frames).
281                // Why: wrapping Labels inside the content claim their
282                // full available width — if we pass the viewport
283                // width here, the window grows to the canvas on the
284                // first frame and never shrinks back.  egui's
285                // equivalent is `default_width`, which also pins.
286                let cap_w = self.bounds.width.max(MIN_W);
287                let cap_h = if max_sz.height.is_finite() && max_sz.height < CAP_SENTINEL {
288                    max_sz.height
289                } else {
290                    available.height.max(MIN_H)
291                };
292                let pref = child.layout(Size::new(cap_w, cap_h));
293                // Auto-size follows content in BOTH directions — so
294                // the window can also shrink back down when the
295                // inner Resize (or any other sizing widget) narrows.
296                // Lower bound: `MIN_W`.  Upper bound: the parent-
297                // provided `available.width` (main_area / canvas).
298                // Matches egui where auto_sized tracks content size
299                // symmetrically.
300                let new_w = pref.width.max(MIN_W).min(available.width.max(MIN_W));
301                let new_h = (pref.height + TITLE_H).min(cap_h + TITLE_H).max(MIN_H);
302                let top = self.bounds.y + self.bounds.height;
303                self.bounds.width = new_w;
304                self.bounds.height = new_h;
305                self.bounds.y = top - new_h;
306                self.pre_collapse_h = new_h;
307            }
308        }
309
310        // ── Tight-fit pre-pass ───────────────────────────────────
311        //
312        // When `with_tight_content_fit(true)` is set (and we're not
313        // already in the auto_size block above, which handles both
314        // axes), ask the content tree what minimum height it needs
315        // at our current width and SNAP `bounds.height` to that.
316        //
317        // Uses `Widget::measure_min_height` rather than `layout` so
318        // the result is independent of flex distribution — a
319        // flex-fill widget like `TextArea` reports its true wrapped-
320        // content height through `measure_min_height` even though
321        // its `layout` returns the full slot.  This is what makes
322        // egui's "no scroll, no clip, no whitespace" contract work
323        // for windows whose content includes a flex-fill child.
324        if self.tight_content_fit && !self.auto_size && !self.collapsed && !self.maximized {
325            if let Some(child) = self.children.first() {
326                let needed = child.measure_min_height(self.bounds.width);
327                let new_h = (needed + TITLE_H).max(MIN_H);
328                let top = self.bounds.y + self.bounds.height;
329                self.bounds.height = new_h;
330                self.bounds.y = top - new_h;
331                self.last_content_natural_h.set(needed);
332            }
333        }
334
335        // When collapsed, bounds.height == TITLE_H (set during toggle).
336        let content_h = (self.bounds.height - TITLE_H).max(0.0);
337
338        if let Some(child) = self.children.first_mut() {
339            if !self.collapsed {
340                let desired = child.layout(Size::new(self.bounds.width, content_h));
341                let child_h = if child.v_anchor().is_stretch() {
342                    content_h
343                } else {
344                    desired.height.clamp(
345                        child.min_size().height,
346                        child.max_size().height.min(content_h),
347                    )
348                };
349                let child_y = if child.v_anchor().contains(VAnchor::BOTTOM) {
350                    0.0
351                } else if child.v_anchor().contains(VAnchor::CENTER) {
352                    ((content_h - child_h) * 0.5).max(0.0)
353                } else {
354                    (content_h - child_h).max(0.0)
355                };
356                if (child_h - content_h).abs() > f64::EPSILON {
357                    child.layout(Size::new(self.bounds.width, child_h));
358                }
359                child.set_bounds(Rect::new(0.0, child_y, self.bounds.width, child_h));
360            }
361            // When collapsed the child keeps its last bounds but is not visible
362            // because hit_test returns false for the content area.
363        }
364
365        // Cache the child's required height via `measure_min_height`
366        // so `apply_resize` and the tight-fit floor see a current
367        // value EVEN when the content's `layout` returns the slot
368        // size (the flex-fill case).  `Widget::measure_min_height`
369        // walks the content tree and returns the actual content
370        // requirement at the supplied width.
371        if (self.tight_content_fit || self.floor_content_height) && !self.collapsed {
372            if let Some(child) = self.children.first() {
373                self.last_content_natural_h
374                    .set(child.measure_min_height(self.bounds.width));
375            }
376        }
377
378        // Position the title-bar strip at the top of the window and
379        // give it a layout pass so the title label knows its size.
380        let tb_y = self.bounds.height - TITLE_H;
381        self.title_bar
382            .set_bounds(Rect::new(0.0, tb_y, self.bounds.width, TITLE_H));
383        self.title_bar.layout(Size::new(self.bounds.width, TITLE_H));
384
385        // Record the canvas size — used by drag / resize / collapse clamp
386        // paths that fire on USER ACTION.  We deliberately do NOT clamp
387        // passively at layout time: platforms fire a Resized event with a
388        // transient smaller size during fullscreen/maximize EXIT (Windows
389        // notably), and if we clamped on shrink the auto-save would persist
390        // those transient clamped bounds — the "all windows pushed down to
391        // the same Y on next startup" bug.  Clamping only on user actions
392        // (dragging a window, resize-handle, collapse toggle) keeps saved
393        // state pinned to what the user actually chose.
394        //
395        // If a later OS shrink genuinely leaves a window's title bar out of
396        // reach, the user can drag it back, use "Organize windows" to
397        // retile, or a dedicated "reset positions" command.
398        self.canvas_size = available;
399        if let Some(ref cell) = self.position_cell {
400            // When maximised, persist the UNDERLYING pre-maximise bounds,
401            // not the stretched-to-canvas ones.  The maximized flag itself is
402            // persisted separately so reloads restore the interaction state
403            // without losing the user's last normal-size bounds.
404            let save_bounds = if self.maximized {
405                self.pre_maximize_bounds
406            } else {
407                self.bounds
408            };
409            cell.set(save_bounds);
410        }
411        if let Some(ref cell) = self.maximized_cell {
412            cell.set(self.maximized);
413        }
414
415        Size::new(self.bounds.width, self.bounds.height)
416    }
417
418    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
419        if !self.is_visible() {
420            return;
421        }
422
423        let v = ctx.visuals();
424        let w = self.bounds.width;
425        // bounds.height == TITLE_H when collapsed (adjusted on toggle).
426        let h = self.bounds.height;
427
428        // Drop shadow — stacked rounded rects approximating a Gaussian blur.
429        // Outer layers inflate outward and fade with a (1−t)² falloff; drawn
430        // outside-in so the denser core overlays the softer halo.
431        let base = v.window_shadow;
432        for i in (0..SHADOW_STEPS).rev() {
433            let t = i as f64 / SHADOW_STEPS as f64;
434            let infl = t * SHADOW_BLUR;
435            let falloff = (1.0 - t).powi(2) as f32;
436            let alpha = base.a * falloff / SHADOW_STEPS as f32 * 6.0;
437            ctx.set_fill_color(Color::rgba(base.r, base.g, base.b, alpha));
438            ctx.begin_path();
439            ctx.rounded_rect(
440                SHADOW_DX - infl,
441                -SHADOW_DY - infl,
442                w + 2.0 * infl,
443                h + 2.0 * infl,
444                CORNER_R + infl,
445            );
446            ctx.fill();
447        }
448
449        self.foreground_layer_active.set(false);
450        if ctx.supports_compositing_layers() {
451            ctx.push_layer(w, h);
452            self.foreground_layer_active.set(true);
453        }
454
455        // Window body. Expanded windows leave the top strip to `WindowTitleBar`
456        // so the top corner alpha comes from one shape, not overlapping fills.
457        let content_h = (h - TITLE_H).max(0.0);
458        if content_h > 0.0 {
459            ctx.set_fill_color(v.window_fill);
460            ctx.begin_path();
461            ctx.rounded_rect(0.0, 0.0, w, content_h, CORNER_R);
462            ctx.rect(
463                0.0,
464                (content_h - CORNER_R).max(0.0),
465                w,
466                CORNER_R.min(content_h),
467            );
468            ctx.fill();
469        }
470
471        ctx.set_layer_rounded_clip(0.0, 0.0, w, h, CORNER_R);
472
473        // Sync the title-bar sub-widget's display state for this frame
474        // and paint it.  Positioning was done in `layout`; we just need
475        // to hand it the per-frame interaction snapshot and dispatch
476        // through `paint_subtree` so the ancestor-chain stack gets the
477        // WindowTitleBar entry (background_color = window_title_fill).
478        {
479            let mut st = self.title_state.borrow_mut();
480            st.bar_color = if self.drag_mode == DragMode::Move {
481                v.window_title_fill_drag
482            } else {
483                v.window_title_fill
484            };
485            st.title_color = v.window_title_text;
486            st.collapsed = self.collapsed;
487            st.maximized = self.maximized;
488            st.close_hovered = self.close_hovered;
489            st.maximize_hovered = self.maximize_hovered;
490        }
491        let tb_bounds = self.title_bar.bounds();
492        ctx.save();
493        ctx.translate(tb_bounds.x, tb_bounds.y);
494        paint_subtree(&mut self.title_bar, ctx);
495        ctx.restore();
496
497        // Outer border — on top of the title bar so the rounded corners
498        // cleanly frame both body and title region.
499        ctx.set_fill_color(v.window_fill); // restore default fill — stroke follows
500        ctx.set_stroke_color(v.window_stroke);
501        ctx.set_line_width(1.0);
502        ctx.begin_path();
503        ctx.rounded_rect(0.5, 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0), CORNER_R);
504        ctx.stroke();
505    }
506
507    // paint_overlay: draws the resize handle dots + edge highlights on top of content.
508    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
509        if !self.is_visible() || self.collapsed {
510            return;
511        }
512        // Skip all resize-related chrome when the window can't be resized,
513        // so an auto-sized or `.resizable(false)` window doesn't look
514        // deceptively interactive.
515        if !self.resizable || self.auto_size {
516            return;
517        }
518        let v = ctx.visuals();
519        let w = self.bounds.width;
520        let h = self.bounds.height;
521
522        // ── SE corner drag grip (3 diagonal lines, egui-style) ───────────────
523        // Only shown when both axes are resizable; for uni-axis resizable
524        // windows the SE grip would suggest a capability that isn't there.
525        if self.resizable_h && self.resizable_v {
526            let is_se_active = matches!(self.drag_mode, DragMode::Resize(ResizeDir::SE));
527            let is_se_hover = self.hover_dir == Some(ResizeDir::SE);
528            let grip_color = if is_se_active {
529                v.window_resize_active
530            } else if is_se_hover {
531                v.window_resize_hover
532            } else {
533                v.window_stroke
534            };
535            ctx.set_stroke_color(grip_color);
536            ctx.set_line_width(1.5);
537            let m = 3.0_f64; // margin from corner edge
538            for i in 1..=3_i32 {
539                let off = i as f64 * 4.0 + m;
540                ctx.begin_path();
541                ctx.move_to(w - off, m);
542                ctx.line_to(w - m, off);
543                ctx.stroke();
544            }
545        }
546
547        // ── Resize edge / corner highlight ────────────────────────────────────
548        // Determine the highlighted direction and whether it is actively dragging.
549        let (highlight, is_active) = match self.drag_mode {
550            DragMode::Resize(d) => (Some(d), true),
551            DragMode::Move => (None, false), // no edge highlight while moving
552            DragMode::None => (self.hover_dir, false),
553        };
554        let dir = match highlight {
555            Some(d) => d,
556            None => return,
557        };
558
559        let color = if is_active {
560            v.window_resize_active
561        } else {
562            v.window_resize_hover
563        };
564        ctx.set_stroke_color(color);
565        ctx.set_line_width(2.0);
566
567        // Which edges to highlight (derived from direction).
568        let (top, bottom, left, right) = match dir {
569            ResizeDir::N => (true, false, false, false),
570            ResizeDir::S => (false, true, false, false),
571            ResizeDir::E => (false, false, false, true),
572            ResizeDir::W => (false, false, true, false),
573            ResizeDir::NE => (true, false, false, true),
574            ResizeDir::NW => (true, false, true, false),
575            ResizeDir::SE => (false, true, false, true),
576            ResizeDir::SW => (false, true, true, false),
577        };
578
579        // Segments run between the rounded-corner tangent points.
580        let cr = CORNER_R;
581        if top {
582            ctx.begin_path();
583            ctx.move_to(cr, h);
584            ctx.line_to(w - cr, h);
585            ctx.stroke();
586        }
587        if bottom {
588            ctx.begin_path();
589            ctx.move_to(cr, 0.0);
590            ctx.line_to(w - cr, 0.0);
591            ctx.stroke();
592        }
593        if left {
594            ctx.begin_path();
595            ctx.move_to(0.0, cr);
596            ctx.line_to(0.0, h - cr);
597            ctx.stroke();
598        }
599        if right {
600            ctx.begin_path();
601            ctx.move_to(w, cr);
602            ctx.line_to(w, h - cr);
603            ctx.stroke();
604        }
605    }
606
607    fn finish_paint(&mut self, ctx: &mut dyn DrawCtx) {
608        if self.foreground_layer_active.replace(false) {
609            ctx.pop_layer();
610        }
611    }
612
613    fn on_event(&mut self, event: &Event) -> EventResult {
614        if !self.requested_visible() {
615            return EventResult::Ignored;
616        }
617
618        match event {
619            Event::MouseMove { pos } => {
620                let was_close = self.close_hovered;
621                let was_max = self.maximize_hovered;
622                let was_dir = self.hover_dir;
623                self.close_hovered = self.in_close_button(*pos);
624                self.maximize_hovered = self.in_maximize_button(*pos);
625
626                match self.drag_mode {
627                    DragMode::Move => {
628                        let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
629                        let dx = world.x - self.drag_start_world.x;
630                        let dy = world.y - self.drag_start_world.y;
631                        self.bounds.x = (self.drag_start_bounds.x + dx).round();
632                        self.bounds.y = (self.drag_start_bounds.y + dy).round();
633                        self.clamp_to_canvas();
634                        self.hover_dir = None;
635                        set_cursor_icon(CursorIcon::Grabbing);
636                        crate::animation::request_draw_without_invalidation();
637                        return EventResult::Ignored;
638                    }
639                    DragMode::Resize(dir) => {
640                        let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
641                        self.apply_resize(world);
642                        set_cursor_icon(resize_cursor(dir));
643                        crate::animation::request_draw();
644                        return EventResult::Consumed;
645                    }
646                    DragMode::None => {
647                        // Track which edge/corner the cursor is hovering over so
648                        // paint_overlay can draw the appropriate highlight.
649                        self.hover_dir = self.resize_dir(*pos);
650                        if let Some(dir) = self.hover_dir {
651                            set_cursor_icon(resize_cursor(dir));
652                        }
653                    }
654                }
655                if was_close != self.close_hovered
656                    || was_max != self.maximize_hovered
657                    || was_dir != self.hover_dir
658                {
659                    crate::animation::request_draw();
660                }
661                EventResult::Ignored
662            }
663
664            Event::MouseDown { button, pos, .. }
665                if matches!(*button, MouseButton::Left | MouseButton::Middle) =>
666            {
667                let is_left_click = *button == MouseButton::Left;
668                // Press-to-raise — any direct press that reaches this Window
669                // (hit-test routed it here in reverse paint order, so we
670                // ARE the topmost widget under the cursor in the stack
671                // sense) requests a raise.  Classic window-manager
672                // behaviour: clicking anywhere on a window pops it to the
673                // top of the z-order.  Consumed by `Stack::layout` on the
674                // next frame via `take_raise_request`; one-frame visual
675                // delay is invisible in practice.
676                self.raise_request.set(true);
677                // Z-order changes are visible; repaint.
678                crate::animation::request_draw();
679                if let Some(cb) = self.on_raised.as_mut() {
680                    cb(&self.title);
681                }
682
683                // Close button — highest priority.
684                if is_left_click && self.in_close_button(*pos) {
685                    self.visible = false;
686                    self.visibility_anim.set_target(0.0);
687                    if let Some(ref cell) = self.visible_cell {
688                        cell.set(false);
689                    }
690                    if let Some(cb) = self.on_close.as_mut() {
691                        cb();
692                    }
693                    crate::animation::request_draw();
694                    return EventResult::Consumed;
695                }
696
697                // Maximize / Restore button.
698                if is_left_click && self.in_maximize_button(*pos) {
699                    self.toggle_maximize();
700                    crate::animation::request_draw();
701                    return EventResult::Consumed;
702                }
703
704                // Collapse / expand chevron.
705                if is_left_click && self.in_chevron_button(*pos) {
706                    self.toggle_collapse();
707                    // Null out the double-click timer so clicking the
708                    // chevron then quickly clicking the bar doesn't
709                    // trigger a maximize toggle.
710                    self.last_title_click = None;
711                    crate::animation::request_draw();
712                    return EventResult::Consumed;
713                }
714
715                // Resize edge — check before title bar to handle corner overlap.
716                if let Some(dir) = self.resize_dir(*pos) {
717                    // Only start resize if not in the close button area and not a pure title bar drag.
718                    // The N edge overlaps the title bar — prefer resize over drag from the top N px.
719                    let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
720                    self.drag_mode = DragMode::Resize(dir);
721                    self.drag_start_world = world;
722                    self.drag_start_bounds = self.bounds;
723                    return EventResult::Consumed;
724                }
725
726                // Title bar drag + double-click maximize.
727                if self.in_title_bar(*pos) {
728                    // Double-click detection.
729                    let is_double = if is_left_click {
730                        let now = Instant::now();
731                        self.last_title_click
732                            .map(|t| now.duration_since(t).as_millis() < DBL_CLICK_MS)
733                            .unwrap_or(false)
734                    } else {
735                        false
736                    };
737
738                    if is_double {
739                        // Windows convention: double-click title bar toggles
740                        // maximize / restore.  Collapse/expand lives on the
741                        // chevron button to the left.
742                        self.toggle_maximize();
743                        self.last_title_click = None;
744                        crate::animation::request_draw();
745                    } else {
746                        if is_left_click {
747                            self.last_title_click = Some(Instant::now());
748                        }
749                        let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
750                        self.drag_mode = DragMode::Move;
751                        self.drag_start_world = world;
752                        self.drag_start_bounds = self.bounds;
753                    }
754                    return EventResult::Consumed;
755                }
756
757                // Click on content area: consume so it doesn't fall through.
758                if is_left_click && !self.collapsed {
759                    EventResult::Consumed
760                } else {
761                    EventResult::Ignored
762                }
763            }
764
765            Event::MouseUp {
766                button: MouseButton::Left | MouseButton::Middle,
767                ..
768            } => {
769                let was_dragging = self.drag_mode != DragMode::None;
770                self.drag_mode = DragMode::None;
771                if was_dragging {
772                    crate::animation::request_draw();
773                    EventResult::Consumed
774                } else {
775                    EventResult::Ignored
776                }
777            }
778
779            _ => EventResult::Ignored,
780        }
781    }
782}