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::{set_cursor_icon, CursorIcon};
39use crate::draw_ctx::DrawCtx;
40use crate::event::{Event, EventResult, MouseButton};
41use crate::geometry::{Point, Rect, Size};
42use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
43use crate::text::Font;
44use crate::widget::{paint_subtree, BackbufferKind, BackbufferSpec, BackbufferState, Widget};
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 VISIBILITY_FADE_SECS: f64 = 0.18;
64const CLOSE_R: f64 = 6.0;
65const CLOSE_PAD: f64 = 10.0;
66/// Horizontal distance from the right edge to the maximize button centre.
67/// = CLOSE_PAD + CLOSE_R*2 + 4 px gap
68const MAX_PAD: f64 = CLOSE_PAD + CLOSE_R * 2.0 + 4.0; // 26 px
69const RESIZE_EDGE: f64 = 6.0; // px from the edge that counts as a resize zone
70const MIN_W: f64 = 120.0;
71const MIN_H: f64 = 80.0;
72const DBL_CLICK_MS: u128 = 500; // double-click detection window
73
74// ── Resize direction ───────────────────────────────────────────────────────────
75
76/// Which edge(s) are being dragged during a resize operation.
77#[derive(Clone, Copy, Debug, PartialEq)]
78enum ResizeDir {
79    N,
80    NE,
81    E,
82    SE,
83    S,
84    SW,
85    W,
86    NW,
87}
88
89// ── Window state ───────────────────────────────────────────────────────────────
90
91/// Interaction mode for the current drag.
92#[derive(Clone, Copy, Debug, PartialEq)]
93enum DragMode {
94    None,
95    Move,
96    Resize(ResizeDir),
97}
98
99/// A floating panel with a draggable/resizable title bar and a single content child.
100pub struct Window {
101    bounds: Rect,
102    children: Vec<Box<dyn Widget>>, // always exactly 1: the content
103    base: WidgetBase,
104
105    font_size: f64,
106
107    visible: bool,
108    visible_cell: Option<Rc<Cell<bool>>>,
109    visibility_anim: crate::animation::Tween,
110    fade_out_active: Cell<bool>,
111    backbuffer: BackbufferState,
112    use_gl_backbuffer: bool,
113    reset_to: Option<Rc<Cell<Option<Rect>>>>,
114    position_cell: Option<Rc<Cell<Rect>>>,
115    maximized_cell: Option<Rc<Cell<bool>>>,
116
117    /// Snapshot of `is_visible()` from the previous `layout()` call.  Used
118    /// to detect the false→true transition (demo toggled on in the
119    /// sidebar) so we can request the parent `Stack` raise us to the top.
120    last_visible: Cell<bool>,
121    /// Set to `true` on a visibility rising edge; read + cleared by
122    /// `take_raise_request` on the next parent-layout pass.
123    raise_request: Cell<bool>,
124
125    collapsed: bool,
126    /// Height before collapsing, so we can restore it.
127    pre_collapse_h: f64,
128
129    drag_mode: DragMode,
130    /// Cursor world position when drag started.
131    drag_start_world: Point,
132    /// Window bounds when drag started.
133    drag_start_bounds: Rect,
134
135    close_hovered: bool,
136    on_close: Option<Box<dyn FnMut()>>,
137
138    /// Whether the window is currently maximized (fills the full canvas).
139    maximized: bool,
140    /// Bounds saved before maximizing so we can restore them.
141    pre_maximize_bounds: Rect,
142    maximize_hovered: bool,
143
144    /// Which resize edge/corner the cursor is currently hovering over.
145    /// Cleared to None when the cursor moves into the interior.
146    hover_dir: Option<ResizeDir>,
147
148    /// Time of last left-click in the title bar — for double-click collapse.
149    last_title_click: Option<Instant>,
150
151    /// Title-bar sub-widget — owns the bar fill, separator, chevron,
152    /// title label, maximize/close buttons.  Painted manually from
153    /// `paint()` so `clip_children_rect` can keep content clipped to the
154    /// body area.  Display state is written into `title_state` every
155    /// layout pass; the sub-widget reads it at paint time.
156    title_bar: WindowTitleBar,
157    title_state: Rc<RefCell<TitleBarView>>,
158
159    /// Canvas size supplied by the last `layout()` call; used for clamping.
160    canvas_size: Size,
161    /// When true, the window is kept fully inside the canvas bounds during drag/resize.
162    constrain: bool,
163
164    /// When true, the window bounds adopt the content's preferred size each
165    /// layout pass (width + height).  Keeps the title-bar top edge pinned so
166    /// the window appears to grow/shrink downward.  User resize is disabled
167    /// while auto-size is active (dragging still works).
168    auto_size: bool,
169
170    /// Whether the user can resize the window by dragging its edges.  When
171    /// `false`, no resize handles are active regardless of `resizable_h` /
172    /// `resizable_v` — matches egui's `.resizable(false)`.  Defaults to
173    /// `true` to preserve existing behaviour for call sites that don't
174    /// explicitly opt out.
175    resizable: bool,
176    /// Fine-grained axis control.  Both default to `true`; setting just
177    /// one to `false` produces an egui `.resizable([true, false])`-style
178    /// uni-axis resizable window.  Only consulted when `resizable` is
179    /// `true`.
180    resizable_h: bool,
181    resizable_v: bool,
182    /// Content-bound resize floor + ceiling.  When `true`, the
183    /// window's height is locked to its content's required height
184    /// each layout (snap pre-pass) AND `apply_resize` refuses to
185    /// drag it smaller than content.  Matches egui's no-scroll-no-
186    /// clip-no-whitespace W4 contract.  Off by default.
187    tight_content_fit: bool,
188    /// Floor-only variant of [`tight_content_fit`].  Same minimum-
189    /// height enforcement, but allows the user to grow the window
190    /// past the content (whitespace below).  Used by W5 where a
191    /// `TextArea` flex-fills extra space and the user can pull the
192    /// window taller than the wrapped text.  Off by default.
193    floor_content_height: bool,
194    /// Most recently observed content required height (via
195    /// `Widget::measure_min_height`).  Updated each layout pass so
196    /// `apply_resize` and the tight-fit pre-pass see a current value
197    /// even when the content tree contains a flex-fill widget.
198    last_content_natural_h: Cell<f64>,
199    /// True between `paint()` and `finish_paint()` when GL compositing opened
200    /// a foreground layer for body/title/children. The shadow stays outside.
201    foreground_layer_active: Cell<bool>,
202
203    /// Window title string — stored so external callers (z-order
204    /// persistence, inspector display, etc.) can identify this window
205    /// without going through the inner `title_bar` sub-widget.
206    title: String,
207    /// Optional callback invoked whenever this window requests a raise
208    /// (click-to-front or visibility rising-edge from the sidebar).
209    /// Receives the window title.  Used by the demo's z-order tracker
210    /// to record "most recently raised" so the stacking order survives
211    /// a save/restore round-trip.
212    on_raised: Option<Box<dyn FnMut(&str)>>,
213}
214
215impl Window {
216    /// Create a new window with the given title, font, and content widget.
217    ///
218    /// Default position: `(60, 60)` with `size = (360, 280)`. Call
219    /// [`with_bounds`] to override.
220    pub fn new(title: impl Into<String>, font: Arc<Font>, content: Box<dyn Widget>) -> Self {
221        let font_size = 13.0;
222        let title_str: String = title.into();
223        let title_state = Rc::new(RefCell::new(TitleBarView::default_visuals()));
224        let title_bar = WindowTitleBar::new(&title_str, Arc::clone(&font), Rc::clone(&title_state));
225        Self {
226            bounds: Rect::new(60.0, 60.0, 360.0, 280.0),
227            children: vec![content],
228            base: WidgetBase::new(),
229            font_size,
230            visible: true,
231            visible_cell: None,
232            visibility_anim: crate::animation::Tween::new(1.0, VISIBILITY_FADE_SECS),
233            fade_out_active: Cell::new(false),
234            backbuffer: BackbufferState::new(),
235            use_gl_backbuffer: true,
236            reset_to: None,
237            position_cell: None,
238            maximized_cell: None,
239            // Seed `last_visible` to `true` (matches `visible` above) so a
240            // window that's open on first frame doesn't spuriously request
241            // a raise before the user has interacted with it.
242            last_visible: Cell::new(true),
243            raise_request: Cell::new(false),
244            collapsed: false,
245            pre_collapse_h: 280.0,
246            drag_mode: DragMode::None,
247            drag_start_world: Point::ORIGIN,
248            drag_start_bounds: Rect::default(),
249            close_hovered: false,
250            on_close: None,
251            maximized: false,
252            pre_maximize_bounds: Rect::new(60.0, 60.0, 360.0, 280.0),
253            maximize_hovered: false,
254            hover_dir: None,
255            last_title_click: None,
256            title_bar,
257            title_state,
258            // Seed as "unknown" so `layout()`'s shrink-detect guard
259            // (`had_prior = prev.w > 0 && prev.h > 0`) correctly skips the
260            // clamp on the very first layout pass.  The old default
261            // `(1280, 720)` was treated as prior, so the first-frame
262            // transition from 1280×720 → <smaller> incorrectly looked like
263            // an OS-window shrink and pulled saved Y-up positions down into
264            // the transient canvas.  Real-value `canvas_size` is populated
265            // by `layout()` before any drag/resize/collapse hit-test runs.
266            canvas_size: Size::new(0.0, 0.0),
267            constrain: true,
268            auto_size: false,
269            resizable: true,
270            resizable_h: true,
271            resizable_v: true,
272            tight_content_fit: false,
273            floor_content_height: false,
274            last_content_natural_h: Cell::new(0.0),
275            foreground_layer_active: Cell::new(false),
276            title: title_str,
277            on_raised: None,
278        }
279    }
280
281    /// Returns the window title as it was passed to [`Window::new`].
282    pub fn title(&self) -> &str {
283        &self.title
284    }
285
286    /// Register a callback fired whenever this window requests a raise
287    /// (click-to-front or visibility rising-edge from the sidebar).
288    /// Receives the window title.  The demo uses this to feed a shared
289    /// z-order tracker that gets persisted to disk.
290    pub fn on_raised(mut self, cb: impl FnMut(&str) + 'static) -> Self {
291        self.on_raised = Some(Box::new(cb));
292        self
293    }
294
295    pub fn with_bounds(mut self, b: Rect) -> Self {
296        self.pre_collapse_h = b.height;
297        self.bounds = b;
298        if self.maximized {
299            self.pre_maximize_bounds = b;
300        }
301        self
302    }
303    pub fn with_font_size(mut self, size: f64) -> Self {
304        self.font_size = size;
305        self
306    }
307
308    pub fn with_visible_cell(mut self, cell: Rc<Cell<bool>>) -> Self {
309        let visible = cell.get();
310        self.last_visible.set(visible);
311        self.fade_out_active.set(false);
312        self.visibility_anim =
313            crate::animation::Tween::new(if visible { 1.0 } else { 0.0 }, VISIBILITY_FADE_SECS);
314        self.visible_cell = Some(cell);
315        self
316    }
317
318    pub fn with_reset_cell(mut self, cell: Rc<Cell<Option<Rect>>>) -> Self {
319        self.reset_to = Some(cell);
320        self
321    }
322
323    pub fn with_position_cell(mut self, cell: Rc<Cell<Rect>>) -> Self {
324        self.position_cell = Some(cell);
325        self
326    }
327
328    /// Wire the window's canvas-maximized state into external persistence.
329    ///
330    /// Call after [`with_bounds`] when restoring saved state so the current
331    /// bounds become the pre-maximize bounds used by the first layout pass.
332    pub fn with_maximized_cell(mut self, cell: Rc<Cell<bool>>) -> Self {
333        self.maximized = cell.get();
334        if self.maximized {
335            self.pre_maximize_bounds = self.bounds;
336        }
337        self.maximized_cell = Some(cell);
338        self
339    }
340
341    pub fn with_margin(mut self, m: Insets) -> Self {
342        self.base.margin = m;
343        self
344    }
345    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
346        self.base.h_anchor = h;
347        self
348    }
349    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
350        self.base.v_anchor = v;
351        self
352    }
353    pub fn with_min_size(mut self, s: Size) -> Self {
354        self.base.min_size = s;
355        self
356    }
357    pub fn with_max_size(mut self, s: Size) -> Self {
358        self.base.max_size = s;
359        self
360    }
361
362    pub fn with_constrain(mut self, constrain: bool) -> Self {
363        self.constrain = constrain;
364        self
365    }
366
367    /// Opt this window in/out of the generic retained GL-FBO backbuffer.
368    /// Disabling renders directly into the inherited parent target.
369    pub fn with_gl_backbuffer(mut self, enabled: bool) -> Self {
370        self.use_gl_backbuffer = enabled;
371        self.backbuffer.invalidate();
372        self
373    }
374
375    /// Make the window size itself to the content's preferred size every frame.
376    /// Top-left pin: as content grows/shrinks, the title bar stays where it is.
377    pub fn with_auto_size(mut self, auto: bool) -> Self {
378        self.auto_size = auto;
379        self
380    }
381
382    /// Toggle user-dragged resize.  `false` hides every edge/corner handle
383    /// and disables resize hit-tests.  Default: `true`.  Matches egui's
384    /// `Window::resizable(bool)`.
385    pub fn with_resizable(mut self, on: bool) -> Self {
386        self.resizable = on;
387        self
388    }
389
390    /// Fine-grained axis-locking of the resize handles — pass `(true, false)`
391    /// for a horizontally-only resizable window, etc.  Implies
392    /// `with_resizable(true)`.  Matches egui's `Window::resizable([h, v])`.
393    pub fn with_resizable_axes(mut self, h: bool, v: bool) -> Self {
394        self.resizable = h || v;
395        self.resizable_h = h;
396        self.resizable_v = v;
397        self
398    }
399
400    /// Lock the window's height to its content's required height.
401    /// The user can grab a vertical resize handle but the next
402    /// layout snaps back — egui's W4 "no scroll, no clip, no
403    /// whitespace" contract.  Requires the content tree to expose
404    /// its required height via [`Widget::measure_min_height`]; our
405    /// `FlexColumn`, `Label`, `TextArea`, and `Container::with_fit_height`
406    /// all do.
407    pub fn with_tight_content_fit(mut self, on: bool) -> Self {
408        self.tight_content_fit = on;
409        self
410    }
411
412    /// Floor-only variant of [`with_tight_content_fit`]: refuses to
413    /// shrink past content but allows the user to pull the window
414    /// taller (whitespace below).  Used for windows whose content
415    /// includes a flex-fill child like a multiline `TextArea` —
416    /// matches egui's W5 where the TextEdit fills extra height and
417    /// the user can grow the window further.
418    pub fn with_height_floor_to_content(mut self, on: bool) -> Self {
419        self.floor_content_height = on;
420        self
421    }
422
423    /// Wrap the window's content in a built-in vertical [`ScrollView`].
424    /// Matches egui's `Window::vscroll(true)`: lets the user shrink the
425    /// window below content height without the caller having to wrap the
426    /// content in a `ScrollView` manually.  Eager — happens at builder
427    /// time so the rest of the layout / event / paint paths see a single
428    /// child as usual.  Has no effect when called with `false` (matches
429    /// the default).
430    ///
431    /// Don't combine with [`with_auto_size`]: the ScrollView claims its
432    /// full available height, which would make auto-sizing grow the
433    /// window to the canvas.  egui's demo never combines the two flags
434    /// either.
435    pub fn with_vscroll(mut self, vscroll: bool) -> Self {
436        if vscroll {
437            if let Some(content) = self.children.pop() {
438                let scroll = crate::widgets::ScrollView::new(content)
439                    .vertical(true)
440                    .horizontal(false);
441                self.children.push(Box::new(scroll));
442            }
443        }
444        self
445    }
446
447    pub fn on_close(mut self, cb: impl FnMut() + 'static) -> Self {
448        self.on_close = Some(Box::new(cb));
449        self
450    }
451
452    fn requested_visible(&self) -> bool {
453        if let Some(ref cell) = self.visible_cell {
454            cell.get()
455        } else {
456            self.visible
457        }
458    }
459
460    fn layer_outsets() -> (f64, f64, f64, f64) {
461        let left = (SHADOW_BLUR - SHADOW_DX).max(0.0).ceil();
462        let bottom = (SHADOW_BLUR + SHADOW_DY).ceil();
463        let right = (SHADOW_BLUR + SHADOW_DX).ceil();
464        let top = (SHADOW_BLUR - SHADOW_DY).max(0.0).ceil();
465        (left, bottom, right, top)
466    }
467
468    fn clamp_to_canvas(&mut self) {
469        if !self.constrain {
470            return;
471        }
472        let cw = self.canvas_size.width;
473        let ch = self.canvas_size.height;
474        // **Policy: keep the TITLE BAR grabbable**, not the whole window.
475        // Horizontally we keep at least `MIN_H_VISIBLE` pixels of the title
476        // bar inside the canvas so the user can always drag the window back
477        // on-screen.  Vertically (Y-up) we keep the FULL title bar inside
478        // the canvas — the body may extend above/below, but the drag handle
479        // is always fully reachable.  This matches how native OS window
480        // managers constrain child windows against their host monitor.
481        const MIN_H_VISIBLE: f64 = 40.0;
482
483        let min_x = MIN_H_VISIBLE - self.bounds.width;
484        let max_x = (cw - MIN_H_VISIBLE).max(min_x);
485        self.bounds.x = self.bounds.x.clamp(min_x, max_x).round();
486
487        // Title bar Y range in parent coords: [bounds.y + h - TITLE_H, bounds.y + h].
488        // Full title bar visible → `bounds.y >= TITLE_H - h` AND `bounds.y <= ch - h`.
489        // `bounds.height` collapses to `TITLE_H` when the user folds the
490        // window, so the collapsed case naturally falls out of the same math.
491        let min_y = TITLE_H - self.bounds.height;
492        let max_y = (ch - self.bounds.height).max(min_y);
493        self.bounds.y = self.bounds.y.clamp(min_y, max_y).round();
494    }
495
496    fn fit_fully_to_canvas(&mut self, available: Size) {
497        if !self.constrain || available.width <= 1.0 || available.height <= 1.0 {
498            return;
499        }
500        let max_w = available.width.max(MIN_W);
501        let max_h = available.height.max(TITLE_H);
502        self.bounds.width = self.bounds.width.clamp(MIN_W.min(max_w), max_w).round();
503        self.bounds.height = self.bounds.height.clamp(TITLE_H, max_h).round();
504        self.bounds.x = self
505            .bounds
506            .x
507            .clamp(0.0, (available.width - self.bounds.width).max(0.0))
508            .round();
509        self.bounds.y = self
510            .bounds
511            .y
512            .clamp(0.0, (available.height - self.bounds.height).max(0.0))
513            .round();
514        self.pre_collapse_h = self.bounds.height;
515        if self.maximized {
516            self.pre_maximize_bounds = self.bounds;
517        }
518    }
519
520    pub fn show(&mut self) {
521        self.visible = true;
522        self.fade_out_active.set(false);
523        self.visibility_anim.set_target(1.0);
524        crate::animation::request_draw();
525    }
526    pub fn hide(&mut self) {
527        self.visible = false;
528        self.visibility_anim.set_target(0.0);
529        crate::animation::request_draw();
530    }
531    pub fn toggle(&mut self) {
532        if self.visible {
533            self.hide();
534        } else {
535            self.show();
536        }
537    }
538    /// Current visibility — honours an optional shared `visible_cell` when
539    /// wired (sidebar toggles, programmatic show/hide).  The inherent
540    /// `self.visible` field is a fallback for windows that aren't wired to
541    /// a cell.  Must match the Widget-trait impl below so rising-edge
542    /// detection in `layout()` observes sidebar toggles.
543    pub fn is_visible(&self) -> bool {
544        self.requested_visible() || self.fade_out_active.get()
545    }
546
547    fn title_bar_bottom(&self) -> f64 {
548        self.bounds.height - TITLE_H
549    }
550
551    fn in_title_bar(&self, local: Point) -> bool {
552        local.y >= self.title_bar_bottom()
553            && local.y <= self.bounds.height
554            && local.x >= 0.0
555            && local.x <= self.bounds.width
556    }
557
558    fn close_center(&self) -> Point {
559        Point::new(
560            self.bounds.width - CLOSE_PAD,
561            self.bounds.height - TITLE_H * 0.5,
562        )
563    }
564
565    fn in_close_button(&self, local: Point) -> bool {
566        let c = self.close_center();
567        let dx = local.x - c.x;
568        let dy = local.y - c.y;
569        dx * dx + dy * dy <= (CLOSE_R + 3.0) * (CLOSE_R + 3.0)
570    }
571
572    fn maximize_center(&self) -> Point {
573        Point::new(
574            self.bounds.width - MAX_PAD,
575            self.bounds.height - TITLE_H * 0.5,
576        )
577    }
578
579    fn in_maximize_button(&self, local: Point) -> bool {
580        let c = self.maximize_center();
581        let dx = local.x - c.x;
582        let dy = local.y - c.y;
583        dx * dx + dy * dy <= (CLOSE_R + 3.0) * (CLOSE_R + 3.0)
584    }
585
586    /// Hit-box for the collapse / expand chevron on the LEFT of the title bar.
587    /// Kept in sync with the paint geometry in
588    /// `WindowTitleBar::paint` (chevron at `x = 12`, half-size 4).  A padded
589    /// square around that point gives users a click target big enough to
590    /// hit without pixel precision.
591    fn in_chevron_button(&self, local: Point) -> bool {
592        let cx = 12.0;
593        let cy = self.bounds.height - TITLE_H * 0.5;
594        let half = 8.0;
595        local.x >= cx - half && local.x <= cx + half && local.y >= cy - half && local.y <= cy + half
596    }
597
598    /// Toggle collapsed <-> expanded, keeping the top edge of the window
599    /// fixed in place.  Factored out of the event path so both the chevron
600    /// click and any future keyboard shortcut go through the same math.
601    fn toggle_collapse(&mut self) {
602        let top = self.bounds.y + self.bounds.height;
603        if self.collapsed {
604            self.bounds.height = self.pre_collapse_h;
605            self.bounds.y = (top - self.pre_collapse_h).round();
606            self.collapsed = false;
607        } else {
608            self.pre_collapse_h = self.bounds.height;
609            self.bounds.height = TITLE_H;
610            self.bounds.y = (top - TITLE_H).round();
611            self.collapsed = true;
612        }
613        self.clamp_to_canvas();
614    }
615
616    fn toggle_maximize(&mut self) {
617        if self.maximized {
618            self.bounds = self.pre_maximize_bounds;
619            self.maximized = false;
620        } else {
621            self.pre_maximize_bounds = self.bounds;
622            self.bounds = snap(Rect::new(
623                0.0,
624                0.0,
625                self.canvas_size.width,
626                self.canvas_size.height,
627            ));
628            self.maximized = true;
629        }
630        if let Some(ref cell) = self.maximized_cell {
631            cell.set(self.maximized);
632        }
633    }
634
635    // ── Resize zone detection ──────────────────────────────────────────────────
636
637    /// Return the resize direction for `local`, or `None` if the point is in
638    /// the interior (or the window is collapsed).
639    fn resize_dir(&self, local: Point) -> Option<ResizeDir> {
640        if self.collapsed || self.auto_size {
641            return None;
642        }
643        if !self.resizable {
644            return None;
645        }
646        let w = self.bounds.width;
647        let h = self.bounds.height;
648        let x = local.x;
649        let y = local.y;
650
651        // Outside the window altogether.
652        if x < 0.0 || x > w || y < 0.0 || y > h {
653            return None;
654        }
655
656        // Mask each edge to the axes the window is allowed to resize on.
657        let on_n = self.resizable_v && y > h - RESIZE_EDGE;
658        let on_s = self.resizable_v && y < RESIZE_EDGE;
659        let on_w = self.resizable_h && x < RESIZE_EDGE;
660        let on_e = self.resizable_h && x > w - RESIZE_EDGE;
661
662        match (on_n, on_e, on_s, on_w) {
663            (true, true, _, _) => Some(ResizeDir::NE),
664            (true, _, _, true) => Some(ResizeDir::NW),
665            (_, _, true, true) => Some(ResizeDir::SW),
666            (_, true, true, _) => Some(ResizeDir::SE),
667            (true, _, _, _) => Some(ResizeDir::N),
668            (_, true, _, _) => Some(ResizeDir::E),
669            (_, _, true, _) => Some(ResizeDir::S),
670            (_, _, _, true) => Some(ResizeDir::W),
671            _ => None,
672        }
673    }
674
675    /// Effective minimum height for this resize pass.  Honours
676    /// either `tight_content_fit` (lock + floor) or
677    /// `floor_content_height` (floor only) so a window whose content
678    /// has a natural height > MIN_H can never be dragged smaller
679    /// than its content.
680    fn effective_min_h(&self) -> f64 {
681        if self.tight_content_fit || self.floor_content_height {
682            let content_min = self.last_content_natural_h.get() + TITLE_H;
683            MIN_H.max(content_min)
684        } else {
685            MIN_H
686        }
687    }
688
689    /// Apply a mouse-world-space delta to bounds according to the resize direction.
690    fn apply_resize(&mut self, world_pos: Point) {
691        let dx = world_pos.x - self.drag_start_world.x;
692        let dy = world_pos.y - self.drag_start_world.y;
693        let sb = self.drag_start_bounds;
694        let min_h = self.effective_min_h();
695
696        let (mut x, mut y, mut w, mut h) = (sb.x, sb.y, sb.width, sb.height);
697
698        if let DragMode::Resize(dir) = self.drag_mode {
699            match dir {
700                ResizeDir::N => {
701                    h = (sb.height + dy).max(min_h);
702                }
703                ResizeDir::S => {
704                    y = sb.y + dy;
705                    h = (sb.height - dy).max(min_h);
706                    if h == min_h {
707                        y = sb.y + sb.height - min_h;
708                    }
709                }
710                ResizeDir::E => {
711                    w = (sb.width + dx).max(MIN_W);
712                }
713                ResizeDir::W => {
714                    x = sb.x + dx;
715                    w = (sb.width - dx).max(MIN_W);
716                    if w == MIN_W {
717                        x = sb.x + sb.width - MIN_W;
718                    }
719                }
720                ResizeDir::NE => {
721                    w = (sb.width + dx).max(MIN_W);
722                    h = (sb.height + dy).max(min_h);
723                }
724                ResizeDir::NW => {
725                    x = sb.x + dx;
726                    w = (sb.width - dx).max(MIN_W);
727                    if w == MIN_W {
728                        x = sb.x + sb.width - MIN_W;
729                    }
730                    h = (sb.height + dy).max(min_h);
731                }
732                ResizeDir::SE => {
733                    w = (sb.width + dx).max(MIN_W);
734                    y = sb.y + dy;
735                    h = (sb.height - dy).max(min_h);
736                    if h == min_h {
737                        y = sb.y + sb.height - min_h;
738                    }
739                }
740                ResizeDir::SW => {
741                    x = sb.x + dx;
742                    w = (sb.width - dx).max(MIN_W);
743                    if w == MIN_W {
744                        x = sb.x + sb.width - MIN_W;
745                    }
746                    y = sb.y + dy;
747                    h = (sb.height - dy).max(min_h);
748                    if h == min_h {
749                        y = sb.y + sb.height - min_h;
750                    }
751                }
752            }
753        }
754
755        self.bounds = snap(Rect::new(x, y, w, h));
756        self.clamp_to_canvas();
757    }
758}
759
760/// Map a resize direction to the appropriate OS cursor icon.
761fn resize_cursor(dir: ResizeDir) -> CursorIcon {
762    match dir {
763        ResizeDir::N => CursorIcon::ResizeNorth,
764        ResizeDir::S => CursorIcon::ResizeSouth,
765        ResizeDir::E => CursorIcon::ResizeEast,
766        ResizeDir::W => CursorIcon::ResizeWest,
767        ResizeDir::NE => CursorIcon::ResizeNorthEast,
768        ResizeDir::NW => CursorIcon::ResizeNorthWest,
769        ResizeDir::SE => CursorIcon::ResizeSouthEast,
770        ResizeDir::SW => CursorIcon::ResizeSouthWest,
771    }
772}
773
774mod widget_impl;