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}