Skip to main content

teksilo_widgets/title_bar/
drag_region.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `DragRegion` — flexible drag region inside a `TitleBar`.
5//!
6//! Captures pointer events that are not consumed by inner content and
7//! forwards them to the platform host: drag gestures begin a window move,
8//! double taps toggle maximize, and right-clicks open the system window
9//! menu (Wayland only). On Windows the drag rect is published into
10//! `HitRegions::drag` so the wndproc subclass returns `HTCAPTION` for
11//! the same area — but the actual publish happens from
12//! [`crate::title_bar::TitleBar::after_paint`], which aggregates this
13//! drag region and the three control buttons into one snapshot per
14//! frame. This widget no longer publishes from `paint()`.
15//!
16//! The region grows via `flex = 1.0` to claim all remaining horizontal
17//! space in the parent `HStack`, so it naturally sits between any leading
18//! widgets (app icon, document title) and the trailing `WindowControls`
19//! cluster. An optional child widget — typically a centered title — is
20//! placed at the full region bounds and passes pointer events upward to
21//! the drag handler when it does not consume them.
22//!
23//! ```ignore
24//! // Used internally by TitleBar; the snippet shows the construction pattern.
25//! let region = DragRegion::with_child(host.clone(), TextWidget::new(lit!("My App")));
26//! ```
27
28use std::rc::Rc;
29
30use teksilo_canvas::{Rect, Size, SizeProposal};
31use teksilo_core::PlatformTitleBarHost;
32use teksilo_core::accessibility::AccessNodeBuilder;
33use teksilo_core::event::{EventResponse, PointerButton, WidgetEvent};
34use teksilo_core::gesture::DragPhase;
35use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
36use teksilo_core::widget_builder::HandlerSet;
37use teksilo_core::widget_id::WidgetId;
38
39/// Flexible, hit-transparent region inside a title bar that routes pointer events to the
40/// platform host for window dragging, maximize-toggle, and the system window menu.
41pub struct DragRegion {
42    host: Rc<dyn PlatformTitleBarHost>,
43    pending_child: Option<PendingChild>,
44    child_id: Option<WidgetId>,
45    /// Forwarded from [`TitleBar::close_action`](crate::TitleBar::close_action)
46    /// so the fallback menu's Close entry does exactly what the close *button*
47    /// does. Unused when the platform has its own window menu.
48    close_action: Option<Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
49}
50
51impl std::fmt::Debug for DragRegion {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("DragRegion")
54            .field("has_child", &self.pending_child.is_some())
55            .finish_non_exhaustive()
56    }
57}
58
59impl DragRegion {
60    /// Create a drag region with no inner content — the entire region is a pure drag handle.
61    pub fn new(host: Rc<dyn PlatformTitleBarHost>) -> Self {
62        Self {
63            host,
64            pending_child: None,
65            child_id: None,
66            close_action: None,
67        }
68    }
69
70    /// Create a drag region wrapping an arbitrary boxed child widget (typically a centered
71    /// title). Pointer events not consumed by the child bubble up to the drag handler.
72    pub fn with_child(host: Rc<dyn PlatformTitleBarHost>, child: Box<dyn Widget>) -> Self {
73        Self {
74            host,
75            pending_child: Some(PendingChild::Deferred(child)),
76            child_id: None,
77            close_action: None,
78        }
79    }
80
81    /// Create a drag region with an already-registered child identified by `id`.
82    /// Use this when the child widget was added to the tree before constructing the
83    /// region (e.g. when you need the child's `WidgetId` for another reference).
84    pub fn with_child_id(host: Rc<dyn PlatformTitleBarHost>, id: WidgetId) -> Self {
85        Self {
86            host,
87            pending_child: Some(PendingChild::Id(id)),
88            child_id: None,
89            close_action: None,
90        }
91    }
92
93    /// Forward the title bar's close-action override, so the fallback window
94    /// menu's Close entry matches the close button. No effect on platforms
95    /// that provide their own window menu.
96    pub fn close_action(
97        mut self,
98        action: Option<Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
99    ) -> Self {
100        self.close_action = action;
101        self
102    }
103}
104
105impl Widget for DragRegion {
106    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
107        if let Some(pending) = self.pending_child.take() {
108            self.child_id = Some(match pending {
109                PendingChild::Id(id) => id,
110                PendingChild::Deferred(w) => ctx.add_boxed(w),
111            });
112        }
113
114        // Drag gesture: begin a window move as soon as the OS recognizes
115        // movement during a primary-button press. Using `on_drag` (rather
116        // than `on_pointer_event` on PointerDown) means a quick click
117        // without movement still flows to the double-tap recognizer, which
118        // is how we get double-click-to-maximize.
119        let host_drag = self.host.clone();
120        let host_pointer = self.host.clone();
121
122        // Right-click opens the window menu. Where the OS provides one we ask
123        // for it; where it does not (X11 — see `window_menu`), we build our
124        // own via the framework's context-menu factory, which handles the
125        // at-pointer overlay, dismissal, and focus for us.
126        let has_os_window_menu = self.host.has_window_menu();
127        let close_action = self.close_action.clone();
128
129        let mut handlers = HandlerSet::new()
130            .on_drag(move |phase, _ctx| {
131                if let DragPhase::Started {
132                    button: PointerButton::Primary,
133                    ..
134                } = phase
135                {
136                    let _ = host_drag.begin_drag();
137                }
138            })
139            .on_double_tap(move |_pos, ctx| {
140                if let Some(w) = ctx.window() {
141                    let next = if w.placement().get().is_maximized() {
142                        teksilo_core::WindowPlacement::Floating
143                    } else {
144                        teksilo_core::WindowPlacement::Maximized
145                    };
146                    w.placement().set(next);
147                }
148            })
149            .on_pointer_event(move |evt, _ctx| {
150                if !has_os_window_menu {
151                    // The context-menu factory below owns the secondary
152                    // button; consuming it here would suppress the menu.
153                    return EventResponse::Ignored;
154                }
155                if let WidgetEvent::PointerDown {
156                    button: PointerButton::Secondary,
157                    position,
158                    ..
159                } = evt
160                {
161                    let _ = host_pointer.show_window_menu(*position);
162                    return EventResponse::Handled;
163                }
164                EventResponse::Ignored
165            });
166
167        if !has_os_window_menu {
168            handlers = handlers.context_menu(move |_at, ctx| {
169                super::window_menu::build_window_menu(ctx, close_action.clone())
170            });
171        }
172
173        ctx.apply_self_handlers(handlers);
174
175        self.child_id.into_iter().collect()
176    }
177
178    fn layout_response(
179        &self,
180        proposal: SizeProposal,
181        _ctx: &LayoutContext,
182    ) -> teksilo_core::widget::LayoutResponse {
183        // Wanted width is 0 — we want pure slack from the parent HStack.
184        // Height matches the title bar's configured height so we paint
185        // through even when the inner child reports zero.
186        // `flex = 1.0` claims the leftover horizontal space; without it the
187        // drag region collapses and there is nothing to drag.
188        teksilo_core::widget::LayoutResponse::flexible(
189            Size::new(0.0, proposal.height.unwrap_or(0.0)),
190            1.0,
191        )
192    }
193
194    fn place_children(
195        &self,
196        bounds: Rect,
197        _proposal: SizeProposal,
198        children: &mut [WidgetPlacement],
199        _ctx: &LayoutContext,
200    ) {
201        // Inner content (the optional `center` widget) fills the drag
202        // region's full bounds.
203        for child in children.iter_mut() {
204            child.origin = bounds.origin();
205            child.size = bounds.size();
206        }
207    }
208
209    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {
210        // No paint — our parent `TitleBar::after_paint` reads our
211        // bounds and publishes them as part of the aggregated
212        // `HitRegions` snapshot.
213    }
214
215    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
216        // Pointer-only affordance — no keyboard or AT analogue for
217        // "drag the window by its title". Hide the node so it doesn't
218        // show up as an unnamed Unknown stop between the title bar
219        // landmark and its real content.
220        builder.set_hidden();
221    }
222
223    fn children(&self) -> Vec<WidgetId> {
224        self.child_id.into_iter().collect()
225    }
226}