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
15use std::rc::Rc;
16
17use teksilo_canvas::{Rect, Size, SizeProposal};
18use teksilo_core::event::{EventResponse, PointerButton, WidgetEvent};
19use teksilo_core::widget::{CursorIcon, LayoutContext, PaintContext, Widget, WidgetPlacement};
20use teksilo_core::widget_builder::HandlerSet;
21use teksilo_core::widget_id::WidgetId;
22use teksilo_core::{PlatformTitleBarHost, ResizeEdge};
23
24/// A single edge of a resize frame. Construct one per side and lay them
25/// out around your content (HStack of left + content + right inside a
26/// VStack of top + middle + bottom is the conventional shape — see the
27/// title bar demo for an example).
28pub struct ResizeStrip {
29    host: Rc<dyn PlatformTitleBarHost>,
30    edge: ResizeEdge,
31    width: f32,
32    height: f32,
33}
34
35impl std::fmt::Debug for ResizeStrip {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.debug_struct("ResizeStrip")
38            .field("edge", &self.edge)
39            .field("width", &self.width)
40            .field("height", &self.height)
41            .finish_non_exhaustive()
42    }
43}
44
45impl ResizeStrip {
46    /// Build a horizontal (top / bottom) strip of the given height. The
47    /// width is unconstrained — the strip claims whatever its parent
48    /// container offers, so it can stretch across the full window width.
49    pub fn horizontal(
50        host: Rc<dyn PlatformTitleBarHost>,
51        edge: ResizeEdge,
52        thickness: f32,
53    ) -> Self {
54        debug_assert!(matches!(edge, ResizeEdge::Top | ResizeEdge::Bottom));
55        Self {
56            host,
57            edge,
58            width: 0.0,
59            height: thickness,
60        }
61    }
62
63    /// Build a vertical (left / right) strip of the given width. The
64    /// height is unconstrained.
65    pub fn vertical(host: Rc<dyn PlatformTitleBarHost>, edge: ResizeEdge, thickness: f32) -> Self {
66        debug_assert!(matches!(edge, ResizeEdge::Left | ResizeEdge::Right));
67        Self {
68            host,
69            edge,
70            width: thickness,
71            height: 0.0,
72        }
73    }
74
75    /// Build a square corner cell of the given size. The corner handles a
76    /// diagonal resize gesture (e.g. `TopLeft` does NW/SE resize). Should
77    /// be placed *on top of* the edge strips at the four corners so the
78    /// framework's hit-test routes the click to the corner rather than
79    /// the adjacent edge.
80    pub fn corner(host: Rc<dyn PlatformTitleBarHost>, edge: ResizeEdge, size: f32) -> Self {
81        debug_assert!(matches!(
82            edge,
83            ResizeEdge::TopLeft
84                | ResizeEdge::TopRight
85                | ResizeEdge::BottomLeft
86                | ResizeEdge::BottomRight
87        ));
88        Self {
89            host,
90            edge,
91            width: size,
92            height: size,
93        }
94    }
95}
96
97fn cursor_for_edge(edge: ResizeEdge) -> CursorIcon {
98    match edge {
99        ResizeEdge::Top | ResizeEdge::Bottom => CursorIcon::RowResize,
100        ResizeEdge::Left | ResizeEdge::Right => CursorIcon::ColResize,
101        // NW/SE diagonal — corners on the top-left and bottom-right.
102        ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorIcon::NwseResize,
103        // NE/SW diagonal — corners on the top-right and bottom-left.
104        ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorIcon::NeswResize,
105    }
106}
107
108impl Widget for ResizeStrip {
109    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
110        let host = self.host.clone();
111        let edge = self.edge;
112
113        let handlers = HandlerSet::new()
114            .cursor(cursor_for_edge(edge))
115            .on_pointer_event(move |evt, _ctx| {
116                if let WidgetEvent::PointerDown {
117                    button: PointerButton::Primary,
118                    ..
119                } = evt
120                {
121                    let _ = host.begin_resize(edge);
122                    return EventResponse::Handled;
123                }
124                EventResponse::Ignored
125            });
126
127        ctx.apply_self_handlers(handlers);
128        Vec::new()
129    }
130
131    fn layout_response(
132        &self,
133        proposal: SizeProposal,
134        _ctx: &LayoutContext,
135    ) -> teksilo_core::widget::LayoutResponse {
136        // Horizontal strips: claim full proposed width, fixed height.
137        // Vertical strips: claim full proposed height, fixed width.
138        let w = if self.width > 0.0 {
139            self.width
140        } else {
141            proposal.width.unwrap_or(0.0)
142        };
143        let h = if self.height > 0.0 {
144            self.height
145        } else {
146            proposal.height.unwrap_or(0.0)
147        };
148        Size::new(w, h).into()
149    }
150
151    fn place_children(
152        &self,
153        _bounds: Rect,
154        _proposal: SizeProposal,
155        _children: &mut [WidgetPlacement],
156        _ctx: &LayoutContext,
157    ) {
158    }
159
160    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {
161        // Invisible.
162    }
163}