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