Skip to main content

teksilo_widgets/title_bar/
window_frame.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! A borderless-window frame: an invisible overlay of resize strips and
5//! corner cells along the four edges of a single content widget.
6//!
7//! `WindowFrame` is the canonical way to wrap a `TitleBar` + body for an
8//! undecorated Wayland window. The content child fills the entire window
9//! bounds — there is *no* visible padding — and the resize strips +
10//! corners sit on top of the content along the edges. teksilo-core's
11//! `hit_test_recursive` walks children in reverse insertion order, so
12//! the strips and corners (added after content) get first crack at any
13//! click that lands within `thickness` pixels of an edge; clicks
14//! anywhere else fall through to the content.
15//!
16//! Layout (with `thickness = t`):
17//!
18//! ```text
19//! ┌─top─edge───────────────────────┐  ← top strip overlays content (0, 0, w, t)
20//! │TL│                          │TR│  ← corners overlay the strip ends
21//! │──│                          │──│
22//! │L │       content (full)     │R │  ← content fills (0, 0, w, h)
23//! │──│                          │──│
24//! │BL│                          │BR│
25//! └─bottom─edge────────────────────┘
26//! ```
27//!
28//! `t` defaults to 6 logical pixels but is configurable via
29//! [`WindowFrame::thickness`]. With a small thickness the frame is
30//! visually undetectable; the cursor only changes (and the resize
31//! gesture only triggers) when the pointer is within `t` pixels of the
32//! window boundary — with a *mouse*. A finger reaches the same edge
33//! through each strip's [`Widget::hit_outset`], which widens the band to
34//! the density's target size without moving a pixel of layout; see
35//! [`resize_strip`](super::resize_strip).
36//!
37//! ## Telling the OS the same number
38//!
39//! On a platform where the window manager answers the resize hit test itself
40//! — Windows, through `WM_NCHITTEST` — the frame's own strips never see the
41//! press, so the two layers have to agree about how wide the band is or a
42//! finger lands in the gap between them. The frame therefore publishes its
43//! **coarse** band (the widened one, in logical pixels) as
44//! [`HitRegions::resize_borders`] every frame, and the backend applies it to
45//! coarse messages only, never shrinking the band a mouse gets.
46//!
47//! That channel has one publisher per window — [`TitleBar`](crate::TitleBar)
48//! aggregates the drag region and the control buttons into one snapshot from
49//! its own `after_paint`. The frame does not compete with it: the snapshot it
50//! publishes carries a non-zero `resize_borders` and nothing else, which is the
51//! documented shape a backend reads as a *band update* rather than a
52//! replacement. `after_paint` is post-order, so wrapping the title bar (the
53//! canonical shape — `WindowFrame::content(VStack { TitleBar, body })`) puts
54//! the band update after the aggregate snapshot every frame.
55//!
56//! Wrapping the frame in a `WidgetBuilder` method is safe:
57//! `WidgetWithHandlers` forwards `wants_after_paint` / `after_paint` along with
58//! the rest of the trait, so `WindowFrame::new(host).content(..).on_tap(..)`
59//! still publishes. It did not always — the wrapper's forwarding list was
60//! incomplete, and an unforwarded hook silences a publish with no diagnostic —
61//! so the list is now exhaustive and lint-guarded at its own impl.
62
63use std::rc::Rc;
64
65use teksilo_canvas::{Point, Rect, Size, SizeProposal};
66use teksilo_core::styles::density::dp;
67use teksilo_core::widget::{
68    LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement, WidgetTreeView,
69};
70use teksilo_core::widget_id::WidgetId;
71use teksilo_core::{HitRegions, PlatformTitleBarHost, ResizeBorders, ResizeEdge};
72use teksilo_tokens::{InputTokens, TargetRole};
73
74use super::resize_strip::ResizeStrip;
75
76/// Invisible overlay of resize strips and corner cells that gives a borderless window
77/// draggable edges. The content child fills the full client area with no visible inset;
78/// the strips are hit-test-only overlays along the outer `thickness` pixels.
79pub struct WindowFrame {
80    host: Rc<dyn PlatformTitleBarHost>,
81    thickness: f32,
82    pending_content: Option<PendingChild>,
83    content_id: Option<WidgetId>,
84    /// Order: [top, bottom, left, right]
85    strip_ids: [Option<WidgetId>; 4],
86    /// Order: [top_left, top_right, bottom_left, bottom_right]
87    corner_ids: [Option<WidgetId>; 4],
88}
89
90impl std::fmt::Debug for WindowFrame {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("WindowFrame")
93            .field("thickness", &self.thickness)
94            .field("has_content", &self.pending_content.is_some())
95            .finish_non_exhaustive()
96    }
97}
98
99/// Logical-pixel thickness of each resize strip, and the frame's default.
100///
101/// The same 6 dp gutter the `Splitter` and the dock resize handle use. It is
102/// **fixed at every density**: the strip is a hit-test-only overlay drawn over
103/// the window's own edge, so widening it would eat into the content rather
104/// than into empty space. A coarse pointer reaches it through
105/// `Widget::hit_outset` (24 dp, 44 at Touch) over an unchanged 6 dp visual —
106/// see `docs/density-inventory.md` and the touch design's Constants table.
107pub const WINDOW_FRAME_RESIZE_THICKNESS: f32 = 6.0;
108
109impl WindowFrame {
110    /// Create a frame bound to the given platform host. Use [`thickness`](WindowFrame::thickness)
111    /// and [`content`](WindowFrame::content) to configure it before adding to the tree.
112    pub fn new(host: Rc<dyn PlatformTitleBarHost>) -> Self {
113        Self {
114            host,
115            thickness: WINDOW_FRAME_RESIZE_THICKNESS,
116            pending_content: None,
117            content_id: None,
118            strip_ids: [None; 4],
119            corner_ids: [None; 4],
120        }
121    }
122
123    /// Logical-pixel thickness of each resize strip. Default:
124    /// [`WINDOW_FRAME_RESIZE_THICKNESS`].
125    pub fn thickness(mut self, t: f32) -> Self {
126        self.thickness = t;
127        self
128    }
129
130    /// Set the inner content widget — typically a `VStack` containing a
131    /// `TitleBar` and the application body.
132    pub fn content(mut self, w: impl teksilo_core::IntoTeksiChild) -> Self {
133        self.pending_content = Some(teksilo_core::IntoTeksiChild::into_pending(w));
134        self
135    }
136
137    /// Set the inner content widget from an already-boxed value. Prefer [`content`](WindowFrame::content)
138    /// for unboxed widgets; use this variant when the concrete type is not known at the call site.
139    pub fn content_boxed(mut self, w: Box<dyn Widget>) -> Self {
140        self.pending_content = Some(PendingChild::Deferred(w));
141        self
142    }
143
144    /// The band a **coarse** pointer actually catches on each edge, in logical
145    /// pixels: the strip's painted thickness widened to the density's target
146    /// size by [`Widget::hit_outset`] (24 dp Compact, 44 dp Touch).
147    ///
148    /// This is what the frame publishes as [`HitRegions::resize_borders`] so a
149    /// backend that answers the resize hit test itself can use the same number.
150    /// It is deliberately *not* the mouse band: a mouse keeps the painted
151    /// thickness, and a backend must never shrink its own metric to this.
152    ///
153    /// Uniform across the four edges — every strip is built at the same
154    /// thickness — so a caller reading one field reads them all.
155    pub fn coarse_resize_borders(&self, tokens: &InputTokens) -> ResizeBorders {
156        ResizeBorders::uniform(dp(self.thickness, TargetRole::Target, tokens))
157    }
158}
159
160impl Widget for WindowFrame {
161    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
162        // Resolve the optional content child first so it sits at index 0
163        // in the children list — `place_children` relies on the order
164        // matching
165        // `[content, top, bottom, left, right, top_left, top_right, bottom_left, bottom_right]`.
166        if let Some(pending) = self.pending_content.take() {
167            self.content_id = Some(match pending {
168                PendingChild::Id(id) => id,
169                PendingChild::Deferred(w) => ctx.add_boxed(w),
170            });
171        }
172
173        self.strip_ids[0] = Some(ctx.add(ResizeStrip::horizontal(
174            self.host.clone(),
175            ResizeEdge::Top,
176            self.thickness,
177        )));
178        self.strip_ids[1] = Some(ctx.add(ResizeStrip::horizontal(
179            self.host.clone(),
180            ResizeEdge::Bottom,
181            self.thickness,
182        )));
183        self.strip_ids[2] = Some(ctx.add(ResizeStrip::vertical(
184            self.host.clone(),
185            ResizeEdge::Left,
186            self.thickness,
187        )));
188        self.strip_ids[3] = Some(ctx.add(ResizeStrip::vertical(
189            self.host.clone(),
190            ResizeEdge::Right,
191            self.thickness,
192        )));
193
194        // Corners — added AFTER the edges so that teksilo-core's hit-test
195        // (children walked in reverse order — see `hit_test_recursive`
196        // in `arena.rs`) checks the corners first. In
197        // practice we also place them at non-overlapping positions, but
198        // walking last also guarantees priority under future layout
199        // refactors.
200        self.corner_ids[0] = Some(ctx.add(ResizeStrip::corner(
201            self.host.clone(),
202            ResizeEdge::TopLeft,
203            self.thickness,
204        )));
205        self.corner_ids[1] = Some(ctx.add(ResizeStrip::corner(
206            self.host.clone(),
207            ResizeEdge::TopRight,
208            self.thickness,
209        )));
210        self.corner_ids[2] = Some(ctx.add(ResizeStrip::corner(
211            self.host.clone(),
212            ResizeEdge::BottomLeft,
213            self.thickness,
214        )));
215        self.corner_ids[3] = Some(ctx.add(ResizeStrip::corner(
216            self.host.clone(),
217            ResizeEdge::BottomRight,
218            self.thickness,
219        )));
220
221        let mut ids = Vec::with_capacity(9);
222        if let Some(c) = self.content_id {
223            ids.push(c);
224        }
225        for s in self.strip_ids.iter().flatten() {
226            ids.push(*s);
227        }
228        for c in self.corner_ids.iter().flatten() {
229            ids.push(*c);
230        }
231        ids
232    }
233
234    fn layout_response(
235        &self,
236        proposal: SizeProposal,
237        _ctx: &LayoutContext,
238    ) -> teksilo_core::widget::LayoutResponse {
239        // Always claim every pixel offered. The frame is meant to wrap a
240        // window's full client area — anything smaller would leave bare
241        // space at the edges.
242        Size::new(
243            proposal.width.unwrap_or(0.0),
244            proposal.height.unwrap_or(0.0),
245        )
246        .into()
247    }
248
249    fn place_children(
250        &self,
251        bounds: Rect,
252        _proposal: SizeProposal,
253        children: &mut [WidgetPlacement],
254        _ctx: &LayoutContext,
255    ) {
256        let t = self.thickness;
257
258        // Children are in insertion order:
259        //   index 0 (if content present) → content
260        //   then [top, bottom, left, right] edges
261        //   then [top_left, top_right, bottom_left, bottom_right] corners
262        //
263        // Hit-test walks `.iter().rev()`, so corners are checked first,
264        // then edges, then content — exactly the priority we want.
265        let mut i = 0;
266
267        if self.content_id.is_some() {
268            // Content fills the FULL window — no inset, no visible
269            // padding. The strips overlay it along the edges.
270            children[i].origin = bounds.origin();
271            children[i].size = bounds.size();
272            i += 1;
273        }
274
275        // Edges — full-length strips overlaying the outer `t` pixels of
276        // the content. They overlap the corners by `t × t`, but the
277        // corner cells (added after) win the hit-test in those regions.
278        // Top.
279        children[i].origin = bounds.origin();
280        children[i].size = Size::new(bounds.width, t);
281        i += 1;
282
283        // Bottom.
284        children[i].origin = Point::new(bounds.x, bounds.bottom() - t);
285        children[i].size = Size::new(bounds.width, t);
286        i += 1;
287
288        // Left.
289        children[i].origin = bounds.origin();
290        children[i].size = Size::new(t, bounds.height);
291        i += 1;
292
293        // Right.
294        children[i].origin = Point::new(bounds.right() - t, bounds.y);
295        children[i].size = Size::new(t, bounds.height);
296        i += 1;
297
298        // Corners — `t × t` squares at the four window corners.
299        // Top-left.
300        children[i].origin = bounds.origin();
301        children[i].size = Size::new(t, t);
302        i += 1;
303
304        // Top-right.
305        children[i].origin = Point::new(bounds.right() - t, bounds.y);
306        children[i].size = Size::new(t, t);
307        i += 1;
308
309        // Bottom-left.
310        children[i].origin = Point::new(bounds.x, bounds.bottom() - t);
311        children[i].size = Size::new(t, t);
312        i += 1;
313
314        // Bottom-right.
315        children[i].origin = Point::new(bounds.right() - t, bounds.bottom() - t);
316        children[i].size = Size::new(t, t);
317    }
318
319    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {
320        // Fully transparent — the inner content paints its own background.
321    }
322
323    fn wants_after_paint(&self) -> bool {
324        // Publish the coarse resize band so a backend that owns the non-client
325        // hit test agrees with what the strips will actually catch. See the
326        // module docs for why this cannot collide with `TitleBar`'s aggregate
327        // snapshot.
328        true
329    }
330
331    fn after_paint(&self, _view: &WidgetTreeView<'_>, ctx: &PaintContext) {
332        let regions = HitRegions {
333            resize_borders: self.coarse_resize_borders(&ctx.theme.input),
334            ..HitRegions::default()
335        };
336        self.host.update_hit_regions(&regions);
337    }
338
339    fn children(&self) -> Vec<WidgetId> {
340        let mut ids = Vec::with_capacity(9);
341        if let Some(c) = self.content_id {
342            ids.push(c);
343        }
344        for s in self.strip_ids.iter().flatten() {
345            ids.push(*s);
346        }
347        for c in self.corner_ids.iter().flatten() {
348            ids.push(*c);
349        }
350        ids
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use std::cell::Cell;
358    use teksilo_canvas::Point;
359    use teksilo_core::Signal;
360    use teksilo_core::widget_tree::WidgetTree;
361    use teksilo_core::{HitRegions, PlatformError};
362
363    struct TestHost {
364        last_resize_edge: Cell<Option<ResizeEdge>>,
365        resize_calls: Cell<u32>,
366        /// Last payload handed to `update_hit_regions` — what a real backend
367        /// would store and answer its non-client hit test from.
368        last_regions: std::cell::RefCell<Option<HitRegions>>,
369        is_max: Signal<bool>,
370    }
371
372    impl Default for TestHost {
373        fn default() -> Self {
374            Self {
375                last_resize_edge: Cell::new(None),
376                resize_calls: Cell::new(0),
377                last_regions: std::cell::RefCell::new(None),
378                is_max: Signal::new(false),
379            }
380        }
381    }
382
383    impl PlatformTitleBarHost for TestHost {
384        fn reserved_leading_inset(&self) -> Size {
385            Size::ZERO
386        }
387        fn reserved_trailing_inset(&self) -> Size {
388            Size::ZERO
389        }
390        fn renders_custom_controls(&self) -> bool {
391            true
392        }
393        fn needs_custom_resize_handles(&self) -> bool {
394            true
395        }
396        fn begin_drag(&self) -> Result<(), PlatformError> {
397            Ok(())
398        }
399        fn begin_resize(&self, edge: ResizeEdge) -> Result<(), PlatformError> {
400            self.last_resize_edge.set(Some(edge));
401            self.resize_calls.set(self.resize_calls.get() + 1);
402            Ok(())
403        }
404        fn show_window_menu(&self, _at: Point) -> Result<(), PlatformError> {
405            Ok(())
406        }
407        fn update_hit_regions(&self, regions: &HitRegions) {
408            *self.last_regions.borrow_mut() = Some(regions.clone());
409        }
410    }
411
412    #[derive(Debug)]
413    struct ContentLeaf;
414    impl Widget for ContentLeaf {
415        fn layout_response(
416            &self,
417            proposal: SizeProposal,
418            _ctx: &LayoutContext,
419        ) -> teksilo_core::widget::LayoutResponse {
420            Size::new(
421                proposal.width.unwrap_or(0.0),
422                proposal.height.unwrap_or(0.0),
423            )
424            .into()
425        }
426    }
427
428    #[test]
429    fn frame_content_fills_full_window_no_visible_padding() {
430        let host: Rc<dyn PlatformTitleBarHost> = Rc::new(TestHost::default());
431        let mut tree = WidgetTree::new();
432        let frame = tree.add(WindowFrame::new(host).thickness(6.0).content(ContentLeaf));
433        tree.layout(SizeProposal::exact(900.0, 600.0));
434
435        // The frame itself fills the window.
436        let f = tree.bounds(frame);
437        assert!((f.width - 900.0).abs() < 0.01);
438        assert!((f.height - 600.0).abs() < 0.01);
439
440        // Content is the FULL window — the resize frame is a hit-test
441        // overlay only, no visible inset.
442        let kids = tree.children(frame);
443        let content = kids[0];
444        let cb = tree.bounds(content);
445        assert!((cb.x - 0.0).abs() < 0.01, "content x = {}", cb.x);
446        assert!((cb.y - 0.0).abs() < 0.01, "content y = {}", cb.y);
447        assert!((cb.width - 900.0).abs() < 0.01, "content w = {}", cb.width);
448        assert!(
449            (cb.height - 600.0).abs() < 0.01,
450            "content h = {}",
451            cb.height
452        );
453    }
454
455    #[test]
456    fn clicking_top_strip_calls_begin_resize_top() {
457        let host = Rc::new(TestHost::default());
458        let mut tree = WidgetTree::new();
459        let _frame = tree.add(
460            WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
461                .thickness(6.0)
462                .content(ContentLeaf),
463        );
464        tree.layout(SizeProposal::exact(900.0, 600.0));
465
466        // Click in the top 6 pixels.
467        tree.pointer_move(Point::new(450.0, 3.0));
468        tree.pointer_down_button(
469            Point::new(450.0, 3.0),
470            teksilo_core::event::PointerButton::Primary,
471        );
472        tree.pointer_up_button(
473            Point::new(450.0, 3.0),
474            teksilo_core::event::PointerButton::Primary,
475        );
476
477        assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::Top));
478    }
479
480    #[test]
481    fn clicking_top_left_corner_calls_begin_resize_top_left() {
482        let host = Rc::new(TestHost::default());
483        let mut tree = WidgetTree::new();
484        let _frame = tree.add(
485            WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
486                .thickness(6.0)
487                .content(ContentLeaf),
488        );
489        tree.layout(SizeProposal::exact(900.0, 600.0));
490
491        // Click inside the 6x6 top-left corner.
492        tree.pointer_move(Point::new(2.0, 2.0));
493        tree.pointer_down_button(
494            Point::new(2.0, 2.0),
495            teksilo_core::event::PointerButton::Primary,
496        );
497        tree.pointer_up_button(
498            Point::new(2.0, 2.0),
499            teksilo_core::event::PointerButton::Primary,
500        );
501
502        assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::TopLeft));
503    }
504
505    #[test]
506    fn clicking_bottom_right_corner_calls_begin_resize_bottom_right() {
507        let host = Rc::new(TestHost::default());
508        let mut tree = WidgetTree::new();
509        let _frame = tree.add(
510            WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
511                .thickness(6.0)
512                .content(ContentLeaf),
513        );
514        tree.layout(SizeProposal::exact(900.0, 600.0));
515
516        // Click inside the 6x6 bottom-right corner: x in [894, 900),
517        // y in [594, 600).
518        let p = Point::new(897.0, 597.0);
519        tree.pointer_move(p);
520        tree.pointer_down_button(p, teksilo_core::event::PointerButton::Primary);
521        tree.pointer_up_button(p, teksilo_core::event::PointerButton::Primary);
522
523        assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::BottomRight));
524    }
525
526    // ---------------------------------------------------------------
527    // The coarse grab band
528    // ---------------------------------------------------------------
529
530    /// The content is **tappable** in these fixtures on purpose: over inert
531    /// content the miss-only slop pass re-attributes a nearby press to a strip
532    /// by itself, so a hit test written over an inert leaf would pass with
533    /// `hit_outset` deleted. Beating a target that owns the press is what the
534    /// outset is for.
535    fn frame_over_tappable_content(
536        host: Rc<TestHost>,
537        density: teksilo_tokens::TargetDensity,
538    ) -> WidgetTree {
539        use teksilo_core::widget_builder::WidgetBuilder;
540        let mut tree = WidgetTree::new()
541            .with_theme(teksilo_core::presets::intui::light().with_density(density))
542            .with_text_backend(Rc::new(std::cell::RefCell::new(
543                teksilo_canvas::MockTextBackend::new(),
544            )));
545        let _frame = tree.add(
546            WindowFrame::new(host as Rc<dyn PlatformTitleBarHost>)
547                .thickness(6.0)
548                .content(ContentLeaf.on_tap(|_, _| {})),
549        );
550        tree.layout(SizeProposal::exact(900.0, 600.0));
551        tree
552    }
553
554    fn finger(raw: u64, primary: bool) -> teksilo_core::pointer::PointerInfo {
555        use teksilo_core::pointer::{BackendDeviceKey, EventTime, PointerIdAllocator, PointerInfo};
556        let id = PointerIdAllocator::global().begin(BackendDeviceKey::new(0x50F2), raw);
557        let mut info = PointerInfo::touch(id, EventTime::ZERO);
558        info.primary = primary;
559        info
560    }
561
562    fn contact(
563        pointer: teksilo_core::pointer::PointerInfo,
564        phase: teksilo_core::pointer::PointerPhase,
565        at: Point,
566    ) -> teksilo_core::pointer::PointerSample {
567        teksilo_core::pointer::PointerSample {
568            pointer,
569            phase,
570            position: at,
571            button: None,
572            modifiers: teksilo_core::event::Modifiers::NONE,
573            coalesced: Vec::new(),
574        }
575    }
576
577    /// A finger reaches the top edge from 14 dp inside the content — the band
578    /// the OS-side publication advertises — and the press really does start a
579    /// resize, not merely hit-test to the strip.
580    #[test]
581    fn a_finger_grabs_the_top_edge_from_inside_the_content() {
582        let host = Rc::new(TestHost::default());
583        let mut tree =
584            frame_over_tappable_content(host.clone(), teksilo_tokens::TargetDensity::Compact);
585        let at = Point::new(450.0, 14.0);
586        let f = finger(31, true);
587        tree.dispatch_pointer(contact(f, teksilo_core::pointer::PointerPhase::Down, at));
588        assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::Top));
589    }
590
591    /// The same point with a mouse is content, exactly as it was before the
592    /// grab existed: the band is direct-pointer-only.
593    #[test]
594    fn a_mouse_below_the_strip_is_still_content() {
595        let host = Rc::new(TestHost::default());
596        let mut tree =
597            frame_over_tappable_content(host.clone(), teksilo_tokens::TargetDensity::Compact);
598        let at = Point::new(450.0, 14.0);
599        tree.pointer_move(at);
600        tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
601        tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
602        assert_eq!(host.last_resize_edge.get(), None);
603        assert_eq!(host.resize_calls.get(), 0);
604    }
605
606    /// Touch density widens the band to 44 dp without moving a pixel: the
607    /// strips are still 6 dp and the content still fills the window.
608    #[test]
609    fn the_grab_moves_no_layout_at_touch_density() {
610        let host = Rc::new(TestHost::default());
611        let mut tree =
612            frame_over_tappable_content(host.clone(), teksilo_tokens::TargetDensity::Touch);
613        let frame = tree.roots()[0];
614        let kids = tree.children(frame);
615        let content = tree.bounds(kids[0]);
616        assert_eq!(content, Rect::new(0.0, 0.0, 900.0, 600.0));
617        // kids[1] is the top strip — 6 dp tall, unchanged.
618        assert_eq!(tree.bounds(kids[1]).height, 6.0);
619
620        // …and a finger reaches 30 dp in, which Compact would not have allowed.
621        let at = Point::new(450.0, 30.0);
622        let f = finger(32, true);
623        tree.dispatch_pointer(contact(f, teksilo_core::pointer::PointerPhase::Down, at));
624        assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::Top));
625    }
626
627    /// One interactive resize per gesture: a second contact must not ask the
628    /// compositor for a second resize of the same window against a live one.
629    #[test]
630    fn a_second_contact_does_not_start_a_second_resize() {
631        let host = Rc::new(TestHost::default());
632        let mut tree =
633            frame_over_tappable_content(host.clone(), teksilo_tokens::TargetDensity::Compact);
634        let first = finger(33, true);
635        tree.dispatch_pointer(contact(
636            first,
637            teksilo_core::pointer::PointerPhase::Down,
638            Point::new(450.0, 3.0),
639        ));
640        assert_eq!(host.resize_calls.get(), 1);
641
642        let second = finger(34, false);
643        tree.dispatch_pointer(contact(
644            second,
645            teksilo_core::pointer::PointerPhase::Down,
646            Point::new(300.0, 3.0),
647        ));
648        assert_eq!(
649            host.resize_calls.get(),
650            1,
651            "the second contact must not start a second resize"
652        );
653    }
654
655    /// The band the frame publishes is the one a finger actually catches, so a
656    /// backend answering the non-client hit test agrees with the widget layer.
657    #[test]
658    fn the_frame_publishes_the_coarse_band_it_will_catch() {
659        for (density, expected) in [
660            (teksilo_tokens::TargetDensity::Compact, 24.0_f32),
661            (teksilo_tokens::TargetDensity::Comfortable, 32.0),
662            (teksilo_tokens::TargetDensity::Touch, 44.0),
663        ] {
664            let host = Rc::new(TestHost::default());
665            let mut tree = frame_over_tappable_content(host.clone(), density);
666            let _ = tree.render();
667            let regions = host
668                .last_regions
669                .borrow()
670                .clone()
671                .expect("the frame publishes every frame");
672            assert_eq!(regions.resize_borders.top, expected, "{density:?}");
673            assert_eq!(regions.resize_borders.left, expected, "{density:?}");
674            // …and nothing else, so a backend reads it as a band update rather
675            // than as a replacement for the title bar's own snapshot.
676            assert!(regions.drag.is_empty());
677            assert!(regions.minimize.is_none());
678        }
679    }
680
681    #[test]
682    fn clicking_in_content_area_does_not_resize() {
683        let host = Rc::new(TestHost::default());
684        let mut tree = WidgetTree::new();
685        let _frame = tree.add(
686            WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
687                .thickness(6.0)
688                .content(ContentLeaf),
689        );
690        tree.layout(SizeProposal::exact(900.0, 600.0));
691
692        // Click in the middle of the content area.
693        tree.pointer_move(Point::new(450.0, 300.0));
694        tree.pointer_down_button(
695            Point::new(450.0, 300.0),
696            teksilo_core::event::PointerButton::Primary,
697        );
698        tree.pointer_up_button(
699            Point::new(450.0, 300.0),
700            teksilo_core::event::PointerButton::Primary,
701        );
702
703        assert_eq!(
704            host.last_resize_edge.get(),
705            None,
706            "interior clicks must not trigger resize"
707        );
708    }
709}