Skip to main content

teksilo_core/widget/
event_context.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use crate::widget_id::WidgetId;
5
6use super::CursorIcon;
7
8/// Selects which overlay-dismissal pathway runs after a handler
9/// returns. Last-write-wins: each `dismiss_*_overlays()` setter
10/// overwrites the previous choice. `None` (the default) falls
11/// through to draining individual ids from `overlay_dismissals`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub(crate) enum DismissScope {
14    /// Dismiss every overlay in the stack, including hosts.
15    All,
16    /// Dismiss every overlay whose content is *not* a host surface
17    /// (`Tooltip`, `Dialog`, `AlertDialog`). Used by popover triggers
18    /// and pre-show cleanup.
19    AllExceptHosts,
20    /// Walk up from the source widget's containing overlay,
21    /// dismissing menu-like overlays and stopping at the first host
22    /// surface. Used by menu / dropdown item activation.
23    SelfChain,
24    /// Dismiss the topmost overlay only.
25    Top,
26}
27
28/// One queued "reveal this rectangle" request, drained after the handler
29/// returns and turned into a [`WidgetEvent::ScrollIntoView`] per clipping
30/// ancestor.
31///
32/// A struct rather than a tuple because the three modifiers (margin, alignment,
33/// motion) are independent and positional tuples of that width stop being
34/// readable at the call site.
35///
36/// [`WidgetEvent::ScrollIntoView`]: crate::event::WidgetEvent::ScrollIntoView
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub(crate) struct ScrollRevealRequest {
39    /// The target, in absolute tree (window) coordinates.
40    pub(crate) rect: teksilo_canvas::Rect,
41    /// Breathing room to keep around the target, in logical pixels.
42    pub(crate) margin: f32,
43    /// Where the target should come to rest vertically.
44    pub(crate) align: crate::event::ScrollAlign,
45    /// Whether to jump or glide.
46    pub(crate) motion: crate::event::ScrollMotion,
47    /// **Whose** ancestors to walk, when that is not the widget whose handler
48    /// queued this.
49    ///
50    /// `None` means the source widget, which is right whenever a widget reveals
51    /// something inside itself. It is wrong — silently — whenever the rect belongs
52    /// to a *different* widget: a find banner's Next button asking for a match in
53    /// the prose, a toolbar revealing a row in the list below it. Those walk the
54    /// button's ancestors, which do not include the scroll container the rect lives
55    /// in, so nothing scrolls and the reveal is a no-op no error reports.
56    pub(crate) from: Option<crate::widget_id::WidgetId>,
57}
58
59/// Context available during event handling.
60pub struct EventContext<'ops> {
61    pub(crate) cursor_request: Option<CursorIcon>,
62    pub(crate) tree_mutations: Vec<TreeMutation>,
63    pub(crate) idle_callbacks: Vec<crate::idle::IdleCallback>,
64    pub(crate) modal_requests: Vec<crate::modal::ModalRequest>,
65    pub(crate) dismiss_modal: bool,
66    pub(crate) overlay_requests: Vec<crate::overlay::OverlayRequest>,
67    pub(crate) overlay_dismissals: Vec<crate::overlay::OverlayId>,
68    /// Content widget ids whose currently-shown overlay (if any) should
69    /// be dismissed. Resolved to an `OverlayId` via
70    /// `OverlayManager::find_by_content` at drain time. Lets a handler
71    /// dismiss an overlay it can only identify by content (e.g. a single
72    /// reusable tooltip surface) — the symmetric companion to
73    /// [`cancel_delayed_overlay`](EventContext::cancel_delayed_overlay).
74    pub(crate) overlay_content_dismissals: Vec<crate::widget_id::WidgetId>,
75    /// Overlay ids whose `auto_dismiss_after` timer should be paused
76    /// or resumed after the handler returns (`true` = pause, `false`
77    /// = resume). Drained by `WidgetTree::process_overlay_requests`
78    /// against `OverlayManager::pause_auto_dismiss` /
79    /// `resume_auto_dismiss`. Used by `ToastHost` for hover-pause.
80    pub(crate) overlay_pause_requests: Vec<(crate::overlay::OverlayId, bool)>,
81    /// The dismissal scope chosen by the handler, if any. Set by
82    /// `dismiss_all_overlays()` / `dismiss_all_except_hosts()` /
83    /// `dismiss_self_overlay_chain()` / `dismiss_top_overlay()` —
84    /// last setter wins. `None` falls through to draining the
85    /// per-id `overlay_dismissals` vec instead.
86    pub(crate) dismiss_scope: Option<DismissScope>,
87    /// Request to capture or release the pointer.
88    pub(crate) pointer_capture: Option<bool>,
89    /// Delayed overlay requests (request, delay, optional focus target).
90    pub(crate) delayed_overlay_requests: Vec<(
91        crate::overlay::OverlayRequest,
92        std::time::Duration,
93        Option<crate::widget_id::WidgetId>,
94    )>,
95    /// Timed overlay requests (request, auto-dismiss delay).
96    pub(crate) timed_overlay_requests: Vec<(crate::overlay::OverlayRequest, std::time::Duration)>,
97    /// Reveal overlay requests (request, caller-owned animated progress
98    /// signal, tween duration). The framework shows the overlay, then
99    /// drives `progress` 0 → 1 on show and 1 → 0 on dismiss, deferring
100    /// the actual stack removal until the roll-back tween completes —
101    /// the same deferral the fade path uses, minus the opacity scope.
102    /// The caller applies `progress` however it wants (e.g. an `Unroll`
103    /// width). See [`show_overlay_with_reveal`](EventContext::show_overlay_with_reveal).
104    pub(crate) reveal_overlay_requests: Vec<(
105        crate::overlay::OverlayRequest,
106        crate::signal::Signal<f32>,
107        std::time::Duration,
108    )>,
109    /// Dismiss descendant overlays of the source widget's containing overlay.
110    /// Optionally preserve the subtree rooted at a specific content widget ID.
111    pub(crate) dismiss_descendant_overlays: Vec<Option<crate::widget_id::WidgetId>>,
112    /// Cancel pending delayed overlays by content widget ID.
113    pub(crate) cancel_delayed_overlays: Vec<crate::widget_id::WidgetId>,
114    /// Widget IDs that need repainting (cross-widget signal propagation).
115    pub(crate) repaint_requests: Vec<crate::widget_id::WidgetId>,
116    /// Synthetic clicks to dispatch on target widgets after event processing.
117    pub(crate) synthetic_clicks: Vec<crate::widget_id::WidgetId>,
118    /// Focus requests — transfer focus to a specific widget (e.g., overlay content on open).
119    pub(crate) focus_requests: Vec<crate::widget_id::WidgetId>,
120    /// Focus-into requests — move focus to the *first focusable descendant* of
121    /// the given widget, with no fallback to the widget itself when the subtree
122    /// has none. The "dive into this region's content" intent (Enter on a tab
123    /// header → into the tab panel), distinct from `focus_requests` which
124    /// focuses the container itself as a last resort.
125    pub(crate) focus_into_requests: Vec<crate::widget_id::WidgetId>,
126    /// Rect-based "scroll this into view" requests, in **absolute tree
127    /// (window) coordinates**. Queued by [`ensure_visible`](EventContext::ensure_visible)
128    /// / [`ensure_visible_with_margin`](EventContext::ensure_visible_with_margin).
129    /// Drained in `collect_from_ctx`, which walks the ancestors of the widget
130    /// whose handler queued the request and dispatches
131    /// [`WidgetEvent::ScrollIntoView`](crate::event::WidgetEvent::ScrollIntoView)
132    /// to every `clips_children` scroll container that doesn't already fully
133    /// contain the rect — the same ancestor-walk the focus path uses, but
134    /// with a caller-supplied rectangle instead of a widget's own bounds
135    /// (so a caret, a virtualized row, or a scrolled-off tab header can be
136    /// revealed even though it is not itself a distinct focused node).
137    pub(crate) scroll_into_view_requests: Vec<ScrollRevealRequest>,
138    /// Widget-id-based "scroll this into view" requests, queued by
139    /// [`ensure_widget_visible`](EventContext::ensure_widget_visible) /
140    /// [`ensure_widget_visible_with_margin`](EventContext::ensure_widget_visible_with_margin).
141    /// Drained in `collect_from_ctx`, which resolves each id to its current
142    /// absolute arena bounds and walks *that widget's* ancestors (the target
143    /// widget itself excluded) dispatching
144    /// [`WidgetEvent::ScrollIntoView`](crate::event::WidgetEvent::ScrollIntoView).
145    /// The convenience form of the rect API for a target that is a real
146    /// mounted, non-virtualized child (a radio tile, a tab header) whose bounds
147    /// the framework already knows — the caller need not compute the rect.
148    pub(crate) scroll_widget_into_view_requests: Vec<(crate::widget_id::WidgetId, f32)>,
149    /// Keyboard-highlight tooltip requests: surface the tooltip of the given
150    /// (menu) item immediately and dismiss the previously-highlighted one.
151    /// Drained after the handler (see `event_dispatch_impl`). Only the last
152    /// entry per handler is honoured — a handler sets one highlight per key.
153    pub(crate) highlight_tooltip_requests: Vec<crate::widget_id::WidgetId>,
154    /// Drag start request: (source_widget_id, payload, optional_preview_widget).
155    pub(crate) drag_start_request: Option<(
156        crate::widget_id::WidgetId,
157        crate::drag_payload::DragPayload,
158        Option<Box<dyn crate::widget::Widget>>,
159    )>,
160    /// Cancel any active drag session.
161    pub(crate) cancel_drag: bool,
162    /// Whether the drag session active while this context is live was
163    /// started by an external (OS) drag. Read via `drag_is_external()`.
164    /// `false` for hand-constructed contexts and when no drag is active.
165    pub(crate) drag_is_external: bool,
166    /// Replace the tree-level theme. Drained after dispatch; triggers a
167    /// composite-widget rebuild and full repaint.
168    pub(crate) theme_request: Option<crate::styles::Theme>,
169    /// Request that the app follow the OS theme (native / system mode).
170    /// Drained after dispatch; the app switches to `ThemeMode::Native` and
171    /// recomputes the theme from the current OS colours. Parameterless so
172    /// `teksilo-widgets` never needs the app-layer `ThemeMode` enum.
173    pub(crate) follow_system_request: bool,
174    /// Replace the tree-level locale identifier. Drained after dispatch;
175    /// triggers a composite-widget rebuild and full repaint.
176    pub(crate) locale_request: Option<String>,
177    /// Set the user-controlled text-scale factor. Drained after dispatch and
178    /// fanned out to every window; grows all text without a rebuild.
179    pub(crate) text_scale_request: Option<f32>,
180    /// Set by `request_frame()`; consumed by the event dispatcher which
181    /// forwards it to `WidgetTree::request_frame()` so the next layout
182    /// pass advances the per-frame tick signal.
183    pub(crate) frame_requested: bool,
184    /// Optional reference to the tree's app-state registry, so handlers
185    /// can look up application-scoped values via `app_state::<T>()`.
186    /// Populated by the dispatcher before running each handler; `None`
187    /// for hand-constructed contexts in tests.
188    pub(crate) app_context: Option<std::rc::Rc<crate::event_source::TreeAppContext>>,
189    /// App-level window-ops sink. Injected by the dispatcher so
190    /// handlers can reach the multi-window API (`open_window`,
191    /// `focus_window`, …) synchronously. For `EventContext`
192    /// instances constructed outside a dispatch (standalone trees,
193    /// tests) this is `None` and the multi-window methods no-op /
194    /// return `None`.
195    pub(crate) window_ops: Option<&'ops mut dyn crate::window::WindowOps>,
196    /// [`WindowState`](crate::window::WindowState) for the window
197    /// this tree belongs to. Cloned from the tree at construction.
198    /// `None` for standalone trees.
199    pub(crate) current_window: Option<crate::window::WindowState>,
200    /// Snapshot of the tree's occlusion-aware window-active state
201    /// (`focused AND not occluded`) at construction time. Distinct from
202    /// `current_window.focused()` (raw OS focus, no occlusion). Read by
203    /// [`window_active`](Self::window_active). Defaults `true` (matches the
204    /// tree's initial value) for standalone / test contexts.
205    pub(crate) tree_window_active: bool,
206    /// Last `PointerMove` position observed by the tree. Snapshotted
207    /// at handler-invocation time so widgets that don't see the live
208    /// pointer event (e.g. an `on_hover` callback that fires on the
209    /// boundary edge) can still query "where is the cursor right
210    /// now". Read by the safe-triangle submenu hover gate.
211    pub(crate) tree_pointer_position: Option<teksilo_canvas::Point>,
212    /// True when the in-flight pointer press's hit target is a strict
213    /// descendant of the widget whose handler is currently running and that
214    /// descendant carries its own tap gesture (a chevron, checkbox, inline
215    /// button). Set per-node by the dispatcher for `PointerDown`/`PointerUp`.
216    /// Read via [`press_claimed_by_interactive_child`](Self::press_claimed_by_interactive_child).
217    pub(crate) press_claimed_by_interactive_child: bool,
218    /// Per-content-widget overlay bounds snapshotted at handler
219    /// invocation. A flat vec is fine — open overlays are typically
220    /// 0–3 per tree. Read by the safe-triangle submenu hover gate
221    /// via [`EventContext::overlay_bounds_for_content`].
222    pub(crate) overlay_bounds_snapshot: Vec<(WidgetId, teksilo_canvas::Rect)>,
223    /// Intents queued by handlers via `send_intent`. Drained by the
224    /// tree after event dispatch and routed source-widget → root.
225    pub(crate) pending_intents: Vec<crate::intent::Intent>,
226    /// The dispatcher sets this to the appropriate
227    /// [`IntentSource`](crate::telemetry::IntentSource) before
228    /// invoking a typed handler (menu select → `Menu`, AccessKit
229    /// action → `Accessibility`, on_tap / button activation →
230    /// `Handler`, …). `send_intent` reads it and stamps the intent
231    /// before queuing. `None` outside a managed handler — bare
232    /// programmatic sends keep their `Intent::source` value
233    /// (default `Programmatic`).
234    pub(crate) current_source: Option<crate::telemetry::IntentSource>,
235    /// Key-capture callback armed via `ctx.begin_key_capture(...)`.
236    /// The callback + its shared slot are installed on the tree by
237    /// `collect_from_ctx`. Only one per ctx; the last caller wins.
238    pub(crate) pending_key_capture: Option<crate::shortcut::KeyCaptureSlot>,
239    /// Set to request cancellation of any armed key capture.
240    pub(crate) cancel_key_capture: bool,
241    /// Deferred mutations to the tree's [`ShortcutRegistry`](crate::shortcut::ShortcutRegistry),
242    /// typically issued by settings-UI buttons to rebind or clear
243    /// overrides. Applied in `collect_from_ctx` after the handler
244    /// returns.
245    pub(crate) pending_shortcut_mutations: Vec<ShortcutMutation>,
246    /// Requests that the app-level event loop close the window this
247    /// tree belongs to. Drained after dispatch via
248    /// `WidgetTree::take_close_window_request`. Routed through the
249    /// window's close guard (if any) — see
250    /// [`WindowConfig::on_close_requested`](crate::window::WindowConfig::on_close_requested).
251    pub(crate) close_window_requested: bool,
252    /// Like [`close_window_requested`](Self::close_window_requested), but
253    /// **bypasses** the window's close guard. Set by
254    /// [`close_window_forced`](Self::close_window_forced). Drained after
255    /// dispatch via `WidgetTree::take_force_close_request`. The escape
256    /// hatch a confirmation dialog uses once the user confirms.
257    pub(crate) force_close_requested: bool,
258    /// Set by [`request_accessibility_update`](EventContext::request_accessibility_update);
259    /// drained in `collect_from_ctx` to set `WidgetTree::a11y_dirty`, forcing the
260    /// next `sync_accessibility` to re-walk the AccessKit tree. The general lever for a
261    /// composing widget that restructured its subtree in a way that changes the AT tree
262    /// (relayout alone no longer re-walks AT).
263    pub(crate) request_a11y_update: bool,
264    /// Layout direction (LTR/RTL) of the hosting tree, snapshotted at
265    /// handler-invocation time by `make_event_context`. Read via
266    /// [`is_rtl`](EventContext::is_rtl) so pointer / keyboard / drag
267    /// handlers can mirror their x-axis logic live — a runtime locale
268    /// switch dirties the tree but does **not** rebuild, so direction
269    /// must be read here rather than captured at `build()` time.
270    /// Defaults to `LeftToRight` for hand-constructed (test) contexts.
271    pub(crate) layout_direction: crate::environment::LayoutDirection,
272    /// Debug-only WCAG 3.2.1 guard: `Some(flag)` where `flag` is set while a
273    /// focus-change dispatch is running. `open_window` / `focus_window` warn if
274    /// invoked while it reads `true` (a focus handler changing context). `None`
275    /// for hand-constructed (test) contexts.
276    pub(crate) in_focus_dispatch: Option<std::rc::Rc<std::cell::Cell<bool>>>,
277}
278
279/// Deferred edit to the tree's shortcut registry, queued on an
280/// `EventContext` and applied in `collect_from_ctx`.
281#[derive(Debug, Clone)]
282pub(crate) enum ShortcutMutation {
283    RebindPrimary {
284        id: String,
285        keystroke: Option<crate::shortcut::KeyStroke>,
286    },
287    RebindSecondary {
288        id: String,
289        keystroke: Option<crate::shortcut::KeyStroke>,
290    },
291    ClearOverride {
292        id: String,
293    },
294}
295
296/// A structural change to the widget tree, deferred until after event dispatch.
297pub(crate) enum TreeMutation {
298    SetDormant(WidgetId),
299    Activate(WidgetId),
300    Destroy(WidgetId),
301    /// Typed mutable access to a mounted widget, applied in
302    /// `apply_tree_mutations` where `&mut arena` is live. The boxed closure
303    /// downcasts the node's `as_any_mut()` to the requested concrete type;
304    /// `dirty` selects the post-mutation re-render level.
305    WithWidgetMut {
306        id: WidgetId,
307        dirty: crate::binding::BindingLevel,
308        apply: Box<dyn FnOnce(&mut dyn std::any::Any)>,
309    },
310    /// Re-run one widget's `build()` **now**, inside `apply_tree_mutations`,
311    /// rather than marking it for the next layout pass. See
312    /// [`EventContext::materialize_now`].
313    MaterializeNow(WidgetId),
314}
315
316// Manual `Debug`: the `WithWidgetMut` closure is not `Debug`.
317impl std::fmt::Debug for TreeMutation {
318    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319        match self {
320            Self::SetDormant(id) => f.debug_tuple("SetDormant").field(id).finish(),
321            Self::Activate(id) => f.debug_tuple("Activate").field(id).finish(),
322            Self::MaterializeNow(id) => f.debug_tuple("MaterializeNow").field(id).finish(),
323            Self::Destroy(id) => f.debug_tuple("Destroy").field(id).finish(),
324            Self::WithWidgetMut { id, dirty, .. } => f
325                .debug_struct("WithWidgetMut")
326                .field("id", id)
327                .field("dirty", dirty)
328                .finish_non_exhaustive(),
329        }
330    }
331}
332
333impl<'ops> EventContext<'ops> {
334    pub(crate) fn new() -> Self {
335        Self {
336            cursor_request: None,
337            tree_mutations: Vec::new(),
338            idle_callbacks: Vec::new(),
339            modal_requests: Vec::new(),
340            dismiss_modal: false,
341            overlay_requests: Vec::new(),
342            overlay_dismissals: Vec::new(),
343            overlay_content_dismissals: Vec::new(),
344            overlay_pause_requests: Vec::new(),
345            dismiss_scope: None,
346            pointer_capture: None,
347            delayed_overlay_requests: Vec::new(),
348            timed_overlay_requests: Vec::new(),
349            reveal_overlay_requests: Vec::new(),
350            dismiss_descendant_overlays: Vec::new(),
351            cancel_delayed_overlays: Vec::new(),
352            repaint_requests: Vec::new(),
353            synthetic_clicks: Vec::new(),
354            focus_requests: Vec::new(),
355            focus_into_requests: Vec::new(),
356            scroll_into_view_requests: Vec::new(),
357            scroll_widget_into_view_requests: Vec::new(),
358            highlight_tooltip_requests: Vec::new(),
359            drag_start_request: None,
360            cancel_drag: false,
361            drag_is_external: false,
362            theme_request: None,
363            follow_system_request: false,
364            locale_request: None,
365            text_scale_request: None,
366            frame_requested: false,
367            app_context: None,
368            pending_intents: Vec::new(),
369            current_source: None,
370            pending_key_capture: None,
371            cancel_key_capture: false,
372            pending_shortcut_mutations: Vec::new(),
373            close_window_requested: false,
374            force_close_requested: false,
375            request_a11y_update: false,
376            window_ops: None,
377            current_window: None,
378            tree_window_active: true,
379            tree_pointer_position: None,
380            press_claimed_by_interactive_child: false,
381            overlay_bounds_snapshot: Vec::new(),
382            layout_direction: crate::environment::LayoutDirection::LeftToRight,
383            in_focus_dispatch: None,
384        }
385    }
386
387    /// Snapshot the hosting tree's layout direction. Called by
388    /// `make_event_context` once per event batch so x-axis handlers
389    /// (resize, drag-reorder, arrow-key navigation) can mirror under
390    /// RTL without a rebuild.
391    pub(crate) fn with_layout_direction(
392        mut self,
393        direction: crate::environment::LayoutDirection,
394    ) -> Self {
395        self.layout_direction = direction;
396        self
397    }
398
399    /// Layout direction of the hosting tree at dispatch time.
400    pub fn layout_direction(&self) -> crate::environment::LayoutDirection {
401        self.layout_direction
402    }
403
404    /// Whether the hosting tree is laid out right-to-left. Mirrors
405    /// [`LayoutContext::is_rtl`](crate::widget::LayoutContext::is_rtl)
406    /// for the event-dispatch side.
407    pub fn is_rtl(&self) -> bool {
408        self.layout_direction == crate::environment::LayoutDirection::RightToLeft
409    }
410
411    /// Attach a per-dispatch snapshot of read-only tree query state
412    /// (current pointer position, overlay bounds). Called by
413    /// `WidgetTree::make_event_context` once per event batch. Test
414    /// `EventContext`s that don't go through that path stay with
415    /// empty snapshots — handlers must treat both reads as `None`-
416    /// safe.
417    pub(crate) fn with_query_snapshot(
418        mut self,
419        pointer: Option<teksilo_canvas::Point>,
420        overlays: Vec<(WidgetId, teksilo_canvas::Rect)>,
421    ) -> Self {
422        self.tree_pointer_position = pointer;
423        self.overlay_bounds_snapshot = overlays;
424        self
425    }
426
427    /// Attach the app-level window-ops sink and the hosting tree's
428    /// [`WindowState`](crate::window::WindowState). Called by the
429    /// dispatcher once per event batch so handlers can reach the
430    /// multi-window API synchronously.
431    pub(crate) fn with_window_context(
432        mut self,
433        ops: &'ops mut dyn crate::window::WindowOps,
434        current_window: Option<crate::window::WindowState>,
435    ) -> Self {
436        self.window_ops = Some(ops);
437        self.current_window = current_window;
438        self
439    }
440
441    /// Snapshot the tree's occlusion-aware window-active state. Called by
442    /// `make_event_context` once per event batch so handlers can read
443    /// [`window_active`](Self::window_active).
444    pub(crate) fn with_window_active(mut self, active: bool) -> Self {
445        self.tree_window_active = active;
446        self
447    }
448
449    /// Attach the tree's shared "inside focus dispatch" flag (WCAG 3.2.1
450    /// debug guard). See [`EventContext::open_window`].
451    pub(crate) fn with_focus_dispatch_flag(
452        mut self,
453        flag: std::rc::Rc<std::cell::Cell<bool>>,
454    ) -> Self {
455        self.in_focus_dispatch = Some(flag);
456        self
457    }
458
459    /// Debug-only: warn (once per call) if a context change is being made from
460    /// inside a focus-change dispatch — a WCAG 3.2.1 (On Focus) anti-pattern.
461    /// Compiled out entirely in release builds.
462    #[inline]
463    fn warn_if_context_change_in_focus_dispatch(&self, what: &str) {
464        #[cfg(debug_assertions)]
465        if self.in_focus_dispatch.as_ref().is_some_and(|f| f.get()) {
466            eprintln!(
467                "[teksilo a11y] WCAG 3.2.1 (On Focus): `{what}` was called from \
468                 inside an on_focus handler. Changing context (opening/focusing a \
469                 window, navigating) merely because a control received focus \
470                 surprises keyboard users tabbing through the UI. Move this to an \
471                 explicit activation handler (on_tap / on_activate / a shortcut)."
472            );
473        }
474        let _ = what;
475    }
476
477    /// Attach the tree's app-state registry so handlers can look up
478    /// application-scoped values (`ClipboardHandle`, `SharedTypesetter`,
479    /// …). Called by the dispatcher once per event batch.
480    pub(crate) fn with_app_context(
481        mut self,
482        ctx: std::rc::Rc<crate::event_source::TreeAppContext>,
483    ) -> Self {
484        self.app_context = Some(ctx);
485        self
486    }
487
488    /// Record whether the drag session active while this context is live
489    /// originated from an external (OS) drag. Set by `make_event_context`.
490    pub(crate) fn with_drag_external(mut self, is_external: bool) -> Self {
491        self.drag_is_external = is_external;
492        self
493    }
494
495    /// Whether a drag is currently in flight that was started by an external
496    /// (OS) drag-and-drop (files / text / URLs from another application),
497    /// rather than by an in-app `start_drag`. Useful in `on_drag_leave` /
498    /// `on_drag_tick` handlers, which don't receive the payload directly;
499    /// in `on_drag_hover` / `on_drop` prefer `payload.is_external()`.
500    pub fn drag_is_external(&self) -> bool {
501        self.drag_is_external
502    }
503
504    /// Look up an application-scoped value by type. Mirrors
505    /// `BuildContext::app_state`. Returns `None` when the handler was
506    /// invoked without a registry (hand-constructed `EventContext` in
507    /// tests, or when no value of that type was registered).
508    pub fn app_state<T: 'static>(&self) -> Option<&T> {
509        self.app_context
510            .as_ref()
511            .and_then(|ctx| ctx.app_state::<T>())
512    }
513
514    /// Borrow the [`AppEventPoster`](crate::AppEventPoster) installed
515    /// by the framework. Used by integrations that need to post
516    /// typed payloads back to the UI loop from a worker thread
517    /// (`teksilo_platform::file_dialog`'s `RfdAsyncBackend`, future
518    /// async-result features). Returns `None` for hand-constructed
519    /// `EventContext`s in tests.
520    pub fn poster(&self) -> Option<&std::sync::Arc<dyn crate::AppEventPoster>> {
521        self.app_context.as_ref().and_then(|ctx| ctx.poster())
522    }
523
524    /// Ask the tree to pump one more frame after this handler returns.
525    /// Use from event handlers that kick off per-frame work (pending
526    /// document events to drain, drag-select auto-scroll, caret blink
527    /// restart on focus). See `WidgetTree::request_frame` for the
528    /// draw-when-needed contract.
529    pub fn request_frame(&mut self) {
530        self.frame_requested = true;
531    }
532
533    /// Dispatch an [`Intent`](crate::intent::Intent) as if the source
534    /// widget pressed its keyboard shortcut. The framework walks
535    /// source-widget → root after the current handler returns,
536    /// invoking any matching [`Action`](crate::action::Action) it
537    /// finds. Unmatched intents are silently dropped.
538    ///
539    /// The intent's `source` is overridden by the dispatcher's
540    /// current handler-source label (`current_source`) when one is
541    /// active. This is how the framework distinguishes
542    /// `IntentSource::Handler` (button taps, generic on_tap) from
543    /// `IntentSource::Menu`, `IntentSource::Accessibility`, etc.
544    /// Programmatic callers outside any handler pass through with
545    /// `IntentSource::Programmatic` (the default).
546    pub fn send_intent(&mut self, intent: impl Into<crate::intent::Intent>) {
547        let mut intent: crate::intent::Intent = intent.into();
548        if let Some(src) = self.current_source {
549            intent.source = src;
550        }
551        self.pending_intents.push(intent);
552    }
553
554    /// Run a closure with the given `IntentSource` active. Any
555    /// `ctx.send_intent(...)` issued from within the closure will
556    /// be tagged with this source instead of the dispatcher's
557    /// default (`Handler` / `Shortcut` / `Accessibility`).
558    ///
559    /// The previous source is restored after the closure returns.
560    /// Panic during the closure unwinds the dispatcher's whole
561    /// frame, so the EventContext is destroyed before the next
562    /// dispatch — no need for a panic-safe drop guard.
563    ///
564    /// Used by framework widgets that want a more specific source
565    /// label than the default — `MenuItem` wraps its activation
566    /// handler to emit `IntentSource::Menu`, etc.
567    pub fn with_intent_source<R>(
568        &mut self,
569        source: crate::telemetry::IntentSource,
570        f: impl FnOnce(&mut Self) -> R,
571    ) -> R {
572        let prev = self.current_source.replace(source);
573        let r = f(self);
574        self.current_source = prev;
575        r
576    }
577
578    /// Arm a one-shot key-capture callback, returning a
579    /// [`CaptureHandle`](crate::shortcut::CaptureHandle) whose `Drop`
580    /// cancels the capture if it hasn't fired yet. The next `KeyDown`
581    /// bypasses shortcut resolution and invokes the callback with:
582    /// - the captured [`KeyStroke`](crate::shortcut::KeyStroke)
583    /// - mutable access to the registry (rebinds in-place)
584    /// - a mutable [`EventContext`] (emit commands, send intents,
585    ///   dismiss overlays, …)
586    ///
587    /// The handle must be stored somewhere with an appropriate
588    /// lifetime (typically in the calling widget's state) or the
589    /// capture will be cancelled immediately when the returned
590    /// handle drops at end of scope.
591    pub fn begin_key_capture(
592        &mut self,
593        callback: impl FnOnce(
594            crate::shortcut::KeyStroke,
595            &mut crate::shortcut::ShortcutRegistry,
596            &mut EventContext,
597        ) + 'static,
598    ) -> crate::shortcut::CaptureHandle {
599        let slot: crate::shortcut::KeyCaptureSlot =
600            std::rc::Rc::new(std::cell::RefCell::new(Some(Box::new(callback))));
601        self.pending_key_capture = Some(slot.clone());
602        self.cancel_key_capture = false;
603        crate::shortcut::CaptureHandle::new(slot)
604    }
605
606    /// Cancel any key capture armed earlier in this handler or via
607    /// `WidgetTree::begin_key_capture` before the handler ran.
608    pub fn cancel_key_capture(&mut self) {
609        self.pending_key_capture = None;
610        self.cancel_key_capture = true;
611    }
612
613    /// Queue a deferred rebind of the primary keystroke for the
614    /// registered shortcut with the given id. Applied by the tree
615    /// after the current handler returns. Use `None` to explicitly
616    /// unbind the slot.
617    pub fn rebind_shortcut_primary(
618        &mut self,
619        id: impl Into<String>,
620        keystroke: Option<crate::shortcut::KeyStroke>,
621    ) {
622        self.pending_shortcut_mutations
623            .push(ShortcutMutation::RebindPrimary {
624                id: id.into(),
625                keystroke,
626            });
627    }
628
629    /// Queue a deferred rebind of the secondary keystroke for the
630    /// registered shortcut with the given id.
631    pub fn rebind_shortcut_secondary(
632        &mut self,
633        id: impl Into<String>,
634        keystroke: Option<crate::shortcut::KeyStroke>,
635    ) {
636        self.pending_shortcut_mutations
637            .push(ShortcutMutation::RebindSecondary {
638                id: id.into(),
639                keystroke,
640            });
641    }
642
643    /// Queue a deferred clear of any user override for the given
644    /// shortcut id, restoring its declared defaults.
645    pub fn clear_shortcut_override(&mut self, id: impl Into<String>) {
646        self.pending_shortcut_mutations
647            .push(ShortcutMutation::ClearOverride { id: id.into() });
648    }
649
650    /// Request that the application close the window this tree
651    /// belongs to. Drained by the app event loop after the handler
652    /// returns. Typical use: title-bar close button handlers.
653    ///
654    /// This is a *guarded* close: if the window declared a close guard
655    /// via
656    /// [`WindowConfig::on_close_requested`](crate::window::WindowConfig::on_close_requested)
657    /// or [`can_close`](crate::window::WindowConfig::can_close), that
658    /// guard runs first and may veto the close. To skip the guard (e.g.
659    /// from the confirmation dialog the guard itself opened), use
660    /// [`close_window_forced`](Self::close_window_forced).
661    pub fn close_window(&mut self) {
662        self.close_window_requested = true;
663    }
664
665    /// Request that the application close this tree's window
666    /// **unconditionally**, bypassing any close guard declared via
667    /// [`WindowConfig::on_close_requested`](crate::window::WindowConfig::on_close_requested)
668    /// / [`can_close`](crate::window::WindowConfig::can_close).
669    ///
670    /// This is the second half of the veto-then-reissue pattern: the
671    /// guard returns [`CloseResponse::Veto`](crate::window::CloseResponse::Veto)
672    /// and opens a confirmation dialog; the dialog's "close anyway"
673    /// button calls `close_window_forced` so the window actually closes
674    /// without re-triggering the guard.
675    pub fn close_window_forced(&mut self) {
676        self.force_close_requested = true;
677    }
678
679    // -------------------- Multi-window API --------------------
680
681    /// The [`WindowState`](crate::window::WindowState) for the window
682    /// hosting this handler. `None` only for handlers run outside
683    /// of an app (hand-constructed `EventContext` in tests).
684    /// Cursor position at the moment this handler was invoked. `None`
685    /// when no `PointerMove` has reached the tree yet, or when the
686    /// context was constructed without a tree-side snapshot (e.g.
687    /// hand-built `EventContext`s in tests). Used by the safe-triangle
688    /// submenu hover gate.
689    pub fn tree_pointer_position(&self) -> Option<teksilo_canvas::Point> {
690        self.tree_pointer_position
691    }
692
693    /// True when the in-flight pointer press's hit target is a strict
694    /// descendant of THIS handler's widget that carries its own tap gesture
695    /// (chevron, checkbox, inline button). A row/container that selects on
696    /// press should early-return `EventResponse::Ignored` when this is set, so
697    /// the press belongs to the inner control, not the row. Only meaningful
698    /// inside `on_pointer_event` handlers for `PointerDown`/`PointerUp`.
699    pub fn press_claimed_by_interactive_child(&self) -> bool {
700        self.press_claimed_by_interactive_child
701    }
702
703    /// Look up the bounds rect of an open overlay by its root content
704    /// widget id. Returns `None` when no such overlay is currently
705    /// active. The snapshot is taken once per dispatch; mid-handler
706    /// `show_overlay` calls will not appear here. Used by the
707    /// safe-triangle submenu hover gate.
708    pub fn overlay_bounds_for_content(&self, content_id: WidgetId) -> Option<teksilo_canvas::Rect> {
709        self.overlay_bounds_snapshot
710            .iter()
711            .find(|(cid, _)| *cid == content_id)
712            .map(|(_, r)| *r)
713    }
714
715    pub fn window(&self) -> Option<&crate::window::WindowState> {
716        self.current_window.as_ref()
717    }
718
719    /// Whether the host window is currently active (`focused AND not
720    /// occluded`) — the occlusion-aware companion to
721    /// `self.window().map(|w| w.focused())` (raw OS focus). Snapshotted at
722    /// context construction. Matches [`BuildContext::window_active`].
723    ///
724    /// [`BuildContext::window_active`]: crate::build_context::BuildContext::window_active
725    pub fn window_active(&self) -> bool {
726        self.tree_window_active
727    }
728
729    /// Open a new window, creating the winit-level surface
730    /// synchronously. The returned id is immediately valid for
731    /// [`focus_window`](Self::focus_window),
732    /// [`window_state`](Self::window_state), and
733    /// [`find_window`](Self::find_window).
734    ///
735    /// Panics when called from a handler on a standalone `WidgetTree`
736    /// (no app context) — tests should not invoke this method.
737    pub fn open_window(
738        &mut self,
739        config: crate::window::WindowConfig,
740    ) -> crate::window::TeksiloWindowId {
741        self.warn_if_context_change_in_focus_dispatch("open_window");
742        self.window_ops
743            .as_deref_mut()
744            .expect("open_window called outside of a dispatch")
745            .open_window(config)
746    }
747
748    /// Find a window by the string id assigned via
749    /// [`WindowConfig::id`](crate::window::WindowConfig::id). Returns
750    /// `None` if no open window carries that id.
751    pub fn find_window(&self, string_id: &str) -> Option<crate::window::TeksiloWindowId> {
752        self.window_ops.as_deref()?.find_window(string_id)
753    }
754
755    /// Read the [`WindowState`](crate::window::WindowState) for a
756    /// specific window.
757    pub fn window_state(
758        &self,
759        id: crate::window::TeksiloWindowId,
760    ) -> Option<crate::window::WindowState> {
761        self.window_ops.as_deref()?.window_state(id)
762    }
763
764    /// Snapshot of every live window's state.
765    pub fn windows(&self) -> Vec<crate::window::WindowState> {
766        self.window_ops
767            .as_deref()
768            .map(|o| o.windows())
769            .unwrap_or_default()
770    }
771
772    /// Raise a window to the front and give it keyboard focus.
773    pub fn focus_window(&mut self, id: crate::window::TeksiloWindowId) {
774        self.warn_if_context_change_in_focus_dispatch("focus_window");
775        if let Some(ops) = self.window_ops.as_deref_mut() {
776            ops.focus_window(id);
777        }
778    }
779
780    /// Request an xdg-activation token for `id` (see
781    /// [`WindowOps::request_activation_token`](crate::window::WindowOps::request_activation_token)).
782    /// `cb` fires once with the token string, or `None` where the platform can't
783    /// provide one — used to hand a token to a child process ("open in new
784    /// window") or an IPC peer that will raise itself on Wayland.
785    pub fn request_activation_token(
786        &mut self,
787        id: crate::window::TeksiloWindowId,
788        cb: Box<dyn FnOnce(Option<String>)>,
789    ) {
790        if let Some(ops) = self.window_ops.as_deref_mut() {
791            ops.request_activation_token(id, cb);
792        } else {
793            cb(None);
794        }
795    }
796
797    /// Request an activation token for the **current** window (see
798    /// [`WindowOps::request_activation_token_self`](crate::window::WindowOps::request_activation_token_self)).
799    /// Use this from a widget handler to mint a token from *this* focused window
800    /// to hand to another window or process — it works mid-dispatch, unlike the
801    /// id-based variant.
802    pub fn request_activation_token_self(&mut self, cb: Box<dyn FnOnce(Option<String>)>) {
803        if let Some(ops) = self.window_ops.as_deref_mut() {
804            ops.request_activation_token_self(cb);
805        } else {
806            cb(None);
807        }
808    }
809
810    /// Close a specific window by id. Equivalent to
811    /// [`close_window`](Self::close_window) when `id` is the current
812    /// window's id.
813    pub fn close_window_by_id(&mut self, id: crate::window::TeksiloWindowId) {
814        if let Some(ops) = self.window_ops.as_deref_mut() {
815            ops.close_window_by_id(id);
816        }
817    }
818
819    /// Report the focused text widget's caret rectangle (window-logical
820    /// pixels) so the platform can position the OS IME candidate window at
821    /// the insertion point. Text-editing widgets call this whenever the
822    /// caret moves. No-op outside a dispatch / on a standalone tree.
823    pub fn set_ime_cursor_area(&mut self, area: teksilo_canvas::Rect) {
824        if let Some(ops) = self.window_ops.as_deref_mut() {
825            ops.set_ime_cursor_area(area);
826        }
827    }
828
829    /// Resolve the platform parent handle of the window currently
830    /// dispatching the event. Used by native-dialog integrations
831    /// (`teksilo_platform::file_dialog`) to parent OS dialogs to the
832    /// originating Teksilo window.
833    ///
834    /// Returns `None` when called from a standalone `WidgetTree` (no
835    /// app-level `WindowOps` sink), or when the platform refuses to
836    /// surface a handle (rare; mostly during teardown).
837    pub fn parent_window_handle(&self) -> Option<crate::raw_handle::ParentHandle> {
838        self.window_ops.as_deref()?.current_parent_handle()
839    }
840
841    /// Request a cursor icon change.
842    pub fn set_cursor(&mut self, cursor: CursorIcon) {
843        self.cursor_request = Some(cursor);
844    }
845
846    /// Set a widget subtree as dormant (preserves state, releases rendering).
847    pub fn set_dormant(&mut self, id: WidgetId) {
848        self.tree_mutations.push(TreeMutation::SetDormant(id));
849    }
850
851    /// Activate a dormant widget subtree.
852    pub fn activate(&mut self, id: WidgetId) {
853        self.tree_mutations.push(TreeMutation::Activate(id));
854    }
855
856    /// Destroy a widget subtree (removes from arena entirely, state is gone).
857    pub fn destroy(&mut self, id: WidgetId) {
858        self.tree_mutations.push(TreeMutation::Destroy(id));
859    }
860
861    /// Imperatively mutate a mounted widget by id, downcasting to the
862    /// concrete type `W`.
863    ///
864    /// The mutation is **deferred**: the closure runs after the handler
865    /// returns, inside `apply_tree_mutations`, where the framework holds
866    /// `&mut` arena access (a handler cannot re-borrow the arena to reach
867    /// another node, so this is the only safe channel — the same model as
868    /// [`destroy`](Self::destroy)). After the closure runs, the target is
869    /// dirty-marked at `dirty` so the mutation takes visual effect.
870    ///
871    /// The target widget must override `Widget::as_any_mut` to return
872    /// `Some(self)`. If the id is gone or is not a `W`, the closure is a
873    /// no-op in release and a `debug_assert` failure in debug — it never
874    /// silently mutates the wrong widget.
875    ///
876    /// Use it for per-view state a handler can't otherwise reach — e.g.
877    /// `SceneView::ensure_visible(...)` (camera) after the view is mounted:
878    /// ```ignore
879    /// ctx.with_widget_mut::<SceneView>(view_id, BindingLevel::Relayout, |v| {
880    ///     v.ensure_visible(card_rect, 40.0);
881    /// });
882    /// ```
883    /// For scene *content*, prefer the shared `SceneModel` handle (`view.model()`)
884    /// — its mutators are `&self`, so a handler holding a clone can drive the
885    /// scene directly and every attached view reconciles, no `with_widget_mut`
886    /// needed.
887    pub fn with_widget_mut<W: 'static>(
888        &mut self,
889        id: WidgetId,
890        dirty: crate::binding::BindingLevel,
891        f: impl FnOnce(&mut W) + 'static,
892    ) {
893        self.tree_mutations.push(TreeMutation::WithWidgetMut {
894            id,
895            dirty,
896            apply: Box::new(move |any| match any.downcast_mut::<W>() {
897                Some(w) => f(w),
898                None => debug_assert!(
899                    false,
900                    "with_widget_mut: widget {id:?} is not the requested type (or does not \
901                     override Widget::as_any_mut)"
902                ),
903            }),
904        });
905    }
906
907    /// Re-run one widget's `build()` **during this handler's drain**, before
908    /// overlays are shown and before focus requests are applied — rather than
909    /// dirty-marking it for the next layout pass, which is what every other
910    /// rebuild trigger does.
911    ///
912    /// It exists for one shape:
913    /// [`DeferredSubtree`](crate::deferred_subtree::DeferredSubtree) content
914    /// that a handler is *about to depend on*. Opening a popover activates its
915    /// content, shows an overlay anchored to it, and moves focus into it — all
916    /// three inside the same drain (see `collect_from_ctx`). A deferred panel
917    /// marked for rebuild would not exist yet at any of those points: the
918    /// overlay would be measured against an empty node and
919    /// `first_focusable_descendant` would find nothing to focus, so the popover
920    /// would open in the wrong place and swallow the keyboard. Materializing
921    /// here closes that window, and makes deferred content behave exactly like
922    /// the eagerly-built content it replaces.
923    ///
924    /// Cheap to call redundantly: a `DeferredSubtree` that is already
925    /// materialized returns its existing child, so a second open costs one
926    /// `build()` of the host and nothing below it.
927    ///
928    /// Not a general "rebuild this widget now" door — reach for
929    /// [`with_widget_mut`](Self::with_widget_mut) or a `Rebuild` binding for
930    /// ordinary reactive updates, which are correctly served by the next
931    /// layout pass.
932    pub fn materialize_now(&mut self, id: WidgetId) {
933        self.tree_mutations.push(TreeMutation::MaterializeNow(id));
934    }
935
936    /// Request that the AccessKit tree be re-walked after this handler
937    /// returns. Use after a mutation that changes the accessibility tree
938    /// **shape** in a way the framework doesn't already detect (relayout
939    /// alone no longer re-walks AT; only events that change the AT tree
940    /// — focus, overlays, locale/shortcut rebinds — set the dirty flag).
941    /// The companion `BuildContext::request_accessibility_update` covers
942    /// the build-time path.
943    pub fn request_accessibility_update(&mut self) {
944        self.request_a11y_update = true;
945    }
946
947    /// Show an overlay (tooltip, menu, popover).
948    pub fn show_overlay(&mut self, request: crate::overlay::OverlayRequest) {
949        self.overlay_requests.push(request);
950    }
951
952    /// Show an overlay whose reveal/dismiss is animated by a
953    /// caller-owned progress signal.
954    ///
955    /// `progress` must be an animated `Signal<f32>` (created with
956    /// [`Signal::new_animated`](crate::signal::Signal::new_animated) or
957    /// [`BuildContext::animated_signal`](crate::build_context::BuildContext::animated_signal)).
958    /// The framework shows the overlay, tweens `progress` 0 → 1 over
959    /// `duration`, and on any dismiss path tweens it 1 → 0 while
960    /// **deferring** the overlay's removal (and its content's dormancy)
961    /// until the roll-back completes — the same window the fade path
962    /// uses, but with no opacity applied. The caller binds `progress`
963    /// to whatever paints the reveal (e.g. an
964    /// [`Unroll`](https://docs.rs/teksilo) width), and is responsible for
965    /// resetting it to `0.0` before the show if a prior reveal left it
966    /// at `1.0`.
967    ///
968    /// Under `prefers-reduced-motion`, skip this and use
969    /// [`show_overlay`](Self::show_overlay) with the progress pinned at
970    /// `1.0` so there is no tween and dismissal is immediate.
971    pub fn show_overlay_with_reveal(
972        &mut self,
973        request: crate::overlay::OverlayRequest,
974        progress: crate::signal::Signal<f32>,
975        duration: std::time::Duration,
976    ) {
977        self.reveal_overlay_requests
978            .push((request, progress, duration));
979    }
980
981    /// Show an overlay that dismisses automatically after `duration`.
982    pub fn show_overlay_for(
983        &mut self,
984        request: crate::overlay::OverlayRequest,
985        duration: std::time::Duration,
986    ) {
987        self.timed_overlay_requests.push((request, duration));
988    }
989
990    /// Dismiss an overlay by ID.
991    pub fn dismiss_overlay(&mut self, id: crate::overlay::OverlayId) {
992        self.overlay_dismissals.push(id);
993    }
994
995    /// Dismiss the currently-shown overlay whose content root is
996    /// `content_id`, if one is active. No-op when no overlay is showing
997    /// that content. Use this to dismiss an overlay you can only name by
998    /// its content widget — the symmetric companion to
999    /// [`cancel_delayed_overlay`](Self::cancel_delayed_overlay), which
1000    /// cancels a *pending* delayed show for the same content. Together
1001    /// they let a caller fully retract a reusable tooltip surface
1002    /// (shown or pending) without tracking the `OverlayId`.
1003    pub fn dismiss_overlay_by_content(&mut self, content_id: crate::widget_id::WidgetId) {
1004        self.overlay_content_dismissals.push(content_id);
1005    }
1006
1007    /// Queue a request to pause an overlay's `auto_dismiss_after`
1008    /// timer. Drained by the framework after this handler returns —
1009    /// equivalent to calling
1010    /// [`OverlayManager::pause_auto_dismiss`](crate::overlay::OverlayManager::pause_auto_dismiss)
1011    /// at the next safe point. Idempotent.
1012    ///
1013    /// Used by `ToastHost` for hover-pause: on pointer-enter the
1014    /// host queues `pause_overlay_auto_dismiss(id)` for every live
1015    /// toast; on pointer-leave it queues `resume_overlay_auto_dismiss`.
1016    pub fn pause_overlay_auto_dismiss(&mut self, id: crate::overlay::OverlayId) {
1017        self.overlay_pause_requests.push((id, true));
1018    }
1019
1020    /// Queue a request to resume an overlay's `auto_dismiss_after`
1021    /// timer paused via
1022    /// [`pause_overlay_auto_dismiss`](Self::pause_overlay_auto_dismiss).
1023    /// Idempotent on un-paused overlays.
1024    pub fn resume_overlay_auto_dismiss(&mut self, id: crate::overlay::OverlayId) {
1025        self.overlay_pause_requests.push((id, false));
1026    }
1027
1028    /// Dismiss all active overlays (e.g., after a menu item is activated).
1029    pub fn dismiss_all_overlays(&mut self) {
1030        self.dismiss_scope = Some(DismissScope::All);
1031    }
1032
1033    /// Dismiss the source widget's containing overlay and any ancestor
1034    /// overlays in the chain that are menu-like (anything that isn't a
1035    /// `Role::Tooltip`, `Role::Dialog`, or `Role::AlertDialog`),
1036    /// preserving an outer composite tooltip or modal hosting the
1037    /// popover. Use for menu / dropdown item activation that wants to
1038    /// close the menu cascade without disturbing the host surface.
1039    pub fn dismiss_self_overlay_chain(&mut self) {
1040        self.dismiss_scope = Some(DismissScope::SelfChain);
1041    }
1042
1043    /// Dismiss every overlay whose content is *not* a host surface
1044    /// (`Role::Tooltip`, `Role::Dialog`, `Role::AlertDialog`),
1045    /// preserving an outer composite tooltip or modal hosting the
1046    /// trigger. Use for popover triggers and pre-show cleanup that
1047    /// want to close stale popovers / menus without taking a hosting
1048    /// surface with them.
1049    pub fn dismiss_all_except_hosts(&mut self) {
1050        self.dismiss_scope = Some(DismissScope::AllExceptHosts);
1051    }
1052
1053    /// Dismiss the topmost overlay only (e.g., closing a submenu while
1054    /// keeping the parent menu open).
1055    pub fn dismiss_top_overlay(&mut self) {
1056        self.dismiss_scope = Some(DismissScope::Top);
1057    }
1058
1059    /// Dismiss descendant overlays of the source widget's containing overlay.
1060    /// Useful for closing sibling submenu branches while keeping the current
1061    /// parent menu open.
1062    pub fn dismiss_child_overlays(&mut self) {
1063        self.dismiss_descendant_overlays.push(None);
1064    }
1065
1066    /// Dismiss descendant overlays of the source widget's containing overlay,
1067    /// preserving the subtree rooted at `content_id` if it is already open.
1068    pub fn dismiss_child_overlays_except(&mut self, content_id: crate::widget_id::WidgetId) {
1069        self.dismiss_descendant_overlays.push(Some(content_id));
1070    }
1071
1072    /// Request an idle callback to be run during the next idle period.
1073    /// Use this for incremental work that takes 5-50ms — too short for a
1074    /// background thread, too long for a single frame.
1075    pub fn request_idle_callback(
1076        &mut self,
1077        callback: impl FnOnce(crate::idle::IdleDeadline) + 'static,
1078    ) {
1079        self.idle_callbacks.push(Box::new(callback));
1080    }
1081
1082    /// Request framework-owned modal presentation.
1083    ///
1084    /// The widget tree records the request together with the originating
1085    /// widget, and the application layer can later resolve `Auto` into a
1086    /// concrete presentation backend.
1087    pub fn present_modal(&mut self, request: crate::modal::ModalRequest) {
1088        self.modal_requests.push(request);
1089    }
1090
1091    /// Synchronously open a modal as a native window — the single
1092    /// unified path for native-window modals. Callers that don't
1093    /// care whether the modal lands in-tree or in a native window
1094    /// use [`present_modal`](Self::present_modal), which routes
1095    /// `ModalPresentation::Auto` through the framework's picker.
1096    ///
1097    /// Returns the new window's id, or `None` when called outside a
1098    /// dispatch context (standalone trees). The window's parent is
1099    /// the current window; focus target and title / size from the
1100    /// request are honored.
1101    ///
1102    /// Only `ModalContent::Deferred` is supported here — an
1103    /// `ExistingWidget` id wouldn't make sense in a fresh tree.
1104    pub fn open_modal(
1105        &mut self,
1106        request: crate::modal::ModalRequest,
1107    ) -> Option<crate::window::TeksiloWindowId> {
1108        let parent = self.current_window.as_ref()?.id();
1109        let crate::modal::ModalContent::Deferred(builder) = request.content else {
1110            return None;
1111        };
1112        let mut config = crate::window::WindowConfig::new().modal(crate::window::ModalConfig {
1113            parent,
1114            focus_target: request.focus_target,
1115        });
1116        if let Some(title) = request.title {
1117            config = config.title(title);
1118        }
1119        if let Some((w, h)) = request.size {
1120            config = config.size(w, h);
1121        }
1122        let config = config.root(move |tree, _state| builder(tree));
1123        Some(self.open_window(config))
1124    }
1125
1126    /// Dismiss the current framework-owned modal presentation.
1127    pub fn dismiss_modal(&mut self) {
1128        self.dismiss_modal = true;
1129    }
1130
1131    /// Show an overlay after a delay. The widget tree checks pending delayed
1132    /// overlays during `layout()` and shows them once the delay elapses.
1133    /// Use this for submenu hover-open delays.
1134    ///
1135    /// The content widget should already be added to the tree (typically
1136    /// dormant). It will be activated automatically when the delay elapses.
1137    pub fn show_overlay_after(
1138        &mut self,
1139        request: crate::overlay::OverlayRequest,
1140        delay: std::time::Duration,
1141    ) {
1142        self.delayed_overlay_requests.push((request, delay, None));
1143    }
1144
1145    /// Show an overlay after a delay and move focus when it opens.
1146    pub fn show_overlay_after_with_focus(
1147        &mut self,
1148        request: crate::overlay::OverlayRequest,
1149        delay: std::time::Duration,
1150        focus_target: crate::widget_id::WidgetId,
1151    ) {
1152        self.delayed_overlay_requests
1153            .push((request, delay, Some(focus_target)));
1154    }
1155
1156    /// Request a repaint on a specific widget. Use this when an event handler
1157    /// on one widget changes state that affects a different widget's appearance
1158    /// (e.g., keyboard navigation highlighting items in an overlay).
1159    pub fn request_repaint(&mut self, id: crate::widget_id::WidgetId) {
1160        self.repaint_requests.push(id);
1161    }
1162
1163    /// Programmatically click a widget (synthetic PointerDown + PointerUp at
1164    /// its center). Use this for keyboard activation of a child widget, e.g.,
1165    /// Enter on a keyboard-focused menu item.
1166    pub fn synthetic_click(&mut self, id: crate::widget_id::WidgetId) {
1167        self.synthetic_clicks.push(id);
1168    }
1169
1170    /// Transfer focus to a specific widget. Use this when opening overlay
1171    /// content (menus, dialogs) that should receive keyboard events.
1172    pub fn request_focus(&mut self, id: crate::widget_id::WidgetId) {
1173        self.focus_requests.push(id);
1174    }
1175
1176    /// Move focus **into** the content of `id`: focus its first focusable
1177    /// descendant in tab order. Unlike [`request_focus`](Self::request_focus),
1178    /// this does **not** fall back to focusing `id` itself when the subtree has
1179    /// no focusable descendant — it is a no-op in that case, so an empty region
1180    /// never traps focus on a non-interactive container.
1181    ///
1182    /// Use this for "dive into this region" gestures, e.g. pressing Enter on a
1183    /// focused tab header to move focus into the tab's content panel. A panel
1184    /// with focusable content lands on its first control; a panel that opted
1185    /// into focusability itself (no inner controls) lands on the panel; a bare
1186    /// panel with neither leaves focus where it was.
1187    pub fn request_focus_into(&mut self, id: crate::widget_id::WidgetId) {
1188        self.focus_into_requests.push(id);
1189    }
1190
1191    /// Scroll the given rectangle into view inside every enclosing scroll
1192    /// container, walking outward from the widget whose handler is running.
1193    ///
1194    /// `rect` is in **absolute tree (window) coordinates** — the same space
1195    /// the arena stores widget bounds in. After the handler returns, the
1196    /// framework walks the current widget's ancestors and, for each
1197    /// `clips_children` scroll container whose viewport does not already
1198    /// fully contain `rect`, dispatches
1199    /// [`WidgetEvent::ScrollIntoView`](crate::event::WidgetEvent::ScrollIntoView)
1200    /// so the container adjusts its offset. Nested scroll areas each get a
1201    /// turn (outermost included), exactly like the focus-driven path.
1202    ///
1203    /// Unlike the automatic focus follow — which can only reveal a *focused
1204    /// widget's own bounds* — this lets a widget reveal an arbitrary interior
1205    /// rectangle it computed itself: a text caret, a virtualized list/table
1206    /// row (which is not a distinct focusable node), or a scrolled-off tab
1207    /// header. The widget remains responsible for scrolling its *own* interior
1208    /// viewport; `ensure_visible` handles the enclosing containers. It is a
1209    /// no-op when there is no scroll container above the widget, or when every
1210    /// container already shows the rect.
1211    ///
1212    /// See [`ensure_visible_with_margin`](Self::ensure_visible_with_margin) to
1213    /// keep breathing room around the target.
1214    pub fn ensure_visible(&mut self, rect: teksilo_canvas::Rect) {
1215        self.scroll_into_view_requests.push(ScrollRevealRequest {
1216            rect,
1217            margin: 0.0,
1218            align: crate::event::ScrollAlign::Minimal,
1219            motion: crate::event::ScrollMotion::Instant,
1220            from: None,
1221        });
1222    }
1223
1224    /// [`ensure_visible`](Self::ensure_visible), for a rect that belongs to
1225    /// **another** widget.
1226    ///
1227    /// The framework walks `owner`'s ancestors rather than the handling widget's.
1228    /// That distinction is the whole of it, and getting it wrong fails silently:
1229    /// a find banner's Next button sits *beside* the scrolling page, not inside
1230    /// it, so a reveal walked from the button climbs out through the banner and
1231    /// never meets the scroll container the match is in. The match is selected,
1232    /// the counter moves, and the viewport does not follow.
1233    ///
1234    /// The same reasoning [`ensure_widget_visible`](Self::ensure_widget_visible)
1235    /// already records for the id-based form; this is its rect-based twin, for a
1236    /// target that is an interior span rather than a mounted child.
1237    ///
1238    /// `rect` is in absolute tree (window) coordinates.
1239    pub fn ensure_visible_from(
1240        &mut self,
1241        owner: crate::widget_id::WidgetId,
1242        rect: teksilo_canvas::Rect,
1243    ) {
1244        self.scroll_into_view_requests.push(ScrollRevealRequest {
1245            rect,
1246            margin: 0.0,
1247            align: crate::event::ScrollAlign::Minimal,
1248            motion: crate::event::ScrollMotion::Instant,
1249            from: Some(owner),
1250        });
1251    }
1252
1253    /// Like [`ensure_visible`](Self::ensure_visible), but keeps `margin`
1254    /// logical pixels of breathing room around `rect` on every edge, so the
1255    /// target does not sit flush against the viewport boundary (the caret at
1256    /// the bottom line, the selected row at the fold). `rect` is in absolute
1257    /// tree (window) coordinates.
1258    pub fn ensure_visible_with_margin(&mut self, rect: teksilo_canvas::Rect, margin: f32) {
1259        self.scroll_into_view_requests.push(ScrollRevealRequest {
1260            rect,
1261            margin: margin.max(0.0),
1262            align: crate::event::ScrollAlign::Minimal,
1263            motion: crate::event::ScrollMotion::Instant,
1264            from: None,
1265        });
1266    }
1267
1268    /// **Pin** `rect` at `fraction` of the way down the innermost enclosing
1269    /// scroll container — `0.0` flush with the top, `0.5` centred, `1.0` flush
1270    /// with the bottom — instead of merely revealing it.
1271    ///
1272    /// The difference from [`ensure_visible`](Self::ensure_visible) is that this
1273    /// scrolls **even when the target is already visible**. That is what makes
1274    /// it usable for typewriter scrolling: a caret that only moved the view once
1275    /// it fell off the edge would not be pinned to anything.
1276    ///
1277    /// Only the **innermost** clipping ancestor aligns; any further ancestors
1278    /// out fall back to a minimal reveal, since an outer container's job is to
1279    /// bring the inner viewport on screen, not to align a rectangle it does not
1280    /// own.
1281    ///
1282    /// `fraction` is clamped to `0.0..=1.0`. The container additionally clamps
1283    /// to its own scroll range, so a target near the start or end of the content
1284    /// lands as close to `fraction` as the range permits — see the scroll
1285    /// container's `scroll_past_end` for buying range past the content's end so
1286    /// the last line can still reach the pin.
1287    ///
1288    /// `rect` is in absolute tree (window) coordinates.
1289    pub fn ensure_visible_aligned(
1290        &mut self,
1291        rect: teksilo_canvas::Rect,
1292        fraction: f32,
1293        motion: crate::event::ScrollMotion,
1294    ) {
1295        self.scroll_into_view_requests.push(ScrollRevealRequest {
1296            rect,
1297            margin: 0.0,
1298            align: crate::event::ScrollAlign::Fraction(fraction.clamp(0.0, 1.0)),
1299            motion,
1300            from: None,
1301        });
1302    }
1303
1304    /// [`ensure_visible_aligned`](Self::ensure_visible_aligned), for a rect that
1305    /// belongs to **another** widget — see [`ensure_visible_from`](Self::ensure_visible_from)
1306    /// for why the distinction exists and how it fails when it is missed.
1307    pub fn ensure_visible_aligned_from(
1308        &mut self,
1309        owner: crate::widget_id::WidgetId,
1310        rect: teksilo_canvas::Rect,
1311        fraction: f32,
1312        motion: crate::event::ScrollMotion,
1313    ) {
1314        self.scroll_into_view_requests.push(ScrollRevealRequest {
1315            rect,
1316            margin: 0.0,
1317            align: crate::event::ScrollAlign::Fraction(fraction.clamp(0.0, 1.0)),
1318            motion,
1319            from: Some(owner),
1320        });
1321    }
1322
1323    /// Scroll a specific mounted widget into view inside every enclosing
1324    /// scroll container — the id-based companion to
1325    /// [`ensure_visible`](Self::ensure_visible).
1326    ///
1327    /// The framework resolves `id` to its current absolute bounds after the
1328    /// handler returns and walks *that widget's* ancestors (never `id`
1329    /// itself), dispatching
1330    /// [`WidgetEvent::ScrollIntoView`](crate::event::WidgetEvent::ScrollIntoView)
1331    /// to each `clips_children` container that doesn't already show it.
1332    ///
1333    /// Use this when the target you want revealed is a real, non-virtualized
1334    /// child whose bounds the arena already knows — a selected radio tile, a
1335    /// tab header — so you don't have to compute a rect. For a target that has
1336    /// no distinct node (a text caret) or that may not be realized (a
1337    /// virtualized list/table row), use [`ensure_visible`](Self::ensure_visible)
1338    /// with an analytic rect instead. No-op if `id` is not currently mounted.
1339    pub fn ensure_widget_visible(&mut self, id: crate::widget_id::WidgetId) {
1340        self.scroll_widget_into_view_requests.push((id, 0.0));
1341    }
1342
1343    /// Like [`ensure_widget_visible`](Self::ensure_widget_visible), but keeps
1344    /// `margin` logical pixels of breathing room around the widget.
1345    pub fn ensure_widget_visible_with_margin(
1346        &mut self,
1347        id: crate::widget_id::WidgetId,
1348        margin: f32,
1349    ) {
1350        self.scroll_widget_into_view_requests
1351            .push((id, margin.max(0.0)));
1352    }
1353
1354    /// Surface the tooltip of a keyboard-highlighted item immediately (no
1355    /// dwell), dismissing the previously-highlighted item's tooltip. Used by
1356    /// `MenuList` on arrow-key navigation so a menu item's rich/composite
1357    /// tooltip is reachable by keyboard — real focus stays on the menu panel,
1358    /// so this is keyed on the item id rather than on focus. Pass the item's
1359    /// own widget id; a tooltip-less item simply dismisses the previous one.
1360    pub fn show_highlight_tooltip(&mut self, id: crate::widget_id::WidgetId) {
1361        self.highlight_tooltip_requests.push(id);
1362    }
1363
1364    /// Cancel a pending delayed overlay by its content widget ID.
1365    /// Call this when the hover ends before the delay elapses.
1366    pub fn cancel_delayed_overlay(&mut self, content_id: crate::widget_id::WidgetId) {
1367        self.cancel_delayed_overlays.push(content_id);
1368    }
1369
1370    /// Capture the pointer: all subsequent `PointerMove` and `PointerUp`
1371    /// events will be routed to the capturing widget until the capture is
1372    /// released. Use this when starting a drag operation.
1373    pub fn capture_pointer(&mut self) {
1374        self.pointer_capture = Some(true);
1375    }
1376
1377    /// Release a previously captured pointer. Pointer events resume normal
1378    /// hit-test dispatch.
1379    pub fn release_pointer(&mut self) {
1380        self.pointer_capture = Some(false);
1381    }
1382
1383    /// Start a drag-and-drop operation from the given source widget.
1384    ///
1385    /// The `payload` carries the data being dragged. During the drag:
1386    /// - `PointerMove` events update the drag position and fire `on_drag_hover`
1387    ///   on widgets under the pointer that have drop handlers
1388    /// - `PointerUp` fires `on_drop` on the target widget (if any)
1389    /// - `Escape` cancels the drag
1390    pub fn start_drag(
1391        &mut self,
1392        source_widget: crate::widget_id::WidgetId,
1393        payload: crate::drag_payload::DragPayload,
1394    ) {
1395        self.drag_start_request = Some((source_widget, payload, None));
1396    }
1397
1398    /// Start a drag-and-drop with a preview widget that follows the pointer.
1399    pub fn start_drag_with_preview(
1400        &mut self,
1401        source_widget: crate::widget_id::WidgetId,
1402        payload: crate::drag_payload::DragPayload,
1403        preview: Box<dyn crate::widget::Widget>,
1404    ) {
1405        self.drag_start_request = Some((source_widget, payload, Some(preview)));
1406    }
1407
1408    /// Cancel the active drag-and-drop session (if any).
1409    pub fn cancel_drag(&mut self) {
1410        self.cancel_drag = true;
1411    }
1412
1413    /// Replace the tree-level theme. Composite widgets are rebuilt so any
1414    /// derived values they captured at build time pick up the new tokens,
1415    /// and all widgets are marked dirty for repaint.
1416    ///
1417    /// An explicit theme also turns **off** OS-following: the app's theme
1418    /// mode is reset to manual, so a later OS light/dark change won't
1419    /// override the chosen theme.
1420    pub fn set_theme(&mut self, theme: crate::styles::Theme) {
1421        self.theme_request = Some(theme);
1422    }
1423
1424    /// Switch the application to follow the OS theme (native / system mode):
1425    /// the app adopts the OS's colours and tracks OS light/dark changes at
1426    /// runtime. On platforms without OS-colour support it falls back to
1427    /// following the built-in light/dark presets.
1428    ///
1429    /// This is the counterpart to [`set_theme`](Self::set_theme): calling
1430    /// `set_theme` pins a fixed theme (manual mode), while this resumes
1431    /// OS-following. Parameterless by design, so widgets need not reference
1432    /// the app-layer theme-mode enum.
1433    pub fn follow_system_theme(&mut self) {
1434        self.follow_system_request = true;
1435    }
1436
1437    /// Replace the tree-level locale identifier. Composite widgets are
1438    /// rebuilt so any tr! lookups picked up at build time are re-evaluated
1439    /// against the new locale.
1440    pub fn set_locale(&mut self, locale: impl Into<String>) {
1441        self.locale_request = Some(locale.into());
1442    }
1443
1444    /// Set the user-controlled global text-scale factor (`1.0` = 100 %).
1445    ///
1446    /// The change is applied app-wide (every window) after the handler returns,
1447    /// mirroring [`set_theme`](Self::set_theme) / [`set_locale`](Self::set_locale).
1448    /// All text grows uniformly without a rebuild. Persist the value through
1449    /// `ctx.settings()` (e.g. `teksilo_settings::TEXT_SCALE_KEY`) so it survives
1450    /// a restart — the `TextScaleControl` widget does both for you.
1451    pub fn set_text_scale(&mut self, factor: f32) {
1452        self.text_scale_request = Some(factor);
1453    }
1454}
1455
1456#[cfg(test)]
1457mod multi_window_tests {
1458    use super::*;
1459    use crate::window::state::WindowStateInit;
1460    use crate::window::{
1461        NoopWindowOps, TeksiloWindowId, WindowConfig, WindowOps, WindowPlacement, WindowState,
1462    };
1463    use std::cell::RefCell;
1464    use std::rc::Rc;
1465
1466    /// Recording implementation of `WindowOps` so tests can assert
1467    /// that `EventContext` routes each method through the trait.
1468    #[derive(Default)]
1469    struct RecordingOps {
1470        open_calls: RefCell<Vec<WindowConfig>>,
1471        focus_calls: RefCell<Vec<TeksiloWindowId>>,
1472        close_calls: RefCell<Vec<TeksiloWindowId>>,
1473        next_id: RefCell<u64>,
1474        // A fake registry so `find_window` / `window_state` / `windows`
1475        // can return values.
1476        states: RefCell<Vec<WindowState>>,
1477    }
1478
1479    impl RecordingOps {
1480        fn alloc_id(&self) -> TeksiloWindowId {
1481            let mut n = self.next_id.borrow_mut();
1482            *n += 1;
1483            TeksiloWindowId::new(*n)
1484        }
1485    }
1486
1487    impl WindowOps for RecordingOps {
1488        fn open_window(&mut self, config: WindowConfig) -> TeksiloWindowId {
1489            let id = self.alloc_id();
1490            let state = WindowState::new(WindowStateInit {
1491                id,
1492                string_id: config.string_id.clone(),
1493                placement: config.initial_placement,
1494                title: config.title.clone(),
1495                size: config.size,
1496                position: config.position.unwrap_or((0, 0)),
1497                focused: true,
1498                resizable: config.resizable,
1499                always_on_top: config.always_on_top,
1500            });
1501            self.states.borrow_mut().push(state);
1502            self.open_calls.borrow_mut().push(config);
1503            id
1504        }
1505
1506        fn find_window(&self, string_id: &str) -> Option<TeksiloWindowId> {
1507            self.states
1508                .borrow()
1509                .iter()
1510                .find(|s| s.string_id() == Some(string_id))
1511                .map(|s| s.id())
1512        }
1513
1514        fn window_state(&self, id: TeksiloWindowId) -> Option<WindowState> {
1515            self.states.borrow().iter().find(|s| s.id() == id).cloned()
1516        }
1517
1518        fn windows(&self) -> Vec<WindowState> {
1519            self.states.borrow().clone()
1520        }
1521
1522        fn focus_window(&mut self, id: TeksiloWindowId) {
1523            self.focus_calls.borrow_mut().push(id);
1524        }
1525
1526        fn close_window_by_id(&mut self, id: TeksiloWindowId) {
1527            self.close_calls.borrow_mut().push(id);
1528        }
1529    }
1530
1531    fn make_state(id: u64, string_id: Option<&str>) -> WindowState {
1532        WindowState::new(WindowStateInit {
1533            id: TeksiloWindowId::new(id),
1534            string_id: string_id.map(String::from),
1535            placement: WindowPlacement::Floating,
1536            title: "Test".into(),
1537            size: (800, 600),
1538            position: (0, 0),
1539            focused: true,
1540            resizable: true,
1541            always_on_top: false,
1542        })
1543    }
1544
1545    #[test]
1546    fn window_returns_current_window_state() {
1547        let state = make_state(1, Some("main"));
1548        let mut noop = NoopWindowOps;
1549        let ctx = EventContext::new().with_window_context(&mut noop, Some(state.clone()));
1550        assert_eq!(ctx.window().unwrap().id(), TeksiloWindowId::new(1));
1551        assert_eq!(ctx.window().unwrap().string_id(), Some("main"));
1552    }
1553
1554    #[test]
1555    fn window_is_none_without_context() {
1556        let ctx = EventContext::new();
1557        assert!(ctx.window().is_none());
1558    }
1559
1560    #[test]
1561    fn open_window_routes_through_ops() {
1562        let mut ops = RecordingOps::default();
1563        let main_state = make_state(1, Some("main"));
1564        let returned_id = {
1565            let mut ctx = EventContext::new().with_window_context(&mut ops, Some(main_state));
1566            ctx.open_window(WindowConfig::new().id("help").title("Help"))
1567        };
1568        assert_eq!(ops.open_calls.borrow().len(), 1);
1569        assert_eq!(
1570            ops.open_calls.borrow()[0].string_id.as_deref(),
1571            Some("help")
1572        );
1573        // Recording ops allocates ids 2+; 1 was reserved for `main`
1574        // only in this test — Recording's counter starts from 0, so the
1575        // first alloc yields 1.
1576        assert_eq!(returned_id, TeksiloWindowId::new(1));
1577    }
1578
1579    #[test]
1580    fn find_window_routes_through_ops() {
1581        let mut ops = RecordingOps::default();
1582        ops.states.borrow_mut().push(make_state(7, Some("foo")));
1583        let main_state = make_state(1, Some("main"));
1584        let ctx = EventContext::new().with_window_context(&mut ops, Some(main_state));
1585        assert_eq!(ctx.find_window("foo"), Some(TeksiloWindowId::new(7)));
1586        assert!(ctx.find_window("missing").is_none());
1587    }
1588
1589    #[test]
1590    fn focus_window_records_via_ops() {
1591        let mut ops = RecordingOps::default();
1592        let main_state = make_state(1, None);
1593        {
1594            let mut ctx = EventContext::new().with_window_context(&mut ops, Some(main_state));
1595            ctx.focus_window(TeksiloWindowId::new(42));
1596        }
1597        assert_eq!(
1598            ops.focus_calls.borrow().as_slice(),
1599            &[TeksiloWindowId::new(42)]
1600        );
1601    }
1602
1603    #[test]
1604    fn close_window_by_id_records_via_ops() {
1605        let mut ops = RecordingOps::default();
1606        let main_state = make_state(1, None);
1607        {
1608            let mut ctx = EventContext::new().with_window_context(&mut ops, Some(main_state));
1609            ctx.close_window_by_id(TeksiloWindowId::new(9));
1610        }
1611        assert_eq!(
1612            ops.close_calls.borrow().as_slice(),
1613            &[TeksiloWindowId::new(9)]
1614        );
1615    }
1616
1617    #[test]
1618    fn close_window_sets_guarded_flag_only() {
1619        let mut ctx = EventContext::new();
1620        ctx.close_window();
1621        assert!(
1622            ctx.close_window_requested,
1623            "close_window must raise the guarded-close flag"
1624        );
1625        assert!(
1626            !ctx.force_close_requested,
1627            "close_window must NOT raise the forced-close flag"
1628        );
1629    }
1630
1631    #[test]
1632    fn close_window_forced_sets_force_flag_only() {
1633        let mut ctx = EventContext::new();
1634        ctx.close_window_forced();
1635        assert!(
1636            ctx.force_close_requested,
1637            "close_window_forced must raise the forced-close flag"
1638        );
1639        assert!(
1640            !ctx.close_window_requested,
1641            "close_window_forced must NOT raise the guarded-close flag"
1642        );
1643    }
1644
1645    /// End-to-end: a handler calling `close_window_forced` during
1646    /// dispatch must transfer the flag onto the `WidgetTree` (the
1647    /// `collect_from_ctx` teardown), where the app loop drains it via
1648    /// `take_force_close_request` — separately from the guarded
1649    /// `take_close_window_request` flag.
1650    #[test]
1651    fn forced_close_flag_propagates_to_tree_and_drains_independently() {
1652        use crate::test_widgets::FillWidget;
1653        use crate::widget_tree::WidgetTree;
1654
1655        // `run_with_event_context` only runs the `collect_from_ctx`
1656        // teardown (which transfers ctx flags onto the tree) when the
1657        // tree has a root to anchor on, so give it one.
1658        let mut tree = WidgetTree::new();
1659        tree.add(FillWidget::new());
1660        tree.run_with_event_context(&mut NoopWindowOps, |ctx| ctx.close_window_forced());
1661        assert!(
1662            tree.take_force_close_request(),
1663            "forced-close flag must reach the tree"
1664        );
1665        assert!(
1666            !tree.take_close_window_request(),
1667            "a forced close must not also raise the guarded flag"
1668        );
1669
1670        // And the guarded path stays on its own channel.
1671        let mut tree = WidgetTree::new();
1672        tree.add(FillWidget::new());
1673        tree.run_with_event_context(&mut NoopWindowOps, |ctx| ctx.close_window());
1674        assert!(tree.take_close_window_request());
1675        assert!(!tree.take_force_close_request());
1676    }
1677
1678    #[test]
1679    fn windows_enumerates_via_ops() {
1680        let mut ops = RecordingOps::default();
1681        ops.states.borrow_mut().push(make_state(1, Some("a")));
1682        ops.states.borrow_mut().push(make_state(2, Some("b")));
1683        let main_state = make_state(1, Some("a"));
1684        let ctx = EventContext::new().with_window_context(&mut ops, Some(main_state));
1685        let ids: Vec<_> = ctx.windows().iter().map(|s| s.id()).collect();
1686        assert_eq!(ids, vec![TeksiloWindowId::new(1), TeksiloWindowId::new(2)]);
1687    }
1688
1689    #[test]
1690    fn standalone_context_returns_empty_windows_and_none_lookups() {
1691        let ctx = EventContext::new();
1692        assert!(ctx.find_window("anything").is_none());
1693        assert!(ctx.window_state(TeksiloWindowId::new(1)).is_none());
1694        assert!(ctx.windows().is_empty());
1695    }
1696
1697    #[test]
1698    #[should_panic(expected = "open_window called outside of a dispatch")]
1699    fn open_window_on_standalone_context_panics() {
1700        let mut ctx = EventContext::new();
1701        let _ = ctx.open_window(WindowConfig::new());
1702    }
1703
1704    #[test]
1705    fn open_modal_builds_window_config_from_request() {
1706        use crate::modal::{ModalContent, ModalRequest};
1707        let mut ops = RecordingOps::default();
1708        let main_state = make_state(1, Some("main"));
1709        let built_widget = Rc::new(RefCell::new(false));
1710        let built_widget_flag = built_widget.clone();
1711        let request = ModalRequest {
1712            content: ModalContent::Deferred(Box::new(move |_tree| {
1713                *built_widget_flag.borrow_mut() = true;
1714                // Return a dummy WidgetId — not used in this test since
1715                // the RecordingOps doesn't actually build the tree.
1716                crate::widget_id::WidgetId::default()
1717            })),
1718            presentation: crate::modal::ModalPresentation::NativeWindow,
1719            close_behavior: crate::modal::ModalCloseBehavior::default(),
1720            title: Some("Confirm".to_string()),
1721            size: Some((420, 180)),
1722            focus_target: None,
1723            on_dismiss: None,
1724        };
1725        {
1726            let mut ctx = EventContext::new().with_window_context(&mut ops, Some(main_state));
1727            let id = ctx.open_modal(request);
1728            assert!(id.is_some());
1729        }
1730        // open_modal is a thin wrapper over open_window — the config
1731        // it built must reach RecordingOps::open_window.
1732        let calls = ops.open_calls.borrow();
1733        assert_eq!(calls.len(), 1);
1734        let cfg = &calls[0];
1735        assert_eq!(cfg.title, "Confirm");
1736        assert_eq!(cfg.size, (420, 180));
1737        assert!(cfg.is_modal());
1738        assert_eq!(cfg.modal_parent(), Some(TeksiloWindowId::new(1)));
1739        // Cell is just to let us observe something reachable via cfg.root_builder;
1740        // the builder hasn't been called yet (RecordingOps records the config
1741        // but doesn't build the tree).
1742        let _ = built_widget;
1743    }
1744
1745    #[test]
1746    fn open_modal_requires_current_window() {
1747        use crate::modal::{ModalContent, ModalRequest};
1748        let mut ops = RecordingOps::default();
1749        let mut ctx = EventContext::new().with_window_context(&mut ops, None);
1750        let request = ModalRequest {
1751            content: ModalContent::Deferred(Box::new(|_tree| {
1752                crate::widget_id::WidgetId::default()
1753            })),
1754            presentation: crate::modal::ModalPresentation::NativeWindow,
1755            close_behavior: crate::modal::ModalCloseBehavior::default(),
1756            title: None,
1757            size: None,
1758            focus_target: None,
1759            on_dismiss: None,
1760        };
1761        assert!(ctx.open_modal(request).is_none());
1762    }
1763}