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}