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.
33
34use std::rc::Rc;
35
36use teksilo_canvas::{Point, Rect, Size, SizeProposal};
37use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
38use teksilo_core::widget_id::WidgetId;
39use teksilo_core::{PlatformTitleBarHost, ResizeEdge};
40
41use super::resize_strip::ResizeStrip;
42
43/// Invisible overlay of resize strips and corner cells that gives a borderless window
44/// draggable edges. The content child fills the full client area with no visible inset;
45/// the strips are hit-test-only overlays along the outer `thickness` pixels.
46pub struct WindowFrame {
47    host: Rc<dyn PlatformTitleBarHost>,
48    thickness: f32,
49    pending_content: Option<PendingChild>,
50    content_id: Option<WidgetId>,
51    /// Order: [top, bottom, left, right]
52    strip_ids: [Option<WidgetId>; 4],
53    /// Order: [top_left, top_right, bottom_left, bottom_right]
54    corner_ids: [Option<WidgetId>; 4],
55}
56
57impl std::fmt::Debug for WindowFrame {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.debug_struct("WindowFrame")
60            .field("thickness", &self.thickness)
61            .field("has_content", &self.pending_content.is_some())
62            .finish_non_exhaustive()
63    }
64}
65
66impl WindowFrame {
67    /// Create a frame bound to the given platform host. Use [`thickness`](WindowFrame::thickness)
68    /// and [`content`](WindowFrame::content) to configure it before adding to the tree.
69    pub fn new(host: Rc<dyn PlatformTitleBarHost>) -> Self {
70        Self {
71            host,
72            thickness: 6.0,
73            pending_content: None,
74            content_id: None,
75            strip_ids: [None; 4],
76            corner_ids: [None; 4],
77        }
78    }
79
80    /// Logical-pixel thickness of each resize strip. Default: 6.
81    pub fn thickness(mut self, t: f32) -> Self {
82        self.thickness = t;
83        self
84    }
85
86    /// Set the inner content widget — typically a `VStack` containing a
87    /// `TitleBar` and the application body.
88    pub fn content(mut self, w: impl Widget + 'static) -> Self {
89        self.pending_content = Some(PendingChild::Deferred(Box::new(w)));
90        self
91    }
92
93    /// Set the inner content widget from an already-boxed value. Prefer [`content`](WindowFrame::content)
94    /// for unboxed widgets; use this variant when the concrete type is not known at the call site.
95    pub fn content_boxed(mut self, w: Box<dyn Widget>) -> Self {
96        self.pending_content = Some(PendingChild::Deferred(w));
97        self
98    }
99
100    /// Set the inner content widget by its already-registered `WidgetId`. Use when the content
101    /// was added to the tree before the frame was constructed and you need to retain its id.
102    pub fn content_id(mut self, id: WidgetId) -> Self {
103        self.pending_content = Some(PendingChild::Id(id));
104        self
105    }
106}
107
108impl Widget for WindowFrame {
109    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
110        // Resolve the optional content child first so it sits at index 0
111        // in the children list — `place_children` relies on the order
112        // matching
113        // `[content, top, bottom, left, right, top_left, top_right, bottom_left, bottom_right]`.
114        if let Some(pending) = self.pending_content.take() {
115            self.content_id = Some(match pending {
116                PendingChild::Id(id) => id,
117                PendingChild::Deferred(w) => ctx.add_boxed(w),
118            });
119        }
120
121        self.strip_ids[0] = Some(ctx.add(ResizeStrip::horizontal(
122            self.host.clone(),
123            ResizeEdge::Top,
124            self.thickness,
125        )));
126        self.strip_ids[1] = Some(ctx.add(ResizeStrip::horizontal(
127            self.host.clone(),
128            ResizeEdge::Bottom,
129            self.thickness,
130        )));
131        self.strip_ids[2] = Some(ctx.add(ResizeStrip::vertical(
132            self.host.clone(),
133            ResizeEdge::Left,
134            self.thickness,
135        )));
136        self.strip_ids[3] = Some(ctx.add(ResizeStrip::vertical(
137            self.host.clone(),
138            ResizeEdge::Right,
139            self.thickness,
140        )));
141
142        // Corners — added AFTER the edges so that teksilo-core's hit-test
143        // (children walked in reverse order — see `hit_test_recursive`
144        // in `event_dispatch_impl.rs`) checks the corners first. In
145        // practice we also place them at non-overlapping positions, but
146        // walking last also guarantees priority under future layout
147        // refactors.
148        self.corner_ids[0] = Some(ctx.add(ResizeStrip::corner(
149            self.host.clone(),
150            ResizeEdge::TopLeft,
151            self.thickness,
152        )));
153        self.corner_ids[1] = Some(ctx.add(ResizeStrip::corner(
154            self.host.clone(),
155            ResizeEdge::TopRight,
156            self.thickness,
157        )));
158        self.corner_ids[2] = Some(ctx.add(ResizeStrip::corner(
159            self.host.clone(),
160            ResizeEdge::BottomLeft,
161            self.thickness,
162        )));
163        self.corner_ids[3] = Some(ctx.add(ResizeStrip::corner(
164            self.host.clone(),
165            ResizeEdge::BottomRight,
166            self.thickness,
167        )));
168
169        let mut ids = Vec::with_capacity(9);
170        if let Some(c) = self.content_id {
171            ids.push(c);
172        }
173        for s in self.strip_ids.iter().flatten() {
174            ids.push(*s);
175        }
176        for c in self.corner_ids.iter().flatten() {
177            ids.push(*c);
178        }
179        ids
180    }
181
182    fn layout_response(
183        &self,
184        proposal: SizeProposal,
185        _ctx: &LayoutContext,
186    ) -> teksilo_core::widget::LayoutResponse {
187        // Always claim every pixel offered. The frame is meant to wrap a
188        // window's full client area — anything smaller would leave bare
189        // space at the edges.
190        Size::new(
191            proposal.width.unwrap_or(0.0),
192            proposal.height.unwrap_or(0.0),
193        )
194        .into()
195    }
196
197    fn place_children(
198        &self,
199        bounds: Rect,
200        _proposal: SizeProposal,
201        children: &mut [WidgetPlacement],
202        _ctx: &LayoutContext,
203    ) {
204        let t = self.thickness;
205
206        // Children are in insertion order:
207        //   index 0 (if content present) → content
208        //   then [top, bottom, left, right] edges
209        //   then [top_left, top_right, bottom_left, bottom_right] corners
210        //
211        // Hit-test walks `.iter().rev()`, so corners are checked first,
212        // then edges, then content — exactly the priority we want.
213        let mut i = 0;
214
215        if self.content_id.is_some() {
216            // Content fills the FULL window — no inset, no visible
217            // padding. The strips overlay it along the edges.
218            children[i].origin = bounds.origin();
219            children[i].size = bounds.size();
220            i += 1;
221        }
222
223        // Edges — full-length strips overlaying the outer `t` pixels of
224        // the content. They overlap the corners by `t × t`, but the
225        // corner cells (added after) win the hit-test in those regions.
226        // Top.
227        children[i].origin = bounds.origin();
228        children[i].size = Size::new(bounds.width, t);
229        i += 1;
230
231        // Bottom.
232        children[i].origin = Point::new(bounds.x, bounds.bottom() - t);
233        children[i].size = Size::new(bounds.width, t);
234        i += 1;
235
236        // Left.
237        children[i].origin = bounds.origin();
238        children[i].size = Size::new(t, bounds.height);
239        i += 1;
240
241        // Right.
242        children[i].origin = Point::new(bounds.right() - t, bounds.y);
243        children[i].size = Size::new(t, bounds.height);
244        i += 1;
245
246        // Corners — `t × t` squares at the four window corners.
247        // Top-left.
248        children[i].origin = bounds.origin();
249        children[i].size = Size::new(t, t);
250        i += 1;
251
252        // Top-right.
253        children[i].origin = Point::new(bounds.right() - t, bounds.y);
254        children[i].size = Size::new(t, t);
255        i += 1;
256
257        // Bottom-left.
258        children[i].origin = Point::new(bounds.x, bounds.bottom() - t);
259        children[i].size = Size::new(t, t);
260        i += 1;
261
262        // Bottom-right.
263        children[i].origin = Point::new(bounds.right() - t, bounds.bottom() - t);
264        children[i].size = Size::new(t, t);
265    }
266
267    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {
268        // Fully transparent — the inner content paints its own background.
269    }
270
271    fn children(&self) -> Vec<WidgetId> {
272        let mut ids = Vec::with_capacity(9);
273        if let Some(c) = self.content_id {
274            ids.push(c);
275        }
276        for s in self.strip_ids.iter().flatten() {
277            ids.push(*s);
278        }
279        for c in self.corner_ids.iter().flatten() {
280            ids.push(*c);
281        }
282        ids
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use std::cell::Cell;
290    use teksilo_canvas::Point;
291    use teksilo_core::Signal;
292    use teksilo_core::widget_tree::WidgetTree;
293    use teksilo_core::{HitRegions, PlatformError};
294
295    struct TestHost {
296        last_resize_edge: Cell<Option<ResizeEdge>>,
297        is_max: Signal<bool>,
298    }
299
300    impl Default for TestHost {
301        fn default() -> Self {
302            Self {
303                last_resize_edge: Cell::new(None),
304                is_max: Signal::new(false),
305            }
306        }
307    }
308
309    impl PlatformTitleBarHost for TestHost {
310        fn reserved_leading_inset(&self) -> Size {
311            Size::ZERO
312        }
313        fn reserved_trailing_inset(&self) -> Size {
314            Size::ZERO
315        }
316        fn renders_custom_controls(&self) -> bool {
317            true
318        }
319        fn needs_custom_resize_handles(&self) -> bool {
320            true
321        }
322        fn begin_drag(&self) -> Result<(), PlatformError> {
323            Ok(())
324        }
325        fn begin_resize(&self, edge: ResizeEdge) -> Result<(), PlatformError> {
326            self.last_resize_edge.set(Some(edge));
327            Ok(())
328        }
329        fn show_window_menu(&self, _at: Point) -> Result<(), PlatformError> {
330            Ok(())
331        }
332        fn update_hit_regions(&self, _regions: &HitRegions) {}
333    }
334
335    #[derive(Debug)]
336    struct ContentLeaf;
337    impl Widget for ContentLeaf {
338        fn layout_response(
339            &self,
340            proposal: SizeProposal,
341            _ctx: &LayoutContext,
342        ) -> teksilo_core::widget::LayoutResponse {
343            Size::new(
344                proposal.width.unwrap_or(0.0),
345                proposal.height.unwrap_or(0.0),
346            )
347            .into()
348        }
349    }
350
351    #[test]
352    fn frame_content_fills_full_window_no_visible_padding() {
353        let host: Rc<dyn PlatformTitleBarHost> = Rc::new(TestHost::default());
354        let mut tree = WidgetTree::new();
355        let frame = tree.add(WindowFrame::new(host).thickness(6.0).content(ContentLeaf));
356        tree.layout(SizeProposal::exact(900.0, 600.0));
357
358        // The frame itself fills the window.
359        let f = tree.bounds(frame);
360        assert!((f.width - 900.0).abs() < 0.01);
361        assert!((f.height - 600.0).abs() < 0.01);
362
363        // Content is the FULL window — the resize frame is a hit-test
364        // overlay only, no visible inset.
365        let kids = tree.children(frame);
366        let content = kids[0];
367        let cb = tree.bounds(content);
368        assert!((cb.x - 0.0).abs() < 0.01, "content x = {}", cb.x);
369        assert!((cb.y - 0.0).abs() < 0.01, "content y = {}", cb.y);
370        assert!((cb.width - 900.0).abs() < 0.01, "content w = {}", cb.width);
371        assert!(
372            (cb.height - 600.0).abs() < 0.01,
373            "content h = {}",
374            cb.height
375        );
376    }
377
378    #[test]
379    fn clicking_top_strip_calls_begin_resize_top() {
380        let host = Rc::new(TestHost::default());
381        let mut tree = WidgetTree::new();
382        let _frame = tree.add(
383            WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
384                .thickness(6.0)
385                .content(ContentLeaf),
386        );
387        tree.layout(SizeProposal::exact(900.0, 600.0));
388
389        // Click in the top 6 pixels.
390        tree.pointer_move(Point::new(450.0, 3.0));
391        tree.pointer_down_button(
392            Point::new(450.0, 3.0),
393            teksilo_core::event::PointerButton::Primary,
394        );
395        tree.pointer_up_button(
396            Point::new(450.0, 3.0),
397            teksilo_core::event::PointerButton::Primary,
398        );
399
400        assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::Top));
401    }
402
403    #[test]
404    fn clicking_top_left_corner_calls_begin_resize_top_left() {
405        let host = Rc::new(TestHost::default());
406        let mut tree = WidgetTree::new();
407        let _frame = tree.add(
408            WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
409                .thickness(6.0)
410                .content(ContentLeaf),
411        );
412        tree.layout(SizeProposal::exact(900.0, 600.0));
413
414        // Click inside the 6x6 top-left corner.
415        tree.pointer_move(Point::new(2.0, 2.0));
416        tree.pointer_down_button(
417            Point::new(2.0, 2.0),
418            teksilo_core::event::PointerButton::Primary,
419        );
420        tree.pointer_up_button(
421            Point::new(2.0, 2.0),
422            teksilo_core::event::PointerButton::Primary,
423        );
424
425        assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::TopLeft));
426    }
427
428    #[test]
429    fn clicking_bottom_right_corner_calls_begin_resize_bottom_right() {
430        let host = Rc::new(TestHost::default());
431        let mut tree = WidgetTree::new();
432        let _frame = tree.add(
433            WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
434                .thickness(6.0)
435                .content(ContentLeaf),
436        );
437        tree.layout(SizeProposal::exact(900.0, 600.0));
438
439        // Click inside the 6x6 bottom-right corner: x in [894, 900),
440        // y in [594, 600).
441        let p = Point::new(897.0, 597.0);
442        tree.pointer_move(p);
443        tree.pointer_down_button(p, teksilo_core::event::PointerButton::Primary);
444        tree.pointer_up_button(p, teksilo_core::event::PointerButton::Primary);
445
446        assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::BottomRight));
447    }
448
449    #[test]
450    fn clicking_in_content_area_does_not_resize() {
451        let host = Rc::new(TestHost::default());
452        let mut tree = WidgetTree::new();
453        let _frame = tree.add(
454            WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
455                .thickness(6.0)
456                .content(ContentLeaf),
457        );
458        tree.layout(SizeProposal::exact(900.0, 600.0));
459
460        // Click in the middle of the content area.
461        tree.pointer_move(Point::new(450.0, 300.0));
462        tree.pointer_down_button(
463            Point::new(450.0, 300.0),
464            teksilo_core::event::PointerButton::Primary,
465        );
466        tree.pointer_up_button(
467            Point::new(450.0, 300.0),
468            teksilo_core::event::PointerButton::Primary,
469        );
470
471        assert_eq!(
472            host.last_resize_edge.get(),
473            None,
474            "interior clicks must not trigger resize"
475        );
476    }
477}