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;