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, 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
122    /// Snapshot of `is_visible()` from the previous `layout()` call.  Used
123    /// to detect the false→true transition (demo toggled on in the
124    /// sidebar) so we can request the parent `Stack` raise us to the top.
125    last_visible: Cell<bool>,
126    /// `true` until the first `layout()` runs.  A window restored as
127    /// already-visible (e.g. saved-state inspector open) misses the
128    /// rising-edge fit-to-canvas pass, so without this one-shot trigger
129    /// its persisted bounds can land outside a smaller live viewport
130    /// (mobile portrait, resized window, etc.) and the user sees the
131    /// sidebar toggle highlighted but no window.  Cleared after the
132    /// first layout completes.
133    needs_initial_fit: Cell<bool>,
134    /// Set to `true` on a visibility rising edge; read + cleared by
135    /// `take_raise_request` on the next parent-layout pass.
136    raise_request: Cell<bool>,
137
138    collapsed: bool,
139    /// Height before collapsing, so we can restore it.
140    pre_collapse_h: f64,
141
142    drag_mode: DragMode,
143    /// Cursor world position when drag started.
144    drag_start_world: Point,
145    /// Window bounds when drag started.
146    drag_start_bounds: Rect,
147
148    close_hovered: bool,
149    on_close: Option<Box<dyn FnMut()>>,
150
151    /// Whether the window is currently maximized (fills the full canvas).
152    maximized: bool,
153    /// Bounds saved before maximizing so we can restore them.
154    pre_maximize_bounds: Rect,
155    maximize_hovered: bool,
156
157    /// Which resize edge/corner the cursor is currently hovering over.
158    /// Cleared to None when the cursor moves into the interior.
159    hover_dir: Option<ResizeDir>,
160
161    /// Time of last left-click in the title bar — for double-click collapse.
162    last_title_click: Option<Instant>,
163
164    /// Title-bar sub-widget — owns the bar fill, separator, chevron,
165    /// title label, maximize/close buttons.  Painted manually from
166    /// `paint()` so `clip_children_rect` can keep content clipped to the
167    /// body area.  Display state is written into `title_state` every
168    /// layout pass; the sub-widget reads it at paint time.
169    title_bar: WindowTitleBar,
170    title_state: Rc<RefCell<TitleBarView>>,
171
172    /// Canvas size supplied by the last `layout()` call; used for clamping.
173    canvas_size: Size,
174    /// When true, the window is kept fully inside the canvas bounds during drag/resize.
175    constrain: bool,
176
177    /// When true, the window bounds adopt the content's preferred size each
178    /// layout pass (width + height).  Keeps the title-bar top edge pinned so
179    /// the window appears to grow/shrink downward.  User resize is disabled
180    /// while auto-size is active (dragging still works).
181    auto_size: bool,
182
183    /// Whether the user can resize the window by dragging its edges.  When
184    /// `false`, no resize handles are active regardless of `resizable_h` /
185    /// `resizable_v` — matches egui's `.resizable(false)`.  Defaults to
186    /// `true` to preserve existing behaviour for call sites that don't
187    /// explicitly opt out.
188    resizable: bool,
189    /// Fine-grained axis control, used when `resizable` is `true`.
190    resizable_h: bool,
191    resizable_v: bool,
192    /// Content-bound resize floor + ceiling.  When `true`, the
193    /// window's height is locked to its content's required height
194    /// each layout (snap pre-pass) AND `apply_resize` refuses to
195    /// drag it smaller than content.  Matches egui's no-scroll-no-
196    /// clip-no-whitespace W4 contract.  Off by default.
197    tight_content_fit: bool,
198    /// Floor-only variant of [`tight_content_fit`].  Same minimum-
199    /// height enforcement, but allows the user to grow the window
200    /// past the content (whitespace below).  Used by W5 where a
201    /// `TextArea` flex-fills extra space and the user can pull the
202    /// window taller than the wrapped text.  Off by default.
203    floor_content_height: bool,
204    /// Most recently observed content required height (via
205    /// `Widget::measure_min_height`).  Updated each layout pass so
206    /// `apply_resize` and the tight-fit pre-pass see a current value
207    /// even when the content tree contains a flex-fill widget.
208    last_content_natural_h: Cell<f64>,
209    /// True between `paint()` and `finish_paint()` when GL compositing opened
210    /// a foreground layer for body/title/children. The shadow stays outside.
211    foreground_layer_active: Cell<bool>,
212
213    /// When `true`, the window's backbuffer is invalidated on every
214    /// frame the window is visible-and-expanded, forcing the content
215    /// widget's `paint()` to run fresh.  See [`with_live_content`] and
216    /// the constructor doc-comment for when to set this.
217    live_content: bool,
218
219    /// Window title string — stored so external callers (z-order
220    /// persistence, inspector display, etc.) can identify this window
221    /// without going through the inner `title_bar` sub-widget.
222    title: String,
223    /// Optional callback invoked whenever this window requests a raise
224    /// (click-to-front or visibility rising-edge from the sidebar).
225    /// Receives the window title.  Used by the demo's z-order tracker
226    /// to record "most recently raised" so the stacking order survives
227    /// a save/restore round-trip.
228    on_raised: Option<Box<dyn FnMut(&str)>>,
229
230    /// Identity for the snap-layout system.  Minted once at
231    /// construction from a process-wide counter and never changes —
232    /// `Snappable` uses it to skip self-matches in the snap engine's
233    /// target list.
234    snap_id: crate::snap::SnapId,
235}
236
237impl Window {
238    /// Create a new window with the given title, font, and content widget.
239    ///
240    /// Default position: `(60, 60)` with `size = (360, 280)`. Call
241    /// [`with_bounds`] to override.
242    ///
243    /// Windows keep a retained backbuffer. Live content must either call
244    /// [`Window::invalidate_backbuffer`] when external data changes or use
245    /// [`Window::with_live_content`] to force repaint while visible.
246    pub fn new(title: impl Into<String>, font: Arc<Font>, content: Box<dyn Widget>) -> Self {
247        let font_size = 13.0;
248        let title_str: String = title.into();
249        let title_state = Rc::new(RefCell::new(TitleBarView::default_visuals()));
250        let title_bar = WindowTitleBar::new(&title_str, Arc::clone(&font), Rc::clone(&title_state));
251        Self {
252            bounds: Rect::new(60.0, 60.0, 360.0, 280.0),
253            children: vec![content],
254            base: WidgetBase::new(),
255            font_size,
256            visible: true,
257            visible_cell: None,
258            visibility_anim: crate::animation::Tween::new(1.0, VISIBILITY_FADE_SECS),
259            fade_out_active: Cell::new(false),
260            backbuffer: BackbufferState::new(),
261            use_gl_backbuffer: true,
262            reset_to: None,
263            position_cell: None,
264            maximized_cell: None,
265            // Seed `last_visible` to `true` (matches `visible` above) so a
266            // window that's open on first frame doesn't spuriously request
267            // a raise before the user has interacted with it.
268            last_visible: Cell::new(true),
269            needs_initial_fit: Cell::new(true),
270            raise_request: Cell::new(false),
271            collapsed: false,
272            pre_collapse_h: 280.0,
273            drag_mode: DragMode::None,
274            drag_start_world: Point::ORIGIN,
275            drag_start_bounds: Rect::default(),
276            close_hovered: false,
277            on_close: None,
278            maximized: false,
279            pre_maximize_bounds: Rect::new(60.0, 60.0, 360.0, 280.0),
280            maximize_hovered: false,
281            hover_dir: None,
282            last_title_click: None,
283            title_bar,
284            title_state,
285            // Seed as "unknown" so `layout()`'s shrink-detect guard
286            // (`had_prior = prev.w > 0 && prev.h > 0`) correctly skips the
287            // clamp on the very first layout pass.  The old default
288            // `(1280, 720)` was treated as prior, so the first-frame
289            // transition from 1280×720 → <smaller> incorrectly looked like
290            // an OS-window shrink and pulled saved Y-up positions down into
291            // the transient canvas.  Real-value `canvas_size` is populated
292            // by `layout()` before any drag/resize/collapse hit-test runs.
293            canvas_size: Size::new(0.0, 0.0),
294            constrain: true,
295            auto_size: false,
296            resizable: true,
297            resizable_h: true,
298            resizable_v: true,
299            tight_content_fit: false,
300            floor_content_height: false,
301            last_content_natural_h: Cell::new(0.0),
302            foreground_layer_active: Cell::new(false),
303            title: title_str,
304            on_raised: None,
305            live_content: false,
306            snap_id: crate::snap::next_snap_id(),
307        }
308    }
309
310    /// Returns the window title as it was passed to [`Window::new`].
311    pub fn title(&self) -> &str {
312        &self.title
313    }
314
315    /// Force the window's retained backbuffer to re-rasterise on the next
316    /// paint pass.  Use this when the content widget reads from a live
317    /// data source (network feed, animation curve, simulation state)
318    /// that the framework can't observe.  Otherwise the cached pixels
319    /// blit unchanged and your live data never reaches the screen.
320    ///
321    /// Pair with [`Window::with_live_content`] for streaming data that
322    /// changes every frame: that flag self-invalidates here automatically
323    /// (and skips when collapsed/hidden).
324    ///
325    /// See [`Window::new`] for the full discussion of when this matters
326    /// and the alternative ("compose live UI out of widgets that
327    /// invalidate on data change") that avoids needing to call this at
328    /// all.
329    pub fn invalidate_backbuffer(&mut self) {
330        self.backbuffer.invalidate();
331    }
332
333    fn requested_visible(&self) -> bool {
334        if let Some(ref cell) = self.visible_cell {
335            cell.get()
336        } else {
337            self.visible
338        }
339    }
340
341    fn layer_outsets() -> (f64, f64, f64, f64) {
342        let left = (SHADOW_BLUR - SHADOW_DX).max(0.0).ceil();
343        let bottom = (SHADOW_BLUR + SHADOW_DY).ceil();
344        let right = (SHADOW_BLUR + SHADOW_DX).ceil();
345        let top = (SHADOW_BLUR - SHADOW_DY).max(0.0).ceil();
346        (left, bottom, right, top)
347    }
348
349    fn clamp_to_canvas(&mut self) {
350        if !self.constrain {
351            return;
352        }
353        let cw = self.canvas_size.width;
354        let ch = self.canvas_size.height;
355        // **Policy: keep the TITLE BAR grabbable**, not the whole window.
356        // Horizontally we keep at least `MIN_H_VISIBLE` pixels of the title
357        // bar inside the canvas so the user can always drag the window back
358        // on-screen.  Vertically (Y-up) we keep the FULL title bar inside
359        // the canvas — the body may extend above/below, but the drag handle
360        // is always fully reachable.  This matches how native OS window
361        // managers constrain child windows against their host monitor.
362        const MIN_H_VISIBLE: f64 = 40.0;
363
364        let min_x = MIN_H_VISIBLE - self.bounds.width;
365        let max_x = (cw - MIN_H_VISIBLE).max(min_x);
366        self.bounds.x = self.bounds.x.clamp(min_x, max_x).round();
367
368        // Title bar Y range in parent coords: [bounds.y + h - TITLE_H, bounds.y + h].
369        // Full title bar visible → `bounds.y >= TITLE_H - h` AND `bounds.y <= ch - h`.
370        // `bounds.height` collapses to `TITLE_H` when the user folds the
371        // window, so the collapsed case naturally falls out of the same math.
372        let min_y = TITLE_H - self.bounds.height;
373        let max_y = (ch - self.bounds.height).max(min_y);
374        self.bounds.y = self.bounds.y.clamp(min_y, max_y).round();
375    }
376
377    fn fit_fully_to_canvas(&mut self, available: Size) {
378        if !self.constrain || available.width <= 1.0 || available.height <= 1.0 {
379            return;
380        }
381        let max_w = available.width.max(MIN_W);
382        let max_h = available.height.max(TITLE_H);
383        self.bounds.width = self.bounds.width.clamp(MIN_W.min(max_w), max_w).round();
384        self.bounds.height = self.bounds.height.clamp(TITLE_H, max_h).round();
385        self.bounds.x = self
386            .bounds
387            .x
388            .clamp(0.0, (available.width - self.bounds.width).max(0.0))
389            .round();
390        self.bounds.y = self
391            .bounds
392            .y
393            .clamp(0.0, (available.height - self.bounds.height).max(0.0))
394            .round();
395        self.pre_collapse_h = self.bounds.height;
396        if self.maximized {
397            self.pre_maximize_bounds = self.bounds;
398        }
399    }
400
401    pub fn show(&mut self) {
402        self.visible = true;
403        self.fade_out_active.set(false);
404        self.visibility_anim.set_target(1.0);
405        crate::animation::request_draw();
406    }
407    pub fn hide(&mut self) {
408        self.visible = false;
409        self.visibility_anim.set_target(0.0);
410        crate::animation::request_draw();
411    }
412    pub fn toggle(&mut self) {
413        if self.visible {
414            self.hide();
415        } else {
416            self.show();
417        }
418    }
419    /// Current visibility — honours an optional shared `visible_cell` when
420    /// wired (sidebar toggles, programmatic show/hide).  The inherent
421    /// `self.visible` field is a fallback for windows that aren't wired to
422    /// a cell.  Must match the Widget-trait impl below so rising-edge
423    /// detection in `layout()` observes sidebar toggles.
424    pub fn is_visible(&self) -> bool {
425        self.requested_visible() || self.fade_out_active.get()
426    }
427
428    fn title_bar_bottom(&self) -> f64 {
429        self.bounds.height - TITLE_H
430    }
431
432    fn in_title_bar(&self, local: Point) -> bool {
433        local.y >= self.title_bar_bottom()
434            && local.y <= self.bounds.height
435            && local.x >= 0.0
436            && local.x <= self.bounds.width
437    }
438
439    fn close_center(&self) -> Point {
440        Point::new(
441            self.bounds.width - CLOSE_PAD,
442            self.bounds.height - TITLE_H * 0.5,
443        )
444    }
445
446    fn in_close_button(&self, local: Point) -> bool {
447        let c = self.close_center();
448        let dx = local.x - c.x;
449        let dy = local.y - c.y;
450        dx * dx + dy * dy <= (CLOSE_R + 3.0) * (CLOSE_R + 3.0)
451    }
452
453    fn maximize_center(&self) -> Point {
454        Point::new(
455            self.bounds.width - MAX_PAD,
456            self.bounds.height - TITLE_H * 0.5,
457        )
458    }
459
460    fn in_maximize_button(&self, local: Point) -> bool {
461        let c = self.maximize_center();
462        let dx = local.x - c.x;
463        let dy = local.y - c.y;
464        dx * dx + dy * dy <= (CLOSE_R + 3.0) * (CLOSE_R + 3.0)
465    }
466
467    /// Toggle collapsed <-> expanded, keeping the top edge of the window
468    /// fixed in place.  Factored out of the event path so both the chevron
469    /// click and any future keyboard shortcut go through the same math.
470    fn toggle_collapse(&mut self) {
471        let top = self.bounds.y + self.bounds.height;
472        if self.collapsed {
473            self.bounds.height = self.pre_collapse_h;
474            self.bounds.y = (top - self.pre_collapse_h).round();
475            self.collapsed = false;
476        } else {
477            self.pre_collapse_h = self.bounds.height;
478            self.bounds.height = TITLE_H;
479            self.bounds.y = (top - TITLE_H).round();
480            self.collapsed = true;
481        }
482        self.clamp_to_canvas();
483    }
484
485    fn toggle_maximize(&mut self) {
486        if self.maximized {
487            self.bounds = self.pre_maximize_bounds;
488            self.maximized = false;
489        } else {
490            self.pre_maximize_bounds = self.bounds;
491            self.bounds = snap(Rect::new(
492                0.0,
493                0.0,
494                self.canvas_size.width,
495                self.canvas_size.height,
496            ));
497            self.maximized = true;
498        }
499        if let Some(ref cell) = self.maximized_cell {
500            cell.set(self.maximized);
501        }
502    }
503
504    /// Return the resize direction for `local`, or `None` if the point is in
505    /// the interior (or the window is collapsed).
506    fn resize_dir(&self, local: Point) -> Option<ResizeDir> {
507        if self.collapsed || self.auto_size {
508            return None;
509        }
510        if !self.resizable {
511            return None;
512        }
513        let w = self.bounds.width;
514        let h = self.bounds.height;
515        let x = local.x;
516        let y = local.y;
517
518        // Outside the window altogether.
519        if x < 0.0 || x > w || y < 0.0 || y > h {
520            return None;
521        }
522
523        // Mask each edge to the axes the window is allowed to resize on.
524        let on_n = self.resizable_v && y > h - RESIZE_EDGE;
525        let on_s = self.resizable_v && y < RESIZE_EDGE;
526        let on_w = self.resizable_h && x < RESIZE_EDGE;
527        let on_e = self.resizable_h && x > w - RESIZE_EDGE;
528
529        match (on_n, on_e, on_s, on_w) {
530            (true, true, _, _) => Some(ResizeDir::NE),
531            (true, _, _, true) => Some(ResizeDir::NW),
532            (_, _, true, true) => Some(ResizeDir::SW),
533            (_, true, true, _) => Some(ResizeDir::SE),
534            (true, _, _, _) => Some(ResizeDir::N),
535            (_, true, _, _) => Some(ResizeDir::E),
536            (_, _, true, _) => Some(ResizeDir::S),
537            (_, _, _, true) => Some(ResizeDir::W),
538            _ => None,
539        }
540    }
541
542    /// Effective minimum height for this resize pass.  Honours
543    /// either `tight_content_fit` (lock + floor) or
544    /// `floor_content_height` (floor only) so a window whose content
545    /// has a natural height > MIN_H can never be dragged smaller
546    /// than its content.
547    fn effective_min_h(&self) -> f64 {
548        if self.tight_content_fit || self.floor_content_height {
549            let content_min = self.last_content_natural_h.get() + TITLE_H;
550            MIN_H.max(content_min)
551        } else {
552            MIN_H
553        }
554    }
555
556    /// Apply a mouse-world-space delta to bounds according to the resize direction.
557    fn apply_resize(&mut self, world_pos: Point) {
558        let dx = world_pos.x - self.drag_start_world.x;
559        let dy = world_pos.y - self.drag_start_world.y;
560        let sb = self.drag_start_bounds;
561        let min_h = self.effective_min_h();
562
563        let (mut x, mut y, mut w, mut h) = (sb.x, sb.y, sb.width, sb.height);
564
565        if let DragMode::Resize(dir) = self.drag_mode {
566            match dir {
567                ResizeDir::N => {
568                    h = (sb.height + dy).max(min_h);
569                }
570                ResizeDir::S => {
571                    y = sb.y + dy;
572                    h = (sb.height - dy).max(min_h);
573                    if h == min_h {
574                        y = sb.y + sb.height - min_h;
575                    }
576                }
577                ResizeDir::E => {
578                    w = (sb.width + dx).max(MIN_W);
579                }
580                ResizeDir::W => {
581                    x = sb.x + dx;
582                    w = (sb.width - dx).max(MIN_W);
583                    if w == MIN_W {
584                        x = sb.x + sb.width - MIN_W;
585                    }
586                }
587                ResizeDir::NE => {
588                    w = (sb.width + dx).max(MIN_W);
589                    h = (sb.height + dy).max(min_h);
590                }
591                ResizeDir::NW => {
592                    x = sb.x + dx;
593                    w = (sb.width - dx).max(MIN_W);
594                    if w == MIN_W {
595                        x = sb.x + sb.width - MIN_W;
596                    }
597                    h = (sb.height + dy).max(min_h);
598                }
599                ResizeDir::SE => {
600                    w = (sb.width + dx).max(MIN_W);
601                    y = sb.y + dy;
602                    h = (sb.height - dy).max(min_h);
603                    if h == min_h {
604                        y = sb.y + sb.height - min_h;
605                    }
606                }
607                ResizeDir::SW => {
608                    x = sb.x + dx;
609                    w = (sb.width - dx).max(MIN_W);
610                    if w == MIN_W {
611                        x = sb.x + sb.width - MIN_W;
612                    }
613                    y = sb.y + dy;
614                    h = (sb.height - dy).max(min_h);
615                    if h == min_h {
616                        y = sb.y + sb.height - min_h;
617                    }
618                }
619            }
620        }
621
622        self.bounds = snap(Rect::new(x, y, w, h));
623        self.clamp_to_canvas();
624    }
625
626    /// Snap pass for a title-bar drag.  Skipped entirely when the
627    /// global toggle is off — cheap when not in use.  Replaces
628    /// `self.bounds` with the engine's snapped result and writes the
629    /// guide list for `SnapOverlay` to paint.
630    pub(crate) fn apply_move_snap(&mut self) {
631        if !crate::snap::is_enabled() {
632            crate::snap::clear_guides();
633            return;
634        }
635        let targets = crate::snap::targets_snapshot();
636        let result = crate::snap::compute_snap(
637            self.bounds,
638            self.snap_id,
639            &targets,
640            crate::snap::DEFAULT_THRESHOLD,
641            crate::snap::SnapMode::Move,
642        );
643        self.bounds = snap(result.rect);
644        crate::snap::set_guides(result.guides);
645    }
646
647    /// Snap pass for an edge / corner resize drag.  Only edges that
648    /// the active handle is allowed to move can snap — the engine
649    /// enforces that internally via `SnapMode::Resize`.
650    pub(crate) fn apply_resize_snap(&mut self, dir: ResizeDir) {
651        if !crate::snap::is_enabled() {
652            crate::snap::clear_guides();
653            return;
654        }
655        let targets = crate::snap::targets_snapshot();
656        let edge = resize_dir_to_snap_edge(dir);
657        let result = crate::snap::compute_snap(
658            self.bounds,
659            self.snap_id,
660            &targets,
661            crate::snap::DEFAULT_THRESHOLD,
662            crate::snap::SnapMode::Resize(edge),
663        );
664        self.bounds = snap(result.rect);
665        crate::snap::set_guides(result.guides);
666    }
667}
668
669/// Map an internal `ResizeDir` to the snap engine's compass-direction
670/// enum.  Kept private — the snap engine owns its own enum so the
671/// engine isn't coupled to the Window widget.
672fn resize_dir_to_snap_edge(dir: ResizeDir) -> crate::snap::ResizeEdge {
673    use crate::snap::ResizeEdge as E;
674    match dir {
675        ResizeDir::N => E::North,
676        ResizeDir::NE => E::NorthEast,
677        ResizeDir::E => E::East,
678        ResizeDir::SE => E::SouthEast,
679        ResizeDir::S => E::South,
680        ResizeDir::SW => E::SouthWest,
681        ResizeDir::W => E::West,
682        ResizeDir::NW => E::NorthWest,
683    }
684}
685
686impl crate::snap::Snappable for Window {
687    fn snap_id(&self) -> crate::snap::SnapId {
688        self.snap_id
689    }
690    fn snap_rect(&self) -> Rect {
691        self.bounds
692    }
693    fn set_snap_rect(&mut self, r: Rect) {
694        self.bounds = snap(r);
695    }
696    fn is_snap_source(&self) -> bool {
697        self.requested_visible() && !self.maximized
698    }
699    fn is_snap_target(&self) -> bool {
700        // Maximized windows fill the canvas — pulling siblings to
701        // their edges would just glue everything to the canvas
702        // perimeter, which isn't useful as a layout aid.  Hidden
703        // windows aren't valid targets either.
704        self.requested_visible() && !self.maximized
705    }
706}
707
708/// Map a resize direction to the appropriate OS cursor icon.
709fn resize_cursor(dir: ResizeDir) -> CursorIcon {
710    match dir {
711        ResizeDir::N => CursorIcon::ResizeNorth,
712        ResizeDir::S => CursorIcon::ResizeSouth,
713        ResizeDir::E => CursorIcon::ResizeEast,
714        ResizeDir::W => CursorIcon::ResizeWest,
715        ResizeDir::NE => CursorIcon::ResizeNorthEast,
716        ResizeDir::NW => CursorIcon::ResizeNorthWest,
717        ResizeDir::SE => CursorIcon::ResizeSouthEast,
718        ResizeDir::SW => CursorIcon::ResizeSouthWest,
719    }
720}
721
722mod builder;
723pub mod chrome;
724mod paint;
725mod widget_impl;
726
727pub use chrome::{
728    paint_chevron, paint_chrome_body, paint_chrome_border, paint_chrome_shadow,
729    paint_chrome_title_bar, ChromeStyle,
730};