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//! # ⚠ Backbuffer caching gotcha — read before adding a custom Window
26//!
27//! `Window` retains its painted pixels in a GL FBO (or CPU bitmap) and only
28//! re-rasterises on widget setter mutations (`Label::set_text`, hover changes,
29//! etc.).  Custom paint code that reads from an `Rc<RefCell<…>>` model the
30//! framework can't observe — telemetry graphs, sensor streams, simulation
31//! views — will blit stale pixels forever unless you tell the window to
32//! invalidate.  Two ways:
33//!
34//! - `.with_live_content(true)` — Window self-invalidates every frame
35//!   (auto-skipped when collapsed or hidden).  Use for streaming data.
36//! - [`Window::invalidate_backbuffer`] — manual flag from the data-arrival
37//!   path.  Use when invalidation is sparse and you want frame-skip when
38//!   nothing changed.
39//!
40//! See [`Window::new`] for the full discussion.
41//!
42//! # Coordinate notes (Y-up)
43//!
44//! `bounds` stores the window's position in its **parent's** coordinate space.
45//! The title bar is at the **top** of the window, i.e. local Y ∈
46//! `[height − TITLE_H .. height]`. The content area fills local Y ∈ `[0 .. height − TITLE_H]`.
47
48use std::cell::{Cell, RefCell};
49use std::rc::Rc;
50use std::sync::Arc;
51
52use web_time::Instant;
53
54use crate::cursor::{set_cursor_icon, CursorIcon};
55use crate::draw_ctx::DrawCtx;
56use crate::event::{Event, EventResult, Key, MouseButton};
57use crate::geometry::{Point, Rect, Size};
58use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
59use crate::text::Font;
60use crate::widget::{BackbufferKind, BackbufferSpec, BackbufferState, Widget};
61use crate::widgets::window_title_bar::{TitleBarView, WindowTitleBar};
62
63/// Round all four components of a Rect to the nearest integer so widgets
64/// are always placed on exact pixel boundaries (crisp bitmap blits, no blur).
65fn snap(r: Rect) -> Rect {
66    Rect::new(r.x.round(), r.y.round(), r.width.round(), r.height.round())
67}
68
69const TITLE_H: f64 = 28.0;
70const CORNER_R: f64 = 8.0;
71const SHADOW_BLUR: f64 = 14.0;
72const SHADOW_DX: f64 = 2.0;
73const SHADOW_DY: f64 = 6.0;
74const VISIBILITY_FADE_SECS: f64 = 0.18;
75const CLOSE_R: f64 = 6.0;
76const CLOSE_PAD: f64 = 10.0;
77const MAX_PAD: f64 = CLOSE_PAD + CLOSE_R * 2.0 + 4.0; // 26 px
78const RESIZE_EDGE: f64 = 6.0; // px from the edge that counts as a resize zone
79const MIN_W: f64 = 120.0;
80const MIN_H: f64 = 80.0;
81const DBL_CLICK_MS: u128 = 500; // double-click detection window
82
83/// Which edge(s) are being dragged during a resize operation.
84#[derive(Clone, Copy, Debug, PartialEq)]
85pub(crate) enum ResizeDir {
86    N,
87    NE,
88    E,
89    SE,
90    S,
91    SW,
92    W,
93    NW,
94}
95
96/// Interaction mode for the current drag.
97#[derive(Clone, Copy, Debug, PartialEq)]
98enum DragMode {
99    None,
100    Move,
101    Resize(ResizeDir),
102}
103
104/// A floating panel with a draggable/resizable title bar and a single content child.
105pub struct Window {
106    bounds: Rect,
107    children: Vec<Box<dyn Widget>>, // always exactly 1: the content
108    base: WidgetBase,
109
110    font_size: f64,
111
112    visible: bool,
113    visible_cell: Option<Rc<Cell<bool>>>,
114    visibility_anim: crate::animation::Tween,
115    fade_out_active: Cell<bool>,
116    backbuffer: BackbufferState,
117    use_gl_backbuffer: bool,
118    reset_to: Option<Rc<Cell<Option<Rect>>>>,
119    position_cell: Option<Rc<Cell<Rect>>>,
120    maximized_cell: Option<Rc<Cell<bool>>>,
121    /// Runtime-driven flag cells (egui `Window::resizable/collapsible`,
122    /// plus our `auto_size` addition). Read each `layout()` so a demo's
123    /// checkboxes can steer the real host window. `None` means the flag is
124    /// fixed at its builder value.
125    resizable_cell: Option<Rc<Cell<bool>>>,
126    auto_size_cell: Option<Rc<Cell<bool>>>,
127    collapsible_cell: Option<Rc<Cell<bool>>>,
128    /// Live title text. Applied to the title-bar label only — the window's
129    /// identity (`self.title`, used as the persistence / z-order key) is
130    /// deliberately left untouched so retitling can't corrupt saved state.
131    title_cell: Option<Rc<RefCell<String>>>,
132    /// Last title string pushed into the title bar, so we only re-set (and
133    /// invalidate the label's glyph cache) when the text actually changes.
134    last_applied_title: RefCell<String>,
135
136    /// Snapshot of `is_visible()` from the previous `layout()` call.  Used
137    /// to detect the false→true transition (demo toggled on in the
138    /// sidebar) so we can request the parent `Stack` raise us to the top.
139    last_visible: Cell<bool>,
140    /// `true` until the first `layout()` runs.  A window restored as
141    /// already-visible (e.g. saved-state inspector open) misses the
142    /// rising-edge fit-to-canvas pass, so without this one-shot trigger
143    /// its persisted bounds can land outside a smaller live viewport
144    /// (mobile portrait, resized window, etc.) and the user sees the
145    /// sidebar toggle highlighted but no window.  Cleared after the
146    /// first layout completes.
147    needs_initial_fit: Cell<bool>,
148    /// Set to `true` on a visibility rising edge; read + cleared by
149    /// `take_raise_request` on the next parent-layout pass.
150    raise_request: Cell<bool>,
151
152    collapsed: bool,
153    /// Whether the collapse affordance is offered. Driven at runtime by
154    /// `collapsible_cell` when wired. Matches egui `Window::collapsible`.
155    collapsible: bool,
156    /// Height before collapsing, so we can restore it.
157    pre_collapse_h: f64,
158
159    drag_mode: DragMode,
160    /// Cursor world position when drag started.
161    drag_start_world: Point,
162    /// Window bounds when drag started.
163    drag_start_bounds: Rect,
164
165    close_hovered: bool,
166    on_close: Option<Box<dyn FnMut(CloseReason)>>,
167
168    /// What an outside pointer press does to this (modal) window. `None` swallows
169    /// it and stays open; `Close` dismisses via [`Window::close`] with
170    /// [`CloseReason::ClickAway`]. Only consulted while `modal` is set.
171    click_away: ClickAwayAction,
172
173    /// Whether the window is currently maximized (fills the full canvas).
174    maximized: bool,
175    /// Bounds saved before maximizing so we can restore them.
176    pre_maximize_bounds: Rect,
177    maximize_hovered: bool,
178
179    /// Which resize edge/corner the cursor is currently hovering over.
180    /// Cleared to None when the cursor moves into the interior.
181    hover_dir: Option<ResizeDir>,
182
183    /// Time of last left-click in the title bar — for double-click collapse.
184    last_title_click: Option<Instant>,
185
186    /// Title-bar sub-widget — owns the bar fill, separator, chevron,
187    /// title label, maximize/close buttons.  Painted manually from
188    /// `paint()` so `clip_children_rect` can keep content clipped to the
189    /// body area.  Display state is written into `title_state` every
190    /// layout pass; the sub-widget reads it at paint time.
191    title_bar: WindowTitleBar,
192    title_state: Rc<RefCell<TitleBarView>>,
193
194    /// Canvas size supplied by the last `layout()` call; used for clamping.
195    canvas_size: Size,
196    /// When true, the window is kept fully inside the canvas bounds during drag/resize.
197    constrain: bool,
198
199    /// When true, the window declares itself an app-modal layer while visible
200    /// (see [`Widget::has_active_modal`]).  The `App` then routes *all* pointer
201    /// and keyboard events into this window's subtree, so a floating dialog
202    /// (e.g. the colour-wheel picker) swallows every click over its bounds
203    /// instead of leaking them to widgets painted underneath.  Opt-in — plain
204    /// windows leave this `false` and hit-test normally.
205    modal: bool,
206
207    /// Chrome toggle. `true` (default) draws the full window frame — title
208    /// bar, close/maximize buttons, drop shadow, and border — and reserves
209    /// [`TITLE_H`] at the top for the bar.  `false` makes the window
210    /// *frameless*: no title bar, no buttons, no shadow, no border, and the
211    /// content child fills the whole bounds. Frameless windows keep the modal
212    /// event routing, focus grab, click-away, Escape, and `on_close`
213    /// machinery — they are the substrate for an in-place inline editor
214    /// (a chrome-less `TextField` positioned exactly over a value pill).
215    chrome: bool,
216
217    /// When true, the window bounds adopt the content's preferred size each
218    /// layout pass (width + height).  Keeps the title-bar top edge pinned so
219    /// the window appears to grow/shrink downward.  User resize is disabled
220    /// while auto-size is active (dragging still works).
221    auto_size: bool,
222
223    /// Whether the user can resize the window by dragging its edges.  When
224    /// `false`, no resize handles are active regardless of `resizable_h` /
225    /// `resizable_v` — matches egui's `.resizable(false)`.  Defaults to
226    /// `true` to preserve existing behaviour for call sites that don't
227    /// explicitly opt out.
228    resizable: bool,
229    /// Fine-grained axis control, used when `resizable` is `true`.
230    resizable_h: bool,
231    resizable_v: bool,
232    /// Content-bound resize floor + ceiling.  When `true`, the
233    /// window's height is locked to its content's required height
234    /// each layout (snap pre-pass) AND `apply_resize` refuses to
235    /// drag it smaller than content.  Matches egui's no-scroll-no-
236    /// clip-no-whitespace W4 contract.  Off by default.
237    tight_content_fit: bool,
238    /// Floor-only variant of [`tight_content_fit`].  Same minimum-
239    /// height enforcement, but allows the user to grow the window
240    /// past the content (whitespace below).  Used by W5 where a
241    /// `TextArea` flex-fills extra space and the user can pull the
242    /// window taller than the wrapped text.  Off by default.
243    floor_content_height: bool,
244    /// Most recently observed content required height (via
245    /// `Widget::measure_min_height`).  Updated each layout pass so
246    /// `apply_resize` and the tight-fit pre-pass see a current value
247    /// even when the content tree contains a flex-fill widget.
248    last_content_natural_h: Cell<f64>,
249    /// True between `paint()` and `finish_paint()` when GL compositing opened
250    /// a foreground layer for body/title/children. The shadow stays outside.
251    foreground_layer_active: Cell<bool>,
252
253    /// When `true`, the window's backbuffer is invalidated on every
254    /// frame the window is visible-and-expanded, forcing the content
255    /// widget's `paint()` to run fresh.  See [`with_live_content`] and
256    /// the constructor doc-comment for when to set this.
257    live_content: bool,
258
259    /// Window title string — stored so external callers (z-order
260    /// persistence, inspector display, etc.) can identify this window
261    /// without going through the inner `title_bar` sub-widget.
262    title: String,
263    /// Optional callback invoked whenever this window requests a raise
264    /// (click-to-front or visibility rising-edge from the sidebar).
265    /// Receives the window title.  Used by the demo's z-order tracker
266    /// to record "most recently raised" so the stacking order survives
267    /// a save/restore round-trip.
268    on_raised: Option<Box<dyn FnMut(&str)>>,
269
270    /// Identity for the snap-layout system.  Minted once at
271    /// construction from a process-wide counter and never changes —
272    /// `Snappable` uses it to skip self-matches in the snap engine's
273    /// target list.
274    snap_id: crate::snap::SnapId,
275
276    /// Canvas-absolute origin offset of the slot this window nests in —
277    /// i.e. `canvas_absolute_origin − bounds.origin`, in logical Y-up units.
278    /// Zero for a top-level window (bounds already canvas-absolute); non-zero
279    /// for a modal dialog nested in an overlay slot, where `bounds` are
280    /// slot-local.
281    ///
282    /// The snap engine and its registry both work in canvas-absolute space, but
283    /// `apply_move_snap` runs during `on_event` with no `DrawCtx` to derive the
284    /// ancestor offset. So we cache it here at modal-paint time (see
285    /// [`paint::clamp_modal_into_viewport`]) where the accumulated root
286    /// transform is available, and the snap path folds it into both the snap
287    /// candidate and the registered target rect. The cached value is at worst
288    /// one frame stale relative to the drag; the host slot rarely moves while
289    /// the user drags the dialog, so this is acceptable.
290    world_offset: Cell<(f64, f64)>,
291}
292
293impl Window {
294    /// Create a new window with the given title, font, and content widget.
295    ///
296    /// Default position: `(60, 60)` with `size = (360, 280)`. Call
297    /// [`with_bounds`] to override.
298    ///
299    /// Windows keep a retained backbuffer. Live content must either call
300    /// [`Window::invalidate_backbuffer`] when external data changes or use
301    /// [`Window::with_live_content`] to force repaint while visible.
302    pub fn new(title: impl Into<String>, font: Arc<Font>, content: Box<dyn Widget>) -> Self {
303        let font_size = 13.0;
304        let title_str: String = title.into();
305        let title_state = Rc::new(RefCell::new(TitleBarView::default_visuals()));
306        let title_bar = WindowTitleBar::new(&title_str, Arc::clone(&font), Rc::clone(&title_state));
307        Self {
308            bounds: Rect::new(60.0, 60.0, 360.0, 280.0),
309            children: vec![content],
310            base: WidgetBase::new(),
311            font_size,
312            visible: true,
313            visible_cell: None,
314            visibility_anim: crate::animation::Tween::new(1.0, VISIBILITY_FADE_SECS),
315            fade_out_active: Cell::new(false),
316            backbuffer: BackbufferState::new(),
317            use_gl_backbuffer: true,
318            reset_to: None,
319            position_cell: None,
320            maximized_cell: None,
321            resizable_cell: None,
322            auto_size_cell: None,
323            collapsible_cell: None,
324            title_cell: None,
325            last_applied_title: RefCell::new(title_str.clone()),
326            // Seed `last_visible` to `true` (matches `visible` above) so a
327            // window that's open on first frame doesn't spuriously request
328            // a raise before the user has interacted with it.
329            last_visible: Cell::new(true),
330            needs_initial_fit: Cell::new(true),
331            raise_request: Cell::new(false),
332            collapsed: false,
333            collapsible: true,
334            pre_collapse_h: 280.0,
335            drag_mode: DragMode::None,
336            drag_start_world: Point::ORIGIN,
337            drag_start_bounds: Rect::default(),
338            close_hovered: false,
339            on_close: None,
340            click_away: ClickAwayAction::None,
341            maximized: false,
342            pre_maximize_bounds: Rect::new(60.0, 60.0, 360.0, 280.0),
343            maximize_hovered: false,
344            hover_dir: None,
345            last_title_click: None,
346            title_bar,
347            title_state,
348            // Seed as "unknown" so `layout()`'s shrink-detect guard
349            // (`had_prior = prev.w > 0 && prev.h > 0`) correctly skips the
350            // clamp on the very first layout pass.  The old default
351            // `(1280, 720)` was treated as prior, so the first-frame
352            // transition from 1280×720 → <smaller> incorrectly looked like
353            // an OS-window shrink and pulled saved Y-up positions down into
354            // the transient canvas.  Real-value `canvas_size` is populated
355            // by `layout()` before any drag/resize/collapse hit-test runs.
356            canvas_size: Size::new(0.0, 0.0),
357            constrain: true,
358            modal: false,
359            chrome: true,
360            auto_size: false,
361            resizable: true,
362            resizable_h: true,
363            resizable_v: true,
364            tight_content_fit: false,
365            floor_content_height: false,
366            last_content_natural_h: Cell::new(0.0),
367            foreground_layer_active: Cell::new(false),
368            title: title_str,
369            on_raised: None,
370            live_content: false,
371            snap_id: crate::snap::next_snap_id(),
372            world_offset: Cell::new((0.0, 0.0)),
373        }
374    }
375
376    /// Returns the window title as it was passed to [`Window::new`].
377    pub fn title(&self) -> &str {
378        &self.title
379    }
380
381    /// Force the window's retained backbuffer to re-rasterise on the next
382    /// paint pass.  Use this when the content widget reads from a live
383    /// data source (network feed, animation curve, simulation state)
384    /// that the framework can't observe.  Otherwise the cached pixels
385    /// blit unchanged and your live data never reaches the screen.
386    ///
387    /// Pair with [`Window::with_live_content`] for streaming data that
388    /// changes every frame: that flag self-invalidates here automatically
389    /// (and skips when collapsed/hidden).
390    ///
391    /// See [`Window::new`] for the full discussion of when this matters
392    /// and the alternative ("compose live UI out of widgets that
393    /// invalidate on data change") that avoids needing to call this at
394    /// all.
395    pub fn invalidate_backbuffer(&mut self) {
396        self.backbuffer.invalidate();
397    }
398
399    fn requested_visible(&self) -> bool {
400        if let Some(ref cell) = self.visible_cell {
401            cell.get()
402        } else {
403            self.visible
404        }
405    }
406
407    fn layer_outsets() -> (f64, f64, f64, f64) {
408        let left = (SHADOW_BLUR - SHADOW_DX).max(0.0).ceil();
409        let bottom = (SHADOW_BLUR + SHADOW_DY).ceil();
410        let right = (SHADOW_BLUR + SHADOW_DX).ceil();
411        let top = (SHADOW_BLUR - SHADOW_DY).max(0.0).ceil();
412        (left, bottom, right, top)
413    }
414
415    fn clamp_to_canvas(&mut self) {
416        if !self.constrain {
417            return;
418        }
419        // A modal window is constrained to the whole app VIEWPORT rather than the
420        // (often tiny) overlay slot it nests in: `clamp_modal_into_viewport`
421        // pulls it fully on-screen during the clip-free global-overlay paint and
422        // folds any correction back into `bounds`, so a drag can travel across
423        // the entire app and the paint clamp keeps it visible. Applying the
424        // slot-based clamp here would re-cage it to the parent slot — the exact
425        // "locked to a dimension less than the app" bug we're fixing.
426        if self.modal {
427            return;
428        }
429        let cw = self.canvas_size.width;
430        let ch = self.canvas_size.height;
431        // **Policy: keep the TITLE BAR grabbable**, not the whole window.
432        // Horizontally we keep at least `MIN_H_VISIBLE` pixels of the title
433        // bar inside the canvas so the user can always drag the window back
434        // on-screen.  Vertically (Y-up) we keep the FULL title bar inside
435        // the canvas — the body may extend above/below, but the drag handle
436        // is always fully reachable.  This matches how native OS window
437        // managers constrain child windows against their host monitor.
438        const MIN_H_VISIBLE: f64 = 40.0;
439
440        let min_x = MIN_H_VISIBLE - self.bounds.width;
441        let max_x = (cw - MIN_H_VISIBLE).max(min_x);
442        self.bounds.x = self.bounds.x.clamp(min_x, max_x).round();
443
444        // Title bar Y range in parent coords: [bounds.y + h - TITLE_H, bounds.y + h].
445        // Full title bar visible → `bounds.y >= TITLE_H - h` AND `bounds.y <= ch - h`.
446        // `bounds.height` collapses to `TITLE_H` when the user folds the
447        // window, so the collapsed case naturally falls out of the same math.
448        let min_y = TITLE_H - self.bounds.height;
449        let max_y = (ch - self.bounds.height).max(min_y);
450        self.bounds.y = self.bounds.y.clamp(min_y, max_y).round();
451    }
452
453    fn fit_fully_to_canvas(&mut self, available: Size) {
454        if !self.constrain || available.width <= 1.0 || available.height <= 1.0 {
455            return;
456        }
457        let max_w = available.width.max(MIN_W);
458        let max_h = available.height.max(TITLE_H);
459        self.bounds.width = self.bounds.width.clamp(MIN_W.min(max_w), max_w).round();
460        self.bounds.height = self.bounds.height.clamp(TITLE_H, max_h).round();
461        self.bounds.x = self
462            .bounds
463            .x
464            .clamp(0.0, (available.width - self.bounds.width).max(0.0))
465            .round();
466        self.bounds.y = self
467            .bounds
468            .y
469            .clamp(0.0, (available.height - self.bounds.height).max(0.0))
470            .round();
471        self.pre_collapse_h = self.bounds.height;
472        if self.maximized {
473            self.pre_maximize_bounds = self.bounds;
474        }
475    }
476
477    /// Close the window: hide it, sync the optional `visible_cell`, and fire
478    /// `on_close`.  Shared by the title-bar × button, the Escape key (modal
479    /// windows), and any programmatic close so every route runs the same
480    /// teardown — critically the `on_close` hook, which a modal dialog uses to
481    /// unwind in-flight state (e.g. cancelling a live colour preview). Before
482    /// this was factored out, only the × button ran it, so an Escape/close of
483    /// the colour dialog left its preview session dangling.
484    ///
485    /// `reason` distinguishes the dismissal route (× button, Escape, click-away)
486    /// so `on_close` can react differently — the colour dialog commits a live
487    /// change on click-away but cancels it on Escape / ×.
488    fn close(&mut self, reason: CloseReason) {
489        self.visible = false;
490        self.visibility_anim.set_target(0.0);
491        if let Some(ref cell) = self.visible_cell {
492            cell.set(false);
493        }
494        if let Some(cb) = self.on_close.as_mut() {
495            cb(reason);
496        }
497        crate::animation::request_draw();
498    }
499
500    pub fn show(&mut self) {
501        self.visible = true;
502        self.fade_out_active.set(false);
503        self.visibility_anim.set_target(1.0);
504        crate::animation::request_draw();
505    }
506    pub fn hide(&mut self) {
507        self.visible = false;
508        self.visibility_anim.set_target(0.0);
509        crate::animation::request_draw();
510    }
511    pub fn toggle(&mut self) {
512        if self.visible {
513            self.hide();
514        } else {
515            self.show();
516        }
517    }
518    /// Current visibility — honours an optional shared `visible_cell` when
519    /// wired (sidebar toggles, programmatic show/hide).  The inherent
520    /// `self.visible` field is a fallback for windows that aren't wired to
521    /// a cell.  Must match the Widget-trait impl below so rising-edge
522    /// detection in `layout()` observes sidebar toggles.
523    pub fn is_visible(&self) -> bool {
524        self.requested_visible() || self.fade_out_active.get()
525    }
526
527    /// Vertical space reserved at the top of the window for the title bar.
528    /// Zero when frameless (`chrome == false`) so the content child fills the
529    /// whole bounds and no strip is stolen for a bar that isn't drawn.
530    fn chrome_h(&self) -> f64 {
531        if self.chrome {
532            TITLE_H
533        } else {
534            0.0
535        }
536    }
537
538    fn title_bar_bottom(&self) -> f64 {
539        self.bounds.height - TITLE_H
540    }
541
542    fn in_title_bar(&self, local: Point) -> bool {
543        // A frameless window has no title bar to grab — a press anywhere in
544        // its body must reach the content child (cursor placement in the
545        // inline editor), never start a window drag.
546        if !self.chrome {
547            return false;
548        }
549        local.y >= self.title_bar_bottom()
550            && local.y <= self.bounds.height
551            && local.x >= 0.0
552            && local.x <= self.bounds.width
553    }
554
555    fn close_center(&self) -> Point {
556        Point::new(
557            self.bounds.width - CLOSE_PAD,
558            self.bounds.height - TITLE_H * 0.5,
559        )
560    }
561
562    fn in_close_button(&self, local: Point) -> bool {
563        if !self.chrome {
564            return false;
565        }
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        if !self.chrome {
581            return false;
582        }
583        let c = self.maximize_center();
584        let dx = local.x - c.x;
585        let dy = local.y - c.y;
586        dx * dx + dy * dy <= (CLOSE_R + 3.0) * (CLOSE_R + 3.0)
587    }
588
589    /// Toggle collapsed <-> expanded, keeping the top edge of the window
590    /// fixed in place.  Factored out of the event path so both the chevron
591    /// click and any future keyboard shortcut go through the same math.
592    fn toggle_collapse(&mut self) {
593        let top = self.bounds.y + self.bounds.height;
594        if self.collapsed {
595            self.bounds.height = self.pre_collapse_h;
596            self.bounds.y = (top - self.pre_collapse_h).round();
597            self.collapsed = false;
598        } else {
599            self.pre_collapse_h = self.bounds.height;
600            self.bounds.height = TITLE_H;
601            self.bounds.y = (top - TITLE_H).round();
602            self.collapsed = true;
603        }
604        self.clamp_to_canvas();
605    }
606
607    fn toggle_maximize(&mut self) {
608        if self.maximized {
609            self.bounds = self.pre_maximize_bounds;
610            self.maximized = false;
611        } else {
612            self.pre_maximize_bounds = self.bounds;
613            self.bounds = snap(Rect::new(
614                0.0,
615                0.0,
616                self.canvas_size.width,
617                self.canvas_size.height,
618            ));
619            self.maximized = true;
620        }
621        if let Some(ref cell) = self.maximized_cell {
622            cell.set(self.maximized);
623        }
624    }
625
626    /// Return the resize direction for `local`, or `None` if the point is in
627    /// the interior (or the window is collapsed).
628    fn resize_dir(&self, local: Point) -> Option<ResizeDir> {
629        if self.collapsed || self.auto_size {
630            return None;
631        }
632        if !self.resizable {
633            return None;
634        }
635        let w = self.bounds.width;
636        let h = self.bounds.height;
637        let x = local.x;
638        let y = local.y;
639
640        // Outside the window altogether.
641        if x < 0.0 || x > w || y < 0.0 || y > h {
642            return None;
643        }
644
645        // Mask each edge to the axes the window is allowed to resize on.
646        let on_n = self.resizable_v && y > h - RESIZE_EDGE;
647        let on_s = self.resizable_v && y < RESIZE_EDGE;
648        let on_w = self.resizable_h && x < RESIZE_EDGE;
649        let on_e = self.resizable_h && x > w - RESIZE_EDGE;
650
651        match (on_n, on_e, on_s, on_w) {
652            (true, true, _, _) => Some(ResizeDir::NE),
653            (true, _, _, true) => Some(ResizeDir::NW),
654            (_, _, true, true) => Some(ResizeDir::SW),
655            (_, true, true, _) => Some(ResizeDir::SE),
656            (true, _, _, _) => Some(ResizeDir::N),
657            (_, true, _, _) => Some(ResizeDir::E),
658            (_, _, true, _) => Some(ResizeDir::S),
659            (_, _, _, true) => Some(ResizeDir::W),
660            _ => None,
661        }
662    }
663
664    /// Effective minimum height for this resize pass.  Honours
665    /// either `tight_content_fit` (lock + floor) or
666    /// `floor_content_height` (floor only) so a window whose content
667    /// has a natural height > MIN_H can never be dragged smaller
668    /// than its content.
669    fn effective_min_h(&self) -> f64 {
670        if self.tight_content_fit || self.floor_content_height {
671            let content_min = self.last_content_natural_h.get() + TITLE_H;
672            MIN_H.max(content_min)
673        } else {
674            MIN_H
675        }
676    }
677
678    /// Apply a mouse-world-space delta to bounds according to the resize direction.
679    fn apply_resize(&mut self, world_pos: Point) {
680        let dx = world_pos.x - self.drag_start_world.x;
681        let dy = world_pos.y - self.drag_start_world.y;
682        let sb = self.drag_start_bounds;
683        let min_h = self.effective_min_h();
684
685        let (mut x, mut y, mut w, mut h) = (sb.x, sb.y, sb.width, sb.height);
686
687        if let DragMode::Resize(dir) = self.drag_mode {
688            match dir {
689                ResizeDir::N => {
690                    h = (sb.height + dy).max(min_h);
691                }
692                ResizeDir::S => {
693                    y = sb.y + dy;
694                    h = (sb.height - dy).max(min_h);
695                    if h == min_h {
696                        y = sb.y + sb.height - min_h;
697                    }
698                }
699                ResizeDir::E => {
700                    w = (sb.width + dx).max(MIN_W);
701                }
702                ResizeDir::W => {
703                    x = sb.x + dx;
704                    w = (sb.width - dx).max(MIN_W);
705                    if w == MIN_W {
706                        x = sb.x + sb.width - MIN_W;
707                    }
708                }
709                ResizeDir::NE => {
710                    w = (sb.width + dx).max(MIN_W);
711                    h = (sb.height + dy).max(min_h);
712                }
713                ResizeDir::NW => {
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                    h = (sb.height + dy).max(min_h);
720                }
721                ResizeDir::SE => {
722                    w = (sb.width + dx).max(MIN_W);
723                    y = sb.y + dy;
724                    h = (sb.height - dy).max(min_h);
725                    if h == min_h {
726                        y = sb.y + sb.height - min_h;
727                    }
728                }
729                ResizeDir::SW => {
730                    x = sb.x + dx;
731                    w = (sb.width - dx).max(MIN_W);
732                    if w == MIN_W {
733                        x = sb.x + sb.width - MIN_W;
734                    }
735                    y = sb.y + dy;
736                    h = (sb.height - dy).max(min_h);
737                    if h == min_h {
738                        y = sb.y + sb.height - min_h;
739                    }
740                }
741            }
742        }
743
744        self.bounds = snap(Rect::new(x, y, w, h));
745        self.clamp_to_canvas();
746    }
747}
748
749/// Map a resize direction to the appropriate OS cursor icon.
750fn resize_cursor(dir: ResizeDir) -> CursorIcon {
751    match dir {
752        ResizeDir::N => CursorIcon::ResizeNorth,
753        ResizeDir::S => CursorIcon::ResizeSouth,
754        ResizeDir::E => CursorIcon::ResizeEast,
755        ResizeDir::W => CursorIcon::ResizeWest,
756        ResizeDir::NE => CursorIcon::ResizeNorthEast,
757        ResizeDir::NW => CursorIcon::ResizeNorthWest,
758        ResizeDir::SE => CursorIcon::ResizeSouthEast,
759        ResizeDir::SW => CursorIcon::ResizeSouthWest,
760    }
761}
762
763mod builder;
764pub mod chrome;
765mod close;
766mod events;
767mod paint;
768mod snap_glue;
769mod widget_impl;
770
771pub use close::{ClickAwayAction, CloseReason};
772
773pub use chrome::{
774    paint_chevron, paint_chrome_body, paint_chrome_border, paint_chrome_shadow,
775    paint_chrome_title_bar, ChromeStyle,
776};