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//! # A finger on the title bar
24//!
25//! The drag region is deliberately **not** a press-time actor: the window move
26//! starts from [`DragPhase::Started`], after the recognizer has decided the
27//! press is a drag, which is why a quick press still reaches the double-tap
28//! recognizer and why a contact that turns out to be a scroll is never stolen.
29//! Nothing here changes that.
30//!
31//! Three routes serve a contact:
32//!
33//! * **Double tap → maximise / restore.** The same handler a double click
34//! drives; the multi-tap recognizer already tunes its slop per pointer kind,
35//! so a finger's looser aim is accounted for without a second code path.
36//! * **Long press → the window menu.** A mouse reaches it with the secondary
37//! button, which a finger does not have. Where the platform owns the menu
38//! (Wayland's `xdg_toplevel.show_window_menu`) the long press asks the host
39//! for it, and that menu's **Move** entry is a finger's only route to a
40//! window move — see below. Where it does not (X11), this widget's
41//! `.context_menu(..)` factory is the menu, and it is reached through the
42//! framework's own long-press context-menu route rather than through a second
43//! copy of the opening machinery here.
44//! * **Drag → nothing, on purpose.** `BackendCaps::touch_window_drag` is
45//! `false` on every platform Teksilo supports, and it is not a to-do:
46//! `xdg_toplevel::move` needs a serial from an input event on a toplevel the
47//! compositor agrees the client owns, and winit 0.30's `drag_window` harvests
48//! a *pointer* serial internally, so a finger cannot reach it however the app
49//! asks. Calling it anyway would be a silent no-op — the compositor drops a
50//! request whose serial does not match — so the drag handler does not call
51//! it for a direct pointer, and the long-press menu is the documented
52//! alternative. This is also the WCAG 2.5.7 single-pointer alternative to the
53//! dragging operation. A headless test cannot detect the real failure (a fake
54//! `PlatformTitleBarHost` will happily record a `begin_drag` that a
55//! compositor would have ignored), so this is a hardware-checklist line.
56//!
57//! ```ignore
58//! // Used internally by TitleBar; the snippet shows the construction pattern.
59//! let region = DragRegion::with_child(host.clone(), TextWidget::new(lit!("My App")));
60//! ```
61
62use std::rc::Rc;
63
64use teksilo_canvas::{Rect, Size, SizeProposal};
65use teksilo_core::PlatformTitleBarHost;
66use teksilo_core::accessibility::AccessNodeBuilder;
67use teksilo_core::event::{EventResponse, PointerButton, WidgetEvent};
68use teksilo_core::gesture::DragPhase;
69use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
70use teksilo_core::widget_builder::HandlerSet;
71use teksilo_core::widget_id::WidgetId;
72
73/// Flexible, hit-transparent region inside a title bar that routes pointer events to the
74/// platform host for window dragging, maximize-toggle, and the system window menu.
75pub struct DragRegion {
76 host: Rc<dyn PlatformTitleBarHost>,
77 pending_child: Option<PendingChild>,
78 child_id: Option<WidgetId>,
79 /// Forwarded from [`TitleBar::close_action`](crate::TitleBar::close_action)
80 /// so the fallback menu's Close entry does exactly what the close *button*
81 /// does. Unused when the platform has its own window menu.
82 close_action: Option<Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
83}
84
85impl std::fmt::Debug for DragRegion {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 f.debug_struct("DragRegion")
88 .field("has_child", &self.pending_child.is_some())
89 .finish_non_exhaustive()
90 }
91}
92
93impl DragRegion {
94 /// Create a drag region with no inner content — the entire region is a pure drag handle.
95 pub fn new(host: Rc<dyn PlatformTitleBarHost>) -> Self {
96 Self {
97 host,
98 pending_child: None,
99 child_id: None,
100 close_action: None,
101 }
102 }
103
104 /// Create a drag region wrapping an arbitrary boxed child widget (typically a centered
105 /// title). Pointer events not consumed by the child bubble up to the drag handler.
106 pub fn with_child(host: Rc<dyn PlatformTitleBarHost>, child: Box<dyn Widget>) -> Self {
107 Self {
108 host,
109 pending_child: Some(PendingChild::Deferred(child)),
110 child_id: None,
111 close_action: None,
112 }
113 }
114
115 /// Create a drag region with an already-registered child identified by `id`.
116 /// Use this when the child widget was added to the tree before constructing the
117 /// region (e.g. when you need the child's `WidgetId` for another reference).
118 pub fn with_child_id(host: Rc<dyn PlatformTitleBarHost>, id: WidgetId) -> Self {
119 Self {
120 host,
121 pending_child: Some(PendingChild::Id(id)),
122 child_id: None,
123 close_action: None,
124 }
125 }
126
127 /// Forward the title bar's close-action override, so the fallback window
128 /// menu's Close entry matches the close button. No effect on platforms
129 /// that provide their own window menu.
130 pub fn close_action(
131 mut self,
132 action: Option<Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
133 ) -> Self {
134 self.close_action = action;
135 self
136 }
137}
138
139impl Widget for DragRegion {
140 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
141 if let Some(pending) = self.pending_child.take() {
142 self.child_id = Some(match pending {
143 PendingChild::Id(id) => id,
144 PendingChild::Deferred(w) => ctx.add_boxed(w),
145 });
146 }
147
148 // Drag gesture: begin a window move as soon as the OS recognizes
149 // movement during a primary-button press. Using `on_drag` (rather
150 // than `on_pointer_event` on PointerDown) means a quick click
151 // without movement still flows to the double-tap recognizer, which
152 // is how we get double-click-to-maximize.
153 let host_drag = self.host.clone();
154 let host_pointer = self.host.clone();
155
156 // Right-click opens the window menu. Where the OS provides one we ask
157 // for it; where it does not (X11 — see `window_menu`), we build our
158 // own via the framework's context-menu factory, which handles the
159 // at-pointer overlay, dismissal, and focus for us.
160 let has_os_window_menu = self.host.has_window_menu();
161 let close_action = self.close_action.clone();
162
163 // The window menu a long press asks for, where the platform has one.
164 // A mouse reaches the same menu with the secondary button below.
165 let host_long_press = self.host.clone();
166
167 let mut handlers = HandlerSet::new()
168 .on_drag(move |phase, ctx| {
169 if let DragPhase::Started {
170 button: PointerButton::Primary,
171 pointer,
172 ..
173 } = phase
174 {
175 // A direct pointer cannot start an OS window move under
176 // winit 0.30 on any backend — `drag_window` harvests a
177 // pointer serial, and every `BackendCaps::touch_window_drag`
178 // is `false`. Asking would be a silent no-op, so the finger
179 // is routed to the long-press window menu (whose Move entry
180 // is the non-drag alternative) instead of being told a lie.
181 if pointer.kind.is_direct() {
182 return;
183 }
184 if host_drag.begin_drag().is_ok() {
185 // The window manager owns the rest of this press and
186 // may consume its release. Retaining our capture would
187 // prevent later hover moves from reaching resize grips.
188 ctx.cancel_pointer_sequence(
189 teksilo_core::pointer::CancelReason::OsDragStarted,
190 );
191 }
192 }
193 })
194 .on_long_press(move |event, ctx| {
195 // The secondary button is a mouse's route to this menu; a
196 // finger has no second button, so a hold is its equivalent.
197 // Precise pointers are excluded so a held mouse button, which
198 // is the *start of a drag*, never pops a menu mid-move.
199 if !event.pointer.kind.is_direct() || !has_os_window_menu {
200 return;
201 }
202 // Window-logical, not widget-local: `show_window_menu` is
203 // documented in client-area coordinates and the drag region
204 // starts wherever the title bar's leading content ends.
205 let at = ctx.pointer_position().unwrap_or(event.position);
206 let _ = host_long_press.show_window_menu(at);
207 })
208 .on_double_tap(move |_pos, ctx| {
209 if let Some(w) = ctx.window() {
210 let next = if w.placement().get().is_maximized() {
211 teksilo_core::WindowPlacement::Floating
212 } else {
213 teksilo_core::WindowPlacement::Maximized
214 };
215 w.placement().set(next);
216 }
217 })
218 .on_pointer_event(move |evt, _ctx| {
219 if !has_os_window_menu {
220 // The context-menu factory below owns the secondary
221 // button; consuming it here would suppress the menu.
222 return EventResponse::Ignored;
223 }
224 if let WidgetEvent::PointerDown {
225 button: PointerButton::Secondary,
226 position,
227 ..
228 } = evt
229 {
230 let _ = host_pointer.show_window_menu(*position);
231 return EventResponse::Handled;
232 }
233 EventResponse::Ignored
234 });
235
236 if !has_os_window_menu {
237 handlers = handlers.context_menu(move |_at, ctx| {
238 super::window_menu::build_window_menu(ctx, close_action.clone())
239 });
240 }
241
242 ctx.apply_self_handlers(handlers);
243
244 self.child_id.into_iter().collect()
245 }
246
247 fn layout_response(
248 &self,
249 proposal: SizeProposal,
250 _ctx: &LayoutContext,
251 ) -> teksilo_core::widget::LayoutResponse {
252 // Wanted width is 0 — we want pure slack from the parent HStack.
253 // Height matches the title bar's configured height so we paint
254 // through even when the inner child reports zero.
255 // `flex = 1.0` claims the leftover horizontal space; without it the
256 // drag region collapses and there is nothing to drag.
257 teksilo_core::widget::LayoutResponse::flexible(
258 Size::new(0.0, proposal.height.unwrap_or(0.0)),
259 1.0,
260 )
261 }
262
263 fn place_children(
264 &self,
265 bounds: Rect,
266 _proposal: SizeProposal,
267 children: &mut [WidgetPlacement],
268 _ctx: &LayoutContext,
269 ) {
270 // Inner content (the optional `center` widget) fills the drag
271 // region's full bounds.
272 for child in children.iter_mut() {
273 child.origin = bounds.origin();
274 child.size = bounds.size();
275 }
276 }
277
278 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {
279 // No paint — our parent `TitleBar::after_paint` reads our
280 // bounds and publishes them as part of the aggregated
281 // `HitRegions` snapshot.
282 }
283
284 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
285 // Pointer-only affordance: no keyboard or AT analogue for "drag the
286 // window by its title". A bare `GenericContainer` keeps the region
287 // from showing up as an unnamed stop between the title bar landmark
288 // and its real content, since every adapter drops it and promotes
289 // what it holds. Never `set_hidden()`: the region holds
290 // `TitleBar::center`, a search box or breadcrumbs, and an adapter
291 // reads a hidden node as hiding all of that with it.
292 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
293 }
294
295 fn children(&self) -> Vec<WidgetId> {
296 self.child_id.into_iter().collect()
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use std::cell::Cell;
303 use std::time::Duration;
304
305 use teksilo_canvas::{Point, Size, SizeProposal};
306 use teksilo_core::event::{Modifiers, PointerButton};
307 use teksilo_core::pointer::clock::ManualClock;
308 use teksilo_core::pointer::{
309 BackendDeviceKey, EventTime, PointerIdAllocator, PointerInfo, PointerPhase, PointerSample,
310 };
311 use teksilo_core::widget::LayoutResponse;
312 use teksilo_core::widget_tree::WidgetTree;
313 use teksilo_core::window::WindowState;
314 use teksilo_core::{
315 HitRegions, PlatformError, ResizeEdge, TeksiloWindowId, WindowPlacement, WindowStateInit,
316 };
317
318 use super::*;
319
320 #[derive(Default)]
321 struct TestHost {
322 drags: Cell<u32>,
323 menus: Cell<u32>,
324 os_menu: bool,
325 }
326
327 impl TestHost {
328 fn with_os_menu() -> Self {
329 Self {
330 os_menu: true,
331 ..Self::default()
332 }
333 }
334 }
335
336 impl PlatformTitleBarHost for TestHost {
337 fn reserved_leading_inset(&self) -> Size {
338 Size::ZERO
339 }
340 fn reserved_trailing_inset(&self) -> Size {
341 Size::ZERO
342 }
343 fn renders_custom_controls(&self) -> bool {
344 true
345 }
346 fn needs_custom_resize_handles(&self) -> bool {
347 true
348 }
349 fn begin_drag(&self) -> Result<(), PlatformError> {
350 self.drags.set(self.drags.get() + 1);
351 Ok(())
352 }
353 fn begin_resize(&self, _edge: ResizeEdge) -> Result<(), PlatformError> {
354 Ok(())
355 }
356 fn show_window_menu(&self, _at: Point) -> Result<(), PlatformError> {
357 self.menus.set(self.menus.get() + 1);
358 Ok(())
359 }
360 fn has_window_menu(&self) -> bool {
361 self.os_menu
362 }
363 fn update_hit_regions(&self, _regions: &HitRegions) {}
364 }
365
366 /// The drag region alone in a 400 × 32 tree, on a manual input clock so a
367 /// hold is a `set` rather than a sleep.
368 fn region(host: Rc<TestHost>) -> (WidgetTree, Rc<ManualClock>, Point) {
369 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
370 let clock = Rc::new(ManualClock::new(EventTime::ZERO));
371 tree.set_input_clock(clock.clone());
372 tree.set_window_state(WindowState::new(WindowStateInit {
373 id: TeksiloWindowId::new(1),
374 string_id: Some("w1".to_string()),
375 placement: WindowPlacement::Floating,
376 title: "Test".to_string(),
377 size: (400, 32),
378 position: (0, 0),
379 focused: true,
380 resizable: true,
381 always_on_top: false,
382 }));
383 let id = tree.add(DragRegion::new(host as Rc<dyn PlatformTitleBarHost>));
384 tree.layout(SizeProposal::exact(400.0, 32.0));
385 let at = tree.bounds(id).center();
386 (tree, clock, at)
387 }
388
389 fn finger(raw: u64) -> PointerInfo {
390 let id = PointerIdAllocator::global().begin(BackendDeviceKey::new(0x50D6), raw);
391 let mut info = PointerInfo::touch(id, EventTime::ZERO);
392 info.primary = true;
393 info
394 }
395
396 fn contact(mut pointer: PointerInfo, phase: PointerPhase, at: Point, ms: u64) -> PointerSample {
397 pointer.time = EventTime::from_millis(ms);
398 PointerSample {
399 pointer,
400 phase,
401 position: at,
402 button: None,
403 modifiers: Modifiers::NONE,
404 coalesced: Vec::new(),
405 }
406 }
407
408 /// A mouse drag on the title bar asks the platform for a window move, as it
409 /// always has.
410 #[test]
411 fn a_mouse_drag_starts_a_window_move() {
412 let host = Rc::new(TestHost::with_os_menu());
413 let (mut tree, _clock, at) = region(host.clone());
414 tree.pointer_move(at);
415 tree.pointer_down_button(at, PointerButton::Primary);
416 tree.pointer_move(Point::new(at.x + 40.0, at.y));
417 assert_eq!(host.drags.get(), 1, "a mouse drag moves the window");
418 }
419
420 #[test]
421 fn resize_cursors_survive_window_moves_without_a_button_release() {
422 use crate::HStack;
423 use crate::title_bar::ResizeStrip;
424 use teksilo_core::widget::CursorIcon;
425
426 let host = Rc::new(TestHost::with_os_menu());
427 let mut tree = WidgetTree::new();
428 let drag = tree.add(DragRegion::new(host.clone()));
429 let grip = tree.add(ResizeStrip::vertical(host.clone(), ResizeEdge::Right, 6.0));
430 tree.add(HStack::new().spacing(0.0).child(drag).child(grip));
431 tree.layout(SizeProposal::exact(400.0, 32.0));
432 let at = tree.bounds(drag).center();
433 let edge = tree.bounds(grip).center();
434
435 for expected_drags in 1..=2 {
436 tree.pointer_move(at);
437 tree.pointer_down_button(at, PointerButton::Primary);
438 tree.pointer_move(Point::new(at.x + 40.0, at.y));
439 assert_eq!(host.drags.get(), expected_drags);
440 assert_eq!(tree.pointer_captured_by(), None);
441
442 // The WM consumes PointerUp. The next client motion must still
443 // hit-test the frame and adopt its declared resize cursor.
444 tree.pointer_move(edge);
445 assert_eq!(tree.hovered(), Some(grip));
446 assert_eq!(tree.current_cursor(), CursorIcon::ColResize);
447 tree.pointer_down_button(edge, PointerButton::Primary);
448 tree.pointer_move(at);
449 assert_eq!(tree.hovered(), Some(drag));
450 assert_eq!(tree.current_cursor(), CursorIcon::Default);
451 }
452 }
453
454 /// A finger drag does **not**, and that is a platform fact rather than an
455 /// omission: `BackendCaps::touch_window_drag` is `false` everywhere because
456 /// winit 0.30's `drag_window` harvests a pointer serial, so the request
457 /// would be dropped by the compositor. The finger's route is the long-press
458 /// window menu below.
459 #[test]
460 fn a_finger_drag_does_not_ask_for_a_window_move() {
461 let host = Rc::new(TestHost::with_os_menu());
462 let (mut tree, _clock, at) = region(host.clone());
463 let f = finger(41);
464 tree.dispatch_pointer(contact(f, PointerPhase::Down, at, 0));
465 // Well past any drag slop, on both axes.
466 for step in [30.0_f32, 60.0, 120.0] {
467 tree.dispatch_pointer(contact(
468 f,
469 PointerPhase::Move,
470 Point::new(at.x + step, at.y),
471 10 + step as u64,
472 ));
473 }
474 assert_eq!(
475 host.drags.get(),
476 0,
477 "a finger must not ask for a move the compositor will drop"
478 );
479 }
480
481 /// A hold with a finger opens the system window menu — the equivalent of
482 /// the secondary button a finger does not have, and the route to its Move
483 /// entry.
484 #[test]
485 fn a_long_press_opens_the_system_window_menu() {
486 let host = Rc::new(TestHost::with_os_menu());
487 let (mut tree, clock, at) = region(host.clone());
488 let f = finger(42);
489 tree.dispatch_pointer(contact(f, PointerPhase::Down, at, 0));
490
491 clock.set(EventTime::from_millis(499));
492 tree.tick_gestures(std::time::Instant::now());
493 assert_eq!(host.menus.get(), 0, "499 ms is under the hold");
494
495 clock.set(EventTime::from_millis(500));
496 tree.tick_gestures(std::time::Instant::now() + Duration::from_millis(500));
497 assert_eq!(host.menus.get(), 1, "500 ms is the hold");
498 }
499
500 /// A held **mouse** button does not: that is the start of a window move,
501 /// and the mouse already reaches the menu with its secondary button.
502 #[test]
503 fn a_held_mouse_button_does_not_open_the_window_menu() {
504 let host = Rc::new(TestHost::with_os_menu());
505 let (mut tree, clock, at) = region(host.clone());
506 tree.pointer_move(at);
507 tree.pointer_down_button(at, PointerButton::Primary);
508 clock.set(EventTime::from_millis(900));
509 tree.tick_gestures(std::time::Instant::now() + Duration::from_millis(900));
510 assert_eq!(host.menus.get(), 0);
511 }
512
513 /// The secondary button still opens it, unchanged.
514 #[test]
515 fn a_secondary_press_still_opens_the_system_window_menu() {
516 let host = Rc::new(TestHost::with_os_menu());
517 let (mut tree, _clock, at) = region(host.clone());
518 tree.pointer_move(at);
519 tree.pointer_down_button(at, PointerButton::Secondary);
520 assert_eq!(host.menus.get(), 1);
521 }
522
523 /// Where the platform has no window menu of its own (X11), the long press
524 /// asks for nothing — the fallback menu is this widget's `.context_menu(..)`
525 /// factory, opened through the framework's own route rather than through a
526 /// second copy of the opening machinery here.
527 #[test]
528 fn without_an_os_menu_the_long_press_asks_for_nothing() {
529 let host = Rc::new(TestHost::default());
530 let (mut tree, clock, at) = region(host.clone());
531 let f = finger(43);
532 tree.dispatch_pointer(contact(f, PointerPhase::Down, at, 0));
533 clock.set(EventTime::from_millis(600));
534 tree.tick_gestures(std::time::Instant::now() + Duration::from_millis(600));
535 assert_eq!(host.menus.get(), 0);
536 }
537
538 /// Double tap toggles maximise, for a finger as for a mouse. The recognizer
539 /// tunes its slop per pointer kind, so the two share one handler.
540 #[test]
541 fn a_double_tap_toggles_maximise() {
542 for coarse in [false, true] {
543 let host = Rc::new(TestHost::with_os_menu());
544 let (mut tree, _clock, at) = region(host.clone());
545 let placement = tree.window_state().unwrap().placement().clone();
546 assert!(!placement.get().is_maximized());
547
548 if coarse {
549 let f = finger(44);
550 for tap in 0..2u64 {
551 tree.dispatch_pointer(contact(f, PointerPhase::Down, at, tap * 100));
552 tree.dispatch_pointer(contact(f, PointerPhase::Up, at, tap * 100 + 20));
553 }
554 } else {
555 tree.pointer_move(at);
556 for _ in 0..2 {
557 tree.pointer_down_button(at, PointerButton::Primary);
558 tree.pointer_up_button(at, PointerButton::Primary);
559 }
560 }
561 assert!(
562 placement.get().is_maximized(),
563 "a double tap must maximise (coarse = {coarse})"
564 );
565 }
566 }
567
568 /// The layout contract the drag region rests on: pure slack, no wanted
569 /// width. Pinned because the grab work above must not have moved it.
570 #[test]
571 fn the_region_claims_only_slack() {
572 let host: Rc<dyn PlatformTitleBarHost> = Rc::new(TestHost::default());
573 let region = DragRegion::new(host);
574 let theme = teksilo_core::presets::intui::light();
575 let ctx = teksilo_core::widget::LayoutContext::for_testing(&theme);
576 let response: LayoutResponse =
577 region.layout_response(SizeProposal::exact(400.0, 32.0), &ctx);
578 assert_eq!(response.size.width, 0.0);
579 assert_eq!(response.flex, 1.0);
580 }
581}