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