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//! # 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}