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