Skip to main content

teksilo_widgets/title_bar/
resize_strip.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! A thin invisible widget that forwards a window resize gesture to the
5//! platform host when the user presses the primary button inside it. Used
6//! to build a 6-px resize frame around a borderless window on Wayland.
7//!
8//! This is the frame complement to [`crate::title_bar::DragRegion`]: drag
9//! moves the window, resize strips drag the window edges. On platforms
10//! that don't expose `Window::drag_resize_window` (notably winit's macOS
11//! backend), [`PlatformTitleBarHost::begin_resize`] returns
12//! `PlatformError::Unsupported` and the strip becomes a silent no-op —
13//! macOS handles edge resize via its own native chrome.
14//!
15//! ## Reaching a 6 dp edge with a finger
16//!
17//! The strip's thickness is fixed at every density — it is an overlay drawn
18//! *over* the window's own edge, so widening it would eat into content rather
19//! than into empty space, and the frame would start swallowing presses meant
20//! for the app.
21//!
22//! The grab is [`Widget::hit_outset`] instead: for a direct pointer the strip
23//! is offered the press against bounds inflated inward to the density's target
24//! size (24 dp Compact, 44 dp Touch), and the arena's outset pre-pass runs
25//! before the ordinary reverse-sibling walk, so the widened band beats the
26//! content underneath. An edge strip inflates only across its thickness; a
27//! corner cell inflates on all four sides, since both of its axes are the
28//! diagonal grab. For a precise pointer the outset is zero and a mouse press
29//! resolves exactly where it always did.
30//!
31//! Two strips whose bands overlap are settled by distance to their own
32//! uninflated rectangles, not by sibling order, so the midpoint between two
33//! adjacent edges belongs to the nearer one.
34//!
35//! ## One resize per gesture
36//!
37//! [`PlatformTitleBarHost::begin_resize`] hands the window to the compositor
38//! for the rest of the gesture, so it must be asked once. The press that asks
39//! is gated on the pointer being the **primary** one: a second finger landing
40//! on the frame while a resize is already running would ask for a second
41//! interactive resize of the same window, which on Wayland means a second
42//! `xdg_toplevel::resize` against a live one. A mouse is always primary, so the
43//! gate never fires for one.
44
45use std::rc::Rc;
46
47use teksilo_canvas::{EdgeInsets, Rect, Size, SizeProposal};
48use teksilo_core::TouchAction;
49use teksilo_core::event::{EventResponse, PointerButton, WidgetEvent};
50use teksilo_core::styles::density::dp;
51use teksilo_core::widget::{CursorIcon, LayoutContext, PaintContext, Widget, WidgetPlacement};
52use teksilo_core::widget_builder::HandlerSet;
53use teksilo_core::widget_id::WidgetId;
54use teksilo_core::{PlatformTitleBarHost, ResizeEdge};
55use teksilo_tokens::{DragActivation, InputTokens, PointerKind, TargetRole};
56
57/// A single edge of a resize frame. Construct one per side and lay them
58/// out around your content (HStack of left + content + right inside a
59/// VStack of top + middle + bottom is the conventional shape — see the
60/// title bar demo for an example).
61pub struct ResizeStrip {
62    host: Rc<dyn PlatformTitleBarHost>,
63    edge: ResizeEdge,
64    width: f32,
65    height: f32,
66}
67
68impl std::fmt::Debug for ResizeStrip {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.debug_struct("ResizeStrip")
71            .field("edge", &self.edge)
72            .field("width", &self.width)
73            .field("height", &self.height)
74            .finish_non_exhaustive()
75    }
76}
77
78impl ResizeStrip {
79    /// Build a horizontal (top / bottom) strip of the given height. The
80    /// width is unconstrained — the strip claims whatever its parent
81    /// container offers, so it can stretch across the full window width.
82    pub fn horizontal(
83        host: Rc<dyn PlatformTitleBarHost>,
84        edge: ResizeEdge,
85        thickness: f32,
86    ) -> Self {
87        debug_assert!(matches!(edge, ResizeEdge::Top | ResizeEdge::Bottom));
88        Self {
89            host,
90            edge,
91            width: 0.0,
92            height: thickness,
93        }
94    }
95
96    /// Build a vertical (left / right) strip of the given width. The
97    /// height is unconstrained.
98    pub fn vertical(host: Rc<dyn PlatformTitleBarHost>, edge: ResizeEdge, thickness: f32) -> Self {
99        debug_assert!(matches!(edge, ResizeEdge::Left | ResizeEdge::Right));
100        Self {
101            host,
102            edge,
103            width: thickness,
104            height: 0.0,
105        }
106    }
107
108    /// Build a square corner cell of the given size. The corner handles a
109    /// diagonal resize gesture (e.g. `TopLeft` does NW/SE resize). Should
110    /// be placed *on top of* the edge strips at the four corners so the
111    /// framework's hit-test routes the click to the corner rather than
112    /// the adjacent edge.
113    pub fn corner(host: Rc<dyn PlatformTitleBarHost>, edge: ResizeEdge, size: f32) -> Self {
114        debug_assert!(matches!(
115            edge,
116            ResizeEdge::TopLeft
117                | ResizeEdge::TopRight
118                | ResizeEdge::BottomLeft
119                | ResizeEdge::BottomRight
120        ));
121        Self {
122            host,
123            edge,
124            width: size,
125            height: size,
126        }
127    }
128}
129
130/// Inward hit inflation that lifts a `visual`-thick window-frame grip to the
131/// density's target size, for a **direct** pointer only.
132///
133/// Unlike the splitter's and the dock divider's gutters — which lie *between*
134/// two panes and can be approached from either side, so their band is split
135/// evenly — a window-frame strip sits on the outer boundary: everything outside
136/// it is off-window. The arena clips an outset to the parent it descends
137/// through, so inflating by the whole shortfall on both sides yields exactly
138/// `target_size` of *reachable* band, all of it inward, and the outward half
139/// is discarded by the clip rather than being wasted band.
140///
141/// Only a hit dimension is routed through [`dp`] here: the strip's painted
142/// thickness is the same at every density, which is what keeps the frame from
143/// eating into content — see `engineering/docs/density-inventory.md`.
144fn grab_outset(visual: f32, kind: PointerKind, tokens: &InputTokens) -> f32 {
145    if !kind.is_direct() || !visual.is_finite() || visual <= 0.0 {
146        return 0.0;
147    }
148    (dp(visual, TargetRole::Target, tokens) - visual).max(0.0)
149}
150
151fn cursor_for_edge(edge: ResizeEdge) -> CursorIcon {
152    match edge {
153        ResizeEdge::Top | ResizeEdge::Bottom => CursorIcon::RowResize,
154        ResizeEdge::Left | ResizeEdge::Right => CursorIcon::ColResize,
155        // NW/SE diagonal — corners on the top-left and bottom-right.
156        ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorIcon::NwseResize,
157        // NE/SW diagonal — corners on the top-right and bottom-left.
158        ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorIcon::NeswResize,
159    }
160}
161
162impl Widget for ResizeStrip {
163    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
164        let host = self.host.clone();
165        let edge = self.edge;
166
167        let handlers = HandlerSet::new()
168            .cursor(cursor_for_edge(edge))
169            // The edge drag *is* the interaction: forbid every default touch
170            // behaviour in this subtree and arm at the slop rather than after a
171            // long press. Direct-pointer policy only — a mouse reads neither.
172            .touch_action(TouchAction::NONE)
173            .drag_activation(DragActivation::Immediate)
174            .on_pointer_event(move |evt, ctx| {
175                if let WidgetEvent::PointerDown {
176                    button: PointerButton::Primary,
177                    ..
178                } = evt
179                {
180                    // One interactive resize per gesture. `begin_resize` hands
181                    // the window to the compositor for the rest of the drag, so
182                    // a second contact must not ask for a second one against a
183                    // live session. A mouse is always primary.
184                    if !ctx.pointer().primary {
185                        return EventResponse::Ignored;
186                    }
187                    if host.begin_resize(edge).is_ok() {
188                        // The compositor may consume the release, so finish
189                        // the local press when handing off the interaction.
190                        ctx.cancel_pointer_sequence(
191                            teksilo_core::pointer::CancelReason::OsDragStarted,
192                        );
193                    }
194                    return EventResponse::Handled;
195                }
196                EventResponse::Ignored
197            });
198
199        ctx.apply_self_handlers(handlers);
200        Vec::new()
201    }
202
203    fn layout_response(
204        &self,
205        proposal: SizeProposal,
206        _ctx: &LayoutContext,
207    ) -> teksilo_core::widget::LayoutResponse {
208        // Horizontal strips: claim full proposed width, fixed height.
209        // Vertical strips: claim full proposed height, fixed width.
210        let w = if self.width > 0.0 {
211            self.width
212        } else {
213            proposal.width.unwrap_or(0.0)
214        };
215        let h = if self.height > 0.0 {
216            self.height
217        } else {
218            proposal.height.unwrap_or(0.0)
219        };
220        Size::new(w, h).into()
221    }
222
223    fn place_children(
224        &self,
225        _bounds: Rect,
226        _proposal: SizeProposal,
227        _children: &mut [WidgetPlacement],
228        _ctx: &LayoutContext,
229    ) {
230    }
231
232    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {
233        // Invisible.
234    }
235
236    /// The grab band.
237    ///
238    /// An edge strip inflates across its thickness axis only; inflating along
239    /// its length would reach past the window's own corner into the adjacent
240    /// edge, which the corner cells already serve. A corner cell inflates on
241    /// all four sides, because both of its axes are the diagonal grab.
242    ///
243    /// The insets are symmetric on each inflated axis even though only the
244    /// inward half is reachable: the outward half falls outside the frame's own
245    /// bounds, and the arena has already tested those before it looks at a
246    /// child, so the clip discards it. Symmetry is also what keeps this correct
247    /// under RTL, where [`EdgeInsets`]'s reading-order `leading`/`trailing` map
248    /// to the opposite screen edges from the [`ResizeEdge`] the strip was built
249    /// for.
250    fn hit_outset(&self, kind: PointerKind, tokens: &InputTokens) -> EdgeInsets {
251        let is_corner = self.width > 0.0 && self.height > 0.0;
252        let thickness = if is_corner {
253            self.width.min(self.height)
254        } else if self.height > 0.0 {
255            self.height
256        } else {
257            self.width
258        };
259        let out = grab_outset(thickness, kind, tokens);
260        if out <= 0.0 {
261            return EdgeInsets::ZERO;
262        }
263        if is_corner {
264            EdgeInsets::uniform(out)
265        } else if self.height > 0.0 {
266            // Horizontal strip: thin vertically.
267            EdgeInsets::new(out, 0.0, out, 0.0)
268        } else {
269            EdgeInsets::new(0.0, out, 0.0, out)
270        }
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use teksilo_canvas::Point;
278    use teksilo_core::{HitRegions, PlatformError};
279    use teksilo_tokens::TargetDensity;
280
281    #[derive(Debug)]
282    struct NoopHost;
283
284    impl PlatformTitleBarHost for NoopHost {
285        fn reserved_leading_inset(&self) -> Size {
286            Size::ZERO
287        }
288        fn reserved_trailing_inset(&self) -> Size {
289            Size::ZERO
290        }
291        fn renders_custom_controls(&self) -> bool {
292            true
293        }
294        fn needs_custom_resize_handles(&self) -> bool {
295            true
296        }
297        fn begin_drag(&self) -> Result<(), PlatformError> {
298            Ok(())
299        }
300        fn begin_resize(&self, _edge: ResizeEdge) -> Result<(), PlatformError> {
301            Ok(())
302        }
303        fn show_window_menu(&self, _at: Point) -> Result<(), PlatformError> {
304            Ok(())
305        }
306        fn update_hit_regions(&self, _regions: &HitRegions) {}
307    }
308
309    fn host() -> Rc<dyn PlatformTitleBarHost> {
310        Rc::new(NoopHost)
311    }
312
313    fn tokens(density: TargetDensity) -> InputTokens {
314        InputTokens::for_density(density)
315    }
316
317    /// A frame strip inflates by the **whole** shortfall, not half of it: the
318    /// outward half falls off the window and is discarded by the parent clip,
319    /// so `thickness + inward` is exactly the target size. 6 dp + 18 dp = 24 dp
320    /// at Compact; 6 + 38 = 44 at Touch.
321    #[test]
322    fn an_edge_strip_reaches_the_target_size_inward() {
323        for (density, expected) in [
324            (TargetDensity::Compact, 18.0_f32),
325            (TargetDensity::Comfortable, 26.0),
326            (TargetDensity::Touch, 38.0),
327        ] {
328            let t = tokens(density);
329            let strip = ResizeStrip::horizontal(host(), ResizeEdge::Top, 6.0);
330            let insets = strip.hit_outset(PointerKind::Touch, &t);
331            assert_eq!(insets.top, expected, "{density:?}");
332            assert_eq!(insets.bottom, expected, "{density:?}");
333            assert_eq!(insets.leading, 0.0, "never along the strip's length");
334            assert_eq!(insets.trailing, 0.0, "never along the strip's length");
335            assert_eq!(
336                6.0 + expected,
337                t.target_size,
338                "thickness plus the inward half is the target size"
339            );
340        }
341    }
342
343    /// A vertical strip is the transpose: it inflates on the reading axis only.
344    #[test]
345    fn a_vertical_strip_inflates_across_the_reading_axis() {
346        let t = tokens(TargetDensity::Compact);
347        let strip = ResizeStrip::vertical(host(), ResizeEdge::Left, 6.0);
348        let insets = strip.hit_outset(PointerKind::Touch, &t);
349        assert_eq!(insets.leading, 18.0);
350        assert_eq!(insets.trailing, 18.0);
351        assert_eq!(insets.top, 0.0);
352        assert_eq!(insets.bottom, 0.0);
353    }
354
355    /// A corner inflates on all four sides — both of its axes are the diagonal
356    /// grab.
357    #[test]
358    fn a_corner_inflates_on_every_side() {
359        let t = tokens(TargetDensity::Compact);
360        let strip = ResizeStrip::corner(host(), ResizeEdge::TopLeft, 6.0);
361        let insets = strip.hit_outset(PointerKind::Touch, &t);
362        assert_eq!(insets.top, 18.0);
363        assert_eq!(insets.bottom, 18.0);
364        assert_eq!(insets.leading, 18.0);
365        assert_eq!(insets.trailing, 18.0);
366    }
367
368    /// Zero for every precise pointer at every density — the mouse invariant,
369    /// stated at the arithmetic rather than only at the tree.
370    #[test]
371    fn a_precise_pointer_is_never_widened() {
372        for density in [
373            TargetDensity::Compact,
374            TargetDensity::Comfortable,
375            TargetDensity::Touch,
376        ] {
377            let t = tokens(density);
378            for strip in [
379                ResizeStrip::horizontal(host(), ResizeEdge::Top, 6.0),
380                ResizeStrip::vertical(host(), ResizeEdge::Left, 6.0),
381                ResizeStrip::corner(host(), ResizeEdge::TopLeft, 6.0),
382            ] {
383                assert_eq!(
384                    strip.hit_outset(PointerKind::Mouse, &t),
385                    EdgeInsets::ZERO,
386                    "{density:?}"
387                );
388            }
389        }
390    }
391
392    /// A frame already built thick enough earns nothing: `dp` is a floor.
393    #[test]
394    fn a_strip_that_already_clears_the_floor_is_not_widened() {
395        let t = tokens(TargetDensity::Compact);
396        let strip = ResizeStrip::horizontal(host(), ResizeEdge::Bottom, 30.0);
397        assert_eq!(strip.hit_outset(PointerKind::Touch, &t), EdgeInsets::ZERO);
398    }
399}