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}