Skip to main content

improv_gui/
nxwin.rs

1//! NeXTSTEP window chrome for in-app floating windows (spec §3, §4.3, §5).
2//!
3//! [`NxWindow`] wraps an [`egui::Window`] that has no egui title bar or frame,
4//! and paints the NeXT chrome itself: a 1 px black border, a 23 px title bar
5//! (black when key, dark gray otherwise), a beveled close box, a bottom resize
6//! bar split into thirds, and a light-gray body. Every piece is a painter
7//! shape; the only glyphs are the title text.
8//!
9//! The *key* window is the most recently clicked `NxWindow`, tracked in egui
10//! memory; see [`is_key`]. Closing the key window hands the key to the next
11//! window shown.
12
13use crate::theme::{BLACK, DARK_GRAY, LIGHT_GRAY, WHITE};
14use egui::{
15    pos2, vec2, Align2, Color32, Context, FontFamily, FontId, Frame, Id, InnerResponse, LayerId,
16    Order, Painter, Pos2, Rect, Sense, Stroke, Ui, UiBuilder, Vec2,
17};
18
19// Geometry, in points (spec §3).
20const BORDER: f32 = 1.0;
21const TITLE_HEIGHT: f32 = 23.0;
22const CLOSE_SIZE: f32 = 15.0;
23const CLOSE_INSET: f32 = 4.0;
24const RESIZE_HEIGHT: f32 = 9.0;
25const MIN_SIZE: Vec2 = vec2(100.0, 60.0);
26const DEFAULT_RECT: Rect = Rect {
27    min: pos2(32.0, 32.0),
28    max: pos2(352.0, 272.0),
29};
30
31/// Title bar fill and text colors: black with white text for the key window,
32/// dark gray with light-gray text otherwise (spec §1).
33pub fn title_colors(key: bool) -> (Color32, Color32) {
34    if key {
35        (BLACK, WHITE)
36    } else {
37        (DARK_GRAY, LIGHT_GRAY)
38    }
39}
40
41/// Is the window with this id the key window (the most recently clicked
42/// [`NxWindow`])? The id is [`NxWindow::id`].
43pub fn is_key(ctx: &Context, id: Id) -> bool {
44    ctx.data(|d| d.get_temp::<Id>(key_slot())) == Some(id)
45}
46
47/// Make the window with this id the key window, as a click on it would.
48pub fn make_key(ctx: &Context, id: Id) {
49    ctx.data_mut(|d| d.insert_temp(key_slot(), id));
50}
51
52/// Memory slot holding the [`Id`] of the key window.
53fn key_slot() -> Id {
54    Id::new("nxwin::key")
55}
56
57/// The outer rect the window with this id ([`NxWindow::id`]) ended its last
58/// [`NxWindow::show`] with: drags, resizes and clamps applied. `None` before
59/// it is first shown.
60pub fn stored_rect(ctx: &Context, id: Id) -> Option<Rect> {
61    ctx.data(|d| d.get_temp::<Rect>(rect_slot(id)))
62}
63
64/// Memory slot holding a window's outer rect between frames.
65fn rect_slot(id: Id) -> Id {
66    id.with("nxwin_rect")
67}
68
69/// A floating window with NeXTSTEP chrome. Build with [`NxWindow::new`],
70/// configure, then call [`NxWindow::show`] every frame.
71#[must_use = "call .show(ctx, ...)"]
72pub struct NxWindow<'open> {
73    id: Id,
74    title: String,
75    open: Option<&'open mut bool>,
76    default_rect: Rect,
77    rect: Option<Rect>,
78    min_size: Vec2,
79    bounds: Option<Rect>,
80    resizable: bool,
81}
82
83impl<'open> NxWindow<'open> {
84    /// A window identified by `id_source` (salted, see [`Self::id`]), showing
85    /// `title` centred in its title bar. Resizable by default, not closable.
86    pub fn new(id_source: impl std::hash::Hash, title: impl Into<String>) -> Self {
87        Self {
88            id: Id::new(("nxwin", id_source)),
89            title: title.into(),
90            open: None,
91            default_rect: DEFAULT_RECT,
92            rect: None,
93            min_size: MIN_SIZE,
94            bounds: None,
95            resizable: true,
96        }
97    }
98
99    /// The window's [`Id`]: its egui area id, and the id [`is_key`] takes.
100    pub fn id(&self) -> Id {
101        self.id
102    }
103
104    /// Show a close box; clicking it sets `open` to `false`. While `open` is
105    /// `false` the window is not shown.
106    pub fn closable(mut self, open: &'open mut bool) -> Self {
107        self.open = Some(open);
108        self
109    }
110
111    /// Outer rect (border included) the first time the window is shown.
112    #[cfg_attr(
113        not(test),
114        expect(dead_code, reason = "the app's windows all own their rect (.rect)")
115    )]
116    pub fn default_rect(mut self, rect: Rect) -> Self {
117        self.default_rect = rect;
118        self
119    }
120
121    /// Outer rect for this frame, overriding the one stored from the last
122    /// frame; this frame's drags apply on top of it. For callers that own the
123    /// rect: feed back [`stored_rect`] after [`Self::show`].
124    pub fn rect(mut self, rect: Rect) -> Self {
125        self.rect = Some(rect);
126        self
127    }
128
129    /// Smallest outer size: resizes stop here, and a smaller incoming rect is
130    /// enlarged (keeping its top-left). Defaults to 100 x 60.
131    pub fn min_size(mut self, min_size: Vec2) -> Self {
132        self.min_size = min_size;
133        self
134    }
135
136    /// Rect the window is kept inside, both when moved and when resized.
137    /// Defaults to [`Context::screen_rect`].
138    pub fn bounds(mut self, bounds: Rect) -> Self {
139        self.bounds = Some(bounds);
140        self
141    }
142
143    /// Whether the bottom resize bar is shown and resizes the window.
144    pub fn resizable(mut self, resizable: bool) -> Self {
145        self.resizable = resizable;
146        self
147    }
148
149    /// Show the window, laying `add_contents` out in the body. Returns `None`
150    /// when the window is closed.
151    pub fn show<R>(
152        self,
153        ctx: &Context,
154        add_contents: impl FnOnce(&mut Ui) -> R,
155    ) -> Option<InnerResponse<R>> {
156        let Self {
157            id,
158            title,
159            open,
160            default_rect,
161            rect,
162            min_size,
163            bounds,
164            resizable,
165        } = self;
166        if matches!(open, Some(false)) {
167            return None;
168        }
169        let closable = open.is_some();
170        let mut rect = rect
171            .or_else(|| stored_rect(ctx, id))
172            .unwrap_or(default_rect);
173        rect.max = rect.min + rect.size().max(min_size);
174
175        // Apply this frame's drags before placing the window, as egui's own
176        // `Area` does, so the chrome tracks the pointer with no frame delay.
177        if let Some(r) = ctx.read_response(id.with("title")) {
178            rect = rect.translate(r.drag_delta());
179        }
180        let screen = bounds.unwrap_or_else(|| ctx.screen_rect());
181        if let Some(r) = ctx
182            .read_response(id.with("nx_resize"))
183            .filter(|r| r.dragged())
184        {
185            // NeXT: the left third drags the bottom-left corner, the middle
186            // the bottom edge, the right third the bottom-right corner. The
187            // third is latched at press, since the bar moves under the pointer.
188            let zone_id = id.with("resize_zone");
189            let zone = if r.drag_started() {
190                let x = r
191                    .interact_pointer_pos()
192                    .map_or(0.0, |p| p.x - r.rect.left());
193                let zone = if x * 3.0 < r.rect.width() {
194                    0
195                } else if x * 3.0 < r.rect.width() * 2.0 {
196                    1
197                } else {
198                    2
199                };
200                ctx.data_mut(|d| d.insert_temp(zone_id, zone));
201                zone
202            } else {
203                ctx.data(|d| d.get_temp::<u8>(zone_id)).unwrap_or(1)
204            };
205            // Clamp the dragged edge to the bounds (never `f32::clamp`, which
206            // panics when the bounds are smaller than `min_size`).
207            let d = r.drag_delta();
208            match zone {
209                0 => {
210                    rect.min.x = (rect.min.x + d.x)
211                        .max(screen.min.x)
212                        .min(rect.max.x - min_size.x);
213                }
214                2 => {
215                    rect.max.x = (rect.max.x + d.x)
216                        .min(screen.max.x)
217                        .max(rect.min.x + min_size.x);
218                }
219                _ => {}
220            }
221            rect.max.y = (rect.max.y + d.y)
222                .min(screen.max.y)
223                .max(rect.min.y + min_size.y);
224        }
225
226        // Keep the window in bounds. Done here rather than by egui, whose first
227        // (sizing) pass clamps against a guessed 600x400 size.
228        let max = (screen.max - rect.size()).max(screen.min);
229        rect = rect.translate(rect.min.clamp(screen.min, max) - rect.min);
230        ctx.data_mut(|d| d.insert_temp(rect_slot(id), rect));
231
232        // A press on this window's layer makes it key; the first window shown
233        // is key until something is clicked.
234        // ponytail: a window shown before the clicked one keeps the key look
235        // for that one frame; a window registry would fix it if it shows.
236        let layer = LayerId::new(Order::Middle, id);
237        let pressed_here = ctx
238            .input(|i| i.pointer.interact_pos().filter(|_| i.pointer.any_pressed()))
239            .is_some_and(|p| ctx.layer_id_at(p) == Some(layer));
240        ctx.data_mut(|d| {
241            if pressed_here || d.get_temp::<Id>(key_slot()).is_none() {
242                d.insert_temp(key_slot(), id);
243            }
244        });
245        let key = is_key(ctx, id);
246
247        let mut close_clicked = false;
248        let shown = egui::Window::new(title.as_str())
249            .id(id)
250            .title_bar(false)
251            .frame(Frame::none())
252            .movable(false)
253            .constrain(false)
254            .resizable(false)
255            .collapsible(false)
256            .fade_in(false)
257            .fade_out(false)
258            .current_pos(rect.min)
259            .fixed_size(rect.size())
260            .show(ctx, |ui| {
261                let outer = ui.max_rect();
262                let l = layout(outer, closable, resizable);
263                let p = ui.painter().clone();
264                let (fill, text) = title_colors(key);
265
266                p.rect_filled(l.body, 0.0, LIGHT_GRAY);
267                p.rect_filled(l.title, 0.0, fill);
268                let title_painter =
269                    p.with_clip_rect(l.title.shrink2(vec2(CLOSE_INSET * 2.0 + CLOSE_SIZE, 0.0)));
270                title_painter.text(
271                    l.title.center(),
272                    Align2::CENTER_CENTER,
273                    &title,
274                    FontId::new(12.0, FontFamily::Name(crate::theme::BOLD.into())),
275                    text,
276                );
277                // The drag is applied before layout, above; this registers it.
278                let _ = ui.interact(l.title, id.with("title"), Sense::drag());
279
280                // Registered after the title so it sits on top for hit-testing.
281                if let Some(close) = l.close {
282                    bevel(&p, close);
283                    let x = close.shrink(4.0);
284                    let stroke = Stroke::new(1.0_f32, BLACK);
285                    p.line_segment([x.left_top(), x.right_bottom()], stroke);
286                    p.line_segment([x.right_top(), x.left_bottom()], stroke);
287                    close_clicked = ui
288                        .interact(close, id.with("close"), Sense::click())
289                        .clicked();
290                }
291
292                if let Some(bar) = l.resize {
293                    bevel(&p, bar);
294                    for f in [1.0 / 3.0, 2.0 / 3.0] {
295                        let x = (bar.left() + bar.width() * f).round();
296                        p.rect_filled(
297                            Rect::from_x_y_ranges(x..=x + 1.0, bar.y_range()),
298                            0.0,
299                            BLACK,
300                        );
301                        p.rect_filled(
302                            Rect::from_x_y_ranges(x + 1.0..=x + 2.0, bar.y_range()),
303                            0.0,
304                            WHITE,
305                        );
306                    }
307                    let _ = ui.interact(bar, id.with("nx_resize"), Sense::drag());
308                }
309
310                let mut body = ui.new_child(UiBuilder::new().max_rect(l.body));
311                body.set_clip_rect(l.body.intersect(ui.clip_rect()));
312                let inner = add_contents(&mut body);
313
314                p.rect_stroke(outer.shrink(BORDER / 2.0), 0.0, Stroke::new(BORDER, BLACK));
315                // Size the window to the chrome, not to whatever the body holds.
316                ui.advance_cursor_after_rect(outer);
317                inner
318            })?;
319
320        if let (true, Some(open)) = (close_clicked, open) {
321            *open = false;
322            // Hand the key to the next window shown.
323            if is_key(ctx, id) {
324                ctx.data_mut(|d| d.remove::<Id>(key_slot()));
325            }
326        }
327        Some(InnerResponse {
328            inner: shown.inner?,
329            response: shown.response,
330        })
331    }
332}
333
334/// Where each piece of chrome sits inside a window's outer rect.
335struct Layout {
336    title: Rect,
337    close: Option<Rect>,
338    body: Rect,
339    resize: Option<Rect>,
340}
341
342fn layout(outer: Rect, closable: bool, resizable: bool) -> Layout {
343    let inner = outer.shrink(BORDER);
344    let title = Rect::from_min_size(inner.min, vec2(inner.width(), TITLE_HEIGHT));
345    let close = closable.then(|| {
346        Rect::from_min_size(
347            pos2(
348                title.right() - CLOSE_INSET - CLOSE_SIZE,
349                title.center().y - CLOSE_SIZE / 2.0,
350            ),
351            Vec2::splat(CLOSE_SIZE),
352        )
353    });
354    let resize = resizable.then(|| {
355        Rect::from_min_max(
356            pos2(inner.left(), inner.bottom() - RESIZE_HEIGHT),
357            inner.max,
358        )
359    });
360    let body = Rect::from_min_max(
361        pos2(inner.left(), title.bottom()),
362        pos2(inner.right(), resize.map_or(inner.bottom(), |r| r.top())),
363    );
364    Layout {
365        title,
366        close,
367        body,
368        resize,
369    }
370}
371
372/// Paint a NeXT raised bevel over `r`: light-gray face, white top-left edge,
373/// dark gray then black bottom-right edge (spec §1).
374fn bevel(p: &Painter, r: Rect) {
375    let edge = |min: Pos2, max: Pos2, c: Color32| {
376        p.rect_filled(Rect::from_min_max(min, max), 0.0, c);
377    };
378    p.rect_filled(r, 0.0, LIGHT_GRAY);
379    edge(r.min, pos2(r.max.x, r.min.y + 1.0), WHITE);
380    edge(r.min, pos2(r.min.x + 1.0, r.max.y), WHITE);
381    edge(
382        pos2(r.min.x + 1.0, r.max.y - 2.0),
383        pos2(r.max.x - 1.0, r.max.y - 1.0),
384        DARK_GRAY,
385    );
386    edge(
387        pos2(r.max.x - 2.0, r.min.y + 1.0),
388        pos2(r.max.x - 1.0, r.max.y - 1.0),
389        DARK_GRAY,
390    );
391    edge(pos2(r.min.x, r.max.y - 1.0), r.max, BLACK);
392    edge(pos2(r.max.x - 1.0, r.min.y), r.max, BLACK);
393}
394
395#[cfg(test)]
396#[cfg_attr(coverage_nightly, coverage(off))]
397mod tests {
398    use super::*;
399    use egui::epaint::ClippedShape;
400    use egui::{Event, Modifiers, PointerButton, RawInput, Shape};
401
402    const SCREEN: Rect = Rect {
403        min: Pos2::ZERO,
404        max: pos2(800.0, 600.0),
405    };
406
407    /// The id [`NxWindow::new`] gives `id_source`.
408    fn wid(id_source: &str) -> Id {
409        NxWindow::new(id_source, "").id()
410    }
411
412    /// Headless frames at a fixed screen rect, like app.rs's `full_frame`.
413    struct Harness {
414        ctx: Context,
415        time: f64,
416    }
417
418    impl Harness {
419        fn new() -> Self {
420            let ctx = Context::default();
421            crate::theme::install_fonts(&ctx);
422            Self { ctx, time: 0.0 }
423        }
424
425        fn frame(&mut self, events: Vec<Event>, ui: impl FnMut(&Context)) -> Vec<ClippedShape> {
426            self.time += 1.0 / 60.0;
427            let raw = RawInput {
428                time: Some(self.time),
429                screen_rect: Some(SCREEN),
430                events,
431                ..Default::default()
432            };
433            self.ctx.run(raw, ui).shapes
434        }
435    }
436
437    fn button(pos: Pos2, pressed: bool) -> Event {
438        Event::PointerButton {
439            pos,
440            button: PointerButton::Primary,
441            pressed,
442            modifiers: Modifiers::NONE,
443        }
444    }
445
446    fn click(at: Pos2) -> Vec<Event> {
447        vec![Event::PointerMoved(at), button(at, true), button(at, false)]
448    }
449
450    /// Press at `from`, move to `to`, release, then settle: one frame each.
451    fn drag(h: &mut Harness, from: Pos2, to: Pos2, mut ui: impl FnMut(&Context)) {
452        h.frame(vec![Event::PointerMoved(from)], &mut ui);
453        h.frame(vec![button(from, true)], &mut ui);
454        h.frame(vec![Event::PointerMoved(to)], &mut ui);
455        h.frame(vec![button(to, false)], &mut ui);
456        h.frame(Vec::new(), &mut ui);
457    }
458
459    /// Fill of the rect shape painted exactly over `rect`.
460    fn fill_at(shapes: &[ClippedShape], rect: Rect) -> Option<Color32> {
461        shapes.iter().find_map(|c| match &c.shape {
462            Shape::Rect(r) if r.rect == rect => Some(r.fill),
463            _ => None,
464        })
465    }
466
467    /// Color of the text shape reading `text`.
468    fn text_color(shapes: &[ClippedShape], text: &str) -> Option<Color32> {
469        shapes.iter().find_map(|c| match &c.shape {
470            Shape::Text(t) if t.galley.text() == text => {
471                Some(t.galley.job.sections[0].format.color)
472            }
473            _ => None,
474        })
475    }
476
477    const R: Rect = Rect {
478        min: pos2(100.0, 100.0),
479        max: pos2(400.0, 300.0),
480    };
481
482    /// (a) The painted title bar is 23 px high and the body Ui starts below it.
483    #[test]
484    fn title_bar_is_23px_and_body_sits_below() {
485        let mut h = Harness::new();
486        let (mut outer, mut body) = (Rect::NOTHING, Rect::NOTHING);
487        let mut shapes = Vec::new();
488        for _ in 0..3 {
489            shapes = h.frame(Vec::new(), |ctx| {
490                let ir = NxWindow::new("a", "Alpha")
491                    .default_rect(R)
492                    .show(ctx, |ui| ui.max_rect())
493                    .unwrap();
494                (outer, body) = (ir.response.rect, ir.inner);
495            });
496        }
497        assert_eq!(outer, R);
498        // The black fill starting just inside the 1 px border is the title.
499        let title = shapes
500            .iter()
501            .find_map(|c| match &c.shape {
502                Shape::Rect(r) if r.fill == BLACK && r.rect.min == R.min + vec2(1.0, 1.0) => {
503                    Some(r.rect)
504                }
505                _ => None,
506            })
507            .expect("a black title bar inside the border");
508        assert_eq!(title.width(), R.width() - 2.0);
509        assert_eq!(title.height(), 23.0);
510        assert_eq!(body.top(), title.bottom());
511        assert_eq!((body.left(), body.right()), (title.left(), title.right()));
512        assert_eq!(
513            body.bottom(),
514            R.bottom() - 1.0 - 9.0,
515            "resize bar below the body"
516        );
517    }
518
519    /// (b) Clicking the close box clears the bool and hides the window.
520    #[test]
521    fn close_box_clears_open() {
522        let mut h = Harness::new();
523        let mut open = true;
524        let mut shown = false;
525        let mut ui = |ctx: &Context| {
526            shown = NxWindow::new("a", "Alpha")
527                .default_rect(R)
528                .closable(&mut open)
529                .show(ctx, |_| ())
530                .is_some();
531        };
532        for _ in 0..3 {
533            h.frame(Vec::new(), &mut ui);
534        }
535        let close = layout(R, true, true).close.unwrap();
536        assert_eq!(close.size(), vec2(15.0, 15.0));
537        assert_eq!(close.right(), R.right() - 1.0 - 4.0);
538        h.frame(click(close.center()), &mut ui);
539        h.frame(Vec::new(), &mut ui);
540        assert!(!shown);
541        assert!(!open);
542    }
543
544    /// (c) Dragging the title bar moves the window by the drag delta.
545    #[test]
546    fn title_drag_moves_window() {
547        let mut h = Harness::new();
548        let mut rect = Rect::NOTHING;
549        let mut ui = |ctx: &Context| {
550            rect = NxWindow::new("a", "Alpha")
551                .default_rect(R)
552                .show(ctx, |_| ())
553                .unwrap()
554                .response
555                .rect;
556        };
557        for _ in 0..3 {
558            h.frame(Vec::new(), &mut ui);
559        }
560        let from = pos2(200.0, 112.0);
561        drag(&mut h, from, from + vec2(60.0, 40.0), &mut ui);
562        assert_eq!(rect, R.translate(vec2(60.0, 40.0)));
563    }
564
565    /// Show "a" (Alpha) at `a` then "b" (Bravo) at `b`, every frame.
566    fn two_windows(a: Rect, b: Rect) -> impl FnMut(&Context) {
567        move |ctx| {
568            let _ = NxWindow::new("a", "Alpha")
569                .default_rect(a)
570                .show(ctx, |_| ());
571            let _ = NxWindow::new("b", "Bravo")
572                .default_rect(b)
573                .show(ctx, |_| ());
574        }
575    }
576
577    fn title(r: Rect) -> Rect {
578        layout(r, false, true).title
579    }
580
581    /// (d) Clicking window B makes it key and A non-key, and the painted title
582    /// fills and text colors follow.
583    #[test]
584    fn click_makes_window_key() {
585        let mut h = Harness::new();
586        let a = Rect::from_min_size(pos2(50.0, 50.0), vec2(200.0, 150.0));
587        let b = a.translate(vec2(350.0, 0.0));
588        let mut ui = two_windows(a, b);
589
590        let mut shapes = Vec::new();
591        for _ in 0..3 {
592            shapes = h.frame(Vec::new(), &mut ui);
593        }
594        assert!(is_key(&h.ctx, wid("a")), "first window shown is key");
595        assert_eq!(fill_at(&shapes, title(a)), Some(BLACK));
596        assert_eq!(fill_at(&shapes, title(b)), Some(DARK_GRAY));
597
598        h.frame(click(b.center()), &mut ui);
599        shapes = h.frame(Vec::new(), &mut ui);
600        assert!(is_key(&h.ctx, wid("b")));
601        assert!(!is_key(&h.ctx, wid("a")));
602        assert_eq!(fill_at(&shapes, title(b)), Some(BLACK));
603        assert_eq!(fill_at(&shapes, title(a)), Some(DARK_GRAY));
604        assert_eq!(text_color(&shapes, "Bravo"), Some(WHITE));
605        assert_eq!(text_color(&shapes, "Alpha"), Some(LIGHT_GRAY));
606    }
607
608    /// A click where windows overlap keys the one on top, not the one shown
609    /// last; clicking a window also raises it.
610    #[test]
611    fn overlap_click_keys_the_top_window() {
612        let mut h = Harness::new();
613        let a = R;
614        let b = R.translate(vec2(150.0, 100.0));
615        let mut ui = two_windows(a, b);
616        for _ in 0..3 {
617            h.frame(Vec::new(), &mut ui);
618        }
619        let overlap = a.intersect(b).center();
620
621        h.frame(click(overlap), &mut ui);
622        assert!(is_key(&h.ctx, wid("b")), "B was shown last, so is on top");
623        assert!(!is_key(&h.ctx, wid("a")));
624
625        h.frame(click(a.min + vec2(20.0, 50.0)), &mut ui);
626        assert!(is_key(&h.ctx, wid("a")));
627        assert!(!is_key(&h.ctx, wid("b")));
628
629        // A is now on top, though B is still shown after it.
630        h.frame(click(overlap), &mut ui);
631        assert!(is_key(&h.ctx, wid("a")));
632    }
633
634    /// The click frame paints both titles black: the clicked window takes the
635    /// key after the earlier-shown one has already painted (one-frame lag).
636    #[test]
637    fn click_frame_paints_both_titles_black() {
638        let mut h = Harness::new();
639        let a = Rect::from_min_size(pos2(50.0, 50.0), vec2(200.0, 150.0));
640        let b = a.translate(vec2(350.0, 0.0));
641        let mut ui = two_windows(a, b);
642        for _ in 0..3 {
643            h.frame(Vec::new(), &mut ui);
644        }
645        let shapes = h.frame(click(b.center()), &mut ui);
646        assert_eq!(fill_at(&shapes, title(a)), Some(BLACK));
647        assert_eq!(fill_at(&shapes, title(b)), Some(BLACK));
648    }
649
650    /// Closing the key window hands the key to the next window shown.
651    #[test]
652    fn closing_key_window_hands_key_on() {
653        let mut h = Harness::new();
654        let mut open = true;
655        let b = R.translate(vec2(350.0, 0.0));
656        let mut ui = |ctx: &Context| {
657            let _ = NxWindow::new("a", "Alpha")
658                .default_rect(R)
659                .closable(&mut open)
660                .show(ctx, |_| ());
661            if !open {
662                let _ = NxWindow::new("b", "Bravo")
663                    .default_rect(b)
664                    .show(ctx, |_| ());
665            }
666        };
667        for _ in 0..3 {
668            h.frame(Vec::new(), &mut ui);
669        }
670        assert!(is_key(&h.ctx, wid("a")));
671        h.frame(
672            click(layout(R, true, true).close.unwrap().center()),
673            &mut ui,
674        );
675        h.frame(Vec::new(), &mut ui);
676        assert!(!open);
677        assert!(is_key(&h.ctx, wid("b")));
678    }
679
680    /// A window placed off screen is moved back on, keeping its size.
681    #[test]
682    fn off_screen_window_is_clamped_on_screen() {
683        let mut h = Harness::new();
684        let mut rect = Rect::NOTHING;
685        for _ in 0..3 {
686            h.frame(Vec::new(), |ctx| {
687                rect = NxWindow::new("a", "Alpha")
688                    .default_rect(R.translate(vec2(1000.0, 1000.0)))
689                    .show(ctx, |_| ())
690                    .unwrap()
691                    .response
692                    .rect;
693            });
694        }
695        assert_eq!(rect.max, SCREEN.max);
696        assert_eq!(rect.size(), R.size());
697    }
698
699    /// Drag the bottom bar at `x` by `delta` and return the window rect.
700    fn drag_resize_bar(resizable: bool, x: f32, delta: Vec2) -> Rect {
701        let mut h = Harness::new();
702        let mut rect = Rect::NOTHING;
703        let mut ui = |ctx: &Context| {
704            rect = NxWindow::new("a", "Alpha")
705                .default_rect(R)
706                .resizable(resizable)
707                .show(ctx, |_| ())
708                .unwrap()
709                .response
710                .rect;
711        };
712        for _ in 0..3 {
713            h.frame(Vec::new(), &mut ui);
714        }
715        let from = pos2(x, R.bottom() - 1.0 - 4.5);
716        drag(&mut h, from, from + delta, &mut ui);
717        rect
718    }
719
720    /// (e) The resize bar resizes only when resizable; the left third drags
721    /// the bottom-left corner, the right third the bottom-right.
722    #[test]
723    fn resize_bar_resizes_only_when_resizable() {
724        let d = vec2(40.0, 30.0);
725        let right = drag_resize_bar(true, R.right() - 20.0, d);
726        assert_eq!(right, Rect::from_min_max(R.min, R.max + d));
727        let left = drag_resize_bar(true, R.left() + 20.0, d);
728        assert_eq!(
729            left,
730            Rect::from_min_max(R.min + vec2(40.0, 0.0), R.max + vec2(0.0, 30.0))
731        );
732        assert_eq!(drag_resize_bar(false, R.right() - 20.0, d), R);
733    }
734
735    /// The middle third drags only the bottom edge.
736    #[test]
737    fn middle_third_resizes_height_only() {
738        let mid = drag_resize_bar(true, R.center().x, vec2(40.0, 30.0));
739        assert_eq!(mid, Rect::from_min_max(R.min, R.max + vec2(0.0, 30.0)));
740    }
741
742    /// Dragging the bar past the screen bottom stops the edge there instead
743    /// of shoving the window up.
744    #[test]
745    fn resize_past_screen_bottom_clamps_the_edge() {
746        let r = drag_resize_bar(true, R.center().x, vec2(0.0, 400.0));
747        assert_eq!(r.min.y, R.min.y);
748        assert_eq!(r.max.y, SCREEN.max.y);
749    }
750
751    /// Show window a (default rect `R`) configured by `f`; its outer rect.
752    fn show_a(ctx: &Context, f: impl FnOnce(NxWindow<'_>) -> NxWindow<'_>) -> Rect {
753        f(NxWindow::new("a", "Alpha").default_rect(R))
754            .show(ctx, |_| ())
755            .unwrap()
756            .response
757            .rect
758    }
759
760    /// Dragging the left third past `min_size` stops the left edge there,
761    /// rather than at `MIN_SIZE` for the caller to enlarge (sliding it right).
762    #[test]
763    fn left_resize_stops_at_min_size() {
764        let min = vec2(240.0, 180.0);
765        let mut h = Harness::new();
766        let mut ui = |ctx: &Context| {
767            show_a(ctx, |w| w.min_size(min));
768        };
769        for _ in 0..3 {
770            h.frame(Vec::new(), &mut ui);
771        }
772        let from = pos2(R.left() + 20.0, R.bottom() - 1.0 - 4.5);
773        drag(&mut h, from, from + vec2(400.0, 0.0), &mut ui);
774        let rect = stored_rect(&h.ctx, wid("a")).unwrap();
775        assert_eq!(rect.max.x, R.max.x);
776        assert_eq!(rect.width(), min.x);
777    }
778
779    /// `bounds` replaces the screen as the rect a dragged window is kept in.
780    #[test]
781    fn bounds_clamp_a_dragged_window() {
782        let bounds = Rect::from_min_max(pos2(50.0, 60.0), pos2(600.0, 500.0));
783        let mut h = Harness::new();
784        let mut ui = |ctx: &Context| {
785            show_a(ctx, |w| w.bounds(bounds));
786        };
787        for _ in 0..3 {
788            h.frame(Vec::new(), &mut ui);
789        }
790        let from = title(R).center();
791        drag(&mut h, from, from + vec2(500.0, 500.0), &mut ui);
792        let rect = stored_rect(&h.ctx, wid("a")).unwrap();
793        assert_eq!(rect.max, bounds.max);
794        assert_eq!(rect.size(), R.size());
795    }
796
797    /// `rect` overrides the stored rect; a caller owning the rect reads it
798    /// back with `stored_rect`, this frame's drag applied.
799    #[test]
800    fn owned_rect_overrides_and_reads_back() {
801        let mut h = Harness::new();
802        for _ in 0..3 {
803            h.frame(Vec::new(), |ctx| {
804                show_a(ctx, |w| w);
805            });
806        }
807        assert_eq!(stored_rect(&h.ctx, wid("a")), Some(R));
808        let start = R.translate(vec2(200.0, 150.0));
809        let mut shown = Rect::NOTHING;
810        h.frame(Vec::new(), |ctx| shown = show_a(ctx, |w| w.rect(start)));
811        assert_eq!(shown, start, "rect() beats the stored R");
812        let mut owned = start;
813        let mut ui = |ctx: &Context| {
814            shown = show_a(ctx, |w| w.rect(owned));
815            owned = stored_rect(ctx, wid("a")).unwrap();
816        };
817        let from = title(start).center();
818        let d = vec2(-60.0, -40.0);
819        drag(&mut h, from, from + d, &mut ui);
820        assert_eq!(owned, start.translate(d));
821        assert_eq!(shown, owned);
822    }
823
824    /// An incoming rect smaller than `min_size` is enlarged, top-left kept.
825    #[test]
826    fn undersized_rect_is_enlarged() {
827        let min = vec2(240.0, 180.0);
828        let small = Rect::from_min_size(pos2(100.0, 100.0), vec2(50.0, 40.0));
829        let mut h = Harness::new();
830        let mut rect = Rect::NOTHING;
831        for _ in 0..3 {
832            h.frame(Vec::new(), |ctx| {
833                rect = show_a(ctx, |w| w.rect(small).min_size(min));
834            });
835        }
836        assert_eq!(rect, Rect::from_min_size(small.min, min));
837        assert_eq!(stored_rect(&h.ctx, wid("a")), Some(rect));
838    }
839}