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}