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