Skip to main content

teksilo_widgets/
title_bar.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Custom window title bar widget.
5//!
6//! `TitleBar` replaces a window's native chrome with a horizontal bar that
7//! can host menus, tools, and the standard window controls (minimize /
8//! maximize / close). The platform plumbing — beginning a window drag,
9//! returning the right `WM_NCHITTEST` codes on Windows, repositioning the
10//! macOS traffic lights — lives behind the
11//! [`PlatformTitleBarHost`] trait in
12//! `teksilo-platform`. The widget itself is platform-agnostic.
13//!
14//! Construct a `TitleBar` from inside the root-builder closure, fetching
15//! the host from the widget tree:
16//!
17//! ```ignore
18//! .root(|tree, _state| {
19//!     let host = tree.title_bar_host().expect("custom_chrome enabled");
20//!     tree.add(
21//!         VStack::new()
22//!             .child(TitleBar::new(host)
23//!                 .background(theme.colors.surface_raised)
24//!                 .border(theme.colors.border, 1.0)
25//!                 .leading(TextWidget::new(lit!("My App"))))
26//!             .child(Expand::new().child(/* body */)))
27//! })
28//! ```
29
30use std::cell::Cell;
31use std::rc::Rc;
32
33use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
34use teksilo_core::accessibility::AccessNodeBuilder;
35use teksilo_core::color_prop::ColorProp;
36use teksilo_core::signal::Prop;
37use teksilo_core::widget::{
38    EventContext, LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement,
39    WidgetTreeView,
40};
41use teksilo_core::widget_id::WidgetId;
42use teksilo_core::{HitRegions, PlatformTitleBarHost, Signal};
43use teksilo_tokens::{Color, CornerRadius};
44
45use crate::primitives::{FixedSize, HStack};
46
47mod controls;
48mod drag_region;
49mod resize_strip;
50mod window_frame;
51mod window_menu;
52
53pub use controls::{ControlAction, ControlButton, WindowControls, WindowControlsLayout};
54pub use drag_region::DragRegion;
55pub use resize_strip::ResizeStrip;
56pub use window_frame::{WINDOW_FRAME_RESIZE_THICKNESS, WindowFrame};
57
58/// Type alias for the user-supplied close action that overrides the close
59/// button's default `ctx.close_window()`. Set via [`TitleBar::close_action`].
60pub type CloseAction = Rc<dyn Fn(&mut EventContext)>;
61
62/// A custom window title bar.
63///
64/// Layout (left to right):
65///
66/// ```text
67/// [leading inset] [leading slot] [drag region (flexible)] [trailing slot] [trailing inset] [window controls]
68/// ```
69///
70/// The leading inset reserves space for the OS-drawn traffic lights on
71/// macOS. The drag region is a `Spacer`-style flex
72/// child that absorbs all leftover horizontal space and forwards
73/// pointer / drag / double-tap gestures to the platform host. The window
74/// controls (minimize / maximize / close) are rendered only when the host
75/// advertises [`PlatformTitleBarHost::renders_custom_controls`] — i.e. on
76/// Windows and Wayland but not on macOS.
77///
78/// ## This widget builds exactly once
79///
80/// `build` consumes the leading / center / trailing slots with `take()`, so a
81/// second pass finds them all `None` and produces a bar containing nothing but
82/// window controls — no menu, no title, no tools. Nothing here may therefore
83/// carry a [`BindingLevel::Rebuild`](teksilo_core::binding::BindingLevel)
84/// binding. Reactive state on this widget is expressed either as a
85/// `RepaintOnly` colour prop or, for structure, as dormancy via
86/// [`teksilo_core::BuildContext::visible_when`] on an always-built child — which is how
87/// [`controls_visible`](TitleBar::controls_visible) works. Memoising the
88/// resolved slot ids is *not* a workaround: a rebuild replaces the inner row
89/// and prunes its subtree, so the cached ids dangle and re-adding them yields
90/// an empty bar just the same.
91pub struct TitleBar {
92    host: Rc<dyn PlatformTitleBarHost>,
93    leading: Option<PendingChild>,
94    center: Option<PendingChild>,
95    trailing: Option<PendingChild>,
96    height: f32,
97    background: ColorProp,
98    border_color: ColorProp,
99    border_width: f32,
100    /// Optional override for the close button. When set, the close
101    /// button invokes this closure instead of `ctx.close_window()`.
102    close_action: Option<CloseAction>,
103    root_child_id: Option<WidgetId>,
104    /// `WidgetId` of the `DragRegion` we install. Memoised at build
105    /// time so `after_paint` can read its bounds via `WidgetTreeView`
106    /// without walking the subtree.
107    drag_region_id: Cell<Option<WidgetId>>,
108    /// Sink that the inner `WindowControls` populates with its
109    /// per-button ids during build. `None` when no controls are
110    /// rendered (macOS, where the OS draws traffic lights).
111    controls_layout: Rc<Cell<Option<WindowControlsLayout>>>,
112    /// Whether the minimize/maximize/close cluster is shown, statically or
113    /// reactively. Applied with [`teksilo_core::BuildContext::visible_when`] — **never** a
114    /// `Rebuild`-level binding. See [`TitleBar::controls_visible`] and
115    /// [`TitleBar`]'s own "builds once" note.
116    controls_visible: Prop<bool>,
117}
118
119impl std::fmt::Debug for TitleBar {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        f.debug_struct("TitleBar")
122            .field("height", &self.height)
123            .field("has_leading", &self.leading.is_some())
124            .field("has_center", &self.center.is_some())
125            .field("has_trailing", &self.trailing.is_some())
126            .finish_non_exhaustive()
127    }
128}
129
130impl TitleBar {
131    /// Construct a `TitleBar` bound to the given platform host.
132    ///
133    /// The maximize/restore glyph follows `WindowState::placement` via
134    /// `ctx.window()` at build time — the host no longer owns the
135    /// maximize signal.
136    pub fn new(host: Rc<dyn PlatformTitleBarHost>) -> Self {
137        Self {
138            host,
139            leading: None,
140            center: None,
141            trailing: None,
142            height: 40.0,
143            background: ColorProp::Static(Color::TRANSPARENT),
144            border_color: ColorProp::Static(Color::TRANSPARENT),
145            border_width: 0.0,
146            close_action: None,
147            root_child_id: None,
148            drag_region_id: Cell::new(None),
149            controls_layout: Rc::new(Cell::new(None)),
150            controls_visible: Prop::Static(true),
151        }
152    }
153
154    /// Show or hide the minimize / maximize / close cluster. Default `true`.
155    ///
156    /// Accepts a plain `bool` or a `Signal<bool>`. Applied through the
157    /// framework's own dormancy ([`teksilo_core::BuildContext::visible_when`]), so a flip
158    /// costs a relayout and **never a rebuild** of the bar: a dormant node is
159    /// skipped by layout, hit-test, focus and paint, so a hidden cluster takes
160    /// no space and receives no input. A derived (`.map`) signal is fine —
161    /// binding resolves through to the mutable roots and never calls `observe`.
162    ///
163    /// The case this exists for is **fullscreen**.
164    /// [`WindowPlacement::Fullscreen`](teksilo_core::WindowPlacement::Fullscreen)
165    /// is documented as "covers the entire display, title bar and all chrome
166    /// hidden", and every desktop convention agrees: macOS hides the traffic
167    /// lights, Windows fullscreen has no caption buttons, browsers and editors
168    /// hide their chrome outright. Minimize and maximize are meaningless for a
169    /// window with no frame. An app drawing custom chrome
170    /// ([`DecorationsMode::CustomChrome`](teksilo_core::DecorationsMode)) owns
171    /// that decision itself, because the framework cannot hide a title bar the
172    /// app composed — so it gates it here.
173    ///
174    /// An app that hides these **must** keep some other visible way out of
175    /// fullscreen: a menu item, an on-screen button, or a documented shortcut.
176    pub fn controls_visible(mut self, visible: impl Into<Prop<bool>>) -> Self {
177        self.controls_visible = visible.into();
178        self
179    }
180
181    /// Set the title bar's logical-pixel height. Default: 40.
182    pub fn height(mut self, height: f32) -> Self {
183        self.height = height;
184        self
185    }
186
187    /// Fill the title bar with a solid background color. Default:
188    /// transparent (the window's clear color shows through).
189    ///
190    /// Accepts a `Color`, a `Signal<Color>`, or any of the role types
191    /// (`SurfaceRole`, `TextRole`, `BorderRole`, or their `Signal<…>`
192    /// variants). Role values resolve at paint time, so the title bar
193    /// retints live across `ctx.set_theme(...)` switches.
194    pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
195        self.background = color.into();
196        self
197    }
198
199    /// Draw a 1px-or-thicker bottom border separating the title bar from
200    /// the body.
201    ///
202    /// Color accepts the same range as [`Self::background`]; pair with
203    /// `BorderRole::Default` for a theme-tracking divider.
204    pub fn border(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
205        self.border_color = color.into();
206        self.border_width = width;
207        self
208    }
209
210    /// Set the leading-edge content (e.g. app icon, menus). Rendered to the
211    /// right of the macOS traffic-light inset.
212    pub fn leading(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
213        self.leading = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
214        self
215    }
216
217    /// Set the center content (e.g. search box, breadcrumbs). Wrapped in a
218    /// flexible drag region: clicks that are not consumed by the child
219    /// initiate a window drag.
220    pub fn center(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
221        self.center = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
222        self
223    }
224
225    /// Set the trailing-edge content (e.g. user avatar, notification bell).
226    /// Rendered before the window controls.
227    pub fn trailing(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
228        self.trailing = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
229        self
230    }
231
232    /// Override the close-button action. When set, the close button calls
233    /// this closure instead of the default `ctx.close_window()` — the hook
234    /// for a "save before closing?" guard. The application typically ends
235    /// the closure by calling `EventContext::close_window` itself, or by
236    /// sending an `Intent` whose root-level `Action` handler calls it.
237    pub fn close_action(mut self, action: impl Fn(&mut EventContext) + 'static) -> Self {
238        self.close_action = Some(Rc::new(action));
239        self
240    }
241}
242
243impl Widget for TitleBar {
244    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
245        // Register repaint-on-change for any signal-bearing ColorProp.
246        // Static + role-only variants need no registration — `set_theme`
247        // already mark-all-dirties the tree.
248        let self_id = ctx.self_id();
249        let registry = ctx.binding_registry();
250        self.background.register_if_bound(
251            self_id,
252            registry,
253            teksilo_core::binding::BindingLevel::RepaintOnly,
254        );
255        self.border_color.register_if_bound(
256            self_id,
257            registry,
258            teksilo_core::binding::BindingLevel::RepaintOnly,
259        );
260
261        let leading_inset = self.host.reserved_leading_inset();
262        let trailing_inset = self.host.reserved_trailing_inset();
263        let renders_controls = self.host.renders_custom_controls();
264        let height = self.height;
265
266        // The drag region is a spacer (claims all leftover horizontal space
267        // in the HStack) that forwards drag / double-tap / right-click to
268        // the host. Its child — if any — fills the spacer's full bounds.
269        let drag_region = match self.center.take() {
270            Some(PendingChild::Deferred(child)) => DragRegion::with_child(self.host.clone(), child),
271            Some(PendingChild::Id(id)) => DragRegion::with_child_id(self.host.clone(), id),
272            None => DragRegion::new(self.host.clone()),
273        }
274        // Only consulted when the platform has no OS window menu and the drag
275        // region therefore builds its own (X11); see `title_bar/window_menu.rs`.
276        .close_action(self.close_action.clone());
277        let drag_region_id = ctx.add(drag_region);
278        self.drag_region_id.set(Some(drag_region_id));
279
280        // Derive the restore signal from the hosting window's
281        // `WindowState::placement`. When no state is attached (standalone
282        // / tests) fall back to a static `false`.
283        //
284        // `is_maximized() || is_fullscreen()`, not `is_maximized()` alone: a
285        // fullscreen window is restorable and must not be offered "maximize",
286        // which is meaningless for a window with no frame. See
287        // `WindowControls::new`'s `show_restore` doc.
288        let show_restore_signal = ctx
289            .window()
290            .map(|w| w.placement().map(|p| p.is_maximized() || p.is_fullscreen()))
291            .unwrap_or_else(|| Signal::new(false));
292        // The cluster is always *built* when the platform renders custom
293        // controls; `controls_visible` gates its **activity**, via the
294        // framework's own dormancy (`visible_when`), not by rebuilding.
295        //
296        // This is deliberate and load-bearing: `build` consumes its slots with
297        // `take()`, so it can only ever run once — a second pass would find
298        // leading/center/trailing all `None` and silently produce a bar with
299        // nothing in it but window controls. A `Rebuild`-level binding here did
300        // exactly that. `visible_when` binds at `Relayout` instead: a dormant
301        // node is skipped by layout, hit-test, focus and paint, so the cluster
302        // takes no space and receives no input while hidden, and comes back
303        // without the bar ever being rebuilt.
304        let controls_id: Option<WidgetId> = if renders_controls {
305            let controls = WindowControls::new(
306                self.host.clone(),
307                show_restore_signal,
308                self.close_action.clone(),
309            )
310            .layout_sink(self.controls_layout.clone());
311            let id = ctx.add(controls);
312            ctx.visible_when(id, self.controls_visible.clone());
313            Some(id)
314        } else {
315            None
316        };
317
318        // The leading and trailing slots arrive as `Box<dyn Widget>`, which
319        // does not itself implement `Widget`, so we register them via
320        // `BuildContext::add_boxed` first and then attach them by id.
321        // `HStack::child` takes an id or a widget and pushes both into the
322        // same ordered pending list, so interleaving is safe.
323        let mut row = HStack::new().spacing(0.0);
324
325        if leading_inset.width > 0.0 {
326            row = row.child(FixedSize::new().width(leading_inset.width).height(height));
327        }
328
329        if let Some(leading) = self.leading.take() {
330            let id = match leading {
331                PendingChild::Id(id) => id,
332                PendingChild::Deferred(w) => ctx.add_boxed(w),
333            };
334            row = row.child(id);
335        }
336
337        row = row.child(drag_region_id);
338
339        if let Some(trailing) = self.trailing.take() {
340            let id = match trailing {
341                PendingChild::Id(id) => id,
342                PendingChild::Deferred(w) => ctx.add_boxed(w),
343            };
344            row = row.child(id);
345        }
346
347        if trailing_inset.width > 0.0 {
348            row = row.child(FixedSize::new().width(trailing_inset.width).height(height));
349        }
350
351        if let Some(id) = controls_id {
352            row = row.child(id);
353        }
354
355        let root = ctx.add(row);
356        self.root_child_id = Some(root);
357        vec![root]
358    }
359
360    fn layout_response(
361        &self,
362        proposal: SizeProposal,
363        _ctx: &LayoutContext,
364    ) -> teksilo_core::widget::LayoutResponse {
365        // Always claim the full width offered by the parent and the
366        // configured fixed height. Ignoring the child HStack's natural
367        // width is intentional: when the title bar is laid out by a
368        // shrink-to-fit container the inner HStack would otherwise
369        // collapse to the sum of its non-spacer children, leaving the
370        // drag region with zero pixels.
371        Size::new(proposal.width.unwrap_or(0.0), self.height).into()
372    }
373
374    fn place_children(
375        &self,
376        bounds: Rect,
377        _proposal: SizeProposal,
378        children: &mut [WidgetPlacement],
379        _ctx: &LayoutContext,
380    ) {
381        for child in children.iter_mut() {
382            child.origin = bounds.origin();
383            child.size = bounds.size();
384        }
385    }
386
387    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
388        let bg = self.background.resolve(ctx.theme, ctx.effective_enabled);
389        if bg.a() > 0.0 {
390            canvas.fill_rounded_rect(bounds, CornerRadius::ZERO, bg);
391        }
392        if self.border_width > 0.0 {
393            let border = self.border_color.resolve(ctx.theme, ctx.effective_enabled);
394            if border.a() > 0.0 {
395                canvas.draw_border_bottom(bounds, border, self.border_width);
396            }
397        }
398    }
399
400    fn wants_after_paint(&self) -> bool {
401        // We aggregate descendant rects (drag region + min/max/close
402        // buttons) into a single `HitRegions` payload for the host
403        // every frame. The Windows backend reads it from
404        // `WM_NCHITTEST`; Wayland and macOS backends ignore it.
405        true
406    }
407
408    fn after_paint(&self, view: &WidgetTreeView<'_>, _ctx: &PaintContext) {
409        // Build one complete `HitRegions` snapshot per frame. The
410        // widget tree publishes logical-pixel rects (its native
411        // coordinate system); platform backends that need physical
412        // pixels (Windows) convert internally.
413        let mut regions = HitRegions::new();
414
415        if let Some(drag_id) = self.drag_region_id.get() {
416            let drag_bounds = view.bounds(drag_id);
417            // A zero-size drag bounds (host doesn't render controls,
418            // tree not laid out yet, etc.) would still hit-test true
419            // for any point at the origin — skip it.
420            if drag_bounds.width > 0.0 && drag_bounds.height > 0.0 {
421                regions.drag.push(drag_bounds);
422                // Punch a hole for every `DeadZone` the app put inside the
423                // `center` slot. On Windows the drag rect becomes `HTCAPTION`,
424                // and the OS then owns those pixels outright — a button living
425                // there would never see a click, a hover or a cursor change; it
426                // would only drag the window. The dead-zone flag already means
427                // "not draggable chrome" to widget-land's drag arming, so it is
428                // the same declaration the OS needs. Wrap an interactive
429                // title-bar control in a `DeadZone` and it works on both layers.
430                collect_dead_zones(view, drag_id, drag_bounds, &mut regions.no_drag);
431            }
432        }
433
434        // Overlays float above every widget, chrome included — so wherever
435        // one covers the title bar, the OS must hand the pixels back to the
436        // client area or the overlay is unclickable there. `DeadZone` can't
437        // express this: the dead-zone walk above is scoped to the drag
438        // region's own subtree, and an overlay is anchored anywhere (the
439        // hamburger `MenuBar`'s revealed bar hangs off the leading slot; a
440        // tall modal hangs off nothing in here at all). The shipped bug:
441        // on Windows every revealed menu title over the caption returned
442        // `HTCAPTION` and dragged the window instead of opening its menu.
443        // Clip to the title bar's own strip — every rect this snapshot
444        // publishes lies inside it, so anything outside is already client
445        // area and would only bloat the per-message scan in the wndproc.
446        if let Some(strip_id) = self.root_child_id {
447            let strip = view.bounds(strip_id);
448            for &overlay in view.overlay_rects() {
449                if let Some(hole) = intersect(overlay, strip) {
450                    regions.no_drag.push(hole);
451                }
452            }
453        }
454
455        // A hidden cluster publishes no control regions. The sink is populated
456        // at build time and survives the cluster going dormant, so without this
457        // guard Windows would keep returning `HTMINBUTTON`/`HTMAXBUTTON`/
458        // `HTCLOSE` for a strip of the caption that no longer has buttons in it
459        // — invisible controls, still clickable.
460        if !self.controls_visible.get() {
461            self.host.update_hit_regions(&regions);
462            return;
463        }
464
465        if let Some(layout) = self.controls_layout.take() {
466            regions.minimize = Some(view.bounds(layout.minimize_id));
467            regions.minimize_id = Some(layout.minimize_id);
468
469            // The maximize id is the Switcher's, not either glyph
470            // button's. The Switcher's bounds are always valid (the
471            // parent HStack lays it out regardless of which child is
472            // visible); a synthetic tap at the Switcher center routes
473            // through hit-testing to whichever child is currently
474            // visible — handles the floating ↔ maximized swap without
475            // the dormant-child-zero-bounds trap.
476            regions.maximize = Some(view.bounds(layout.maximize_id));
477            regions.maximize_id = Some(layout.maximize_id);
478
479            regions.close = Some(view.bounds(layout.close_id));
480            regions.close_id = Some(layout.close_id);
481
482            // Restore the layout cell so we don't have to rebuild it
483            // every frame.
484            self.controls_layout.set(Some(layout));
485        }
486
487        self.host.update_hit_regions(&regions);
488    }
489
490    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
491        builder.set_role(teksilo_core::accesskit::Role::Banner);
492        builder.set_name(teksilo_i18n::tr_widget!(a11y_title_bar_name()).resolve_now());
493    }
494
495    fn children(&self) -> Vec<WidgetId> {
496        self.root_child_id.into_iter().collect()
497    }
498}
499
500/// Depth-first walk of `root`'s descendants collecting the bounds of every
501/// gesture dead zone, clipped to `clip` (the drag rect). A dead zone is not
502/// descended into — its whole subtree is already inside its bounds.
503///
504/// Two nodes are deliberately skipped: dormant ones (a `Switcher`'s hidden page
505/// keeps stale bounds), and anything that does not overlap the drag rect — an
506/// *open* popover is an arena descendant of its trigger but hangs below the
507/// title bar, and its rect must not be mistaken for a hole in the caption.
508fn collect_dead_zones(view: &WidgetTreeView<'_>, root: WidgetId, clip: Rect, out: &mut Vec<Rect>) {
509    for &child in view.children(root) {
510        if !view.is_active(child) {
511            continue;
512        }
513        let Some(hit) = intersect(view.bounds(child), clip) else {
514            continue;
515        };
516        if view.is_gesture_dead_zone(child) {
517            out.push(hit);
518            continue;
519        }
520        collect_dead_zones(view, child, clip, out);
521    }
522}
523
524/// Overlap of two rects, or `None` when they do not overlap — `Rect` has
525/// `contains` but no intersection helper. Guards against publishing a
526/// degenerate (zero-area) exclusion rect.
527fn intersect(a: Rect, b: Rect) -> Option<Rect> {
528    let x0 = a.x.max(b.x);
529    let y0 = a.y.max(b.y);
530    let x1 = a.right().min(b.right());
531    let y1 = a.bottom().min(b.bottom());
532    (x1 > x0 && y1 > y0).then(|| Rect::new(x0, y0, x1 - x0, y1 - y0))
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use crate::primitives::{DeadZone, Expand};
539    use std::cell::{Cell, RefCell};
540    use teksilo_canvas::Point;
541    use teksilo_core::event::PointerButton;
542    use teksilo_core::widget_tree::WidgetTree;
543    use teksilo_core::{HitRegions, PlatformError, PlatformTitleBarHost, ResizeEdge};
544
545    /// A test host that records calls. Pretends the platform supports
546    /// custom controls (`renders_custom_controls = true`) and reports
547    /// zero macOS traffic-light insets.
548    struct TestHost {
549        minimized: Cell<u32>,
550        maximize_toggled: Cell<u32>,
551        closed: Cell<u32>,
552        drags_started: Cell<u32>,
553        is_max: Signal<bool>,
554        /// Last snapshot handed to `update_hit_regions` — what a real
555        /// platform backend would hit-test against.
556        last_regions: RefCell<HitRegions>,
557    }
558
559    impl Default for TestHost {
560        fn default() -> Self {
561            Self {
562                minimized: Cell::new(0),
563                maximize_toggled: Cell::new(0),
564                closed: Cell::new(0),
565                drags_started: Cell::new(0),
566                is_max: Signal::new(false),
567                last_regions: RefCell::new(HitRegions::default()),
568            }
569        }
570    }
571
572    impl PlatformTitleBarHost for TestHost {
573        fn reserved_leading_inset(&self) -> Size {
574            Size::ZERO
575        }
576        fn reserved_trailing_inset(&self) -> Size {
577            Size::ZERO
578        }
579        fn renders_custom_controls(&self) -> bool {
580            true
581        }
582        fn needs_custom_resize_handles(&self) -> bool {
583            true
584        }
585        fn begin_drag(&self) -> Result<(), PlatformError> {
586            self.drags_started.set(self.drags_started.get() + 1);
587            Ok(())
588        }
589        fn begin_resize(&self, _edge: ResizeEdge) -> Result<(), PlatformError> {
590            Ok(())
591        }
592        fn show_window_menu(&self, _at: Point) -> Result<(), PlatformError> {
593            Ok(())
594        }
595        fn update_hit_regions(&self, regions: &HitRegions) {
596            *self.last_regions.borrow_mut() = regions.clone();
597        }
598    }
599
600    /// Build a tree where the title bar is wrapped in the same VStack +
601    /// Expand body shape the demo uses. Returns the laid-out tree plus the
602    /// title-bar widget id.
603    fn build_realistic_tree(
604        host: Rc<TestHost>,
605        bar_setup: impl FnOnce(TitleBar) -> TitleBar,
606    ) -> (WidgetTree, WidgetId) {
607        use crate::primitives::{Expand, VStack};
608
609        let bar_widget =
610            bar_setup(TitleBar::new(host as Rc<dyn PlatformTitleBarHost>).height(40.0));
611
612        // A theme + text backend so `render()` (and with it the `after_paint`
613        // pass that publishes `HitRegions`) can run: the control buttons carry
614        // glyphs, which need a typesetter.
615        let mut tree = WidgetTree::new()
616            .with_theme(teksilo_core::presets::intui::light())
617            .with_text_backend(Rc::new(std::cell::RefCell::new(
618                teksilo_canvas::MockTextBackend::new(),
619            )));
620        let bar_id = tree.add(bar_widget);
621        let body_id = tree.add(Expand::new());
622        let _root = tree.add(VStack::new().spacing(0.0).child(bar_id).child(body_id));
623        tree.layout(SizeProposal::exact(900.0, 600.0));
624        (tree, bar_id)
625    }
626
627    /// Walk the title bar tree to find the trio of control button ids in
628    /// order: minimize, maximize, close. Layout-shape-aware — if the build
629    /// changes shape this test will tell us by panicking with a helpful
630    /// debug print of the children at each level.
631    ///
632    /// The maximize slot is a `Switcher` whose two pages (normal and zoomed,
633    /// both deliberately drawing `□`) are pre-mounted ControlButtons handed
634    /// in via `child(id)`. With Switcher's lazy-mount semantics, `PreMounted`
635    /// entries become `Mounted` eagerly on first build, so the Switcher
636    /// reports both pages as direct children — this helper picks the
637    /// first (normal-state), which is the page `TitleBar` selects when no
638    /// `WindowState` is attached and `show_restore` falls back to `false`.
639    fn locate_control_buttons(tree: &WidgetTree, bar: WidgetId) -> [WidgetId; 3] {
640        // bar -> [HStack root]
641        let bar_kids = tree.children(bar);
642        assert_eq!(
643            bar_kids.len(),
644            1,
645            "TitleBar should have a single root: {bar_kids:?}"
646        );
647        let row = bar_kids[0];
648
649        // row -> [DragRegion (spacer), WindowControls]
650        let row_kids = tree.children(row);
651        assert_eq!(
652            row_kids.len(),
653            2,
654            "row should have drag_region + controls, got {row_kids:?}"
655        );
656        let controls = row_kids[1];
657
658        // controls -> [inner HStack]
659        let controls_kids = tree.children(controls);
660        assert_eq!(controls_kids.len(), 1, "controls should wrap one HStack");
661        let inner_row = controls_kids[0];
662
663        // inner_row -> [minimize, max_switcher, close]
664        let inner_kids = tree.children(inner_row);
665        assert_eq!(
666            inner_kids.len(),
667            3,
668            "inner controls row should contain 3 items, got {inner_kids:?}"
669        );
670        // Switcher's direct children are its mounted pages — both
671        // pre-mounted ControlButtons (normal + zoomed, both `□`) in
672        // declaration order.
673        let max_buttons = tree.children(inner_kids[1]);
674        assert_eq!(
675            max_buttons.len(),
676            2,
677            "maximize Switcher should expose 2 ControlButtons (□ + ❐), got {max_buttons:?}"
678        );
679        [inner_kids[0], max_buttons[0], inner_kids[2]]
680    }
681
682    /// Whether the control cluster is currently *live* — built and active.
683    ///
684    /// Deliberately not a child count: the cluster is always built, and
685    /// `controls_visible` parks it dormant rather than removing it. Counting
686    /// children would report it present in both states.
687    fn controls_are_live(tree: &WidgetTree, bar: WidgetId) -> bool {
688        let row = tree.children(bar)[0];
689        tree.children(row)
690            .last()
691            .is_some_and(|&id| tree.is_active(id) && tree.bounds(id).width > 0.0)
692    }
693
694    /// Build a bar over a real `WindowState` at `placement`, so `ctx.window()`
695    /// resolves and the restore/maximize derivation is exercised for real
696    /// rather than falling back to its no-window `false`.
697    fn tree_at_placement(placement: teksilo_core::WindowPlacement) -> (WidgetTree, WidgetId) {
698        use crate::primitives::VStack;
699        use teksilo_core::window::WindowState;
700        use teksilo_core::{TeksiloWindowId, WindowStateInit};
701
702        let host = Rc::new(TestHost::default());
703        let mut tree = WidgetTree::new()
704            .with_theme(teksilo_core::presets::intui::light())
705            .with_text_backend(Rc::new(std::cell::RefCell::new(
706                teksilo_canvas::MockTextBackend::new(),
707            )));
708        tree.set_window_state(WindowState::new(WindowStateInit {
709            id: TeksiloWindowId::new(1),
710            string_id: Some("w1".to_string()),
711            placement,
712            title: "Test".to_string(),
713            size: (900, 600),
714            position: (0, 0),
715            focused: true,
716            resizable: true,
717            always_on_top: false,
718        }));
719        let bar_id = tree.add(TitleBar::new(host as Rc<dyn PlatformTitleBarHost>).height(40.0));
720        let body_id = tree.add(Expand::new());
721        let _root = tree.add(VStack::new().spacing(0.0).child(bar_id).child(body_id));
722        tree.layout(SizeProposal::exact(900.0, 600.0));
723        (tree, bar_id)
724    }
725
726    /// The maximize slot's *visible* page: its index (0 = Maximize,
727    /// 1 = Restore) and its `WidgetId`. Read off which Switcher child is
728    /// active rather than off the glyph, since both pages deliberately draw
729    /// the same `□`.
730    ///
731    /// The id matters as much as the index: `locate_control_buttons` always
732    /// returns page 0, so clicking *that* in a state where page 1 is showing
733    /// hits an inactive widget and silently does nothing.
734    fn visible_maximize_page(tree: &WidgetTree, bar: WidgetId) -> (usize, WidgetId) {
735        let row = tree.children(bar)[0];
736        let controls = tree.children(row)[1];
737        let inner = tree.children(controls)[0];
738        let switcher = tree.children(inner)[1];
739        let pages = tree.children(switcher);
740        pages
741            .iter()
742            .enumerate()
743            .find(|&(_, &p)| tree.is_active(p))
744            .map(|(i, &p)| (i, p))
745            .expect("one maximize page must be active")
746    }
747
748    /// **Rebuilding a `TitleBar` must not eat its slots.**
749    ///
750    /// `build` used to `take()` leading/center/trailing, so it worked exactly
751    /// once. Nothing bound the bar at `Rebuild` level, so nothing ever rebuilt
752    /// it and the bug was unreachable — until `controls_visible` added the
753    /// first such binding, at which point the very first fullscreen toggle
754    /// emptied the bar of its menu, title and tools while leaving the window
755    /// controls (which *are* rebuilt each pass) in place.
756    ///
757    /// Driven through the real `controls_visible` binding rather than a
758    /// synthetic rebuild: that is the path that broke, and a test that forced
759    /// a rebuild some other way could pass while the shipping one still ate
760    /// the slots.
761    #[test]
762    fn rebuilding_keeps_the_leading_and_trailing_slots() {
763        use crate::TextWidget;
764        use crate::primitives::VStack;
765        use teksilo_i18n::lit;
766
767        let host = Rc::new(TestHost::default());
768        let visible = Signal::new(true);
769        let mut tree = WidgetTree::new()
770            .with_theme(teksilo_core::presets::intui::light())
771            .with_text_backend(Rc::new(std::cell::RefCell::new(
772                teksilo_canvas::MockTextBackend::new(),
773            )));
774        let bar = tree.add(
775            TitleBar::new(host as Rc<dyn PlatformTitleBarHost>)
776                .height(40.0)
777                .leading(TextWidget::new(lit!("MENU")))
778                .center(TextWidget::new(lit!("TITLE")))
779                .trailing(TextWidget::new(lit!("TOOLS")))
780                .controls_visible(visible.clone()),
781        );
782        let body = tree.add(Expand::new());
783        let _root = tree.add(VStack::new().spacing(0.0).child(bar).child(body));
784        tree.layout(SizeProposal::exact(900.0, 600.0));
785
786        // row = [leading, drag_region, trailing, controls]. Asserted as a shape
787        // plus the drag region's width, because that is exactly what the real
788        // failure looked like: the row collapsed to the three 46 px control
789        // cells (138 px total), with the spacer and both slots gone, so the
790        // buttons ended up flush LEFT against an otherwise empty bar.
791        let shape = |t: &WidgetTree| {
792            let row = t.children(bar)[0];
793            let kids = t.children(row);
794            let drag_w = kids.get(1).map(|&id| t.bounds(id).width).unwrap_or(0.0);
795            (kids.len(), drag_w > 0.0, t.bounds(row).width)
796        };
797
798        let (n, drag_fills, row_w) = shape(&tree);
799        assert_eq!(n, 4, "leading + drag + trailing + controls");
800        assert!(
801            drag_fills,
802            "the drag region is a spacer and must have width"
803        );
804        assert!((row_w - 900.0).abs() < 1.0, "row spans the bar: {row_w}");
805
806        // Toggle the gate, twice, in both directions.
807        visible.set(false);
808        tree.layout(SizeProposal::exact(900.0, 600.0));
809        assert!(
810            !controls_are_live(&tree, bar),
811            "the cluster parks when the gate goes false"
812        );
813        let (n, drag_fills, row_w) = shape(&tree);
814        assert_eq!(n, 4, "the cluster parks, it is not removed");
815        assert!(drag_fills && (row_w - 900.0).abs() < 1.0, "slots intact");
816
817        visible.set(true);
818        tree.layout(SizeProposal::exact(900.0, 600.0));
819        assert!(controls_are_live(&tree, bar), "and comes back");
820        let (n, drag_fills, row_w) = shape(&tree);
821        assert_eq!(
822            n, 4,
823            "the leading/center/trailing slots must survive a gate flip — \
824             `build` consumes them, so anything that rebuilds this bar empties it"
825        );
826        assert!(
827            drag_fills && (row_w - 900.0).abs() < 1.0,
828            "slots still intact"
829        );
830    }
831
832    #[test]
833    fn controls_visible_false_parks_the_cluster() {
834        let host = Rc::new(TestHost::default());
835        let (tree, bar) = build_realistic_tree(host, |b| b.controls_visible(false));
836        assert!(
837            !controls_are_live(&tree, bar),
838            "controls_visible(false) must leave the cluster dormant and zero-width"
839        );
840    }
841
842    #[test]
843    fn controls_visible_defaults_to_showing_them() {
844        let host = Rc::new(TestHost::default());
845        let (tree, bar) = build_realistic_tree(host, |b| b);
846        assert!(controls_are_live(&tree, bar), "default is shown");
847    }
848
849    /// A bound gate flips a MOUNTED bar in both directions. The two tests above
850    /// each build a fresh bar, so they would pass even if the gate were read
851    /// once and frozen — this is the path an app takes when it enters and
852    /// leaves fullscreen with the bar already on screen.
853    #[test]
854    fn controls_visible_flips_a_mounted_bar_both_ways() {
855        use crate::primitives::VStack;
856        let host = Rc::new(TestHost::default());
857        let visible = Signal::new(true);
858        let mut tree = WidgetTree::new()
859            .with_theme(teksilo_core::presets::intui::light())
860            .with_text_backend(Rc::new(std::cell::RefCell::new(
861                teksilo_canvas::MockTextBackend::new(),
862            )));
863        let bar = tree.add(
864            TitleBar::new(host as Rc<dyn PlatformTitleBarHost>)
865                .height(40.0)
866                .controls_visible(visible.clone()),
867        );
868        let body = tree.add(Expand::new());
869        let _root = tree.add(VStack::new().spacing(0.0).child(bar).child(body));
870        tree.layout(SizeProposal::exact(900.0, 600.0));
871        assert!(controls_are_live(&tree, bar), "starts shown");
872
873        visible.set(false);
874        tree.layout(SizeProposal::exact(900.0, 600.0));
875        assert!(
876            !controls_are_live(&tree, bar),
877            "hiding must park the cluster on a mounted bar"
878        );
879
880        visible.set(true);
881        tree.layout(SizeProposal::exact(900.0, 600.0));
882        assert!(
883            controls_are_live(&tree, bar),
884            "and a hidden cluster must still learn to come back"
885        );
886    }
887
888    /// Fullscreen offers **Restore**, not Maximize. `WindowPlacement::is_maximized`
889    /// reports `false` in `Fullscreen`, so reading it alone used to render the
890    /// Maximize affordance over a window that has no frame to maximize.
891    #[test]
892    fn fullscreen_shows_the_restore_page_not_maximize() {
893        use teksilo_core::WindowPlacement as P;
894        let (tree, bar) = tree_at_placement(P::Floating);
895        assert_eq!(
896            visible_maximize_page(&tree, bar).0,
897            0,
898            "floating offers Maximize"
899        );
900
901        let (tree, bar) = tree_at_placement(P::Maximized);
902        assert_eq!(
903            visible_maximize_page(&tree, bar).0,
904            1,
905            "maximized offers Restore"
906        );
907
908        let (tree, bar) = tree_at_placement(P::Fullscreen);
909        assert_eq!(
910            visible_maximize_page(&tree, bar).0,
911            1,
912            "fullscreen must offer Restore — maximize is meaningless there"
913        );
914    }
915
916    /// ...and activating it from fullscreen restores, rather than sending the
917    /// window to `Maximized` — a state no command asked for, and one that
918    /// silently drops fullscreen while an app-level mode keyed off it stays on.
919    #[test]
920    fn activating_restore_from_fullscreen_leaves_fullscreen() {
921        use teksilo_core::WindowPlacement as P;
922        let (mut tree, bar) = tree_at_placement(P::Fullscreen);
923        let (_, restore) = visible_maximize_page(&tree, bar);
924        let b = tree.bounds(restore);
925        let centre = Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
926        tree.pointer_down_button(centre, PointerButton::Primary);
927        tree.pointer_up_button(centre, PointerButton::Primary);
928
929        let placement = tree
930            .window_state()
931            .expect("window state attached")
932            .placement()
933            .get();
934        assert_eq!(
935            placement,
936            P::Floating,
937            "restore from fullscreen must not land on Maximized"
938        );
939    }
940
941    #[test]
942    fn title_bar_claims_full_width_and_configured_height() {
943        let host = Rc::new(TestHost::default());
944        let (tree, bar) = build_realistic_tree(host, |b| b);
945        let b = tree.bounds(bar);
946        assert!((b.width - 900.0).abs() < 0.01, "width = {}", b.width);
947        assert!((b.height - 40.0).abs() < 0.01, "height = {}", b.height);
948    }
949
950    #[test]
951    fn drag_region_is_a_spacer_so_controls_sit_flush_right() {
952        // Regression: in the first M2 cut DragRegion was not a spacer and
953        // collapsed to zero width, leaving the buttons clustered next to
954        // the leading text instead of at the trailing edge.
955        let host = Rc::new(TestHost::default());
956        let (tree, bar) = build_realistic_tree(host, |b| b);
957
958        let [_minimize, _maximize, close] = locate_control_buttons(&tree, bar);
959        let close_b = tree.bounds(close);
960
961        // 46 px wide cell, three of them, flush right against the 900 px
962        // window edge → close button right edge ≈ 900, left edge ≈ 854.
963        assert!(
964            (close_b.right() - 900.0).abs() < 1.0,
965            "close right edge = {}, expected ~900",
966            close_b.right()
967        );
968        assert!(
969            (close_b.width - 46.0).abs() < 1.0,
970            "close cell width = {}, expected 46",
971            close_b.width
972        );
973    }
974
975    #[test]
976    fn close_action_override_is_invoked_instead_of_host_close() {
977        let host = Rc::new(TestHost::default());
978        let close_calls = Rc::new(Cell::new(0u32));
979        let close_calls_clone = close_calls.clone();
980
981        let host_clone = host.clone();
982        let (mut tree, bar) = build_realistic_tree(host_clone, move |b| {
983            b.close_action(move |_ctx| {
984                close_calls_clone.set(close_calls_clone.get() + 1);
985            })
986        });
987
988        let [_min, _max, close] = locate_control_buttons(&tree, bar);
989        tree.click(close);
990
991        assert!(
992            close_calls.get() >= 1,
993            "close_action should have been called, got {}",
994            close_calls.get()
995        );
996        assert_eq!(
997            host.closed.get(),
998            0,
999            "host.close() must NOT be called when close_action override is set"
1000        );
1001    }
1002
1003    /// Attach a fresh `WindowState` to the tree so the title bar's
1004    /// maximize/minimize actions have a target. Returns the state so
1005    /// the test can assert against `placement().get()` after an action.
1006    fn attach_window_state(tree: &mut WidgetTree) -> teksilo_core::WindowState {
1007        let state = teksilo_core::WindowState::new(teksilo_core::WindowStateInit {
1008            id: teksilo_core::TeksiloWindowId::new(1),
1009            string_id: None,
1010            placement: teksilo_core::WindowPlacement::Floating,
1011            title: "Test".to_string(),
1012            size: (800, 600),
1013            position: (0, 0),
1014            focused: true,
1015            resizable: true,
1016            always_on_top: false,
1017        });
1018        tree.set_window_state(state.clone());
1019        state
1020    }
1021
1022    #[test]
1023    fn minimize_button_sets_placement_to_minimized() {
1024        let host = Rc::new(TestHost::default());
1025        let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1026        let state = attach_window_state(&mut tree);
1027
1028        let [minimize, _max, _close] = locate_control_buttons(&tree, bar);
1029        tree.click(minimize);
1030
1031        assert_eq!(
1032            state.placement().get(),
1033            teksilo_core::WindowPlacement::Minimized,
1034            "minimize button should flip WindowState::placement to Minimized"
1035        );
1036    }
1037
1038    #[test]
1039    fn maximize_button_toggles_placement() {
1040        let host = Rc::new(TestHost::default());
1041        let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1042        let state = attach_window_state(&mut tree);
1043
1044        let [_min, maximize, _close] = locate_control_buttons(&tree, bar);
1045        tree.click(maximize);
1046        assert_eq!(
1047            state.placement().get(),
1048            teksilo_core::WindowPlacement::Maximized
1049        );
1050
1051        tree.click(maximize);
1052        assert_eq!(
1053            state.placement().get(),
1054            teksilo_core::WindowPlacement::Floating
1055        );
1056    }
1057
1058    /// Each control button advertises `Action::Click` — on macOS that is
1059    /// precisely what makes VoiceOver offer a press (`is_clickable` ==
1060    /// `supports_action(Click)`). Invoking it must actually drive the
1061    /// window, or a screen-reader user cannot minimize / maximize /
1062    /// close the window at all.
1063    #[test]
1064    fn access_click_drives_window_controls() {
1065        let host = Rc::new(TestHost::default());
1066        let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1067        let state = attach_window_state(&mut tree);
1068
1069        let [minimize, maximize, _close] = locate_control_buttons(&tree, bar);
1070
1071        let at_click = |tree: &mut WidgetTree, id: WidgetId| {
1072            tree.dispatch_event(teksilo_core::event::WidgetEvent::AccessAction {
1073                action: teksilo_core::accesskit::Action::Click,
1074                target: Some(id),
1075                target_node: teksilo_core::accessibility::root_node_id(),
1076                data: None,
1077            });
1078        };
1079
1080        at_click(&mut tree, minimize);
1081        assert_eq!(
1082            state.placement().get(),
1083            teksilo_core::WindowPlacement::Minimized,
1084            "AT click on minimize must flip placement to Minimized"
1085        );
1086
1087        state
1088            .placement()
1089            .set(teksilo_core::WindowPlacement::Floating);
1090        at_click(&mut tree, maximize);
1091        assert_eq!(
1092            state.placement().get(),
1093            teksilo_core::WindowPlacement::Maximized,
1094            "AT click on maximize must flip placement to Maximized"
1095        );
1096    }
1097
1098    /// The close button's AT click must run the same action a pointer tap
1099    /// does — including a `close_action` override.
1100    #[test]
1101    fn access_click_invokes_close_action() {
1102        let host = Rc::new(TestHost::default());
1103        let close_calls = Rc::new(Cell::new(0u32));
1104        let close_calls_clone = close_calls.clone();
1105
1106        let (mut tree, bar) = build_realistic_tree(host.clone(), move |b| {
1107            b.close_action(move |_ctx| {
1108                close_calls_clone.set(close_calls_clone.get() + 1);
1109            })
1110        });
1111
1112        let [_min, _max, close] = locate_control_buttons(&tree, bar);
1113        tree.dispatch_event(teksilo_core::event::WidgetEvent::AccessAction {
1114            action: teksilo_core::accesskit::Action::Click,
1115            target: Some(close),
1116            target_node: teksilo_core::accessibility::root_node_id(),
1117            data: None,
1118        });
1119
1120        assert_eq!(
1121            close_calls.get(),
1122            1,
1123            "AT click on close must invoke the close action"
1124        );
1125    }
1126
1127    /// Locate the `DragRegion` widget id by walking the title-bar subtree.
1128    /// Path: bar → HStack root → [drag_region, controls].
1129    fn locate_drag_region(tree: &WidgetTree, bar: WidgetId) -> WidgetId {
1130        let bar_kids = tree.children(bar);
1131        let row = bar_kids[0];
1132        let row_kids = tree.children(row);
1133        row_kids[0]
1134    }
1135
1136    #[test]
1137    fn dragging_inside_drag_region_calls_host_begin_drag() {
1138        // Regression for: in M2 the gesture-arena auto-wiring in teksilo-core
1139        // only built a TapRecognizer when on_tap was set. DragRegion uses
1140        // on_drag (no on_tap) and so was getting no arena at all → drag
1141        // never fired. The fix in gesture_dispatch_impl::ensure_gesture_arena
1142        // installs DragRecognizer whenever on_drag is set.
1143        let host = Rc::new(TestHost::default());
1144        let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1145
1146        let drag = locate_drag_region(&tree, bar);
1147        let drag_b = tree.bounds(drag);
1148        let from = Point::new(drag_b.x + 50.0, drag_b.y + drag_b.height / 2.0);
1149        let to = Point::new(drag_b.x + 200.0, drag_b.y + drag_b.height / 2.0);
1150
1151        tree.drag(from, to);
1152
1153        assert!(
1154            host.drags_started.get() >= 1,
1155            "host.begin_drag() should be called on drag-start, got {}",
1156            host.drags_started.get()
1157        );
1158    }
1159
1160    #[test]
1161    fn title_bar_exposes_banner_landmark() {
1162        let host = Rc::new(TestHost::default());
1163        let (tree, bar) = build_realistic_tree(host, |b| b);
1164        let info = tree.accessibility_node(bar);
1165        assert_eq!(info.role(), teksilo_core::accesskit::Role::Banner);
1166        assert!(
1167            info.name().is_some(),
1168            "TitleBar Banner landmark should have a localised name"
1169        );
1170    }
1171
1172    #[test]
1173    fn window_control_glyphs_retint_on_theme_switch() {
1174        // Regression: `WindowControls` froze `text_primary` / `surface_hover`
1175        // / `status_error_bg` into `Color` snapshots at build time and relied
1176        // on `mark_all_dirty` to "follow the theme" — but a static `Color` is
1177        // a `ColorProp::Static` that always re-resolves to the same value, so
1178        // the min/max/close glyphs kept the build-time theme's color after a
1179        // `set_theme`. The fix hands the buttons `TextRole::Primary` /
1180        // `SurfaceRole::*`, which resolve against the live theme at paint
1181        // time. This test renders the control glyphs under light then dark
1182        // and asserts they actually change color.
1183        use crate::primitives::{Expand, VStack};
1184        use std::cell::RefCell;
1185        use teksilo_canvas::MockTextBackend;
1186
1187        let host = Rc::new(TestHost::default());
1188        let bar_widget = TitleBar::new(host as Rc<dyn PlatformTitleBarHost>).height(40.0);
1189
1190        let mut tree = WidgetTree::new()
1191            .with_theme(teksilo_core::presets::intui::light())
1192            .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
1193        let bar_id = tree.add(bar_widget);
1194        let body_id = tree.add(Expand::new());
1195        tree.add(VStack::new().spacing(0.0).child(bar_id).child(body_id));
1196
1197        tree.layout(SizeProposal::exact(900.0, 600.0));
1198        let light_glyphs: Vec<[f32; 4]> = tree.render().glyphs.iter().map(|g| g.color).collect();
1199        assert!(
1200            !light_glyphs.is_empty(),
1201            "control glyphs (—, □, ×) should have rendered"
1202        );
1203
1204        // Every control glyph uses TextRole::Primary; under light it must
1205        // resolve to the light theme's primary text color.
1206        let light_primary = teksilo_core::presets::intui::light()
1207            .colors
1208            .text_primary
1209            .to_array();
1210        assert!(
1211            light_glyphs.iter().all(|c| *c == light_primary),
1212            "control glyphs should paint with the light theme's text_primary, got {light_glyphs:?}"
1213        );
1214
1215        tree.set_theme(teksilo_core::presets::intui::dark());
1216        tree.layout(SizeProposal::exact(900.0, 600.0));
1217        let dark_glyphs: Vec<[f32; 4]> = tree.render().glyphs.iter().map(|g| g.color).collect();
1218
1219        let dark_primary = teksilo_core::presets::intui::dark()
1220            .colors
1221            .text_primary
1222            .to_array();
1223        assert!(
1224            dark_glyphs.iter().all(|c| *c == dark_primary),
1225            "control glyphs should retint to the dark theme's text_primary, got {dark_glyphs:?}"
1226        );
1227        assert_ne!(
1228            light_glyphs, dark_glyphs,
1229            "control glyph colors must change across a theme switch"
1230        );
1231    }
1232
1233    #[test]
1234    fn window_controls_have_semantic_names_not_glyphs() {
1235        let host = Rc::new(TestHost::default());
1236        let (tree, bar) = build_realistic_tree(host, |b| b);
1237        let [minimize, maximize, close] = locate_control_buttons(&tree, bar);
1238
1239        let min_info = tree.accessibility_node(minimize);
1240        let max_info = tree.accessibility_node(maximize);
1241        let close_info = tree.accessibility_node(close);
1242
1243        // Screen readers must get a semantic verb, not the raw glyph
1244        // character (`—`, `□`, `×`) which Unicode-aware AT pronounces
1245        // as "em dash" / "white square" / "multiplication sign".
1246        for info in [&min_info, &max_info, &close_info] {
1247            let name = info.name().expect("control button must have a name");
1248            assert!(!name.is_empty(), "name empty");
1249            assert_ne!(name, "\u{2014}", "minimize reads glyph literal");
1250            assert_ne!(name, "\u{25A1}", "maximize reads glyph literal");
1251            assert_ne!(name, "\u{00D7}", "close reads glyph literal");
1252            assert_eq!(info.role(), teksilo_core::accesskit::Role::Button);
1253        }
1254    }
1255
1256    #[test]
1257    fn drag_region_is_no_stop_for_assistive_technology() {
1258        // Pointer-only, so a reader must never stop on it. It says so as a
1259        // bare `GenericContainer`, which every adapter drops while keeping
1260        // what it holds. This test used to ask for the hidden flag, which
1261        // pinned the defect: an adapter reads a hidden node as hiding its
1262        // subtree, and `TitleBar::center` sits inside the region.
1263        let host = Rc::new(TestHost::default());
1264        let (mut tree, bar) = build_realistic_tree(host, |b| b);
1265        let drag = locate_drag_region(&tree, bar);
1266        let info = tree.accessibility_node(drag);
1267        assert_eq!(info.role(), teksilo_core::accesskit::Role::GenericContainer);
1268        assert!(
1269            !info.is_hidden(),
1270            "a hidden DragRegion hides the title bar's centre content with it"
1271        );
1272
1273        let update = tree.sync_accessibility();
1274        let target = teksilo_core::accessibility::widget_id_to_node_id(drag);
1275        let consumer = accesskit_consumer::Tree::new(update, false);
1276        let mut stack = vec![consumer.state().root()];
1277        while let Some(node) = stack.pop() {
1278            assert_ne!(
1279                node.locate().0,
1280                target,
1281                "the DragRegion must not be a stop in the tree an adapter walks"
1282            );
1283            stack.extend(node.filtered_children(&accesskit_consumer::common_filter));
1284        }
1285    }
1286
1287    /// Render one frame so `after_paint` runs and the host receives a
1288    /// `HitRegions` snapshot. `WidgetTree::render` drives the paint pass.
1289    fn paint_once(tree: &mut WidgetTree) {
1290        tree.layout(SizeProposal::exact(900.0, 600.0));
1291        let _ = tree.render();
1292    }
1293
1294    #[test]
1295    fn dead_zone_in_center_is_published_as_a_no_drag_hole() {
1296        // Regression (Windows): the whole `center` slot is wrapped in a
1297        // DragRegion whose rect goes out as `HitRegions::drag`, which the
1298        // Windows backend answers with HTCAPTION. An interactive control
1299        // living there was therefore unclickable — the OS took the press and
1300        // started a window move instead. Wrapping it in a `DeadZone` must now
1301        // punch a hole in the caption so the OS hands the pixels back.
1302
1303        let host = Rc::new(TestHost::default());
1304        let host_for_bar = host.clone();
1305        let (mut tree, _bar) = build_realistic_tree(host_for_bar, |b| {
1306            b.center(
1307                HStack::new()
1308                    .child(DeadZone::new().child(FixedSize::new().width(60.0).height(30.0)))
1309                    .child(Expand::new()),
1310            )
1311        });
1312        paint_once(&mut tree);
1313
1314        let regions = host.last_regions.borrow();
1315        assert_eq!(
1316            regions.drag.len(),
1317            1,
1318            "the drag region should still be published"
1319        );
1320        assert_eq!(
1321            regions.no_drag.len(),
1322            1,
1323            "the DeadZone in `center` must be published as one no_drag hole, got {:?}",
1324            regions.no_drag
1325        );
1326        let hole = regions.no_drag[0];
1327        let drag = regions.drag[0];
1328        assert!(
1329            (hole.width - 60.0).abs() < 1.0,
1330            "the hole should match the dead zone's width, got {}",
1331            hole.width
1332        );
1333        // The hole must lie inside the caption it is carving out of, or the
1334        // Windows backend would test it against a region that never matches.
1335        assert!(
1336            hole.x >= drag.x - 0.01 && hole.right() <= drag.right() + 0.01,
1337            "hole {hole:?} must be clipped to the drag rect {drag:?}"
1338        );
1339    }
1340
1341    #[test]
1342    fn passive_center_content_punches_no_hole() {
1343        // The inverse guard: a plain centred title must NOT become a no_drag
1344        // hole, or the user could no longer drag the window by its title —
1345        // which is the drag region's entire purpose.
1346        let host = Rc::new(TestHost::default());
1347        let host_for_bar = host.clone();
1348        let (mut tree, _bar) = build_realistic_tree(host_for_bar, |b| {
1349            b.center(crate::TextWidget::new(teksilo_i18n::lit!("My App")))
1350        });
1351        paint_once(&mut tree);
1352
1353        let regions = host.last_regions.borrow();
1354        assert_eq!(regions.drag.len(), 1, "drag region still published");
1355        assert!(
1356            regions.no_drag.is_empty(),
1357            "a passive centred title must not punch a hole in the caption, got {:?}",
1358            regions.no_drag
1359        );
1360    }
1361
1362    #[test]
1363    fn overlay_over_the_caption_is_published_as_a_no_drag_hole() {
1364        // Regression (Windows): the hamburger `MenuBar`'s revealed bar is an
1365        // *overlay* anchored in the leading slot — outside the drag region —
1366        // so no `DeadZone` walk could ever reach it, and every menu title
1367        // painted over the caption returned `HTCAPTION`: clicking a menu
1368        // dragged the window instead of opening it (except where an
1369        // unrelated dead-zoned control happened to sit beneath). Any
1370        // interactive overlay must carve its caption overlap out of the
1371        // published regions.
1372        use teksilo_core::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
1373
1374        let host = Rc::new(TestHost::default());
1375        let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1376
1377        // 200×60 content shown at (100, 20): its top half overlaps the
1378        // 40 dp title bar strip, its bottom half hangs below into the
1379        // client area.
1380        let content = tree.add(FixedSize::new().width(200.0).height(60.0));
1381        tree.show_overlay(OverlayRequest::new(
1382            content,
1383            bar,
1384            OverlayPlacement::AtPointer(Point::new(100.0, 20.0)),
1385            DismissBehavior::Manual,
1386        ));
1387        paint_once(&mut tree);
1388
1389        let regions = host.last_regions.borrow();
1390        assert_eq!(regions.drag.len(), 1, "the drag region is still published");
1391        assert_eq!(
1392            regions.no_drag.len(),
1393            1,
1394            "the overlay's caption overlap must be published as one no_drag \
1395             hole, got {:?}",
1396            regions.no_drag
1397        );
1398        let hole = regions.no_drag[0];
1399        assert!(
1400            (hole.x - 100.0).abs() < 0.01 && (hole.width - 200.0).abs() < 0.01,
1401            "the hole should span the overlay's width at its position, got {hole:?}"
1402        );
1403        // Clipped to the strip: the overlay reaches y=80 but the title bar
1404        // ends at y=40, and everything below is client area already.
1405        assert!(
1406            (hole.y - 20.0).abs() < 0.01 && (hole.bottom() - 40.0).abs() < 0.01,
1407            "the hole must be clipped to the title bar strip, got {hole:?}"
1408        );
1409    }
1410
1411    #[test]
1412    fn overlay_below_the_caption_punches_no_hole() {
1413        // The inverse guard: a dropdown, popover or toast that floats
1414        // entirely below the title bar must NOT punch a hole — its rect
1415        // never overlaps the published regions, and a spurious hole would
1416        // eat the caption's drag under it.
1417        use teksilo_core::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
1418
1419        let host = Rc::new(TestHost::default());
1420        let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1421
1422        let content = tree.add(FixedSize::new().width(200.0).height(60.0));
1423        tree.show_overlay(OverlayRequest::new(
1424            content,
1425            bar,
1426            OverlayPlacement::AtPointer(Point::new(100.0, 300.0)),
1427            DismissBehavior::Manual,
1428        ));
1429        paint_once(&mut tree);
1430
1431        let regions = host.last_regions.borrow();
1432        assert!(
1433            regions.no_drag.is_empty(),
1434            "an overlay fully below the caption must not punch a hole, got {:?}",
1435            regions.no_drag
1436        );
1437    }
1438
1439    #[test]
1440    fn dead_zone_in_center_does_not_arm_the_window_drag() {
1441        // The widget-land half of the same bug, live on every platform: a
1442        // press on a control inside the drag region armed the DragRegion's
1443        // `on_drag` via `PointerSequence` enrolment, so a few px of pointer jitter
1444        // during an ordinary click started a window move and ate the tap.
1445        // The `DeadZone` boundary must stop that arming.
1446
1447        let host = Rc::new(TestHost::default());
1448        let host_for_bar = host.clone();
1449        let (mut tree, _bar) = build_realistic_tree(host_for_bar, |b| {
1450            b.center(
1451                HStack::new()
1452                    .child(DeadZone::new().child(FixedSize::new().width(60.0).height(30.0)))
1453                    .child(Expand::new()),
1454            )
1455        });
1456        paint_once(&mut tree);
1457
1458        let hole = host.last_regions.borrow().no_drag[0];
1459        let (cx, cy) = (hole.x + hole.width / 2.0, hole.y + hole.height / 2.0);
1460
1461        // A jittery press on the dead-zoned control.
1462        tree.pointer_down_button(Point::new(cx, cy), PointerButton::Primary);
1463        for i in 1..=10 {
1464            tree.pointer_move(Point::new(cx + (i as f32) * 3.0, cy + 1.0));
1465        }
1466        tree.pointer_up_button(Point::new(cx + 30.0, cy + 1.0), PointerButton::Primary);
1467
1468        assert_eq!(
1469            host.drags_started.get(),
1470            0,
1471            "a jittery click on a DeadZone inside the title bar must not drag the window"
1472        );
1473    }
1474
1475    #[test]
1476    fn dragging_the_bare_drag_region_still_works_with_a_dead_zone_present() {
1477        // Guard the fix's blast radius: punching a hole must not disable the
1478        // drag surface around it.
1479
1480        let host = Rc::new(TestHost::default());
1481        let host_for_bar = host.clone();
1482        let (mut tree, bar) = build_realistic_tree(host_for_bar, |b| {
1483            b.center(
1484                HStack::new()
1485                    .child(DeadZone::new().child(FixedSize::new().width(60.0).height(30.0)))
1486                    .child(Expand::new()),
1487            )
1488        });
1489        paint_once(&mut tree);
1490
1491        // Drag from well to the right of the dead zone — still bare caption.
1492        let drag_b = tree.bounds(locate_drag_region(&tree, bar));
1493        let from = Point::new(drag_b.right() - 40.0, drag_b.y + drag_b.height / 2.0);
1494        let to = Point::new(drag_b.right() - 200.0, drag_b.y + drag_b.height / 2.0);
1495        tree.drag(from, to);
1496
1497        assert!(
1498            host.drags_started.get() >= 1,
1499            "the drag region outside the hole must still move the window"
1500        );
1501    }
1502
1503    #[test]
1504    fn double_clicking_drag_region_toggles_placement() {
1505        // Regression for: same auto-wiring gap. on_double_tap was wired in
1506        // HandlerSet but the dispatch never installed a DoubleTapRecognizer
1507        // unless on_tap was also set, so the handler was unreachable.
1508        let host = Rc::new(TestHost::default());
1509        let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1510        let state = attach_window_state(&mut tree);
1511
1512        let drag = locate_drag_region(&tree, bar);
1513        tree.click(drag);
1514        tree.click(drag);
1515
1516        assert_eq!(
1517            state.placement().get(),
1518            teksilo_core::WindowPlacement::Maximized,
1519            "double-tap on drag region should flip WindowState::placement to Maximized"
1520        );
1521    }
1522}