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