Skip to main content

agg_gui/widgets/
window.rs

1//! `Window` — a floating, draggable, resizable panel with a title bar.
2//!
3//! # Usage
4//!
5//! Create a `Window` and place it as the **last** child of a [`Stack`] so it
6//! paints on top of everything and receives hit-test priority.
7//!
8//! ```ignore
9//! let win = Window::new("Inspector", font, Box::new(my_content));
10//! Stack::new()
11//!     .add(Box::new(main_ui))
12//!     .add(Box::new(win))
13//! ```
14//!
15//! # Features
16//!
17//! - **Drag** — click-drag the title bar to move the window.
18//! - **Resize** — drag any of the 8 edges/corners to resize; min size 120×80.
19//! - **Collapse** — click the chevron on the left of the title bar to collapse
20//!   to title-bar-only height (click again to expand).
21//! - **Maximize** — double-click the title bar (or click the maximize button)
22//!   to toggle between maximised and restored size.
23//! - **Close** — click the × button; syncs with an optional shared `visible_cell`.
24//!
25//! # Coordinate notes (Y-up)
26//!
27//! `bounds` stores the window's position in its **parent's** coordinate space.
28//! The title bar is at the **top** of the window, i.e. local Y ∈
29//! `[height − TITLE_H .. height]`. The content area fills local Y ∈ `[0 .. height − TITLE_H]`.
30
31use std::cell::{Cell, RefCell};
32use std::rc::Rc;
33use std::sync::Arc;
34
35use web_time::Instant;
36
37use crate::color::Color;
38use crate::cursor::{CursorIcon, set_cursor_icon};
39use crate::event::{Event, EventResult, MouseButton};
40use crate::geometry::{Point, Rect, Size};
41use crate::draw_ctx::DrawCtx;
42use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
43use crate::text::Font;
44use crate::widget::{Widget, paint_subtree};
45use crate::widgets::window_title_bar::{TitleBarView, WindowTitleBar};
46
47/// Round all four components of a Rect to the nearest integer so widgets
48/// are always placed on exact pixel boundaries (crisp bitmap blits, no blur).
49fn snap(r: Rect) -> Rect {
50    Rect::new(r.x.round(), r.y.round(), r.width.round(), r.height.round())
51}
52
53const TITLE_H:      f64 = 28.0;
54const CORNER_R:     f64 = 8.0;
55/// Shadow blur radius in pixels (egui default Shadow::blur is ≈16; we use 14
56/// for a slightly tighter falloff since windows live on a panel background).
57const SHADOW_BLUR:  f64 = 14.0;
58/// Shadow offset from the window (Y-down visually → −y in Y-up space).
59const SHADOW_DX:    f64 = 2.0;
60const SHADOW_DY:    f64 = 6.0;
61/// Number of stacked layers approximating a Gaussian blur falloff.
62const SHADOW_STEPS: usize = 10;
63const CLOSE_R:      f64 = 6.0;
64const CLOSE_PAD:    f64 = 10.0;
65/// Horizontal distance from the right edge to the maximize button centre.
66/// = CLOSE_PAD + CLOSE_R*2 + 4 px gap
67const MAX_PAD:      f64 = CLOSE_PAD + CLOSE_R * 2.0 + 4.0; // 26 px
68const RESIZE_EDGE:  f64 = 6.0;   // px from the edge that counts as a resize zone
69const MIN_W:        f64 = 120.0;
70const MIN_H:        f64 = 80.0;
71const DBL_CLICK_MS: u128 = 500;  // double-click detection window
72
73// ── Resize direction ───────────────────────────────────────────────────────────
74
75/// Which edge(s) are being dragged during a resize operation.
76#[derive(Clone, Copy, Debug, PartialEq)]
77enum ResizeDir {
78    N, NE, E, SE, S, SW, W, NW,
79}
80
81// ── Window state ───────────────────────────────────────────────────────────────
82
83/// Interaction mode for the current drag.
84#[derive(Clone, Copy, Debug, PartialEq)]
85enum DragMode {
86    None,
87    Move,
88    Resize(ResizeDir),
89}
90
91/// A floating panel with a draggable/resizable title bar and a single content child.
92pub struct Window {
93    bounds: Rect,
94    children: Vec<Box<dyn Widget>>, // always exactly 1: the content
95    base: WidgetBase,
96
97    font_size: f64,
98
99    visible: bool,
100    visible_cell: Option<Rc<Cell<bool>>>,
101    reset_to: Option<Rc<Cell<Option<Rect>>>>,
102    position_cell: Option<Rc<Cell<Rect>>>,
103
104    /// Snapshot of `is_visible()` from the previous `layout()` call.  Used
105    /// to detect the false→true transition (demo toggled on in the
106    /// sidebar) so we can request the parent `Stack` raise us to the top.
107    last_visible: Cell<bool>,
108    /// Set to `true` on a visibility rising edge; read + cleared by
109    /// `take_raise_request` on the next parent-layout pass.
110    raise_request: Cell<bool>,
111
112    collapsed: bool,
113    /// Height before collapsing, so we can restore it.
114    pre_collapse_h: f64,
115
116    drag_mode: DragMode,
117    /// Cursor world position when drag started.
118    drag_start_world: Point,
119    /// Window bounds when drag started.
120    drag_start_bounds: Rect,
121
122    close_hovered: bool,
123    on_close: Option<Box<dyn FnMut()>>,
124
125    /// Whether the window is currently maximized (fills the full canvas).
126    maximized: bool,
127    /// Bounds saved before maximizing so we can restore them.
128    pre_maximize_bounds: Rect,
129    maximize_hovered: bool,
130
131    /// Which resize edge/corner the cursor is currently hovering over.
132    /// Cleared to None when the cursor moves into the interior.
133    hover_dir: Option<ResizeDir>,
134
135    /// Time of last left-click in the title bar — for double-click collapse.
136    last_title_click: Option<Instant>,
137
138    /// Title-bar sub-widget — owns the bar fill, separator, chevron,
139    /// title label, maximize/close buttons.  Painted manually from
140    /// `paint()` so `clip_children_rect` can keep content clipped to the
141    /// body area.  Display state is written into `title_state` every
142    /// layout pass; the sub-widget reads it at paint time.
143    title_bar:   WindowTitleBar,
144    title_state: Rc<RefCell<TitleBarView>>,
145
146    /// Canvas size supplied by the last `layout()` call; used for clamping.
147    canvas_size: Size,
148    /// When true, the window is kept fully inside the canvas bounds during drag/resize.
149    constrain: bool,
150
151    /// When true, the window bounds adopt the content's preferred size each
152    /// layout pass (width + height).  Keeps the title-bar top edge pinned so
153    /// the window appears to grow/shrink downward.  User resize is disabled
154    /// while auto-size is active (dragging still works).
155    auto_size: bool,
156
157    /// Window title string — stored so external callers (z-order
158    /// persistence, inspector display, etc.) can identify this window
159    /// without going through the inner `title_bar` sub-widget.
160    title: String,
161    /// Optional callback invoked whenever this window requests a raise
162    /// (click-to-front or visibility rising-edge from the sidebar).
163    /// Receives the window title.  Used by the demo's z-order tracker
164    /// to record "most recently raised" so the stacking order survives
165    /// a save/restore round-trip.
166    on_raised: Option<Box<dyn FnMut(&str)>>,
167}
168
169impl Window {
170    /// Create a new window with the given title, font, and content widget.
171    ///
172    /// Default position: `(60, 60)` with `size = (360, 280)`. Call
173    /// [`with_bounds`] to override.
174    pub fn new(title: impl Into<String>, font: Arc<Font>, content: Box<dyn Widget>) -> Self {
175        let font_size = 13.0;
176        let title_str: String = title.into();
177        let title_state = Rc::new(RefCell::new(TitleBarView::default_visuals()));
178        let title_bar = WindowTitleBar::new(&title_str, Arc::clone(&font), Rc::clone(&title_state));
179        Self {
180            bounds: Rect::new(60.0, 60.0, 360.0, 280.0),
181            children: vec![content],
182            base: WidgetBase::new(),
183            font_size,
184            visible: true,
185            visible_cell: None,
186            reset_to: None,
187            position_cell: None,
188            // Seed `last_visible` to `true` (matches `visible` above) so a
189            // window that's open on first frame doesn't spuriously request
190            // a raise before the user has interacted with it.
191            last_visible: Cell::new(true),
192            raise_request: Cell::new(false),
193            collapsed: false,
194            pre_collapse_h: 280.0,
195            drag_mode: DragMode::None,
196            drag_start_world: Point::ORIGIN,
197            drag_start_bounds: Rect::default(),
198            close_hovered: false,
199            on_close: None,
200            maximized: false,
201            pre_maximize_bounds: Rect::new(60.0, 60.0, 360.0, 280.0),
202            maximize_hovered: false,
203            hover_dir: None,
204            last_title_click: None,
205            title_bar,
206            title_state,
207            // Seed as "unknown" so `layout()`'s shrink-detect guard
208            // (`had_prior = prev.w > 0 && prev.h > 0`) correctly skips the
209            // clamp on the very first layout pass.  The old default
210            // `(1280, 720)` was treated as prior, so the first-frame
211            // transition from 1280×720 → <smaller> incorrectly looked like
212            // an OS-window shrink and pulled saved Y-up positions down into
213            // the transient canvas.  Real-value `canvas_size` is populated
214            // by `layout()` before any drag/resize/collapse hit-test runs.
215            canvas_size: Size::new(0.0, 0.0),
216            constrain: true,
217            auto_size: false,
218            title: title_str,
219            on_raised: None,
220        }
221    }
222
223    /// Returns the window title as it was passed to [`Window::new`].
224    pub fn title(&self) -> &str { &self.title }
225
226    /// Register a callback fired whenever this window requests a raise
227    /// (click-to-front or visibility rising-edge from the sidebar).
228    /// Receives the window title.  The demo uses this to feed a shared
229    /// z-order tracker that gets persisted to disk.
230    pub fn on_raised(mut self, cb: impl FnMut(&str) + 'static) -> Self {
231        self.on_raised = Some(Box::new(cb));
232        self
233    }
234
235    pub fn with_bounds(mut self, b: Rect) -> Self {
236        self.pre_collapse_h = b.height;
237        self.bounds = b;
238        self
239    }
240    pub fn with_font_size(mut self, size: f64) -> Self { self.font_size = size; self }
241
242    pub fn with_visible_cell(mut self, cell: Rc<Cell<bool>>) -> Self {
243        self.visible_cell = Some(cell);
244        self
245    }
246
247    pub fn with_reset_cell(mut self, cell: Rc<Cell<Option<Rect>>>) -> Self {
248        self.reset_to = Some(cell);
249        self
250    }
251
252    pub fn with_position_cell(mut self, cell: Rc<Cell<Rect>>) -> Self {
253        self.position_cell = Some(cell);
254        self
255    }
256
257    pub fn with_margin(mut self, m: Insets)    -> Self { self.base.margin   = m; self }
258    pub fn with_h_anchor(mut self, h: HAnchor) -> Self { self.base.h_anchor = h; self }
259    pub fn with_v_anchor(mut self, v: VAnchor) -> Self { self.base.v_anchor = v; self }
260    pub fn with_min_size(mut self, s: Size)    -> Self { self.base.min_size = s; self }
261    pub fn with_max_size(mut self, s: Size)    -> Self { self.base.max_size = s; self }
262
263    pub fn with_constrain(mut self, constrain: bool) -> Self { self.constrain = constrain; self }
264
265    /// Make the window size itself to the content's preferred size every frame.
266    /// Top-left pin: as content grows/shrinks, the title bar stays where it is.
267    pub fn with_auto_size(mut self, auto: bool) -> Self { self.auto_size = auto; self }
268
269    pub fn on_close(mut self, cb: impl FnMut() + 'static) -> Self {
270        self.on_close = Some(Box::new(cb));
271        self
272    }
273
274    fn clamp_to_canvas(&mut self) {
275        if !self.constrain { return; }
276        let cw = self.canvas_size.width;
277        let ch = self.canvas_size.height;
278        // **Policy: keep the TITLE BAR grabbable**, not the whole window.
279        // Horizontally we keep at least `MIN_H_VISIBLE` pixels of the title
280        // bar inside the canvas so the user can always drag the window back
281        // on-screen.  Vertically (Y-up) we keep the FULL title bar inside
282        // the canvas — the body may extend above/below, but the drag handle
283        // is always fully reachable.  This matches how native OS window
284        // managers constrain child windows against their host monitor.
285        const MIN_H_VISIBLE: f64 = 40.0;
286
287        let min_x = MIN_H_VISIBLE - self.bounds.width;
288        let max_x = (cw - MIN_H_VISIBLE).max(min_x);
289        self.bounds.x = self.bounds.x.clamp(min_x, max_x).round();
290
291        // Title bar Y range in parent coords: [bounds.y + h - TITLE_H, bounds.y + h].
292        // Full title bar visible → `bounds.y >= TITLE_H - h` AND `bounds.y <= ch - h`.
293        // `bounds.height` collapses to `TITLE_H` when the user folds the
294        // window, so the collapsed case naturally falls out of the same math.
295        let min_y = TITLE_H - self.bounds.height;
296        let max_y = (ch - self.bounds.height).max(min_y);
297        self.bounds.y = self.bounds.y.clamp(min_y, max_y).round();
298    }
299
300    pub fn show(&mut self) { self.visible = true; }
301    pub fn hide(&mut self) { self.visible = false; }
302    pub fn toggle(&mut self) { self.visible = !self.visible; }
303    /// Current visibility — honours an optional shared `visible_cell` when
304    /// wired (sidebar toggles, programmatic show/hide).  The inherent
305    /// `self.visible` field is a fallback for windows that aren't wired to
306    /// a cell.  Must match the Widget-trait impl below so rising-edge
307    /// detection in `layout()` observes sidebar toggles.
308    pub fn is_visible(&self) -> bool {
309        if let Some(ref cell) = self.visible_cell { cell.get() } else { self.visible }
310    }
311
312    fn title_bar_bottom(&self) -> f64 { self.bounds.height - TITLE_H }
313
314    fn in_title_bar(&self, local: Point) -> bool {
315        local.y >= self.title_bar_bottom() && local.y <= self.bounds.height
316            && local.x >= 0.0 && local.x <= self.bounds.width
317    }
318
319    fn close_center(&self) -> Point {
320        Point::new(
321            self.bounds.width - CLOSE_PAD,
322            self.bounds.height - TITLE_H * 0.5,
323        )
324    }
325
326    fn in_close_button(&self, local: Point) -> bool {
327        let c = self.close_center();
328        let dx = local.x - c.x;
329        let dy = local.y - c.y;
330        dx * dx + dy * dy <= (CLOSE_R + 3.0) * (CLOSE_R + 3.0)
331    }
332
333    fn maximize_center(&self) -> Point {
334        Point::new(
335            self.bounds.width - MAX_PAD,
336            self.bounds.height - TITLE_H * 0.5,
337        )
338    }
339
340    fn in_maximize_button(&self, local: Point) -> bool {
341        let c = self.maximize_center();
342        let dx = local.x - c.x;
343        let dy = local.y - c.y;
344        dx * dx + dy * dy <= (CLOSE_R + 3.0) * (CLOSE_R + 3.0)
345    }
346
347    /// Hit-box for the collapse / expand chevron on the LEFT of the title bar.
348    /// Kept in sync with the paint geometry in
349    /// `WindowTitleBar::paint` (chevron at `x = 12`, half-size 4).  A padded
350    /// square around that point gives users a click target big enough to
351    /// hit without pixel precision.
352    fn in_chevron_button(&self, local: Point) -> bool {
353        let cx = 12.0;
354        let cy = self.bounds.height - TITLE_H * 0.5;
355        let half = 8.0;
356        local.x >= cx - half && local.x <= cx + half
357            && local.y >= cy - half && local.y <= cy + half
358    }
359
360    /// Toggle collapsed <-> expanded, keeping the top edge of the window
361    /// fixed in place.  Factored out of the event path so both the chevron
362    /// click and any future keyboard shortcut go through the same math.
363    fn toggle_collapse(&mut self) {
364        let top = self.bounds.y + self.bounds.height;
365        if self.collapsed {
366            self.bounds.height = self.pre_collapse_h;
367            self.bounds.y = (top - self.pre_collapse_h).round();
368            self.collapsed = false;
369        } else {
370            self.pre_collapse_h = self.bounds.height;
371            self.bounds.height = TITLE_H;
372            self.bounds.y = (top - TITLE_H).round();
373            self.collapsed = true;
374        }
375        self.clamp_to_canvas();
376    }
377
378    fn toggle_maximize(&mut self) {
379        if self.maximized {
380            self.bounds = self.pre_maximize_bounds;
381            self.maximized = false;
382        } else {
383            self.pre_maximize_bounds = self.bounds;
384            self.bounds = snap(Rect::new(
385                0.0, 0.0,
386                self.canvas_size.width,
387                self.canvas_size.height,
388            ));
389            self.maximized = true;
390        }
391    }
392
393    // ── Resize zone detection ──────────────────────────────────────────────────
394
395    /// Return the resize direction for `local`, or `None` if the point is in
396    /// the interior (or the window is collapsed).
397    fn resize_dir(&self, local: Point) -> Option<ResizeDir> {
398        if self.collapsed || self.auto_size { return None; }
399        let w = self.bounds.width;
400        let h = self.bounds.height;
401        let x = local.x;
402        let y = local.y;
403
404        // Outside the window altogether.
405        if x < 0.0 || x > w || y < 0.0 || y > h { return None; }
406
407        let on_n = y > h - RESIZE_EDGE;
408        let on_s = y < RESIZE_EDGE;
409        let on_w = x < RESIZE_EDGE;
410        let on_e = x > w - RESIZE_EDGE;
411
412        match (on_n, on_e, on_s, on_w) {
413            (true,  true,  _,     _    ) => Some(ResizeDir::NE),
414            (true,  _,     _,     true ) => Some(ResizeDir::NW),
415            (_,     _,     true,  true ) => Some(ResizeDir::SW),
416            (_,     true,  true,  _    ) => Some(ResizeDir::SE),
417            (true,  _,     _,     _    ) => Some(ResizeDir::N),
418            (_,     true,  _,     _    ) => Some(ResizeDir::E),
419            (_,     _,     true,  _    ) => Some(ResizeDir::S),
420            (_,     _,     _,     true ) => Some(ResizeDir::W),
421            _                            => None,
422        }
423    }
424
425    /// Apply a mouse-world-space delta to bounds according to the resize direction.
426    fn apply_resize(&mut self, world_pos: Point) {
427        let dx = world_pos.x - self.drag_start_world.x;
428        let dy = world_pos.y - self.drag_start_world.y;
429        let sb = self.drag_start_bounds;
430
431        let (mut x, mut y, mut w, mut h) = (sb.x, sb.y, sb.width, sb.height);
432
433        if let DragMode::Resize(dir) = self.drag_mode {
434            match dir {
435                ResizeDir::N  => { h = (sb.height + dy).max(MIN_H); }
436                ResizeDir::S  => { y = sb.y + dy; h = (sb.height - dy).max(MIN_H); if h == MIN_H { y = sb.y + sb.height - MIN_H; } }
437                ResizeDir::E  => { w = (sb.width  + dx).max(MIN_W); }
438                ResizeDir::W  => { x = sb.x + dx; w = (sb.width  - dx).max(MIN_W); if w == MIN_W { x = sb.x + sb.width - MIN_W; } }
439                ResizeDir::NE => { w = (sb.width  + dx).max(MIN_W); h = (sb.height + dy).max(MIN_H); }
440                ResizeDir::NW => { x = sb.x + dx; w = (sb.width  - dx).max(MIN_W); if w == MIN_W { x = sb.x + sb.width - MIN_W; } h = (sb.height + dy).max(MIN_H); }
441                ResizeDir::SE => { w = (sb.width  + dx).max(MIN_W); y = sb.y + dy; h = (sb.height - dy).max(MIN_H); if h == MIN_H { y = sb.y + sb.height - MIN_H; } }
442                ResizeDir::SW => { x = sb.x + dx; w = (sb.width  - dx).max(MIN_W); if w == MIN_W { x = sb.x + sb.width - MIN_W; } y = sb.y + dy; h = (sb.height - dy).max(MIN_H); if h == MIN_H { y = sb.y + sb.height - MIN_H; } }
443            }
444        }
445
446        self.bounds = snap(Rect::new(x, y, w, h));
447        self.clamp_to_canvas();
448    }
449}
450
451/// Map a resize direction to the appropriate OS cursor icon.
452fn resize_cursor(dir: ResizeDir) -> CursorIcon {
453    match dir {
454        ResizeDir::N  => CursorIcon::ResizeNorth,
455        ResizeDir::S  => CursorIcon::ResizeSouth,
456        ResizeDir::E  => CursorIcon::ResizeEast,
457        ResizeDir::W  => CursorIcon::ResizeWest,
458        ResizeDir::NE => CursorIcon::ResizeNorthEast,
459        ResizeDir::NW => CursorIcon::ResizeNorthWest,
460        ResizeDir::SE => CursorIcon::ResizeSouthEast,
461        ResizeDir::SW => CursorIcon::ResizeSouthWest,
462    }
463}
464
465impl Widget for Window {
466    fn type_name(&self) -> &'static str { "Window" }
467    /// External identity for z-order persistence, inspector lookup, etc.
468    fn id(&self) -> Option<&str> { Some(&self.title) }
469
470    fn is_visible(&self) -> bool {
471        if let Some(ref cell) = self.visible_cell { cell.get() } else { self.visible }
472    }
473
474    /// A collapsed window paints only its title bar — nothing inside the
475    /// content area is visible, so no child can legitimately request a
476    /// repaint.  Closing (`is_visible` false) also short-circuits, matching
477    /// the default trait impl.  Without these overrides a cursor blink or
478    /// hover tween inside a collapsed/closed window would keep the host
479    /// loop awake despite being invisible.
480    fn needs_paint(&self) -> bool {
481        if !self.is_visible() || self.collapsed { return false; }
482        self.children().iter().any(|c| c.needs_paint())
483    }
484
485    fn next_paint_deadline(&self) -> Option<web_time::Instant> {
486        if !self.is_visible() || self.collapsed { return None; }
487        let mut best: Option<web_time::Instant> = None;
488        for c in self.children() {
489            if let Some(t) = c.next_paint_deadline() {
490                best = Some(match best { Some(b) if b <= t => b, _ => t });
491            }
492        }
493        best
494    }
495
496    fn bounds(&self) -> Rect { self.bounds }
497
498    fn margin(&self)   -> Insets  { self.base.margin }
499    fn h_anchor(&self) -> HAnchor { self.base.h_anchor }
500    fn v_anchor(&self) -> VAnchor { self.base.v_anchor }
501    fn min_size(&self) -> Size    { self.base.min_size }
502    fn max_size(&self) -> Size    { self.base.max_size }
503
504    /// Pop this window to the top of the parent `Stack` when the
505    /// false→true visibility edge fires (see `layout`).
506    fn take_raise_request(&mut self) -> bool {
507        let pending = self.raise_request.get();
508        self.raise_request.set(false);
509        pending
510    }
511
512    fn set_bounds(&mut self, b: Rect) {
513        if let Some(ref cell) = self.reset_to {
514            if let Some(new_b) = cell.get() {
515                self.bounds = new_b;
516                self.pre_collapse_h = new_b.height;
517                self.collapsed = false;
518                cell.set(None);
519                return;
520            }
521        }
522        if self.bounds.width == 0.0 || self.bounds.height == 0.0 {
523            self.bounds = b;
524            self.pre_collapse_h = b.height;
525        }
526    }
527
528    fn children(&self) -> &[Box<dyn Widget>] { &self.children }
529    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
530
531    /// Clip child painting to the content area (below the title bar).
532    /// When collapsed bounds.height == TITLE_H so the content rect has zero height,
533    /// preventing any child from drawing outside the visible title-bar strip.
534    fn clip_children_rect(&self) -> Option<(f64, f64, f64, f64)> {
535        if !self.is_visible() { return None; }
536        let w = self.bounds.width;
537        let content_h = (self.bounds.height - TITLE_H).max(0.0);
538        // Clip to content area: y=0 (bottom) up to content_h, full width.
539        Some((0.0, 0.0, w, content_h))
540    }
541
542    fn hit_test(&self, local_pos: Point) -> bool {
543        if !self.is_visible() { return false; }
544        if self.drag_mode != DragMode::None { return true; }
545        let b = self.bounds();
546        local_pos.x >= 0.0 && local_pos.x <= b.width
547            && local_pos.y >= 0.0 && local_pos.y <= b.height
548    }
549
550    fn layout(&mut self, available: Size) -> Size {
551        // Rising-edge visibility detection → request parent raise.  The
552        // sidebar toggles `visible_cell`; we observe the transition here
553        // and set `raise_request`, which the parent `Stack` drains on its
554        // next layout (one-frame delay, invisible to the user).
555        let now_visible = self.is_visible();
556        if now_visible && !self.last_visible.get() {
557            self.raise_request.set(true);
558            if let Some(cb) = self.on_raised.as_mut() { cb(&self.title); }
559            // Un-maximize on reopen.  Clicking a sidebar checkbox is "open
560            // this window for use" — the user expects the window to come
561            // up at its normal size, not still stretched to fill the canvas
562            // from the last session's maximise.  Restore `pre_maximize_bounds`
563            // which `toggle_maximize` saved when the user maximised.
564            if self.maximized {
565                self.bounds    = self.pre_maximize_bounds;
566                self.maximized = false;
567            }
568        }
569        self.last_visible.set(now_visible);
570
571        if !now_visible {
572            return Size::new(self.bounds.width, self.bounds.height);
573        }
574
575        // Auto-size: measure the child's preferred size, then adopt it as the
576        // new window size (pinning the top edge — Y-up → adjust `bounds.y` so
577        // the title bar stays put when the height changes).  Skip while
578        // collapsed: the user toggled a fixed TITLE_H height.
579        //
580        // We cap the measurement request by `child.max_size()` when finite
581        // (otherwise by the canvas size): flex containers return their given
582        // `available.width` rather than an intrinsic natural width, so without
583        // a cap we'd produce an infinite/canvas-wide window.  Callers wanting
584        // a content-fitted window set `with_max_size(Size::new(w, f64::MAX))`
585        // on their root widget.
586        if self.auto_size && !self.collapsed && !self.maximized {
587            if let Some(child) = self.children.first_mut() {
588                let max_sz = child.max_size();
589                let cap_w  = if max_sz.width.is_finite()  { max_sz.width  }
590                             else                        { available.width.max(MIN_W)  };
591                let cap_h  = if max_sz.height.is_finite() { max_sz.height }
592                             else                        { available.height.max(MIN_H) };
593                let pref   = child.layout(Size::new(cap_w, cap_h));
594                let new_w  = pref.width.min(cap_w).max(MIN_W);
595                let new_h  = (pref.height + TITLE_H).min(cap_h + TITLE_H).max(MIN_H);
596                let top    = self.bounds.y + self.bounds.height;
597                self.bounds.width   = new_w;
598                self.bounds.height  = new_h;
599                self.bounds.y       = top - new_h;
600                self.pre_collapse_h = new_h;
601            }
602        }
603
604        // When collapsed, bounds.height == TITLE_H (set during toggle).
605        let content_h = (self.bounds.height - TITLE_H).max(0.0);
606
607        if let Some(child) = self.children.first_mut() {
608            if !self.collapsed {
609                child.layout(Size::new(self.bounds.width, content_h));
610                child.set_bounds(Rect::new(0.0, 0.0, self.bounds.width, content_h));
611            }
612            // When collapsed the child keeps its last bounds but is not visible
613            // because hit_test returns false for the content area.
614        }
615
616        // Position the title-bar strip at the top of the window and
617        // give it a layout pass so the title label knows its size.
618        let tb_y = self.bounds.height - TITLE_H;
619        self.title_bar.set_bounds(Rect::new(0.0, tb_y, self.bounds.width, TITLE_H));
620        self.title_bar.layout(Size::new(self.bounds.width, TITLE_H));
621
622        // Record the canvas size — used by drag / resize / collapse clamp
623        // paths that fire on USER ACTION.  We deliberately do NOT clamp
624        // passively at layout time: platforms fire a Resized event with a
625        // transient smaller size during fullscreen/maximize EXIT (Windows
626        // notably), and if we clamped on shrink the auto-save would persist
627        // those transient clamped bounds — the "all windows pushed down to
628        // the same Y on next startup" bug.  Clamping only on user actions
629        // (dragging a window, resize-handle, collapse toggle) keeps saved
630        // state pinned to what the user actually chose.
631        //
632        // If a later OS shrink genuinely leaves a window's title bar out of
633        // reach, the user can drag it back, use "Organize windows" to
634        // retile, or a dedicated "reset positions" command.
635        self.canvas_size = available;
636        if let Some(ref cell) = self.position_cell {
637            // When maximised, persist the UNDERLYING pre-maximise bounds,
638            // not the stretched-to-canvas ones.  Maximise is an interaction
639            // state, not a saved size: we want cold reloads to come up at
640            // the user's last chosen "real" size, then let them re-maximise
641            // if they want.  Matches native window-manager behaviour.
642            let save_bounds = if self.maximized {
643                self.pre_maximize_bounds
644            } else {
645                self.bounds
646            };
647            cell.set(save_bounds);
648        }
649
650        Size::new(self.bounds.width, self.bounds.height)
651    }
652
653    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
654        if !self.is_visible() { return; }
655
656        let v  = ctx.visuals();
657        let w  = self.bounds.width;
658        // bounds.height == TITLE_H when collapsed (adjusted on toggle).
659        let h  = self.bounds.height;
660
661        // Drop shadow — stacked rounded rects approximating a Gaussian blur.
662        // Outer layers inflate outward and fade with a (1−t)² falloff; drawn
663        // outside-in so the denser core overlays the softer halo.
664        let base = v.window_shadow;
665        for i in (0..SHADOW_STEPS).rev() {
666            let t     = i as f64 / SHADOW_STEPS as f64;
667            let infl  = t * SHADOW_BLUR;
668            let falloff = (1.0 - t).powi(2) as f32;
669            let alpha = base.a * falloff / SHADOW_STEPS as f32 * 6.0;
670            ctx.set_fill_color(Color::rgba(base.r, base.g, base.b, alpha));
671            ctx.begin_path();
672            ctx.rounded_rect(
673                SHADOW_DX - infl,
674                -SHADOW_DY - infl,
675                w + 2.0 * infl,
676                h + 2.0 * infl,
677                CORNER_R + infl,
678            );
679            ctx.fill();
680        }
681
682        // Window body.
683        ctx.set_fill_color(v.window_fill);
684        ctx.begin_path();
685        ctx.rounded_rect(0.0, 0.0, w, h, CORNER_R);
686        ctx.fill();
687
688        // Sync the title-bar sub-widget's display state for this frame
689        // and paint it.  Positioning was done in `layout`; we just need
690        // to hand it the per-frame interaction snapshot and dispatch
691        // through `paint_subtree` so the ancestor-chain stack gets the
692        // WindowTitleBar entry (background_color = window_title_fill).
693        {
694            let mut st = self.title_state.borrow_mut();
695            st.bar_color = if self.drag_mode == DragMode::Move {
696                v.window_title_fill_drag
697            } else {
698                v.window_title_fill
699            };
700            st.title_color      = v.window_title_text;
701            st.collapsed        = self.collapsed;
702            st.maximized        = self.maximized;
703            st.close_hovered    = self.close_hovered;
704            st.maximize_hovered = self.maximize_hovered;
705        }
706        let tb_bounds = self.title_bar.bounds();
707        ctx.save();
708        ctx.translate(tb_bounds.x, tb_bounds.y);
709        paint_subtree(&mut self.title_bar, ctx);
710        ctx.restore();
711
712        // Outer border — on top of the title bar so the rounded corners
713        // cleanly frame both body and title region.
714        ctx.set_fill_color(v.window_fill); // restore default fill — stroke follows
715        ctx.set_stroke_color(v.window_stroke);
716        ctx.set_line_width(1.0);
717        ctx.begin_path();
718        ctx.rounded_rect(0.0, 0.0, w, h, CORNER_R);
719        ctx.stroke();
720
721    }
722
723    // paint_overlay: draws the resize handle dots + edge highlights on top of content.
724    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
725        if !self.is_visible() || self.collapsed { return; }
726        let v = ctx.visuals();
727        let w = self.bounds.width;
728        let h = self.bounds.height;
729
730        // ── SE corner drag grip (3 diagonal lines, egui-style) ───────────────
731        // Highlight when SE is hovered or actively being dragged.
732        let is_se_active = matches!(self.drag_mode, DragMode::Resize(ResizeDir::SE));
733        let is_se_hover  = self.hover_dir == Some(ResizeDir::SE);
734        let grip_color = if is_se_active {
735            v.window_resize_active
736        } else if is_se_hover {
737            v.window_resize_hover
738        } else {
739            v.window_stroke
740        };
741        ctx.set_stroke_color(grip_color);
742        ctx.set_line_width(1.5);
743        let m = 3.0_f64; // margin from corner edge
744        for i in 1..=3_i32 {
745            let off = i as f64 * 4.0 + m;
746            ctx.begin_path();
747            ctx.move_to(w - off, m);
748            ctx.line_to(w - m, off);
749            ctx.stroke();
750        }
751
752        // ── Resize edge / corner highlight ────────────────────────────────────
753        // Determine the highlighted direction and whether it is actively dragging.
754        let (highlight, is_active) = match self.drag_mode {
755            DragMode::Resize(d) => (Some(d), true),
756            DragMode::Move      => (None, false), // no edge highlight while moving
757            DragMode::None      => (self.hover_dir, false),
758        };
759        let dir = match highlight { Some(d) => d, None => return };
760
761        let color = if is_active { v.window_resize_active } else { v.window_resize_hover };
762        ctx.set_stroke_color(color);
763        ctx.set_line_width(2.0);
764
765        // Which edges to highlight (derived from direction).
766        let (top, bottom, left, right) = match dir {
767            ResizeDir::N  => (true,  false, false, false),
768            ResizeDir::S  => (false, true,  false, false),
769            ResizeDir::E  => (false, false, false, true),
770            ResizeDir::W  => (false, false, true,  false),
771            ResizeDir::NE => (true,  false, false, true),
772            ResizeDir::NW => (true,  false, true,  false),
773            ResizeDir::SE => (false, true,  false, true),
774            ResizeDir::SW => (false, true,  true,  false),
775        };
776
777        // Segments run between the rounded-corner tangent points.
778        let cr = CORNER_R;
779        if top {
780            ctx.begin_path();
781            ctx.move_to(cr, h);
782            ctx.line_to(w - cr, h);
783            ctx.stroke();
784        }
785        if bottom {
786            ctx.begin_path();
787            ctx.move_to(cr, 0.0);
788            ctx.line_to(w - cr, 0.0);
789            ctx.stroke();
790        }
791        if left {
792            ctx.begin_path();
793            ctx.move_to(0.0, cr);
794            ctx.line_to(0.0, h - cr);
795            ctx.stroke();
796        }
797        if right {
798            ctx.begin_path();
799            ctx.move_to(w, cr);
800            ctx.line_to(w, h - cr);
801            ctx.stroke();
802        }
803    }
804
805    fn on_event(&mut self, event: &Event) -> EventResult {
806        if !self.is_visible() { return EventResult::Ignored; }
807
808        match event {
809            Event::MouseMove { pos } => {
810                let was_close = self.close_hovered;
811                let was_max   = self.maximize_hovered;
812                let was_dir   = self.hover_dir;
813                self.close_hovered    = self.in_close_button(*pos);
814                self.maximize_hovered = self.in_maximize_button(*pos);
815
816                match self.drag_mode {
817                    DragMode::Move => {
818                        let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
819                        let dx = world.x - self.drag_start_world.x;
820                        let dy = world.y - self.drag_start_world.y;
821                        self.bounds.x = (self.drag_start_bounds.x + dx).round();
822                        self.bounds.y = (self.drag_start_bounds.y + dy).round();
823                        self.clamp_to_canvas();
824                        self.hover_dir = None;
825                        set_cursor_icon(CursorIcon::Grabbing);
826                        crate::animation::request_tick();
827                        return EventResult::Consumed;
828                    }
829                    DragMode::Resize(dir) => {
830                        let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
831                        self.apply_resize(world);
832                        set_cursor_icon(resize_cursor(dir));
833                        crate::animation::request_tick();
834                        return EventResult::Consumed;
835                    }
836                    DragMode::None => {
837                        // Track which edge/corner the cursor is hovering over so
838                        // paint_overlay can draw the appropriate highlight.
839                        self.hover_dir = self.resize_dir(*pos);
840                        if let Some(dir) = self.hover_dir {
841                            set_cursor_icon(resize_cursor(dir));
842                        }
843                    }
844                }
845                if was_close != self.close_hovered
846                    || was_max != self.maximize_hovered
847                    || was_dir != self.hover_dir
848                {
849                    crate::animation::request_tick();
850                }
851                EventResult::Ignored
852            }
853
854            Event::MouseDown { button: MouseButton::Left, pos, .. } => {
855                // Click-to-raise — any left click that reaches this Window
856                // (hit-test routed it here in reverse paint order, so we
857                // ARE the topmost widget under the cursor in the stack
858                // sense) requests a raise.  Classic window-manager
859                // behaviour: clicking anywhere on a window pops it to the
860                // top of the z-order.  Consumed by `Stack::layout` on the
861                // next frame via `take_raise_request`; one-frame visual
862                // delay is invisible in practice.
863                self.raise_request.set(true);
864                // Z-order changes are visible; repaint.
865                crate::animation::request_tick();
866                if let Some(cb) = self.on_raised.as_mut() { cb(&self.title); }
867
868                // Close button — highest priority.
869                if self.in_close_button(*pos) {
870                    self.visible = false;
871                    if let Some(ref cell) = self.visible_cell { cell.set(false); }
872                    if let Some(cb) = self.on_close.as_mut() { cb(); }
873                    crate::animation::request_tick();
874                    return EventResult::Consumed;
875                }
876
877                // Maximize / Restore button.
878                if self.in_maximize_button(*pos) {
879                    self.toggle_maximize();
880                    crate::animation::request_tick();
881                    return EventResult::Consumed;
882                }
883
884                // Collapse / expand chevron.
885                if self.in_chevron_button(*pos) {
886                    self.toggle_collapse();
887                    // Null out the double-click timer so clicking the
888                    // chevron then quickly clicking the bar doesn't
889                    // trigger a maximize toggle.
890                    self.last_title_click = None;
891                    crate::animation::request_tick();
892                    return EventResult::Consumed;
893                }
894
895                // Resize edge — check before title bar to handle corner overlap.
896                if let Some(dir) = self.resize_dir(*pos) {
897                    // Only start resize if not in the close button area and not a pure title bar drag.
898                    // The N edge overlaps the title bar — prefer resize over drag from the top N px.
899                    let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
900                    self.drag_mode = DragMode::Resize(dir);
901                    self.drag_start_world  = world;
902                    self.drag_start_bounds = self.bounds;
903                    return EventResult::Consumed;
904                }
905
906                // Title bar drag + double-click maximize.
907                if self.in_title_bar(*pos) {
908                    // Double-click detection.
909                    let now = Instant::now();
910                    let is_double = self.last_title_click
911                        .map(|t| now.duration_since(t).as_millis() < DBL_CLICK_MS)
912                        .unwrap_or(false);
913
914                    if is_double {
915                        // Windows convention: double-click title bar toggles
916                        // maximize / restore.  Collapse/expand lives on the
917                        // chevron button to the left.
918                        self.toggle_maximize();
919                        self.last_title_click = None;
920                        crate::animation::request_tick();
921                    } else {
922                        self.last_title_click = Some(now);
923                        let world = Point::new(pos.x + self.bounds.x, pos.y + self.bounds.y);
924                        self.drag_mode = DragMode::Move;
925                        self.drag_start_world  = world;
926                        self.drag_start_bounds = self.bounds;
927                    }
928                    return EventResult::Consumed;
929                }
930
931                // Click on content area: consume so it doesn't fall through.
932                if !self.collapsed {
933                    EventResult::Consumed
934                } else {
935                    EventResult::Ignored
936                }
937            }
938
939            Event::MouseUp { button: MouseButton::Left, .. } => {
940                let was_dragging = self.drag_mode != DragMode::None;
941                self.drag_mode = DragMode::None;
942                if was_dragging {
943                    crate::animation::request_tick();
944                    EventResult::Consumed
945                } else {
946                    EventResult::Ignored
947                }
948            }
949
950            _ => EventResult::Ignored,
951        }
952    }
953}