Skip to main content

teksilo_app/
app.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use std::any::{Any, TypeId};
5use std::collections::HashMap;
6use std::rc::Rc;
7use std::time::{Duration, Instant};
8use teksilo_canvas::SizeProposal;
9use teksilo_core::Theme;
10use teksilo_core::app_event::AppEvent;
11use teksilo_core::event::WidgetEvent;
12use teksilo_core::event_source::{
13    AppEventPoster, EventSource, EventSourceAdapter, SubscriptionId, TreeAppContext,
14};
15use teksilo_core::modal::{ModalCloseBehavior, ModalContent, ModalPresentation, ModalRequest};
16use teksilo_core::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
17use teksilo_core::{WidgetId, WidgetTree};
18use teksilo_i18n::{I18nConfig, I18nManager, LanguageIdentifier};
19use teksilo_platform::event_translation;
20use winit::application::ApplicationHandler;
21use winit::event::{StartCause, WindowEvent};
22use winit::event_loop::{ActiveEventLoop, ControlFlow};
23#[allow(unused_imports)]
24use winit::raw_window_handle::HasWindowHandle;
25use winit::window::WindowId;
26
27/// How the application resolves its theme.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum ThemeMode {
30    /// Use a specific fixed theme (current behavior, default).
31    #[default]
32    Manual,
33    /// Follow the OS light/dark preference using Teksilo's built-in themes.
34    FollowSystem,
35    /// Adopt colors read directly from the OS/DE config files (GNOME/KDE/Cinnamon).
36    /// Falls back to `FollowSystem` on unsupported platforms or DEs.
37    Native,
38}
39
40#[cfg(feature = "text")]
41use teksilo_text::SharedTypesetter;
42
43use crate::window_config::{SizeToContent, TeksiloWindowId, WindowConfig};
44use crate::window_manager::WindowManager;
45use teksilo_core::WindowPlacement;
46
47/// Interrogate the winit window for its current placement so an
48/// `OS-initiated` state change can be mirrored into the corresponding
49/// `WindowState::placement` signal without the observer pushing it
50/// back out as a `WindowCommand` (re-entrancy guard on `from_os`).
51fn query_window_placement(win: &winit::window::Window) -> WindowPlacement {
52    if win.is_minimized() == Some(true) {
53        WindowPlacement::Minimized
54    } else if win.fullscreen().is_some() {
55        WindowPlacement::Fullscreen
56    } else if win.is_maximized() {
57        WindowPlacement::Maximized
58    } else {
59        WindowPlacement::Floating
60    }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum ResolvedModalPresentation {
65    InTree,
66    NativeWindow,
67}
68
69/// Generate a per-process random session id for telemetry.
70///
71/// Not persisted across restarts — by design (a stable id would be
72/// pseudonymous tracking, distinct from `InstallId`'s 13-month UUID).
73/// The first 16 hex chars of a fresh UUID are sufficient for grouping
74/// events within one process lifetime.
75#[cfg(feature = "telemetry")]
76fn generate_session_id() -> String {
77    let uuid = uuid::Uuid::new_v4().simple().to_string();
78    uuid[..16].to_string()
79}
80
81fn resolve_modal_presentation(
82    requested: ModalPresentation,
83    content: &ModalContent,
84    native_supported: bool,
85) -> ResolvedModalPresentation {
86    let can_use_native = native_supported && matches!(content, ModalContent::Deferred(_));
87
88    match requested {
89        ModalPresentation::InTree => ResolvedModalPresentation::InTree,
90        ModalPresentation::NativeWindow => {
91            if can_use_native {
92                ResolvedModalPresentation::NativeWindow
93            } else {
94                ResolvedModalPresentation::InTree
95            }
96        }
97        ModalPresentation::Auto => {
98            if can_use_native {
99                ResolvedModalPresentation::NativeWindow
100            } else {
101                ResolvedModalPresentation::InTree
102            }
103        }
104    }
105}
106
107fn modal_close_behavior_to_overlay_dismiss(behavior: ModalCloseBehavior) -> DismissBehavior {
108    match behavior {
109        ModalCloseBehavior::ClickOutside => DismissBehavior::ClickOutside,
110        ModalCloseBehavior::EscapeKey => DismissBehavior::EscapeKey,
111        ModalCloseBehavior::EscapeOrClickOutside => DismissBehavior::EscapeOrClickOutside,
112        ModalCloseBehavior::Manual => DismissBehavior::Manual,
113    }
114}
115
116fn present_in_tree_modal_request(
117    tree: &mut WidgetTree,
118    source_widget: WidgetId,
119    request: ModalRequest,
120) {
121    let dismiss = modal_close_behavior_to_overlay_dismiss(request.close_behavior);
122    let requested_focus = request.focus_target;
123    let user_on_dismiss = request.on_dismiss;
124    let close_behavior = request.close_behavior;
125    // Capture the focus owner BEFORE the modal moves focus into itself
126    // (below). Recorded as the modal overlay's `focus_restore` so that
127    // dismissing the dialog returns keyboard focus to the trigger — e.g.
128    // tabbing to a "Rename…" button, opening the InputDialog, then
129    // accepting/cancelling lands back on that button. Without this, the
130    // modal shows via `show_overlay` (which, unlike
131    // `show_overlay_from_source`, records no restore target) and focus is
132    // dropped on dismiss.
133    let focus_before_modal = tree.focused();
134    // Capture the `:focus-visible` input modality at the same instant. A
135    // modal is a transient interruption: when it closes and focus snaps
136    // back to the trigger, the trigger's focus ring should look exactly as
137    // it did before the modal opened — NOT inherit keyboard modality from
138    // input directed *at the dialog* (typing a name, pressing Enter to
139    // accept). Without restoring this, mouse-clicking the trigger then
140    // pressing Enter inside the dialog leaves the global modality "keyboard"
141    // and the trigger sprouts a focus ring it never had. We restore it on
142    // dismiss alongside focus. (`focus_ops` itself never touches this
143    // signal, so the value we restore is the value that sticks.)
144    let focus_visible_before = tree.focus_visible_signal().get();
145    let focus_visible_signal = tree.focus_visible_signal();
146    let content_id = match request.content {
147        ModalContent::ExistingWidget(id) => id,
148        ModalContent::Deferred(builder) => {
149            let id = builder(tree);
150            tree.set_dormant(id);
151            id
152        }
153    };
154
155    // Mount the dialog scrim FIRST so it z-orders below the modal
156    // panel in the overlay stack. The scrim chrome (a full-viewport
157    // dim) comes from the active `DialogStyle::make_scrim`; clicks on
158    // it dismiss the modal when its `ModalCloseBehavior` permits
159    // click-outside dismissal. The framework patches the scrim's
160    // `parent_overlay` after the modal is pushed so that dismissing
161    // the modal cascades through and also dismisses the scrim.
162    let click_to_dismiss = matches!(
163        close_behavior,
164        ModalCloseBehavior::ClickOutside | ModalCloseBehavior::EscapeOrClickOutside,
165    );
166    let dismiss_target: std::rc::Rc<std::cell::Cell<Option<teksilo_core::overlay::OverlayId>>> =
167        std::rc::Rc::new(std::cell::Cell::new(None));
168    let scrim_id = tree.add(
169        teksilo_widgets::ModalScrim::new()
170            .dismiss_target(dismiss_target.clone())
171            .click_to_dismiss(click_to_dismiss),
172    );
173    let scrim_overlay = tree.show_overlay(OverlayRequest {
174        content_id: scrim_id,
175        anchor: source_widget,
176        placement: OverlayPlacement::FullViewport,
177        dismiss: DismissBehavior::Manual,
178        layer: OverlayLayer::InTree,
179        parent_overlay: None,
180        on_dismiss: None,
181        fade_duration: None,
182    });
183
184    tree.activate(content_id);
185    // Wrap the caller's `on_dismiss` so the framework also restores the
186    // pre-modal `:focus-visible` modality when the dialog closes (by any
187    // path: OK, Cancel, Escape, click-outside). Only when a focus owner
188    // was captured — if nothing was focused before, there's no prior state
189    // to return to. The overlay fires `on_dismiss` during dismissal, just
190    // before focus is restored to the trigger, so the value we set here is
191    // the one the trigger paints with.
192    let restore_modality = focus_before_modal.is_some();
193    let on_dismiss: Option<teksilo_core::overlay::OverlayDismissCallback> =
194        if restore_modality || user_on_dismiss.is_some() {
195            Some(std::rc::Rc::new(move || {
196                if restore_modality {
197                    focus_visible_signal.set(focus_visible_before);
198                }
199                if let Some(cb) = &user_on_dismiss {
200                    cb();
201                }
202            }))
203        } else {
204            None
205        };
206    // Present the modal as a WINDOW-LEVEL overlay via `show_overlay` rather than
207    // `show_overlay_from_source`: the latter re-parents the overlay to the source
208    // widget's overlay ancestor, so a modal opened from a menu item would be
209    // trapped in (and positioned relative to) the transient menu overlay instead
210    // of centering on the window. `Centered` already ignores the anchor; keeping
211    // `parent_overlay: None` makes it center on the viewport.
212    let modal_overlay = tree.show_overlay(OverlayRequest {
213        content_id,
214        anchor: source_widget,
215        placement: OverlayPlacement::Centered,
216        dismiss,
217        layer: OverlayLayer::InTree,
218        parent_overlay: None,
219        on_dismiss,
220        fade_duration: None,
221    });
222    // The modal is now the topmost overlay; record where focus should
223    // return when it dismisses. Mirrors `show_overlay_from_source`'s
224    // capture-then-set-top pattern. The `is_active` guard on the restore
225    // side makes a stale id (e.g. a menu trigger that went dormant) a
226    // graceful no-op.
227    if let Some(restore) = focus_before_modal {
228        tree.overlay_manager_mut().set_top_focus_restore(restore);
229    }
230    // Cascade-dismiss the scrim when the modal is dismissed (by any
231    // path: Escape, click-outside, manual). The scrim is below the
232    // modal in the stack but counts as its "child" in the parent-
233    // overlay graph, so `dismiss_immediate` walks the descendants and
234    // dismisses it too.
235    tree.overlay_manager_mut()
236        .set_parent_overlay(scrim_overlay, Some(modal_overlay));
237    // Fill in the dismiss target NOW that the modal id is known. The
238    // scrim's on-tap reads through this `Cell` at click time.
239    dismiss_target.set(Some(modal_overlay));
240
241    let focus_target = requested_focus
242        .filter(|id| tree.is_active(*id) && tree.is_descendant_of(*id, content_id))
243        .or_else(|| tree.widget_initial_focus_hint(content_id))
244        .or_else(|| tree.first_focusable_descendant(content_id));
245    if let Some(id) = focus_target {
246        tree.focus(id);
247    }
248}
249
250fn apply_cursor_to_window(
251    platform_window: &teksilo_platform::PlatformWindow,
252    cursor: teksilo_core::CursorIcon,
253) {
254    let winit_cursor = match cursor {
255        teksilo_core::CursorIcon::Default => winit::window::CursorIcon::Default,
256        teksilo_core::CursorIcon::Pointer => winit::window::CursorIcon::Pointer,
257        teksilo_core::CursorIcon::Text => winit::window::CursorIcon::Text,
258        teksilo_core::CursorIcon::Crosshair => winit::window::CursorIcon::Crosshair,
259        teksilo_core::CursorIcon::Move => winit::window::CursorIcon::Move,
260        teksilo_core::CursorIcon::NotAllowed => winit::window::CursorIcon::NotAllowed,
261        teksilo_core::CursorIcon::Grab => winit::window::CursorIcon::Grab,
262        teksilo_core::CursorIcon::Grabbing => winit::window::CursorIcon::Grabbing,
263        teksilo_core::CursorIcon::ColResize => winit::window::CursorIcon::ColResize,
264        teksilo_core::CursorIcon::RowResize => winit::window::CursorIcon::RowResize,
265        teksilo_core::CursorIcon::NeswResize => winit::window::CursorIcon::NeswResize,
266        teksilo_core::CursorIcon::NwseResize => winit::window::CursorIcon::NwseResize,
267    };
268    platform_window.window().set_cursor(winit_cursor);
269}
270
271#[derive(Debug)]
272struct IdleTrace {
273    last_report: Instant,
274    resume_time_reached: u64,
275    redraw_requested: u64,
276    rendered_frames: u64,
277    request_redraw_all: u64,
278    cursor_redraw_requests: u64,
279    mouse_input_redraw_requests: u64,
280    mouse_wheel_redraw_requests: u64,
281    keyboard_redraw_requests: u64,
282    resize_redraw_requests: u64,
283    /// Post-render redraw requests caused by `tree.frame_requested()`
284    /// (a widget asked for another frame from a `frame_tick` effect or
285    /// similar). Surfaces the only redraw source that was previously
286    /// invisible to the trace.
287    frame_request_redraws: u64,
288    /// Windows poked by `WindowManager::request_redraw_needing_render`
289    /// (a sibling window dirtied by another window's `Signal` mutation),
290    /// distinct from `request_redraw_all` — surfaces how often the
291    /// targeted cross-window path actually fires versus the blanket one.
292    cross_window_redraws: u64,
293    idle_callbacks_run: u64,
294    control_flow_wait: u64,
295    control_flow_wait_until: u64,
296    timer_windows: usize,
297    animation_timers: usize,
298    tooltip_timers: usize,
299}
300
301impl IdleTrace {
302    fn from_env() -> Option<Self> {
303        match std::env::var("TEKSILO_IDLE_TRACE") {
304            Ok(value) if value != "0" && !value.is_empty() => Some(Self {
305                last_report: Instant::now(),
306                resume_time_reached: 0,
307                redraw_requested: 0,
308                rendered_frames: 0,
309                request_redraw_all: 0,
310                cursor_redraw_requests: 0,
311                mouse_input_redraw_requests: 0,
312                mouse_wheel_redraw_requests: 0,
313                keyboard_redraw_requests: 0,
314                resize_redraw_requests: 0,
315                frame_request_redraws: 0,
316                cross_window_redraws: 0,
317                idle_callbacks_run: 0,
318                control_flow_wait: 0,
319                control_flow_wait_until: 0,
320                timer_windows: 0,
321                animation_timers: 0,
322                tooltip_timers: 0,
323            }),
324            _ => None,
325        }
326    }
327
328    fn note_control_flow(
329        &mut self,
330        has_deadline: bool,
331        timer_windows: usize,
332        animation_timers: usize,
333        tooltip_timers: usize,
334    ) {
335        if has_deadline {
336            self.control_flow_wait_until += 1;
337        } else {
338            self.control_flow_wait += 1;
339        }
340        self.timer_windows = timer_windows;
341        self.animation_timers = animation_timers;
342        self.tooltip_timers = tooltip_timers;
343        self.maybe_report();
344    }
345
346    fn note_request_redraw_all(&mut self) {
347        self.request_redraw_all += 1;
348        self.maybe_report();
349    }
350
351    fn note_redraw_request(&mut self, reason: &'static str) {
352        match reason {
353            "cursor" => self.cursor_redraw_requests += 1,
354            "mouse_input" => self.mouse_input_redraw_requests += 1,
355            "mouse_wheel" => self.mouse_wheel_redraw_requests += 1,
356            "keyboard" => self.keyboard_redraw_requests += 1,
357            "resize" => self.resize_redraw_requests += 1,
358            _ => {}
359        }
360        self.maybe_report();
361    }
362
363    fn note_cross_window_redraw(&mut self, windows: usize) {
364        self.cross_window_redraws += windows as u64;
365        self.maybe_report();
366    }
367
368    fn note_resume_time_reached(&mut self) {
369        self.resume_time_reached += 1;
370        self.maybe_report();
371    }
372
373    fn note_redraw_requested(&mut self) {
374        self.redraw_requested += 1;
375        self.maybe_report();
376    }
377
378    fn note_rendered_frame(&mut self) {
379        self.rendered_frames += 1;
380        self.maybe_report();
381    }
382
383    fn note_idle_callbacks_run(&mut self) {
384        self.idle_callbacks_run += 1;
385        self.maybe_report();
386    }
387
388    fn maybe_report(&mut self) {
389        if self.last_report.elapsed() < Duration::from_secs(1) {
390            return;
391        }
392
393        eprintln!(
394            "teksilo_idle_trace redraw_requested={} rendered_frames={} resume_time_reached={} request_redraw_all={} cross_window_redraws={} input_redraws={{cursor:{},mouse_input:{},mouse_wheel:{},keyboard:{},resize:{},frame_request:{}}} idle_callbacks={} control_flow={{wait:{},wait_until:{}}} timers={{windows:{},animations:{},tooltips:{}}}",
395            self.redraw_requested,
396            self.rendered_frames,
397            self.resume_time_reached,
398            self.request_redraw_all,
399            self.cross_window_redraws,
400            self.cursor_redraw_requests,
401            self.mouse_input_redraw_requests,
402            self.mouse_wheel_redraw_requests,
403            self.keyboard_redraw_requests,
404            self.resize_redraw_requests,
405            self.frame_request_redraws,
406            self.idle_callbacks_run,
407            self.control_flow_wait,
408            self.control_flow_wait_until,
409            self.timer_windows,
410            self.animation_timers,
411            self.tooltip_timers,
412        );
413
414        self.last_report = Instant::now();
415        self.resume_time_reached = 0;
416        self.redraw_requested = 0;
417        self.rendered_frames = 0;
418        self.request_redraw_all = 0;
419        self.cross_window_redraws = 0;
420        self.cursor_redraw_requests = 0;
421        self.mouse_input_redraw_requests = 0;
422        self.mouse_wheel_redraw_requests = 0;
423        self.keyboard_redraw_requests = 0;
424        self.resize_redraw_requests = 0;
425        self.frame_request_redraws = 0;
426        self.idle_callbacks_run = 0;
427        self.control_flow_wait = 0;
428        self.control_flow_wait_until = 0;
429    }
430}
431
432/// An app-supplied router for [`AppEvent::External`] payloads that need to
433/// perform **window operations** — open a window, focus one, look one up by its
434/// string id.
435///
436/// Registered with [`TeksiloAppBuilder::on_external_with_ctx`]. Returns `true`
437/// to say "this payload was mine"; `false` leaves it unclaimed.
438///
439/// The plain [`on_app_event`](TeksiloAppBuilder::on_app_event) hook receives only
440/// `&AppEvent` — no tree, no [`WindowOps`](teksilo_core::WindowOps) — so a handler
441/// there cannot call `open_window` at all: `EventContext::open_window` panics on a
442/// standalone context. This one runs against a real window's tree with a real ops
443/// sink, which is what makes the multi-window recipes in `docs/multi-window.md`
444/// reachable from a background thread (a single-instance app's IPC listener being
445/// the motivating case: a second launch forwards its command line and the running
446/// process opens the document window).
447pub type ExternalCtxHandler =
448    Box<dyn FnMut(&(dyn std::any::Any + Send), &mut teksilo_core::widget::EventContext) -> bool>;
449
450struct TeksiloAppHandler {
451    wm: WindowManager,
452    app_event_handler: Option<Box<dyn FnMut(&AppEvent)>>,
453    /// App-supplied `AppEvent::External` router with window ops — see
454    /// [`ExternalCtxHandler`]. Consulted only for payloads no framework router
455    /// and no built-in downcast arm claimed.
456    external_ctx_handler: Option<ExternalCtxHandler>,
457    initial_window: Option<WindowConfig>,
458    initial_created: bool,
459    idle_budget: Duration,
460    idle_trace: Option<IdleTrace>,
461    #[cfg(feature = "text")]
462    typesetter: SharedTypesetter,
463    /// Kept alive for the lifetime of the event loop so that the
464    /// `notify::RecommendedWatcher` background thread keeps running.
465    /// Created in `TeksiloAppBuilder::run` when the `I18nConfig` registers
466    /// any `runtime_override`s; otherwise `None`.
467    _i18n_watcher: Option<teksilo_i18n::FtlFileWatcher>,
468    /// Kept alive for the lifetime of the event loop so that the
469    /// settings directory watcher's background thread keeps running.
470    /// Created in `TeksiloAppBuilder::run` when a settings bundle was
471    /// opened and live-reload was not disabled; otherwise `None`.
472    _settings_watcher: Option<teksilo_settings::SettingsWatcher>,
473    /// Optional per-loop-turn closure (e.g. an async executor poll) installed
474    /// via [`TeksiloAppBuilder::on_loop_tick`]. Runs at the top of
475    /// `about_to_wait`; returning `true` means tasks advanced and a repaint is
476    /// needed. Async-agnostic — the loop only ever sees `FnMut`.
477    loop_tick: Option<Box<dyn FnMut() -> bool>>,
478    /// Shared flag a `loop_tick` owner sets while it wants continuous polling.
479    /// Read in `update_control_flow` to force `ControlFlow::Poll`; when clear,
480    /// the loop sleeps until the next event (off-thread wakes via the proxy).
481    loop_tick_poll: Option<std::rc::Rc<std::cell::Cell<bool>>>,
482}
483
484impl TeksiloAppHandler {
485    fn new(
486        theme: Theme,
487        theme_mode: ThemeMode,
488        app_event_handler: Option<Box<dyn FnMut(&AppEvent)>>,
489        initial_window: WindowConfig,
490        app_context_template: Option<std::rc::Rc<TreeAppContext>>,
491        #[cfg(feature = "text")] typesetter: SharedTypesetter,
492        i18n_watcher: Option<teksilo_i18n::FtlFileWatcher>,
493        settings_watcher: Option<teksilo_settings::SettingsWatcher>,
494        event_proxy: AppEventProxy,
495    ) -> Self {
496        let mut wm = WindowManager::new(theme);
497        wm.set_theme_mode(theme_mode);
498        wm.set_event_proxy(event_proxy);
499        if let Some(template) = app_context_template {
500            // Seed the persisted user text-scale factor (if settings are
501            // installed) so every initially-created window opens at the saved
502            // scale. No per-app boilerplate: apps without settings stay at 1.0.
503            if let Some(store) = template.app_state::<teksilo_settings::SettingsStore>() {
504                let scale = store.signal_for(&teksilo_settings::TEXT_SCALE_KEY).get();
505                wm.set_initial_text_scale(scale);
506            }
507            wm.set_app_context_template(template);
508        }
509
510        #[cfg(feature = "text")]
511        {
512            wm.set_typesetter(typesetter.clone());
513        }
514
515        Self {
516            wm,
517            app_event_handler,
518            external_ctx_handler: None,
519            initial_window: Some(initial_window),
520            initial_created: false,
521            idle_budget: Duration::from_millis(4),
522            idle_trace: IdleTrace::from_env(),
523            #[cfg(feature = "text")]
524            typesetter,
525            _i18n_watcher: i18n_watcher,
526            _settings_watcher: settings_watcher,
527            loop_tick: None,
528            loop_tick_poll: None,
529        }
530    }
531
532    fn process_pending(&mut self, event_loop: &ActiveEventLoop) {
533        self.wm.process_pending(event_loop);
534    }
535
536    fn process_modal_requests(&mut self, event_loop: &ActiveEventLoop) -> bool {
537        let native_supported = teksilo_platform::supports_native_modal_windows();
538        let requests = self.wm.drain_pending_modal_requests();
539        let had_requests = !requests.is_empty();
540
541        for (source_window, requests) in requests {
542            for queued in requests {
543                let resolved = resolve_modal_presentation(
544                    queued.request.presentation,
545                    &queued.request.content,
546                    native_supported,
547                );
548
549                match resolved {
550                    ResolvedModalPresentation::InTree => {
551                        if let Some(managed) = self.wm.get_by_teksilo_mut(source_window) {
552                            present_in_tree_modal_request(
553                                &mut managed.tree,
554                                queued.source_widget,
555                                queued.request,
556                            );
557                        }
558                    }
559                    ResolvedModalPresentation::NativeWindow => {
560                        let ModalRequest {
561                            content,
562                            title,
563                            size,
564                            focus_target,
565                            ..
566                        } = queued.request;
567
568                        let ModalContent::Deferred(builder) = content else {
569                            continue;
570                        };
571
572                        let mut config =
573                            WindowConfig::new().modal(crate::window_config::ModalConfig {
574                                parent: source_window,
575                                focus_target,
576                            });
577                        if let Some(title) = title {
578                            config = config.title(title);
579                        }
580                        if let Some((width, height)) = size {
581                            // Native modals size their height to content: the
582                            // requested (width, height) is the floor and the OS
583                            // window grows to fit taller content (e.g. a
584                            // MessageBox "Show details" expander). Without this
585                            // the fixed height clips content that exceeds it —
586                            // the footer buttons fall below the client edge and
587                            // stop receiving clicks. NOTE: deliberately NOT
588                            // `resizable(false)` — winit encodes that as
589                            // min==max size hints on X11, which would clamp away
590                            // the programmatic growth this relies on.
591                            config = config
592                                .size(width, height)
593                                .min_size(width, height)
594                                .size_to_content(SizeToContent::Height);
595                        }
596                        self.wm.create_window(
597                            config.root(move |tree, _state| builder(tree)),
598                            event_loop,
599                        );
600                    }
601                }
602            }
603        }
604
605        had_requests
606    }
607
608    fn process_modal_dismissals(&mut self) -> bool {
609        let windows_to_close = self.wm.drain_pending_modal_dismissals();
610        let had_dismissals = !windows_to_close.is_empty();
611
612        for window_id in windows_to_close {
613            self.wm.queue_close(window_id);
614        }
615
616        had_dismissals
617    }
618
619    fn maybe_exit(&self, event_loop: &ActiveEventLoop) {
620        if self.wm.is_empty() {
621            event_loop.exit();
622        }
623    }
624
625    fn update_control_flow(&mut self, event_loop: &ActiveEventLoop) {
626        // Tick time-driven gesture recognizers (long-press) on every tree
627        // before computing the next deadline. Without this, a long-press
628        // that expired between frames would never fire until the next
629        // unrelated pointer event. Handlers that run may emit commands
630        // and mark nodes dirty — request a redraw on those windows.
631        let now = Instant::now();
632        // Collect winit ids up front so we can safely iterate without
633        // holding a borrow on `self.wm.windows` across the
634        // `tick_gestures_in_window` calls (each of which briefly
635        // takes a window out of the map).
636        let winit_ids: Vec<_> = self.wm.windows_map().keys().copied().collect();
637        for winit_id in winit_ids {
638            let before = self
639                .wm
640                .get_by_winit_mut(winit_id)
641                .map(|m| m.tree.has_idle_work())
642                .unwrap_or(false);
643            self.tick_gestures_in_window(winit_id, now, event_loop);
644            if let Some(managed) = self.wm.get_by_winit_mut(winit_id)
645                && managed.tree.has_idle_work() != before
646            {
647                managed.platform_window.request_redraw();
648            }
649        }
650
651        let mut earliest_deadline: Option<Instant> = None;
652        let mut timer_windows = 0_usize;
653        let mut animation_timers = 0_usize;
654        let mut tooltip_timers = 0_usize;
655        for managed in self.wm.iter() {
656            let animation_count = managed.tree.active_animation_count();
657            let tooltip_count = managed.tree.pending_tooltip_count();
658            if animation_count > 0 || tooltip_count > 0 {
659                timer_windows += 1;
660            }
661            animation_timers += animation_count;
662            tooltip_timers += tooltip_count;
663            // `next_timer_deadline` now folds in the per-frame-effect
664            // path's fixed 60 Hz deadline (Pulse / Cycle / caret blink /
665            // drag auto-scroll) alongside the tween + shader schedulers,
666            // so continuous animations pace through `WaitUntil` below
667            // instead of forcing `ControlFlow::Poll` (which free-ran at
668            // the display's refresh rate — 300 fps on a 300 Hz panel).
669            if let Some(deadline) = managed.tree.next_timer_deadline() {
670                earliest_deadline = Some(match earliest_deadline {
671                    Some(current) => current.min(deadline),
672                    None => deadline,
673                });
674            }
675        }
676
677        // The ONLY remaining consumer that forces true `ControlFlow::Poll`:
678        // an installed loop-tick owner (e.g. the `teksilo-async` executor)
679        // with runnable work. Async task processing wants to run as fast as
680        // possible and is not an animation, so it is deliberately *not*
681        // 60 Hz-capped. Every per-frame *animation* effect now paces through
682        // the `WaitUntil` deadline instead.
683        let force_poll = self.loop_tick_poll.as_ref().is_some_and(|poll| poll.get());
684
685        if force_poll {
686            event_loop.set_control_flow(ControlFlow::Poll);
687        } else if let Some(deadline) = earliest_deadline {
688            event_loop.set_control_flow(ControlFlow::WaitUntil(deadline));
689        } else {
690            event_loop.set_control_flow(ControlFlow::Wait);
691        }
692
693        if let Some(trace) = &mut self.idle_trace {
694            trace.note_control_flow(
695                earliest_deadline.is_some(),
696                timer_windows,
697                animation_timers,
698                tooltip_timers,
699            );
700        }
701    }
702
703    fn post_event(&mut self, event_loop: &ActiveEventLoop) {
704        // App-wide environment changes (theme / locale) raised by a handler
705        // in one window fan out to every window's tree, marking the
706        // non-originating windows dirty. Those windows never received the
707        // triggering event, so they would otherwise stay un-repainted —
708        // `request_redraw_all()` below (gated on these flags) fixes that.
709        let had_locale = self.wm.drain_pending_locale_requests();
710        let had_theme = self.wm.drain_pending_theme_requests();
711        let had_follow_system = self.wm.drain_pending_follow_system_requests();
712        let had_text_scale = self.wm.drain_pending_text_scale_requests();
713        let had_commands = self.wm.drain_close_window_requests();
714        let had_modal_requests = self.process_modal_requests(event_loop);
715        let had_modal_dismissals = self.process_modal_dismissals();
716        self.process_pending(event_loop);
717        // Drain post-mount actions (e.g. a WebView opening its native engine
718        // subview, which needs the OS parent handle only reachable here).
719        self.process_pending_mount_actions(event_loop);
720        // Drain per-window command queues: app-side writes to
721        // WindowState signals emitted WindowCommand values that the
722        // registry routes through the per-window queue. Translate each
723        // into the appropriate winit call.
724        self.wm.drain_window_commands();
725        if had_locale
726            || had_theme
727            || had_follow_system
728            || had_text_scale
729            || had_commands
730            || had_modal_requests
731            || had_modal_dismissals
732        {
733            if let Some(trace) = &mut self.idle_trace {
734                trace.note_request_redraw_all();
735            }
736            self.wm.request_redraw_all();
737        }
738        // Targeted counterpart to the blanket call above: a handler may have
739        // mutated an app-level `Signal` that sibling windows also read,
740        // dirtying their trees without those windows ever seeing the
741        // triggering event. See `WindowManager::request_redraw_needing_render`
742        // for why this is filtered rather than another `request_redraw_all()`.
743        let cross_window_redraws = self.wm.request_redraw_needing_render();
744        if cross_window_redraws > 0
745            && let Some(trace) = &mut self.idle_trace
746        {
747            trace.note_cross_window_redraw(cross_window_redraws);
748        }
749        self.maybe_exit(event_loop);
750        self.update_control_flow(event_loop);
751    }
752
753    /// Dispatch a widget event into the named window's `WidgetTree`
754    /// with a real [`teksilo_core::WindowOps`] sink so handlers can
755    /// synchronously `open_window`, `focus_window`, etc.
756    ///
757    /// Re-entry pattern: the current `ManagedWindow` is temporarily
758    /// removed from `WindowManager::windows` before dispatch and put
759    /// back afterwards. The removed tree is borrowed mutably for the
760    /// handler run; the `WindowOpsImpl` holds `&mut WindowManager`
761    /// (with the tree out of the way) plus `&ActiveEventLoop`. Opening
762    /// a new window from a handler therefore goes straight into
763    /// `wm.create_window` without borrow-checker conflicts.
764    fn dispatch_in_window(
765        &mut self,
766        window_id: WindowId,
767        event: WidgetEvent,
768        event_loop: &ActiveEventLoop,
769    ) {
770        let Some(mut current) = self.wm.take_managed(window_id) else {
771            return;
772        };
773        let current_id = current.teksilo_id;
774
775        #[cfg(not(target_os = "macos"))]
776        let current_handle = current
777            .platform_window
778            .window()
779            .window_handle()
780            .ok()
781            .map(|h| h.as_raw());
782        let current_arc = Some(current.platform_window.window_arc());
783
784        {
785            let mut ops = crate::window_manager::WindowOpsImpl::new(
786                &mut self.wm,
787                event_loop,
788                current_id,
789                #[cfg(not(target_os = "macos"))]
790                current_handle,
791                current_arc,
792            );
793            current.tree.dispatch_event_with_ops(event, &mut ops);
794        }
795
796        Self::reconcile_ime(&mut current);
797        self.wm.reinsert_managed(window_id, current);
798    }
799
800    /// Apply a [`MenubarAction`](teksilo_core::window::MenubarAction)
801    /// decision from a window-level menubar dispatcher. Takes the
802    /// managed window aside the same way
803    /// [`Self::dispatch_in_window`] does so the action runs with
804    /// `WindowOps` wired up (focus changes need to repaint, etc.).
805    ///
806    /// - `OpenMenu`: focus the trigger and synthesise a primary click
807    ///   on it. The MenuBarTrigger's `on_tap` handler then runs the
808    ///   normal `MenuContext::open_at` path.
809    /// - `FocusTrigger`: focus the trigger and stop. Matches Win32
810    ///   F10 behaviour (menubar mode, no menu).
811    /// - `Intercept`: do nothing — the key was swallowed.
812    fn apply_menubar_action(
813        &mut self,
814        window_id: WindowId,
815        action: teksilo_core::window::MenubarAction,
816        event_loop: &ActiveEventLoop,
817    ) {
818        use teksilo_core::window::MenubarAction;
819        let Some(mut current) = self.wm.take_managed(window_id) else {
820            return;
821        };
822        let current_id = current.teksilo_id;
823
824        #[cfg(not(target_os = "macos"))]
825        let current_handle = current
826            .platform_window
827            .window()
828            .window_handle()
829            .ok()
830            .map(|h| h.as_raw());
831        let current_arc = Some(current.platform_window.window_arc());
832
833        // For a collapsed (hamburger) MenuBar, the action carries a
834        // `reveal` closure. We must run it (it shows the bar as a
835        // floating overlay) and then re-layout synchronously, so the
836        // trigger has valid bounds before we focus / synthesise the
837        // click on it. Compute the same layout proposal the redraw
838        // path uses.
839        let proposal = {
840            let size = current.platform_window.surface_size();
841            let sf = current.platform_window.scale_factor() as f32;
842            SizeProposal::exact(size.0 as f32 / sf, size.1 as f32 / sf)
843        };
844
845        {
846            let mut ops = crate::window_manager::WindowOpsImpl::new(
847                &mut self.wm,
848                event_loop,
849                current_id,
850                #[cfg(not(target_os = "macos"))]
851                current_handle,
852                current_arc,
853            );
854            match action {
855                MenubarAction::Intercept => {}
856                MenubarAction::FocusTrigger { trigger_id, reveal } => {
857                    if let Some(reveal) = reveal {
858                        current
859                            .tree
860                            .run_with_event_context(&mut ops, |ctx| reveal(ctx));
861                        current.tree.layout_with_ops(proposal, &mut ops);
862                    }
863                    current.tree.focus_ops(trigger_id, &mut ops);
864                }
865                MenubarAction::OpenMenu { trigger_id, reveal } => {
866                    if let Some(reveal) = reveal {
867                        current
868                            .tree
869                            .run_with_event_context(&mut ops, |ctx| reveal(ctx));
870                        current.tree.layout_with_ops(proposal, &mut ops);
871                    }
872                    current.tree.focus_ops(trigger_id, &mut ops);
873                    let pointer = current.tree.bounds(trigger_id).center();
874                    current.tree.dispatch_event_with_ops(
875                        WidgetEvent::PointerDown {
876                            position: pointer,
877                            button: teksilo_core::event::PointerButton::Primary,
878                            modifiers: teksilo_core::event::Modifiers::NONE,
879                        },
880                        &mut ops,
881                    );
882                    current.tree.dispatch_event_with_ops(
883                        WidgetEvent::PointerUp {
884                            position: pointer,
885                            button: teksilo_core::event::PointerButton::Primary,
886                            modifiers: teksilo_core::event::Modifiers::NONE,
887                        },
888                        &mut ops,
889                    );
890                }
891            }
892        }
893
894        Self::reconcile_ime(&mut current);
895        self.wm.reinsert_managed(window_id, current);
896    }
897
898    /// Bring the winit window's OS-IME state in line with the focused
899    /// widget's descriptor. Enablement + purpose are declarative: a focused
900    /// text widget carries `Some(ImeContext { purpose })`, everything else
901    /// `None`. Applied only on change vs. the per-window cache — repeated
902    /// `set_ime_allowed(true)` can cancel an active composition. The caret
903    /// area is reported separately (and idempotently) by the focused widget
904    /// via `WindowOps::set_ime_cursor_area`.
905    fn reconcile_ime(managed: &mut crate::window_manager::ManagedWindow) {
906        match managed.tree.ime_context_for_focused() {
907            Some(ctx) => {
908                if managed.ime_purpose != Some(ctx.purpose) {
909                    managed
910                        .platform_window
911                        .window()
912                        .set_ime_purpose(Self::map_ime_purpose(ctx.purpose));
913                    managed.ime_purpose = Some(ctx.purpose);
914                }
915                if managed.ime_allowed != Some(true) {
916                    managed.platform_window.window().set_ime_allowed(true);
917                    managed.ime_allowed = Some(true);
918                }
919            }
920            None => {
921                if managed.ime_allowed != Some(false) {
922                    managed.platform_window.window().set_ime_allowed(false);
923                    managed.ime_allowed = Some(false);
924                    // Force the purpose to re-apply when IME is next enabled.
925                    managed.ime_purpose = None;
926                }
927            }
928        }
929    }
930
931    /// Map the core `ImePurpose` onto winit's enum at the platform boundary.
932    fn map_ime_purpose(purpose: teksilo_core::ImePurpose) -> winit::window::ImePurpose {
933        match purpose {
934            teksilo_core::ImePurpose::Normal => winit::window::ImePurpose::Normal,
935            teksilo_core::ImePurpose::Password => winit::window::ImePurpose::Password,
936            teksilo_core::ImePurpose::Terminal => winit::window::ImePurpose::Terminal,
937        }
938    }
939
940    /// Run `f` against window `winit_id`'s tree with a real
941    /// [`WindowOps`](teksilo_core::WindowOps) sink (so `open_window`,
942    /// `parent_window_handle`, etc. work). Encapsulates the take-out /
943    /// build-`WindowOpsImpl` / reinsert dance that the `AppEvent::External`
944    /// routers and the mount-action drain all share — keeping the reinsert
945    /// (whose omission silently freezes a window) in exactly one place.
946    /// No-op if `winit_id` is not a managed window.
947    /// Route a debug-bridge [`AutomationPayload`](crate::automation_bridge::AutomationPayload):
948    /// resolve the target window, then run the op against the live tree
949    /// (and, for screenshots, the live `PlatformWindow`). `list_windows` and
950    /// `screenshot` are served here (they need the window manager / platform
951    /// window); everything else goes through [`teksilo_automation::execute`]
952    /// with a real `WindowOps`. The settle runs synchronously on this (the
953    /// main) thread, never across a frame boundary.
954    #[cfg(all(feature = "automation", debug_assertions))]
955    fn try_route_automation_payload(
956        &mut self,
957        payload: Box<dyn std::any::Any + Send>,
958        event_loop: &ActiveEventLoop,
959    ) -> Result<(), Box<dyn std::any::Any + Send>> {
960        use teksilo_automation::dto::{AutomationOp, AutomationReply, WindowInfo, codes};
961
962        let payload = *payload.downcast::<crate::automation_bridge::AutomationPayload>()?;
963
964        // Resolve target window: explicit id, else focused, else primary.
965        let bid = match payload.window_id {
966            Some(raw) => crate::window_config::TeksiloWindowId::new(raw),
967            None => self
968                .wm
969                .iter()
970                .find(|m| m.focused)
971                .map(|m| m.teksilo_id)
972                .unwrap_or_else(|| self.wm.primary_window_id()),
973        };
974        let Some(winit_id) = self.wm.winit_id_for_teksilo(bid) else {
975            let _ = payload
976                .reply_tx
977                .send(AutomationReply::err(codes::NOT_FOUND, "no such window"));
978            return Ok(());
979        };
980
981        // `list_windows` is served straight from the window manager.
982        if matches!(payload.op, AutomationOp::ListWindows) {
983            let windows: Vec<WindowInfo> = self
984                .wm
985                .iter()
986                .map(|m| WindowInfo {
987                    id: m.teksilo_id.raw(),
988                    label: m.string_id.clone(),
989                    title: Some(m.state.title().get()),
990                    focused: m.focused,
991                })
992                .collect();
993            let _ = payload.reply_tx.send(AutomationReply::ok_json(&windows));
994            return Ok(());
995        }
996
997        // Screenshots reach the `ManagedWindow` (tree + platform window).
998        if matches!(payload.op, AutomationOp::Screenshot { .. }) {
999            self.automation_screenshot(winit_id, event_loop, &payload);
1000            return Ok(());
1001        }
1002
1003        // Everything else: a per-tree op with a real `WindowOps`.
1004        let crate::automation_bridge::AutomationPayload {
1005            op,
1006            settle,
1007            reply_tx,
1008            ..
1009        } = payload;
1010        // Clamp the settle: this runs on the winit main thread, so an
1011        // unbounded wait/settle would freeze the live UI (see Risk 1).
1012        let settle = crate::automation_bridge::clamp_live_settle(&settle);
1013        self.run_in_window(winit_id, event_loop, move |tree, ops| {
1014            let reply = teksilo_automation::execute(tree, ops, &op, &settle);
1015            let _ = reply_tx.send(reply);
1016        });
1017        if let Some(m) = self.wm.windows_map().get(&winit_id) {
1018            m.platform_window.request_redraw();
1019        }
1020        Ok(())
1021    }
1022
1023    /// The screenshot arm of the automation bridge: take the window out of
1024    /// the manager (so we can borrow both its tree and its platform window),
1025    /// settle, render, capture offscreen, reinsert, then reply with a
1026    /// base64-PNG.
1027    #[cfg(all(feature = "automation", debug_assertions))]
1028    fn automation_screenshot(
1029        &mut self,
1030        winit_id: winit::window::WindowId,
1031        event_loop: &ActiveEventLoop,
1032        payload: &crate::automation_bridge::AutomationPayload,
1033    ) {
1034        use teksilo_automation::dto::{AutomationOp, AutomationReply, codes};
1035
1036        let node = match &payload.op {
1037            AutomationOp::Screenshot { node } => *node,
1038            _ => None,
1039        };
1040
1041        let Some(mut current) = self.wm.take_managed(winit_id) else {
1042            let _ = payload
1043                .reply_tx
1044                .send(AutomationReply::err(codes::NOT_FOUND, "window vanished"));
1045            return;
1046        };
1047        let current_id = current.teksilo_id;
1048        #[cfg(not(target_os = "macos"))]
1049        let current_handle = current
1050            .platform_window
1051            .window()
1052            .window_handle()
1053            .ok()
1054            .map(|h| h.as_raw());
1055        let current_arc = Some(current.platform_window.window_arc());
1056
1057        // Settle synchronously on the main thread with a real `WindowOps`.
1058        {
1059            let mut ops = crate::window_manager::WindowOpsImpl::new(
1060                &mut self.wm,
1061                event_loop,
1062                current_id,
1063                #[cfg(not(target_os = "macos"))]
1064                current_handle,
1065                current_arc,
1066            );
1067            let settle = crate::automation_bridge::clamp_live_settle(&payload.settle);
1068            let _ = teksilo_automation::run_settle(&mut current.tree, &mut ops, &settle);
1069        }
1070
1071        // Optional crop rect in physical pixels (logical bounds × scale).
1072        let scale = current.tree.device_scale_factor();
1073        let crop = node.and_then(|n| {
1074            let nid = teksilo_core::accesskit::NodeId(n);
1075            let wid = teksilo_core::accessibility::node_id_to_widget_id_maybe(nid)
1076                .or_else(|| current.tree.widget_for_synthetic(nid))?;
1077            let b = current.tree.bounds(wid);
1078            Some(teksilo_canvas::Rect {
1079                x: b.x * scale,
1080                y: b.y * scale,
1081                width: b.width * scale,
1082                height: b.height * scale,
1083            })
1084        });
1085
1086        // WebView blind-spot warning.
1087        let warnings = {
1088            let update = current.tree.sync_accessibility();
1089            if update
1090                .nodes
1091                .iter()
1092                .any(|(_, nd)| nd.role() == teksilo_core::accesskit::Role::WebView)
1093            {
1094                vec!["webview_hole_possible".to_string()]
1095            } else {
1096                Vec::new()
1097            }
1098        };
1099
1100        let clear = teksilo_render::vertex::srgb_to_linear_rgba(
1101            current.tree.theme().colors.surface_main.to_array(),
1102        );
1103        let frame = current.tree.render();
1104        // The GPU readback inside `capture_offscreen` can `.expect()`-panic on
1105        // device loss (compositor restart, driver crash, memory pressure).
1106        // Catch it so the window is still reinserted (no zombie) and the app
1107        // survives — a screenshot failure must not abort a live session.
1108        let captured = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1109            current
1110                .platform_window
1111                .capture_offscreen(&frame, clear, crop)
1112        }));
1113
1114        current.platform_window.request_redraw();
1115        self.wm.reinsert_managed(winit_id, current);
1116
1117        let reply = match captured {
1118            Ok((rgba, w, h)) if w != 0 && h != 0 => {
1119                crate::automation_bridge::screenshot_reply(&rgba, w, h, warnings)
1120            }
1121            Ok(_) => {
1122                AutomationReply::err(codes::BAD_ARGUMENT, "crop region empty / outside window")
1123            }
1124            Err(_) => AutomationReply::err(
1125                "GPU_READBACK_FAILED",
1126                "offscreen capture failed (GPU device lost?)",
1127            ),
1128        };
1129        let _ = payload.reply_tx.send(reply);
1130    }
1131
1132    fn run_in_window(
1133        &mut self,
1134        winit_id: winit::window::WindowId,
1135        event_loop: &ActiveEventLoop,
1136        f: impl FnOnce(&mut WidgetTree, &mut crate::window_manager::WindowOpsImpl),
1137    ) {
1138        let Some(mut current) = self.wm.take_managed(winit_id) else {
1139            return;
1140        };
1141        let current_id = current.teksilo_id;
1142
1143        #[cfg(not(target_os = "macos"))]
1144        let current_handle = current
1145            .platform_window
1146            .window()
1147            .window_handle()
1148            .ok()
1149            .map(|h| h.as_raw());
1150        let current_arc = Some(current.platform_window.window_arc());
1151
1152        {
1153            let mut ops = crate::window_manager::WindowOpsImpl::new(
1154                &mut self.wm,
1155                event_loop,
1156                current_id,
1157                #[cfg(not(target_os = "macos"))]
1158                current_handle,
1159                current_arc,
1160            );
1161            f(&mut current.tree, &mut ops);
1162        }
1163
1164        self.wm.reinsert_managed(winit_id, current);
1165    }
1166
1167    /// Last stop for an `AppEvent::External` payload: hand it to the app's own
1168    /// [`ExternalCtxHandler`] (if one was registered) with a live
1169    /// [`EventContext`](teksilo_core::widget::EventContext), so it can open,
1170    /// find and focus windows.
1171    ///
1172    /// **Target window** = the focused one, else the primary — the same
1173    /// resolution [`try_route_automation_payload`](Self::try_route_automation_payload)
1174    /// uses. The handler is about *application*-level intent ("open this
1175    /// document"), so which window hosts the context is an implementation
1176    /// detail; it just has to be a real one, because `open_window` on a
1177    /// standalone context panics.
1178    ///
1179    /// The handler is `take`n for the duration of the call and put back
1180    /// afterwards: [`run_in_window`](Self::run_in_window) needs `&mut self`, and
1181    /// the handler lives on `self`. Re-entrancy (a handler whose body somehow
1182    /// pumps another external event) therefore sees `None` and is a no-op rather
1183    /// than a double borrow.
1184    ///
1185    /// No window open (the instant between the last close and loop exit) is a
1186    /// silent no-op — there is nowhere to mint a context from.
1187    fn route_external_with_ctx(
1188        &mut self,
1189        payload: &(dyn std::any::Any + Send),
1190        event_loop: &ActiveEventLoop,
1191    ) {
1192        let Some(mut handler) = self.external_ctx_handler.take() else {
1193            return;
1194        };
1195        let target = self
1196            .wm
1197            .iter()
1198            .find(|m| m.focused)
1199            .map(|m| m.teksilo_id)
1200            .unwrap_or_else(|| self.wm.primary_window_id());
1201        if let Some(winit_id) = self.wm.winit_id_for_teksilo(target) {
1202            let handler = &mut handler;
1203            self.run_in_window(winit_id, event_loop, move |tree, ops| {
1204                tree.run_with_event_context(ops, |ctx| {
1205                    handler(payload, ctx);
1206                });
1207            });
1208        }
1209        self.external_ctx_handler = Some(handler);
1210    }
1211
1212    /// Try to route an `AppEvent::External` payload as a
1213    /// [`FileDialogEventPayload`](teksilo_platform::file_dialog::FileDialogEventPayload).
1214    /// Returns `Ok(())` if the payload matched and was delivered to
1215    /// the originating window's tree, `Err(payload)` to hand the
1216    /// box back for fallthrough to other downcast attempts.
1217    ///
1218    /// Routing details:
1219    /// - Resolves `payload.window_id_owner` to the matching winit
1220    ///   `WindowId` via `WindowManager::teksilo_to_winit_map`.
1221    /// - Temporarily takes the window out of `WindowManager::windows`
1222    ///   (matches the `dispatch_in_window` re-entry pattern) so
1223    ///   `open_window` / other ops calls inside the result callback
1224    ///   can run.
1225    /// - Builds a `WidgetTree::run_with_event_context` closure that
1226    ///   pops the pending callback from `FileDialogHandle` and
1227    ///   invokes it.
1228    /// - On any miss (no matching window, no handle in app-state,
1229    ///   already-purged callback) the result is silently dropped —
1230    ///   no panic, no leaked callback.
1231    #[cfg_attr(not(feature = "file-dialog"), allow(unused_variables))]
1232    fn try_route_file_dialog_payload(
1233        &mut self,
1234        payload: Box<dyn std::any::Any + Send>,
1235        event_loop: &ActiveEventLoop,
1236    ) -> Result<(), Box<dyn std::any::Any + Send>> {
1237        #[cfg(feature = "file-dialog")]
1238        {
1239            use teksilo_platform::file_dialog::{FileDialogEventPayload, FileDialogHandle};
1240
1241            let payload = *payload.downcast::<FileDialogEventPayload>()?;
1242
1243            // Find the originating window.
1244            let target_winit = self
1245                .wm
1246                .teksilo_to_winit_map()
1247                .get(&payload.window_id_owner)
1248                .copied();
1249            let Some(winit_id) = target_winit else {
1250                // Window already torn down — drop silently.
1251                return Ok(());
1252            };
1253
1254            // Pull the FileDialogHandle out of the shared app context
1255            // template. Same Rc held by every window's tree, so this
1256            // does not fight take_managed below.
1257            let handle = self
1258                .wm
1259                .app_context_template()
1260                .and_then(|t| t.app_state::<FileDialogHandle>().cloned());
1261            let Some(handle) = handle else {
1262                // Application did not install a FileDialogHandle —
1263                // shouldn't happen if a payload was dispatched, but
1264                // drop silently rather than panic.
1265                return Ok(());
1266            };
1267
1268            let Some(mut current) = self.wm.take_managed(winit_id) else {
1269                return Ok(());
1270            };
1271            let current_id = current.teksilo_id;
1272
1273            #[cfg(not(target_os = "macos"))]
1274            let current_handle = current
1275                .platform_window
1276                .window()
1277                .window_handle()
1278                .ok()
1279                .map(|h| h.as_raw());
1280            let current_arc = Some(current.platform_window.window_arc());
1281
1282            {
1283                let mut ops = crate::window_manager::WindowOpsImpl::new(
1284                    &mut self.wm,
1285                    event_loop,
1286                    current_id,
1287                    #[cfg(not(target_os = "macos"))]
1288                    current_handle,
1289                    current_arc,
1290                );
1291                current
1292                    .tree
1293                    .run_with_event_context(&mut ops, |ctx| handle.deliver(payload, ctx));
1294            }
1295
1296            self.wm.reinsert_managed(winit_id, current);
1297            Ok(())
1298        }
1299        #[cfg(not(feature = "file-dialog"))]
1300        {
1301            Err(payload)
1302        }
1303    }
1304
1305    /// Drain queued post-mount actions for every window that has any, each
1306    /// with a real [`EventContext`](teksilo_core::widget::EventContext) (so `ctx.parent_window_handle()` resolves).
1307    /// Modal-blocked windows are skipped — their actions (e.g. a WebView
1308    /// opening its native engine subview) stay queued until the modal closes,
1309    /// so a native surface can't appear over a modal. Cheap when nothing is
1310    /// queued (the common case): one map scan, the returned Vec is empty and
1311    /// unallocated.
1312    fn process_pending_mount_actions(&mut self, event_loop: &ActiveEventLoop) {
1313        let winit_ids = self.wm.winit_ids_with_pending_mount_actions();
1314        for winit_id in winit_ids {
1315            self.run_in_window(winit_id, event_loop, |tree, ops| {
1316                tree.run_mount_actions(ops)
1317            });
1318        }
1319    }
1320
1321    /// Try to route an `AppEvent::External` payload as a
1322    /// [`WebViewEventPayload`](teksilo_webview::WebViewEventPayload) posted by a
1323    /// web-view engine backend, delivering it to the originating window's tree
1324    /// via [`WebViewRegistry::deliver`](teksilo_webview::WebViewRegistry::deliver).
1325    /// Returns `Ok(())` if matched and delivered, `Err(payload)` to hand the
1326    /// box back for fallthrough. Same take/run-with-context/reinsert dance as
1327    /// [`Self::try_route_file_dialog_payload`].
1328    #[cfg_attr(not(feature = "web-view"), allow(unused_variables))]
1329    fn try_route_web_view_payload(
1330        &mut self,
1331        payload: Box<dyn std::any::Any + Send>,
1332        event_loop: &ActiveEventLoop,
1333    ) -> Result<(), Box<dyn std::any::Any + Send>> {
1334        #[cfg(feature = "web-view")]
1335        {
1336            use teksilo_webview::{WebViewEventPayload, WebViewRegistry};
1337
1338            let payload = *payload.downcast::<WebViewEventPayload>()?;
1339
1340            let target_winit = self
1341                .wm
1342                .teksilo_to_winit_map()
1343                .get(&payload.window_id_owner)
1344                .copied();
1345            let Some(winit_id) = target_winit else {
1346                return Ok(());
1347            };
1348
1349            let registry = self
1350                .wm
1351                .app_context_template()
1352                .and_then(|t| t.app_state::<WebViewRegistry>().cloned());
1353            let Some(registry) = registry else {
1354                return Ok(());
1355            };
1356
1357            self.run_in_window(winit_id, event_loop, move |tree, ops| {
1358                tree.run_with_event_context(ops, |ctx| registry.deliver(payload, ctx));
1359            });
1360            Ok(())
1361        }
1362        #[cfg(not(feature = "web-view"))]
1363        {
1364            Err(payload)
1365        }
1366    }
1367
1368    /// Try to route an `AppEvent::External` payload as an
1369    /// [`AsyncCompletionPayload`](teksilo_core::AsyncCompletionPayload) posted
1370    /// by the `teksilo-async` executor when a `spawn_local_with` future
1371    /// resolves. Returns `Ok(())` if matched and delivered, `Err(payload)` to
1372    /// hand the box back for fallthrough.
1373    ///
1374    /// Uses only teksilo-core types ([`AsyncCompletionHandle`](teksilo_core::AsyncCompletionHandle)),
1375    /// so `teksilo-async` (which depends on `teksilo-app`) never has to be a
1376    /// dependency here — the same take/run-with-context/reinsert pattern as
1377    /// the file-dialog path. On any miss (window gone, runtime not installed,
1378    /// already-purged completion) the result is dropped silently.
1379    fn try_route_async_completion_payload(
1380        &mut self,
1381        payload: Box<dyn std::any::Any + Send>,
1382        event_loop: &ActiveEventLoop,
1383    ) -> Result<(), Box<dyn std::any::Any + Send>> {
1384        use teksilo_core::{AsyncCompletionHandle, AsyncCompletionPayload};
1385
1386        let payload = *payload.downcast::<AsyncCompletionPayload>()?;
1387
1388        let target_winit = self
1389            .wm
1390            .teksilo_to_winit_map()
1391            .get(&payload.window_id)
1392            .copied();
1393        let Some(winit_id) = target_winit else {
1394            // Window already torn down — drop silently.
1395            return Ok(());
1396        };
1397
1398        let handle = self
1399            .wm
1400            .app_context_template()
1401            .and_then(|t| t.app_state::<AsyncCompletionHandle>().cloned());
1402        let Some(handle) = handle else {
1403            // No async runtime installed — drop silently.
1404            return Ok(());
1405        };
1406
1407        let Some(mut current) = self.wm.take_managed(winit_id) else {
1408            return Ok(());
1409        };
1410        let current_id = current.teksilo_id;
1411
1412        #[cfg(not(target_os = "macos"))]
1413        let current_handle = current
1414            .platform_window
1415            .window()
1416            .window_handle()
1417            .ok()
1418            .map(|h| h.as_raw());
1419        let current_arc = Some(current.platform_window.window_arc());
1420
1421        {
1422            let mut ops = crate::window_manager::WindowOpsImpl::new(
1423                &mut self.wm,
1424                event_loop,
1425                current_id,
1426                #[cfg(not(target_os = "macos"))]
1427                current_handle,
1428                current_arc,
1429            );
1430            current.tree.run_with_event_context(&mut ops, |ctx| {
1431                handle.deliver(payload.id, payload.window_id, ctx)
1432            });
1433        }
1434
1435        self.wm.reinsert_managed(winit_id, current);
1436        Ok(())
1437    }
1438
1439    /// Deliver a backend `AppEvent::SubscriptionEvent` to a *context-bearing*
1440    /// subscription registered via
1441    /// [`BuildContext::subscribe_event_with_ctx`](teksilo_core::BuildContext::subscribe_event_with_ctx):
1442    /// mint a fresh [`EventContext`](teksilo_core::EventContext) from the
1443    /// subscriber's window tree and invoke the stored callback inside it.
1444    ///
1445    /// Returns `true` iff `sub_id` names a context-bearing subscription — the
1446    /// caller then skips the plain, context-free dispatch (a `sub_id` lives in
1447    /// exactly one callback map). A `true` return with the window torn down (or
1448    /// mid-teardown) drops the event, exactly like the async-completion path;
1449    /// it still returns `true` so the stale event never falls through to the
1450    /// plain map.
1451    ///
1452    /// Mirrors [`try_route_async_completion_payload`](Self::try_route_async_completion_payload)'s
1453    /// take / run-with-context / reinsert dance — the one supported way to run
1454    /// application code with a fresh `EventContext` from the event loop.
1455    fn try_dispatch_subscription_with_ctx(
1456        &mut self,
1457        sub_id: SubscriptionId,
1458        event: &dyn std::any::Any,
1459        event_loop: &ActiveEventLoop,
1460    ) -> bool {
1461        let Some(template) = self.wm.app_context_template().cloned() else {
1462            return false;
1463        };
1464        let Some(window_id) = template.ctx_subscription_window(sub_id) else {
1465            return false;
1466        };
1467        // From here `sub_id` IS a context-bearing subscription: consume it
1468        // (return `true`) even if the window is gone, so a late event never
1469        // falls back to the plain, context-free map.
1470        let Some(winit_id) = self.wm.teksilo_to_winit_map().get(&window_id).copied() else {
1471            return true;
1472        };
1473        let Some(mut current) = self.wm.take_managed(winit_id) else {
1474            return true;
1475        };
1476        let current_id = current.teksilo_id;
1477
1478        #[cfg(not(target_os = "macos"))]
1479        let current_handle = current
1480            .platform_window
1481            .window()
1482            .window_handle()
1483            .ok()
1484            .map(|h| h.as_raw());
1485        let current_arc = Some(current.platform_window.window_arc());
1486
1487        {
1488            let mut ops = crate::window_manager::WindowOpsImpl::new(
1489                &mut self.wm,
1490                event_loop,
1491                current_id,
1492                #[cfg(not(target_os = "macos"))]
1493                current_handle,
1494                current_arc,
1495            );
1496            current.tree.run_with_event_context(&mut ops, |ctx| {
1497                template.dispatch_subscription_event_with_ctx(sub_id, event, ctx);
1498            });
1499        }
1500
1501        self.wm.reinsert_managed(winit_id, current);
1502        true
1503    }
1504
1505    /// Try to route an `AppEvent::External` payload as a
1506    /// [`NativeMenuEventPayload`](teksilo_platform::native_menu::NativeMenuEventPayload)
1507    /// posted when the user chose an item in the platform's native menu bar.
1508    /// Resolves the item's [`MenuItemId`](teksilo_core::MenuItemId) to its
1509    /// recorded intent / action via the [`NativeMenuHandle`](teksilo_platform::native_menu::NativeMenuHandle)
1510    /// and fires it inside the originating window's `EventContext` with
1511    /// `IntentSource::Menu` — the same pipeline an in-window `MenuItem` uses.
1512    /// Same take/run-with-context/reinsert shape as the file-dialog router; any
1513    /// miss is dropped silently.
1514    fn try_route_native_menu_payload(
1515        &mut self,
1516        payload: Box<dyn std::any::Any + Send>,
1517        event_loop: &ActiveEventLoop,
1518    ) -> Result<(), Box<dyn std::any::Any + Send>> {
1519        use teksilo_core::Intent;
1520        use teksilo_core::telemetry::IntentSource;
1521        use teksilo_platform::native_menu::{NativeMenuEventPayload, NativeMenuHandle};
1522
1523        let payload = *payload.downcast::<NativeMenuEventPayload>()?;
1524
1525        let target_winit = self
1526            .wm
1527            .teksilo_to_winit_map()
1528            .get(&payload.window_id_owner)
1529            .copied();
1530        let Some(winit_id) = target_winit else {
1531            return Ok(());
1532        };
1533
1534        let handle = self
1535            .wm
1536            .app_context_template()
1537            .and_then(|t| t.app_state::<NativeMenuHandle>().cloned());
1538        let Some(handle) = handle else {
1539            return Ok(());
1540        };
1541        let Some(activation) = handle.activation(payload.window_id_owner, payload.item_id) else {
1542            // Item not found (menu replaced / window torn down) — drop.
1543            return Ok(());
1544        };
1545
1546        let Some(mut current) = self.wm.take_managed(winit_id) else {
1547            return Ok(());
1548        };
1549        let current_id = current.teksilo_id;
1550
1551        #[cfg(not(target_os = "macos"))]
1552        let current_handle = current
1553            .platform_window
1554            .window()
1555            .window_handle()
1556            .ok()
1557            .map(|h| h.as_raw());
1558        let current_arc = Some(current.platform_window.window_arc());
1559
1560        {
1561            let mut ops = crate::window_manager::WindowOpsImpl::new(
1562                &mut self.wm,
1563                event_loop,
1564                current_id,
1565                #[cfg(not(target_os = "macos"))]
1566                current_handle,
1567                current_arc,
1568            );
1569            current.tree.run_with_event_context(&mut ops, |ctx| {
1570                ctx.with_intent_source(IntentSource::Menu, |ctx| {
1571                    if let Some(name) = activation.intent {
1572                        ctx.send_intent(Intent::new(name));
1573                    }
1574                    if let Some(action) = &activation.action {
1575                        action(ctx);
1576                    }
1577                });
1578            });
1579        }
1580
1581        self.wm.reinsert_managed(winit_id, current);
1582        Ok(())
1583    }
1584
1585    /// Try to interpret an `AppEvent::External` payload as an
1586    /// [`ExternalDndEventPayload`](teksilo_platform::external_dnd::ExternalDndEventPayload)
1587    /// posted by a platform drag backend and route it to the originating
1588    /// window's tree, driving the matching `*_external_drag` method.
1589    ///
1590    /// Returns `Ok(())` if the payload was an external-drag event (consumed),
1591    /// or `Err(payload)` to hand it back for other downcast attempts. Mirrors
1592    /// [`Self::try_route_file_dialog_payload`]'s take/dispatch/reinsert dance.
1593    fn try_route_external_dnd_payload(
1594        &mut self,
1595        payload: Box<dyn std::any::Any + Send>,
1596        event_loop: &ActiveEventLoop,
1597    ) -> Result<(), Box<dyn std::any::Any + Send>> {
1598        use teksilo_platform::external_dnd::{
1599            ExternalDndEventPayload, ExternalDndHandle, ExternalDragEvent, OutboundOsDragRequest,
1600        };
1601
1602        // Deferred blocking outbound (app → OS) drag: run OLE DoDragDrop here,
1603        // outside the in-app dispatch that started it (Windows). No window is
1604        // taken out of the manager at this point, so the drag's modal message
1605        // loop can't strand a borrowed window.
1606        let payload = match payload.downcast::<OutboundOsDragRequest>() {
1607            Ok(req) => {
1608                if let Some(handle) = self
1609                    .wm
1610                    .app_context_template()
1611                    .and_then(|t| t.app_state::<ExternalDndHandle>().cloned())
1612                {
1613                    handle.run_pending_outbound_drag(req.window_id);
1614                }
1615                return Ok(());
1616            }
1617            Err(other) => other,
1618        };
1619
1620        let payload = *payload.downcast::<ExternalDndEventPayload>()?;
1621
1622        let Some(winit_id) = self
1623            .wm
1624            .teksilo_to_winit_map()
1625            .get(&payload.window_id_owner)
1626            .copied()
1627        else {
1628            // Window already torn down — drop silently.
1629            return Ok(());
1630        };
1631        let Some(mut current) = self.wm.take_managed(winit_id) else {
1632            return Ok(());
1633        };
1634        let current_id = current.teksilo_id;
1635
1636        #[cfg(not(target_os = "macos"))]
1637        let current_handle = current
1638            .platform_window
1639            .window()
1640            .window_handle()
1641            .ok()
1642            .map(|h| h.as_raw());
1643        let current_arc = Some(current.platform_window.window_arc());
1644
1645        {
1646            let mut ops = crate::window_manager::WindowOpsImpl::new(
1647                &mut self.wm,
1648                event_loop,
1649                current_id,
1650                #[cfg(not(target_os = "macos"))]
1651                current_handle,
1652                current_arc,
1653            );
1654            match payload.event {
1655                ExternalDragEvent::Entered { data, position } => {
1656                    current.tree.begin_external_drag(position, data, &mut ops);
1657                }
1658                ExternalDragEvent::Moved { position } => {
1659                    current.tree.update_external_drag(position, &mut ops);
1660                }
1661                ExternalDragEvent::Left => {
1662                    current.tree.cancel_external_drag(&mut ops);
1663                }
1664                ExternalDragEvent::Dropped { data, position } => {
1665                    current.tree.end_external_drag(position, data, &mut ops);
1666                }
1667                ExternalDragEvent::DragEnded { outcome } => {
1668                    current.tree.handle_os_drag_ended(outcome, &mut ops);
1669                }
1670            }
1671        }
1672
1673        // Repaint so hover feedback / drop results show promptly.
1674        current.platform_window.request_redraw();
1675        self.wm.reinsert_managed(winit_id, current);
1676        Ok(())
1677    }
1678
1679    /// Tick gestures on every window with a real `WindowOps` sink so
1680    /// long-press / drag-tick handlers can open windows.
1681    fn tick_gestures_in_window(
1682        &mut self,
1683        window_id: WindowId,
1684        now: Instant,
1685        event_loop: &ActiveEventLoop,
1686    ) {
1687        let Some(mut current) = self.wm.take_managed(window_id) else {
1688            return;
1689        };
1690        let current_id = current.teksilo_id;
1691
1692        #[cfg(not(target_os = "macos"))]
1693        let current_handle = current
1694            .platform_window
1695            .window()
1696            .window_handle()
1697            .ok()
1698            .map(|h| h.as_raw());
1699        let current_arc = Some(current.platform_window.window_arc());
1700
1701        {
1702            let mut ops = crate::window_manager::WindowOpsImpl::new(
1703                &mut self.wm,
1704                event_loop,
1705                current_id,
1706                #[cfg(not(target_os = "macos"))]
1707                current_handle,
1708                current_arc,
1709            );
1710            current.tree.tick_gestures_with_ops(now, &mut ops);
1711        }
1712
1713        self.wm.reinsert_managed(window_id, current);
1714    }
1715
1716    fn handle_accessibility_actions(
1717        &mut self,
1718        window_id: WindowId,
1719        event: &WindowEvent,
1720        event_loop: &ActiveEventLoop,
1721    ) {
1722        // Collect events while holding the `ManagedWindow` borrow;
1723        // dispatch them below through `dispatch_in_window`, which
1724        // needs the borrow to be released first.
1725        let mut a11y_events: Vec<WidgetEvent> = Vec::new();
1726        if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
1727            managed.platform_window.process_accessibility_event(event);
1728
1729            let actions = managed.platform_window.drain_accessibility_actions();
1730            for req in actions {
1731                // Synthetic NodeIds (TextRun children emitted by the
1732                // rich text editor) can't be decoded back to a
1733                // WidgetId by value alone — look them up via the
1734                // tree's reverse-map. For plain widget NodeIds the
1735                // infallible converter is fine.
1736                let target_widget = if teksilo_core::accessibility::is_synthetic(req.target_node) {
1737                    managed.tree.widget_for_synthetic(req.target_node)
1738                } else {
1739                    Some(teksilo_core::accessibility::node_id_to_widget_id(
1740                        req.target_node,
1741                    ))
1742                };
1743                let evt = WidgetEvent::AccessAction {
1744                    action: req.action,
1745                    target: target_widget,
1746                    target_node: req.target_node,
1747                    data: req.data,
1748                };
1749                a11y_events.push(evt);
1750            }
1751        }
1752        for evt in a11y_events {
1753            self.dispatch_in_window(window_id, evt, event_loop);
1754        }
1755    }
1756
1757    fn handle_redraw_requested(&mut self, window_id: WindowId, event_loop: &ActiveEventLoop) {
1758        // Pre-render: take the window out so we can construct a real
1759        // WindowOpsImpl and pass it into layout + render. This lets
1760        // rebuild-triggered handlers (data-driven state changes,
1761        // delayed-overlay activation, drag-tick) open windows.
1762        let Some(mut current) = self.wm.take_managed(window_id) else {
1763            return;
1764        };
1765        let current_id = current.teksilo_id;
1766        #[cfg(not(target_os = "macos"))]
1767        let current_handle = current
1768            .platform_window
1769            .window()
1770            .window_handle()
1771            .ok()
1772            .map(|h| h.as_raw());
1773        let current_arc = Some(current.platform_window.window_arc());
1774
1775        if let Some(trace) = &mut self.idle_trace {
1776            trace.note_redraw_requested();
1777        }
1778        if current.tree.has_idle_work() {
1779            if let Some(trace) = &mut self.idle_trace {
1780                trace.note_idle_callbacks_run();
1781            }
1782            current.tree.run_idle_callbacks(self.idle_budget);
1783        }
1784
1785        let size = current.platform_window.surface_size();
1786        let sf = current.platform_window.scale_factor() as f32;
1787        let proposal = SizeProposal::exact(size.0 as f32 / sf, size.1 as f32 / sf);
1788
1789        {
1790            let mut ops = crate::window_manager::WindowOpsImpl::new(
1791                &mut self.wm,
1792                event_loop,
1793                current_id,
1794                #[cfg(not(target_os = "macos"))]
1795                current_handle,
1796                current_arc.clone(),
1797            );
1798            current.tree.layout_with_ops(proposal, &mut ops);
1799        }
1800
1801        // Size-to-content: after layout, measure the content's intrinsic height
1802        // at the fixed width and grow/shrink the OS window to fit. The native-
1803        // window modal path lays the tree out at the window's *exact* size, so
1804        // (unlike the in-tree overlay) the content's natural height never
1805        // reaches the OS window on its own — a `MessageBox` taller than its
1806        // fixed height clips, dropping the footer buttons below the client edge.
1807        // Drive the size through the reactive `WindowState::size()` → `SetSize`
1808        // path (drained in `post_event`); `last_autosize_height` guards against
1809        // a measure → resize → re-measure oscillation.
1810        if current.size_to_content.sizes_height() {
1811            let width_logical = size.0 as f32 / sf;
1812            if let Some(intrinsic) = current.tree.measure_root_intrinsic(SizeProposal {
1813                width: Some(width_logical),
1814                height: None,
1815            }) {
1816                let target_h = intrinsic.height.ceil().max(1.0) as u32;
1817                let cur_h = (size.1 as f32 / sf).round() as u32;
1818                if target_h != cur_h && current.last_autosize_height != Some(target_h) {
1819                    current.last_autosize_height = Some(target_h);
1820                    current
1821                        .state
1822                        .size()
1823                        .set((width_logical.round() as u32, target_h));
1824                }
1825            }
1826        }
1827
1828        let a11y_update = current.tree.sync_accessibility();
1829        current.platform_window.update_accessibility(a11y_update);
1830
1831        // Catch-all IME reconcile: covers focus changes from any source
1832        // (access actions, programmatic focus, rebuild) that didn't go
1833        // through `dispatch_in_window`. Layout has settled, so the focused
1834        // node's descriptor is current. Cheap + deduped, safe every frame.
1835        Self::reconcile_ime(&mut current);
1836
1837        let mut frame = {
1838            let mut ops = crate::window_manager::WindowOpsImpl::new(
1839                &mut self.wm,
1840                event_loop,
1841                current_id,
1842                #[cfg(not(target_os = "macos"))]
1843                current_handle,
1844                current_arc.clone(),
1845            );
1846            current.tree.render_with_ops(&mut ops)
1847        };
1848        let managed = &mut current;
1849
1850        #[cfg(feature = "text")]
1851        {
1852            let atlas = self
1853                .typesetter
1854                .bridge()
1855                .borrow_mut()
1856                .atlas_info(managed.atlas_uploaded_version);
1857            if atlas.version != managed.atlas_uploaded_version
1858                && atlas.width > 0
1859                && atlas.height > 0
1860            {
1861                managed.platform_window.renderer_mut().upload_atlas(
1862                    atlas.width,
1863                    atlas.height,
1864                    &atlas.pixels,
1865                );
1866                managed.atlas_uploaded_version = atlas.version;
1867            }
1868
1869            if atlas.glyphs_evicted {
1870                // Glyphs were evicted since the previous atlas_info call
1871                // (any path: snapshot scan, rich-text render scan, or
1872                // scale-factor reset). Every retained paint frame in
1873                // EVERY window may hold quads whose atlas UVs now point
1874                // at recycled slots — and invalidate_cache() below clears
1875                // the bridge's layout/glyph caches, which also kills the
1876                // touch_layout keep-alive for frames baked before the
1877                // clear. Invalidate all windows, not just the current
1878                // one; the others re-render at their own requested
1879                // redraw with fresh layouts and pull the current atlas
1880                // pixels through the version comparison above.
1881                self.typesetter.bridge().borrow_mut().invalidate_cache();
1882                managed.tree.invalidate_all_paints();
1883                for other in self.wm.iter_mut() {
1884                    other.tree.invalidate_all_paints();
1885                    other.platform_window.request_redraw();
1886                }
1887                // Re-render after atlas invalidation with a real ops
1888                // sink so rebuild-triggered handlers on this recovery
1889                // path can still open windows.
1890                let mut ops = crate::window_manager::WindowOpsImpl::new(
1891                    &mut self.wm,
1892                    event_loop,
1893                    current_id,
1894                    #[cfg(not(target_os = "macos"))]
1895                    current_handle,
1896                    current_arc.clone(),
1897                );
1898                frame = managed.tree.render_with_ops(&mut ops);
1899                let atlas2 = self
1900                    .typesetter
1901                    .bridge()
1902                    .borrow_mut()
1903                    .atlas_info(managed.atlas_uploaded_version);
1904                // The recovery re-render cannot legitimately evict again
1905                // (the eviction scan's generation-cadence gate just
1906                // reset), but atlas_info consumes the epoch delta — a
1907                // report here would be silently lost, so check the
1908                // assumption instead of assuming it.
1909                debug_assert!(
1910                    !atlas2.glyphs_evicted,
1911                    "glyph eviction during eviction recovery — epoch delta would be lost"
1912                );
1913                if atlas2.version != managed.atlas_uploaded_version
1914                    && atlas2.width > 0
1915                    && atlas2.height > 0
1916                {
1917                    managed.platform_window.renderer_mut().upload_atlas(
1918                        atlas2.width,
1919                        atlas2.height,
1920                        &atlas2.pixels,
1921                    );
1922                    managed.atlas_uploaded_version = atlas2.version;
1923                }
1924            }
1925        }
1926
1927        // The wgpu surface is Rgba8UnormSrgb: it expects linear-light color
1928        // values and applies sRGB encoding on write. Our Color stores sRGB-
1929        // encoded bytes (as designers specify them), so we must linearize
1930        // the clear color here the same way we do for vertex colors.
1931        let clear = teksilo_render::vertex::srgb_to_linear_rgba(
1932            managed.tree.theme().colors.surface_main.to_array(),
1933        );
1934        match managed.platform_window.render_frame(&frame, clear) {
1935            teksilo_platform::FrameOutcome::Rendered => {
1936                if let Some(trace) = &mut self.idle_trace {
1937                    trace.note_rendered_frame();
1938                }
1939            }
1940            teksilo_platform::FrameOutcome::Skipped => {
1941                if !managed.occluded {
1942                    managed.platform_window.request_redraw();
1943                }
1944                self.wm.reinsert_managed(window_id, current);
1945                return;
1946            }
1947            teksilo_platform::FrameOutcome::NeedsReconfigure => {
1948                managed.platform_window.reconfigure_surface();
1949                managed.platform_window.request_redraw();
1950                self.wm.reinsert_managed(window_id, current);
1951                return;
1952            }
1953            teksilo_platform::FrameOutcome::Error(e) => {
1954                eprintln!("teksilo-app: {e}, reconfiguring surface");
1955                managed.platform_window.reconfigure_surface();
1956                managed.platform_window.request_redraw();
1957                self.wm.reinsert_managed(window_id, current);
1958                return;
1959            }
1960        }
1961
1962        // A live per-frame effect (Pulse / Cycle / caret blink / drag
1963        // auto-scroll) leaves `frame_requested()` armed after this render.
1964        // We deliberately do NOT `request_redraw()` here: an immediate
1965        // redraw request makes winit skip the `WaitUntil` sleep and
1966        // free-run at the display's refresh rate — the exact 300 fps
1967        // uncapped behaviour we're removing. Instead the fixed 60 Hz
1968        // deadline published by `WidgetTree::frame_tick_deadline` (folded
1969        // into `next_timer_deadline`) drives the next frame: at the
1970        // deadline, `new_events(ResumeTimeReached)` calls
1971        // `request_redraw_all()`. This mirrors how the shader-quad
1972        // animation path has always paced itself, so per-frame animations
1973        // now show in the idle trace as `resume_time_reached` /
1974        // `request_redraw_all` rather than `frame_request`.
1975
1976        self.wm.reinsert_managed(window_id, current);
1977    }
1978
1979    fn handle_window_event_inner(
1980        &mut self,
1981        event_loop: &ActiveEventLoop,
1982        window_id: WindowId,
1983        event: WindowEvent,
1984    ) {
1985        let teksilo_id = self.wm.teksilo_id_for_winit(window_id);
1986
1987        if let Some(fid) = teksilo_id
1988            && self.wm.is_blocked(fid)
1989            && !matches!(
1990                event,
1991                WindowEvent::CloseRequested | WindowEvent::ActivationTokenDone { .. }
1992            )
1993        {
1994            self.wm.refocus_modal_child(fid);
1995            self.update_control_flow(event_loop);
1996            return;
1997        }
1998
1999        self.handle_accessibility_actions(window_id, &event, event_loop);
2000
2001        match event {
2002            WindowEvent::CloseRequested => {
2003                if let Some(fid) = teksilo_id {
2004                    // Guarded close: the OS close button / Alt+F4 / Cmd+W
2005                    // is an interactive gesture, so it runs through the
2006                    // window's close guard (if any) on the next
2007                    // `process_pending` tick and may be vetoed.
2008                    self.wm.request_close(fid);
2009                }
2010            }
2011            WindowEvent::Resized(new_size) => {
2012                if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2013                    managed.platform_window.resize(new_size);
2014                    // Mirror OS-initiated geometry / placement changes
2015                    // into WindowState so widgets bound to those signals
2016                    // re-render. The `*_from_os` setters flip the
2017                    // re-entrancy guard so observers on the signal do
2018                    // not push the change back out as a WindowCommand.
2019                    // Covers OS-initiated maximize (drag-to-top-snap on
2020                    // Wayland/Windows, green-light zoom on macOS) —
2021                    // query_window_placement reads the winit state and
2022                    // the Switcher glyph swap on `TitleBar`'s maximize
2023                    // button (bound to `WindowState::placement`) stays
2024                    // in sync.
2025                    let sf = managed.platform_window.scale_factor();
2026                    let logical_w = (new_size.width as f64 / sf).round().max(0.0) as u32;
2027                    let logical_h = (new_size.height as f64 / sf).round().max(0.0) as u32;
2028                    managed.state.set_size_from_os((logical_w, logical_h));
2029                    let placement = query_window_placement(managed.platform_window.window());
2030                    managed.state.set_placement_from_os(placement);
2031                    if let Some(trace) = &mut self.idle_trace {
2032                        trace.note_redraw_request("resize");
2033                    }
2034                    managed.platform_window.request_redraw();
2035                }
2036            }
2037            WindowEvent::Moved(pos) => {
2038                if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2039                    let sf = managed.platform_window.scale_factor();
2040                    let lx = (pos.x as f64 / sf).round() as i32;
2041                    let ly = (pos.y as f64 / sf).round() as i32;
2042                    managed.state.set_position_from_os((lx, ly));
2043                }
2044            }
2045            WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
2046                let mut teksilo_id = None;
2047                if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2048                    managed.translation_state.set_scale_factor(scale_factor);
2049                    managed.platform_window.set_scale_factor(scale_factor);
2050                    managed.tree.set_device_scale_factor(scale_factor as f32);
2051                    teksilo_id = Some(managed.teksilo_id);
2052                }
2053                // Keep the external-DnD backend's idea of the scale current:
2054                // dragging the window onto a monitor with a different scale
2055                // mid-drag would otherwise start reporting drops at the wrong
2056                // place (X11 only — see `ExternalDndGuard::set_scale_factor`).
2057                if let Some(teksilo_id) = teksilo_id
2058                    && let Some(handle) = self
2059                        .wm
2060                        .app_context_template()
2061                        .and_then(|t| {
2062                            t.app_state::<teksilo_platform::external_dnd::ExternalDndHandle>()
2063                        })
2064                        .cloned()
2065                {
2066                    handle.set_scale_factor(teksilo_id, scale_factor);
2067                }
2068                #[cfg(feature = "text")]
2069                {
2070                    self.typesetter.set_scale_factor(scale_factor as f32);
2071                }
2072            }
2073            WindowEvent::CursorMoved { position, .. } => {
2074                let maybe_evt = if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2075                    event_translation::translate_cursor_moved(
2076                        position.x,
2077                        position.y,
2078                        &mut managed.translation_state,
2079                    )
2080                } else {
2081                    None
2082                };
2083                if let Some(evt) = maybe_evt {
2084                    self.dispatch_in_window(window_id, evt, event_loop);
2085                }
2086                if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2087                    apply_cursor_to_window(&managed.platform_window, managed.tree.current_cursor());
2088                    if managed.tree.needs_redraw() {
2089                        if let Some(trace) = &mut self.idle_trace {
2090                            trace.note_redraw_request("cursor");
2091                        }
2092                        managed.platform_window.request_redraw();
2093                    }
2094                }
2095            }
2096            WindowEvent::MouseInput { state, button, .. } => {
2097                let maybe_evt = if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2098                    event_translation::translate_mouse_input(
2099                        state,
2100                        button,
2101                        &managed.translation_state,
2102                    )
2103                } else {
2104                    None
2105                };
2106                if let Some(evt) = maybe_evt {
2107                    self.dispatch_in_window(window_id, evt, event_loop);
2108                }
2109                if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2110                    apply_cursor_to_window(&managed.platform_window, managed.tree.current_cursor());
2111                    if let Some(trace) = &mut self.idle_trace {
2112                        trace.note_redraw_request("mouse_input");
2113                    }
2114                    managed.platform_window.request_redraw();
2115                }
2116            }
2117            WindowEvent::MouseWheel { delta, phase, .. } => {
2118                let maybe_evt = if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2119                    event_translation::translate_mouse_wheel(
2120                        delta,
2121                        phase,
2122                        &managed.translation_state,
2123                    )
2124                } else {
2125                    None
2126                };
2127                if let Some(evt) = maybe_evt {
2128                    self.dispatch_in_window(window_id, evt, event_loop);
2129                }
2130                if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2131                    if let Some(trace) = &mut self.idle_trace {
2132                        trace.note_redraw_request("mouse_wheel");
2133                    }
2134                    managed.platform_window.request_redraw();
2135                }
2136            }
2137            WindowEvent::ModifiersChanged(mods) => {
2138                // Capture state before the alt_down write so we can
2139                // detect the falling edge without re-reading after.
2140                let alt_tap_action = if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2141                    managed.current_modifiers = mods.state();
2142                    managed
2143                        .translation_state
2144                        .set_modifiers(event_translation::translate_modifiers(mods.state()));
2145
2146                    let new_alt = mods.state().alt_key();
2147                    let prev_alt = managed.state.alt_down().get();
2148                    let other_pressed = managed.state.other_key_pressed_during_alt();
2149                    // Alt-tap tracking: surface the OS Alt-held edge on
2150                    // the window's `alt_down` signal so `MenuLabel` can
2151                    // gate mnemonic underlines and `MenuBar` can detect
2152                    // bare-Alt-tap on the falling edge. winit reports
2153                    // Alt presses through `ModifiersChanged` (not as a
2154                    // `Key::Alt` KeyDown, which doesn't exist in our
2155                    // Key enum), so this is the only correct hook.
2156                    managed.state.set_alt_from_os(new_alt);
2157                    // Detect the bare-Alt-tap pattern: true → false
2158                    // with no non-Alt KeyDowns during the hold.
2159                    if prev_alt && !new_alt && !other_pressed {
2160                        managed
2161                            .state
2162                            .menubar_dispatcher()
2163                            .and_then(|d| d.on_alt_tap())
2164                    } else {
2165                        None
2166                    }
2167                } else {
2168                    None
2169                };
2170                if let Some(action) = alt_tap_action {
2171                    self.apply_menubar_action(window_id, action, event_loop);
2172                }
2173            }
2174            WindowEvent::KeyboardInput {
2175                event: key_event, ..
2176            } => {
2177                // Track Caps Lock from the discrete key press — winit's
2178                // `ModifiersState` carries no lock state — toggling on
2179                // each key-down edge and pushing the result to
2180                // `WindowState::caps_lock` for the password-field warning.
2181                if key_event.state == winit::event::ElementState::Pressed
2182                    && matches!(
2183                        event_translation::translate_key(&key_event.logical_key),
2184                        Some(teksilo_core::event::Key::CapsLock)
2185                    )
2186                    && let Some(managed) = self.wm.get_by_winit_mut(window_id)
2187                {
2188                    managed.caps_lock_active = !managed.caps_lock_active;
2189                    managed
2190                        .state
2191                        .set_caps_lock_from_os(managed.caps_lock_active);
2192                }
2193
2194                // Bare-Alt-tap detection: every non-Alt KeyDown while
2195                // Alt is held flips the sticky flag, so the falling
2196                // edge of `alt_down` only counts as a tap when no
2197                // chord was composed. winit fires modifier keys
2198                // through `ModifiersChanged`, not `KeyboardInput`, so
2199                // every KeyDown we see here is a non-modifier and
2200                // qualifies as an "other key" press.
2201                if key_event.state == winit::event::ElementState::Pressed
2202                    && event_translation::translate_key(&key_event.logical_key).is_some()
2203                    && let Some(managed) = self.wm.get_by_winit_mut(window_id)
2204                {
2205                    managed.state.note_non_alt_keydown_during_alt();
2206                }
2207                let maybe_evt = if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2208                    event_translation::translate_key(&key_event.logical_key).map(|key| {
2209                        let modifiers =
2210                            event_translation::translate_modifiers(managed.current_modifiers);
2211                        let text = key_event.text.as_ref().map(|t| t.to_string());
2212                        match key_event.state {
2213                            winit::event::ElementState::Pressed => WidgetEvent::KeyDown {
2214                                key,
2215                                modifiers,
2216                                text,
2217                            },
2218                            winit::event::ElementState::Released => {
2219                                WidgetEvent::KeyUp { key, modifiers }
2220                            }
2221                        }
2222                    })
2223                } else {
2224                    None
2225                };
2226                if let Some(evt) = maybe_evt {
2227                    // Window-level menubar pre-dispatch (F10 / Alt+letter):
2228                    // intercepts BEFORE the normal focus-based path so the
2229                    // event reaches the menubar even when focus is in a
2230                    // TextInput or some other unrelated widget. Matches
2231                    // Win32's `WM_SYSKEYDOWN` → `DefWindowProc` route.
2232                    let intercept = if let WidgetEvent::KeyDown { key, modifiers, .. } = &evt {
2233                        self.wm
2234                            .get_by_winit_mut(window_id)
2235                            .and_then(|m| {
2236                                m.state.menubar_dispatcher().map(|d| {
2237                                    d.try_handle(&teksilo_core::window::MenubarKeyEvent {
2238                                        key: *key,
2239                                        modifiers: *modifiers,
2240                                    })
2241                                })
2242                            })
2243                            .flatten()
2244                    } else {
2245                        None
2246                    };
2247                    if let Some(action) = intercept {
2248                        self.apply_menubar_action(window_id, action, event_loop);
2249                    } else {
2250                        self.dispatch_in_window(window_id, evt, event_loop);
2251                    }
2252                }
2253                if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2254                    if let Some(trace) = &mut self.idle_trace {
2255                        trace.note_redraw_request("keyboard");
2256                    }
2257                    managed.platform_window.request_redraw();
2258                }
2259            }
2260            WindowEvent::Ime(ime) => {
2261                // Dedup consecutive empty preedits at the funnel. Some Linux IME
2262                // backends (ibus / fcitx via winit) flood empty `Ime::Preedit("")`
2263                // events while a field is focused. The first is meaningful (it
2264                // clears any active composition); every consecutive repeat is a
2265                // no-op that would still translate + dispatch through the tree AND
2266                // wake a full unconditional layout+render pass here. Skip the
2267                // repeats entirely — neither dispatch nor redraw. Any non-empty
2268                // preedit (or a Commit / Enabled / Disabled) resets the flag so
2269                // the next empty preedit is again treated as meaningful.
2270                let empty_preedit =
2271                    matches!(&ime, winit::event::Ime::Preedit(t, _) if t.is_empty());
2272                let skip = if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2273                    crate::window_manager::ime_should_skip_empty_preedit(
2274                        &mut managed.last_ime_preedit_empty,
2275                        empty_preedit,
2276                    )
2277                } else {
2278                    false
2279                };
2280                if !skip {
2281                    let maybe_evt = if self.wm.get_by_winit_mut(window_id).is_some() {
2282                        event_translation::translate_ime(ime)
2283                    } else {
2284                        None
2285                    };
2286                    if let Some(evt) = maybe_evt {
2287                        self.dispatch_in_window(window_id, evt, event_loop);
2288                    }
2289                    if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2290                        if let Some(trace) = &mut self.idle_trace {
2291                            trace.note_redraw_request("ime");
2292                        }
2293                        managed.platform_window.request_redraw();
2294                    }
2295                }
2296            }
2297            WindowEvent::RedrawRequested => {
2298                self.handle_redraw_requested(window_id, event_loop);
2299            }
2300            WindowEvent::ThemeChanged(winit_theme) => {
2301                self.handle_theme_changed(winit_theme);
2302                if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2303                    managed.platform_window.request_redraw();
2304                }
2305            }
2306            // Pause all looping animations on the unfocused window so it
2307            // stops waking the event loop at the animation frame
2308            // interval. The scheduler rebases start_time on resume so
2309            // the animation phase is continuous — a half-swept
2310            // indeterminate bar picks up at exactly the same position,
2311            // not snapped forward by the elapsed unfocused time.
2312            //
2313            // On Linux/Windows (winit 0.30) minimize fires `Focused(false)`
2314            // — no separate minimize event — so this path covers it.
2315            WindowEvent::Focused(focused) => {
2316                let mut newly_focused = None;
2317                if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2318                    managed.focused = focused;
2319                    let active = managed.focused && !managed.occluded;
2320                    managed.tree.set_window_active(active);
2321                    managed.state.set_focused_from_os(focused);
2322                    // Drive a redraw on every focus transition so the
2323                    // window-active observers (caret hide/restore, selection
2324                    // desaturation, DimWhenInactive) reach a paint pass
2325                    // promptly — the OS does not reliably emit RedrawRequested
2326                    // on focus change across all platforms.
2327                    managed.platform_window.request_redraw();
2328                    if focused {
2329                        newly_focused = Some(managed.teksilo_id);
2330                    }
2331                }
2332                // A window regaining focus is the natural, zero-idle-cost moment
2333                // to re-check the OS accessibility preferences (WCAG / EN 301
2334                // 549 §11.7): the user may have toggled "increase contrast" /
2335                // "reduce motion" / text scale in System Settings and switched
2336                // back. `refresh_accessibility_preferences` applies any change
2337                // to every window (marking them dirty for repaint).
2338                if focused {
2339                    self.wm.refresh_accessibility_preferences();
2340                }
2341                // The global native menu (macOS) follows window focus: make the
2342                // focused window's installed menu the visible one.
2343                if let Some(teksilo_id) = newly_focused
2344                    && let Some(handle) = self.wm.app_context_template().and_then(|t| {
2345                        t.app_state::<teksilo_platform::native_menu::NativeMenuHandle>()
2346                            .cloned()
2347                    })
2348                {
2349                    handle.activate_window(teksilo_id);
2350                }
2351            }
2352            // macOS-only in winit 0.30 (X11/Wayland/Windows never emit
2353            // this). Handled for parity with Focused so a macOS app
2354            // that is hidden behind another window — still focused —
2355            // also parks its animations.
2356            WindowEvent::Occluded(occluded) => {
2357                if let Some(managed) = self.wm.get_by_winit_mut(window_id) {
2358                    managed.occluded = occluded;
2359                    let active = managed.focused && !managed.occluded;
2360                    managed.tree.set_window_active(active);
2361                    // Drive a redraw on both directions. On reveal
2362                    // (`!occluded`) the render loop stopped pinging while we
2363                    // were occluded, so without this nudge the window stays
2364                    // frozen until the user moves the mouse or hits a key. On
2365                    // occlusion (`occluded`) the active-state flip must reach a
2366                    // paint pass so the caret hides / selection desaturates
2367                    // before the window is hidden behind another.
2368                    managed.platform_window.request_redraw();
2369                }
2370            }
2371            WindowEvent::ActivationTokenDone { token, .. } => {
2372                // A `request_activation_token` we issued resolved — hand the
2373                // freshly-minted token to whoever asked (child-process spawn or
2374                // an IPC peer). One request outstanding per window, so key by
2375                // window id and ignore the serial.
2376                if let Some(cb) = self.wm.take_activation_token_callback(window_id) {
2377                    cb(Some(token.into_raw()));
2378                }
2379            }
2380            _ => {}
2381        }
2382
2383        self.post_event(event_loop);
2384    }
2385
2386    fn handle_theme_changed(&mut self, winit_theme: winit::window::Theme) {
2387        // Read the mode from the WindowManager (the live owner) so a runtime
2388        // switch to "follow system" via `EventContext::follow_system_theme`
2389        // is honoured here too. OS-following results carry the id "system".
2390        match self.wm.theme_mode() {
2391            ThemeMode::Manual => {} // ignore OS theme changes
2392            ThemeMode::FollowSystem => {
2393                // Trust winit's per-window signal (authoritative on
2394                // macOS/Windows where OS-colour querying is unimplemented).
2395                let theme = match winit_theme {
2396                    winit::window::Theme::Dark => teksilo_core::presets::intui::dark(),
2397                    winit::window::Theme::Light => teksilo_core::presets::intui::light(),
2398                }
2399                .with_id("system");
2400                self.wm.set_theme(theme);
2401            }
2402            // Native adopts the OS's actual colours on Linux; on macOS/Windows
2403            // (no OS-colour query) it follows winit's authoritative light/dark
2404            // hint. The shared helper stamps the "system" id.
2405            ThemeMode::Native => self
2406                .wm
2407                .apply_os_theme(Some(matches!(winit_theme, winit::window::Theme::Dark))),
2408        }
2409    }
2410}
2411
2412impl ApplicationHandler<AppEvent> for TeksiloAppHandler {
2413    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
2414        if !self.initial_created
2415            && let Some(config) = self.initial_window.take()
2416        {
2417            self.wm.create_window(config, event_loop);
2418            self.initial_created = true;
2419        }
2420
2421        self.process_pending(event_loop);
2422        self.update_control_flow(event_loop);
2423    }
2424
2425    fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: StartCause) {
2426        if matches!(cause, StartCause::ResumeTimeReached { .. }) {
2427            if let Some(trace) = &mut self.idle_trace {
2428                trace.note_resume_time_reached();
2429                trace.note_request_redraw_all();
2430            }
2431            // Redraw only the windows whose frame deadline is actually due —
2432            // NOT every window. A blanket redraw here pins non-animating
2433            // windows at the animation frame rate and, on Windows (one
2434            // RedrawRequested serviced per loop iteration), starves an inactive
2435            // window's own pending repaint so it freezes. See
2436            // `WindowManager::request_redraw_due`.
2437            self.wm.request_redraw_due(Instant::now());
2438        }
2439        self.update_control_flow(event_loop);
2440    }
2441
2442    fn user_event(&mut self, event_loop: &ActiveEventLoop, event: AppEvent) {
2443        if let Some(handler) = &mut self.app_event_handler {
2444            handler(&event);
2445        }
2446        // Composed framework observers (see `AppEventObservers` /
2447        // `TeksiloAppBuilder::register_app_event_observer`) run in
2448        // addition to the app's own `on_app_event` handler above — this
2449        // is what lets `teksilo::install_toast` react to
2450        // `AppEvent::SettingsWriteFailed` without clobbering (or being
2451        // clobbered by) an app that also called `on_app_event`.
2452        if let Some(template) = self.wm.app_context_template()
2453            && let Some(observers) =
2454                template.app_state::<crate::app_event_observers::AppEventObservers>()
2455        {
2456            (observers.0)(&event);
2457        }
2458        match event {
2459            // Backend-event subscription delivery (architecture §9.4): look
2460            // up the UI-side callback in the shared app context and invoke
2461            // it with the downcast event payload. The shared template is
2462            // the same Rc held by every window's tree, so we don't need to
2463            // route by window.
2464            AppEvent::SubscriptionEvent { sub_id, event } => {
2465                // A context-bearing subscription (`subscribe_event_with_ctx`)
2466                // needs a fresh `EventContext` minted from its window's tree;
2467                // a plain one dispatches against the shared template with no
2468                // context. `try_dispatch_subscription_with_ctx` returns `true`
2469                // when `sub_id` names a context-bearing subscription (so we
2470                // skip the plain path — a sub_id lives in exactly one map).
2471                if !self.try_dispatch_subscription_with_ctx(sub_id, &*event, event_loop)
2472                    && let Some(template) = self.wm.app_context_template()
2473                {
2474                    template.dispatch_subscription_event(sub_id, &*event);
2475                }
2476            }
2477            // Hot-reload of an `.ftl` file registered via
2478            // `I18nConfig::runtime_override(...)`. Architecture §12.7:
2479            // the reload must *not* trigger a composite rebuild — only
2480            // the version signal is bumped, and the existing binding
2481            // system propagates the change to every `LocalizedString`
2482            // observer. Direction and active locale are unchanged.
2483            AppEvent::I18nReload { locale, path } => {
2484                let parsed: Result<teksilo_i18n::LanguageIdentifier, _> = locale.parse();
2485                match parsed {
2486                    Ok(loc) => {
2487                        let reloaded = teksilo_i18n::thread_local::with_active(|mgr| {
2488                            mgr.reload_from_path(&loc, &path)
2489                        });
2490                        match reloaded {
2491                            Some(Ok(())) => {}
2492                            Some(Err(e)) => eprintln!(
2493                                "teksilo-app: hot-reload failed for {loc} ({}): {e}",
2494                                path.display()
2495                            ),
2496                            None => eprintln!(
2497                                "teksilo-app: hot-reload event for {loc} but no i18n manager installed"
2498                            ),
2499                        }
2500                    }
2501                    Err(e) => {
2502                        eprintln!(
2503                            "teksilo-app: hot-reload event with invalid locale `{locale}`: {e}"
2504                        )
2505                    }
2506                }
2507            }
2508            // Live cross-process settings sync: a `teksilo-settings`
2509            // managed file changed on disk (a peer process's write, or
2510            // harmlessly this process's own write being noticed by its
2511            // own watcher). Look the path up in the app's
2512            // `SettingsRegistry` and let it dispatch to whichever
2513            // `Reloadable` owns it. This must *not* trigger a composite
2514            // rebuild — `reload_from_disk` only mutates signals/models
2515            // in place, and the existing reactive binding system
2516            // propagates the change to every observer, exactly like
2517            // `I18nReload` above.
2518            AppEvent::SettingsReload { path } => {
2519                if let Some(template) = self.wm.app_context_template()
2520                    && let Some(registry) =
2521                        template.app_state::<teksilo_settings::SettingsRegistry>()
2522                    && let Err(e) = registry.dispatch(&path)
2523                {
2524                    eprintln!(
2525                        "teksilo-app: settings reload failed for {}: {e}",
2526                        path.display()
2527                    );
2528                }
2529            }
2530            // F3: a `teksilo-settings` `DebouncedWriter` permanently gave
2531            // up on a queued write (retry cap reached, or a still-failing
2532            // write forced by process teardown) — the patches for `path`
2533            // were discarded. `teksilo-app` itself stays widget-agnostic
2534            // (it cannot depend on `teksilo-widgets`' `Toast` /
2535            // `NotificationArchive`), so this log is only half the
2536            // story: the composed `AppEventObservers` dispatched just
2537            // above also sees this event, and `teksilo::install_toast`
2538            // (the umbrella crate, which sees both `AppEvent` and
2539            // `Toast`) registers an observer that turns it into a
2540            // persistent error toast — see
2541            // `ToastRegistry::show_settings_write_failed`. This log
2542            // stays too: a headless/CI app with no toast host installed
2543            // still needs *some* signal that a write was lost.
2544            AppEvent::SettingsWriteFailed {
2545                path,
2546                attempts,
2547                dropped_patches,
2548                message,
2549            } => {
2550                eprintln!(
2551                    "teksilo-app: settings write permanently failed for {} after {} attempts ({} patches dropped): {}",
2552                    path.display(),
2553                    attempts,
2554                    dropped_patches,
2555                    message
2556                );
2557            }
2558            // Title-bar hosts route their `close()` through this variant so
2559            // the operation hops back onto the main thread before touching
2560            // `WindowManager` (see `title_bar_host.rs`). File-dialog
2561            // backends post their results through the same variant. The
2562            // arm tries each known payload type in turn; unrecognized
2563            // payloads are ignored — application-authored `send_external`
2564            // payloads can coexist with framework-internal ones.
2565            AppEvent::External(payload) => {
2566                // Try each framework-internal payload type in turn; the first
2567                // that consumes it wins. Unrecognized payloads fall through to
2568                // the title-bar / close-request downcast chain.
2569                let payload = self
2570                    .try_route_file_dialog_payload(payload, event_loop)
2571                    .err();
2572                let payload = match payload {
2573                    None => None,
2574                    Some(payload) => self
2575                        .try_route_external_dnd_payload(payload, event_loop)
2576                        .err(),
2577                };
2578                let payload = match payload {
2579                    None => None,
2580                    Some(payload) => self
2581                        .try_route_async_completion_payload(payload, event_loop)
2582                        .err(),
2583                };
2584                let payload = match payload {
2585                    None => None,
2586                    Some(payload) => self
2587                        .try_route_native_menu_payload(payload, event_loop)
2588                        .err(),
2589                };
2590                let payload = match payload {
2591                    None => None,
2592                    Some(payload) => self.try_route_web_view_payload(payload, event_loop).err(),
2593                };
2594                #[cfg(all(feature = "automation", debug_assertions))]
2595                let payload = match payload {
2596                    None => None,
2597                    Some(payload) => self.try_route_automation_payload(payload, event_loop).err(),
2598                };
2599                if let Some(payload) = payload {
2600                    // Did one of the framework's own built-in arms below claim
2601                    // it? Only what is left over is offered to the app's
2602                    // `on_external_with_ctx` router (see
2603                    // `route_external_with_ctx`). The framework arms run FIRST,
2604                    // so an app router that returns `true` too eagerly can never
2605                    // swallow a `CloseWindowRequest` or a title-bar synthetic
2606                    // event; and because the answer is the chain's own trailing
2607                    // `else`, a built-in arm added later is withheld from the
2608                    // app router automatically — there is no second list of
2609                    // "framework-owned types" to keep in step.
2610                    let mut consumed = true;
2611                    {
2612                        if let Some(req) = payload.downcast_ref::<CloseWindowRequest>() {
2613                            // Custom-chrome (Teksilo-drawn) title-bar close
2614                            // button — an interactive gesture, so it runs
2615                            // through the window's close guard (guarded
2616                            // close), matching the OS close button.
2617                            self.wm.request_close(req.teksilo_id);
2618                        } else if let Some(evt) = payload.downcast_ref::<TitleBarSyntheticEvent>() {
2619                            // Windows custom-chrome wndproc sends this when
2620                            // `WM_NCLBUTTONUP` fires over a control-button
2621                            // hit-region. The button's pixels are owned by
2622                            // the OS so the widget tree never saw the click;
2623                            // re-issue it as a synthetic tap on the
2624                            // matching `ControlButton`.
2625                            self.wm
2626                                .route_title_bar_synthetic_tap(evt.teksilo_id, evt.target);
2627                        } else if let Some(evt) = payload.downcast_ref::<TitleBarHoverEvent>() {
2628                            // Same idea for hover: `WM_NCMOUSEMOVE` over a
2629                            // control-button hit-region delivers an
2630                            // entered/leave event the widget tree never
2631                            // sees, so we drive the matching button's
2632                            // hover signal explicitly.
2633                            self.wm.route_title_bar_synthetic_hover(
2634                                evt.teksilo_id,
2635                                evt.target,
2636                                evt.entered,
2637                            );
2638                        } else if let Some(inject) = payload.downcast_ref::<SyntheticImeInject>() {
2639                            // Test / demo hook: replay a scripted IME
2640                            // sequence into the focused window's focused
2641                            // widget through the real dispatch path — no OS
2642                            // IME needed. Mirrors exactly what the
2643                            // `WindowEvent::Ime` arm produces.
2644                            let target = self
2645                                .wm
2646                                .windows_map()
2647                                .iter()
2648                                .find(|(_, m)| m.focused)
2649                                .or_else(|| self.wm.windows_map().iter().next())
2650                                .map(|(id, _)| *id);
2651                            if let Some(winit_id) = target {
2652                                for evt in inject.events.clone() {
2653                                    self.dispatch_in_window(winit_id, evt, event_loop);
2654                                }
2655                            }
2656                        } else if let Some(req) =
2657                            payload.downcast_ref::<teksilo_core::RepaintWindowRequest>()
2658                        {
2659                            // Off-thread "repaint this window" — e.g. a
2660                            // terminal's PTY-reader thread whose bytes changed a
2661                            // widget's content outside the UI thread. A bare
2662                            // redraw re-presents the cached frame, so mark the
2663                            // window's tree paint-dirty; the unconditional
2664                            // `request_redraw_all()` below then re-runs the
2665                            // changed widget's `paint()`.
2666                            let winit_id =
2667                                self.wm.teksilo_to_winit_map().get(&req.window_id).copied();
2668                            if let Some(winit_id) = winit_id
2669                                && let Some(managed) = self.wm.get_by_winit_mut(winit_id)
2670                            {
2671                                managed.tree.mark_all_needs_paint_only();
2672                            }
2673                        } else {
2674                            consumed = false;
2675                        }
2676                    }
2677                    if !consumed {
2678                        self.route_external_with_ctx(&*payload, event_loop);
2679                    }
2680                }
2681            }
2682            _ => {}
2683        }
2684        if let Some(trace) = &mut self.idle_trace {
2685            trace.note_request_redraw_all();
2686        }
2687        self.wm.request_redraw_all();
2688        self.post_event(event_loop);
2689    }
2690
2691    fn window_event(
2692        &mut self,
2693        event_loop: &ActiveEventLoop,
2694        window_id: WindowId,
2695        event: WindowEvent,
2696    ) {
2697        self.handle_window_event_inner(event_loop, window_id, event);
2698    }
2699
2700    fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
2701        // Drive any registered per-turn closure (the async executor poll when
2702        // `teksilo-async` is installed) before computing the next control
2703        // flow. A `true` return means tasks advanced and may have mutated
2704        // reactive state, so repaint the open windows — mirroring the
2705        // subscription-delivery redraw in `user_event`.
2706        if let Some(tick) = &mut self.loop_tick
2707            && tick()
2708        {
2709            self.wm.request_redraw_all();
2710        }
2711        self.process_pending(event_loop);
2712        self.maybe_exit(event_loop);
2713        self.update_control_flow(event_loop);
2714    }
2715}
2716
2717/// Payload used by `TitleBarHostCallbacks::request_close` to route a
2718/// host-initiated close back to the main event loop. The host's
2719/// close callback boxes one of these through `AppEventProxy::send_external`;
2720/// `TeksiloAppHandler::user_event` downcasts the payload and calls
2721/// `WindowManager::queue_close` so the window tears down on the next tick
2722/// (matching the `WindowEvent::CloseRequested` path).
2723#[derive(Debug, Clone, Copy)]
2724pub struct CloseWindowRequest {
2725    pub teksilo_id: TeksiloWindowId,
2726}
2727
2728/// Test / demo payload that replays a scripted IME sequence into the
2729/// focused window's focused widget, through the same dispatch path the
2730/// real `WindowEvent::Ime` arm uses — so the full preedit pipeline
2731/// (document mutation, underline, caret-area reporting, AT selection) can
2732/// be exercised without an OS input method installed.
2733///
2734/// Post it via [`AppEventPoster::post_external`](teksilo_core::AppEventPoster)
2735/// (reachable from a handler with `ctx.poster()`).
2736#[derive(Debug, Clone)]
2737pub struct SyntheticImeInject {
2738    pub events: Vec<teksilo_core::event::WidgetEvent>,
2739}
2740
2741// `TitleBarSyntheticEvent` and `TitleBarHoverEvent` live in
2742// `teksilo_core::window_chrome` so teksilo-platform (which posts them from
2743// the Windows wndproc subclass) and teksilo-app (which routes them) can
2744// both name the type without teksilo-platform depending on teksilo-app.
2745pub use teksilo_core::{TitleBarHoverEvent, TitleBarSyntheticEvent};
2746
2747/// A thread-safe handle for posting `AppEvent`s to the UI thread.
2748///
2749/// Clone and send to background threads. The event loop wakes up
2750/// and processes the event like any other input.
2751#[derive(Clone)]
2752pub struct AppEventProxy {
2753    inner: winit::event_loop::EventLoopProxy<AppEvent>,
2754}
2755
2756impl AppEventProxy {
2757    /// Post a background completion event.
2758    pub fn send_background_complete(&self, operation_id: String) {
2759        let _ = self
2760            .inner
2761            .send_event(AppEvent::BackgroundComplete { operation_id });
2762    }
2763
2764    /// Post a background progress event.
2765    pub fn send_background_progress(&self, operation_id: String, percent: f32, message: String) {
2766        let _ = self.inner.send_event(AppEvent::BackgroundProgress {
2767            operation_id,
2768            percent,
2769            message,
2770        });
2771    }
2772
2773    /// Post an arbitrary external event.
2774    pub fn send_external(&self, payload: impl std::any::Any + Send + 'static) {
2775        let _ = self.inner.send_event(AppEvent::External(Box::new(payload)));
2776    }
2777
2778    /// Post a pre-boxed external event. Used by callers that already
2779    /// hold a `Box<dyn Any + Send>` (notably
2780    /// `TitleBarHostCallbacks::post_external`, which abstracts the
2781    /// posting mechanism behind a closure that teksilo-core can hold
2782    /// without depending on winit).
2783    pub fn send_external_boxed(&self, payload: Box<dyn std::any::Any + Send>) {
2784        let _ = self.inner.send_event(AppEvent::External(payload));
2785    }
2786
2787    /// Post a backend-event delivery for the given subscription id. Called
2788    /// by the framework's event-source wrapper from the publisher thread.
2789    pub fn post_subscription_event(
2790        &self,
2791        sub_id: SubscriptionId,
2792        event: Box<dyn std::any::Any + Send>,
2793    ) {
2794        let _ = self
2795            .inner
2796            .send_event(AppEvent::SubscriptionEvent { sub_id, event });
2797    }
2798}
2799
2800/// `AppEventProxy` implements [`AppEventPoster`] directly so it can be both the
2801/// `Arc<dyn AppEventPoster>` every widget tree holds AND handed to background
2802/// integrations (e.g. the `teksilo-async` executor's cross-thread waker, wired
2803/// via [`TeksiloAppBuilder::on_ready`]). teksilo-core cannot import winit, so
2804/// this trait implementation lives here.
2805impl AppEventPoster for AppEventProxy {
2806    fn post_subscription_event(
2807        &self,
2808        sub_id: SubscriptionId,
2809        event: Box<dyn std::any::Any + Send>,
2810    ) {
2811        let _ = self
2812            .inner
2813            .send_event(AppEvent::SubscriptionEvent { sub_id, event });
2814    }
2815
2816    fn post_external(&self, payload: Box<dyn std::any::Any + Send>) {
2817        let _ = self.inner.send_event(AppEvent::External(payload));
2818    }
2819}
2820
2821/// Builder for a Teksilo application.
2822pub struct TeksiloAppBuilder {
2823    theme: Theme,
2824    theme_mode: ThemeMode,
2825    #[cfg(feature = "text")]
2826    typesetter: Option<SharedTypesetter>,
2827    #[cfg(feature = "text")]
2828    font_registrars: Vec<Box<dyn teksilo_text::FontRegistrar>>,
2829    app_event_handler: Option<Box<dyn FnMut(&AppEvent)>>,
2830    external_ctx_handler: Option<ExternalCtxHandler>,
2831    on_ready: Vec<Box<dyn FnOnce(AppEventProxy)>>,
2832    initial_window: Option<WindowConfig>,
2833    /// Type-erased adapter for the application's backend event source.
2834    /// Installed via `event_source<S>(source)`.
2835    event_source: Option<EventSourceAdapter>,
2836    /// Application-scoped values keyed by `TypeId`.
2837    /// Installed via `app_state::<T>(value)` and reachable from any
2838    /// `BuildContext` via `ctx.app_state::<T>()`.
2839    app_state_registry: HashMap<TypeId, Box<dyn Any>>,
2840    /// Internationalization configuration. Installed
2841    /// via `i18n(I18nConfig)`. When present, an `I18nManager` is built at
2842    /// `build_headless` / `run` time and registered on the thread-local so
2843    /// `tr!`-expanded code can resolve translations.
2844    i18n: Option<I18nConfig>,
2845    /// Tooltip content entries registered via
2846    /// [`register_tooltips`](Self::register_tooltips). Frozen into a
2847    /// thread-local registry in `run` / `build_headless` before the
2848    /// first frame builds.
2849    tooltip_contents: Vec<teksilo_widgets::tooltip::TooltipContent>,
2850    /// OS-correct application paths (config / data dirs). Set via
2851    /// [`application`](Self::application) or [`app_paths`](Self::app_paths).
2852    /// Required when `settings_bundle` is set.
2853    app_paths: Option<teksilo_settings::AppPaths>,
2854    /// Persistence configuration. When present, the bundle is opened
2855    /// at startup and each enabled service is registered into the
2856    /// `app_state` registry under its concrete type.
2857    settings_bundle: Option<teksilo_settings::SettingsBundle>,
2858    /// Whether `run()` should start a `SettingsWatcher` over the settings
2859    /// directories so a peer process's write is picked up live. On by
2860    /// default whenever a settings bundle is configured — this is the
2861    /// entire point of `SettingsBundle`'s cross-process-safe writes.
2862    /// Toggle off via [`settings_watch`](Self::settings_watch) for tests
2863    /// or environments without a usable filesystem watcher.
2864    settings_watch_enabled: bool,
2865    /// Telemetry configuration. When present, the bundle is opened
2866    /// after `settings_bundle` (it depends on `SettingsStore`) and the
2867    /// resulting `OpenedTelemetry` + `TelemetryContext` are registered
2868    /// into the `app_state` registry. The `TelemetryContext` is the
2869    /// hook the dispatch tap in
2870    /// [`teksilo_core::widget_tree::WidgetTree::dispatch_intent`] uses to
2871    /// emit `intent.dispatched` events.
2872    #[cfg(feature = "telemetry")]
2873    telemetry_bundle: Option<teksilo_telemetry::TelemetryBundle>,
2874    /// Per-loop-turn closure + poll flag installed via
2875    /// [`on_loop_tick`](Self::on_loop_tick). Async-agnostic; moved into the
2876    /// handler at `run`.
2877    loop_tick: Option<Box<dyn FnMut() -> bool>>,
2878    loop_tick_poll: Option<std::rc::Rc<std::cell::Cell<bool>>>,
2879}
2880
2881impl TeksiloAppBuilder {
2882    pub fn new() -> Self {
2883        Self {
2884            theme: teksilo_core::presets::intui::light(),
2885            theme_mode: ThemeMode::Manual,
2886            #[cfg(feature = "text")]
2887            typesetter: None,
2888            #[cfg(feature = "text")]
2889            font_registrars: Vec::new(),
2890            app_event_handler: None,
2891            external_ctx_handler: None,
2892            on_ready: Vec::new(),
2893            initial_window: None,
2894            event_source: None,
2895            app_state_registry: HashMap::new(),
2896            i18n: None,
2897            tooltip_contents: Vec::new(),
2898            app_paths: None,
2899            settings_bundle: None,
2900            settings_watch_enabled: true,
2901            #[cfg(feature = "telemetry")]
2902            telemetry_bundle: None,
2903            loop_tick: None,
2904            loop_tick_poll: None,
2905        }
2906    }
2907
2908    /// Identify the application for OS-correct path resolution. The
2909    /// `(qualifier, organization, application)` triple follows the
2910    /// `directories` convention (e.g. `("eu", "FernTech", "Skribisto")`).
2911    /// Required when [`settings`](Self::settings) is used.
2912    ///
2913    /// # Panics
2914    ///
2915    /// Panics if the OS does not expose a usable home directory
2916    /// (typically a sandboxed environment with `HOME` unset). Use
2917    /// [`app_paths`](Self::app_paths) to supply an explicit path
2918    /// in that situation.
2919    pub fn application(mut self, qualifier: &str, organization: &str, application: &str) -> Self {
2920        let paths = teksilo_settings::AppPaths::new(qualifier, organization, application)
2921            .unwrap_or_else(|| {
2922                panic!(
2923                    "TeksiloAppBuilder::application(\"{qualifier}\", \"{organization}\", \
2924                     \"{application}\"): could not resolve a usable OS config directory. \
2925                     This typically happens in sandboxed environments with no HOME set. \
2926                     Use TeksiloAppBuilder::app_paths(AppPaths::for_testing(...) or \
2927                     AppPaths::from_dirs(...)) to supply an explicit location.",
2928                )
2929            });
2930        self.app_paths = Some(paths);
2931        self
2932    }
2933
2934    /// Provide an explicit [`AppPaths`](teksilo_settings::AppPaths). Used
2935    /// for portable-mode apps and tests.
2936    pub fn app_paths(mut self, paths: teksilo_settings::AppPaths) -> Self {
2937        self.app_paths = Some(paths);
2938        self
2939    }
2940
2941    /// Read the currently-configured `AppPaths`, if any. Used by
2942    /// builder-extension traits (e.g. `install_toast` in `teksilo`)
2943    /// that need to open persistent files at install time before
2944    /// `run` fires.
2945    pub fn configured_app_paths(&self) -> Option<&teksilo_settings::AppPaths> {
2946        self.app_paths.as_ref()
2947    }
2948
2949    /// Configure the persistence bundle. When `run`/`build_headless`
2950    /// fires, the bundle is opened against the configured `AppPaths`
2951    /// and every active service is registered in `app_state`, where
2952    /// it becomes reachable via the
2953    /// [`SettingsExt`](teksilo_settings::SettingsExt) trait.
2954    ///
2955    /// # Panics
2956    ///
2957    /// Panics during `run` / `build_headless` if no `AppPaths` was
2958    /// configured first via [`application`](Self::application) or
2959    /// [`app_paths`](Self::app_paths).
2960    pub fn settings(mut self, bundle: teksilo_settings::SettingsBundle) -> Self {
2961        self.settings_bundle = Some(bundle);
2962        self
2963    }
2964
2965    /// Enable or disable the live cross-process settings-reload watcher
2966    /// started in [`run`](Self::run) (windowed apps only —
2967    /// [`build_headless`](Self::build_headless) never starts one, since
2968    /// there is no event loop to post the reload event through).
2969    ///
2970    /// **On by default** whenever [`settings`](Self::settings) is
2971    /// configured: this is what makes a peer process's write to a
2972    /// shared settings file (Skribisto's one-process-per-project model
2973    /// shares `general.toml` / `recents.toml` / `window_state.toml`
2974    /// across every open project) show up in this process's UI with no
2975    /// restart and no polling. Pass `false` to opt out — e.g. a
2976    /// sandboxed test environment with no usable filesystem watcher, or
2977    /// an app that wants to poll `Reloadable::reload_from_disk` on its
2978    /// own schedule instead.
2979    pub fn settings_watch(mut self, enabled: bool) -> Self {
2980        self.settings_watch_enabled = enabled;
2981        self
2982    }
2983
2984    /// Configure the telemetry stack (`teksilo-telemetry`). Mirrors
2985    /// [`settings`](Self::settings): the bundle is opened during
2986    /// `run` / `build_headless` against the configured `AppPaths`
2987    /// **and** the live `SettingsStore`, and the resulting handles
2988    /// (`OpenedTelemetry`, `TelemetryContext`, `DynamicReporter`) are
2989    /// registered into `app_state`. Apps reach them via
2990    /// [`teksilo_telemetry::TelemetryExt`] (`use teksilo_telemetry::TelemetryExt;`).
2991    ///
2992    /// # Panics
2993    ///
2994    /// Panics during `run` / `build_headless` if no `AppPaths` was
2995    /// configured first via [`application`](Self::application) or
2996    /// [`app_paths`](Self::app_paths), or if no
2997    /// [`settings`](Self::settings) bundle was registered (the
2998    /// telemetry consent file is opened via the same `AppPaths` and
2999    /// the endpoint-override key is read from the `SettingsStore`).
3000    #[cfg(feature = "telemetry")]
3001    pub fn telemetry(mut self, bundle: teksilo_telemetry::TelemetryBundle) -> Self {
3002        self.telemetry_bundle = Some(bundle);
3003        self
3004    }
3005
3006    /// Register the application's tooltip string catalog.
3007    ///
3008    /// Each [`TooltipContent`](teksilo_widgets::tooltip::TooltipContent)
3009    /// in the list maps a short stable key (referenced from inline
3010    /// markup as `[label](:key)`) to a translatable body, an optional
3011    /// long-form "more" body revealed by the Accordion disclosure
3012    /// inside a sticky rich tooltip, and an optional keyboard shortcut
3013    /// (literal label — registry-backed auto-lookup is a follow-up).
3014    ///
3015    /// This is a **single-call registration**: the list is the
3016    /// application's complete tooltip catalog. Call once at app boot,
3017    /// before `run()`. Calling multiple times panics in debug builds.
3018    ///
3019    /// ```ignore
3020    /// use teksilo_widgets::tooltip::TooltipContent;
3021    ///
3022    /// TeksiloAppBuilder::new()
3023    ///     .register_tooltips(vec![
3024    ///         TooltipContent::new("save-as", tr!(save_as_tooltip))
3025    ///             .for_shortcut("app.save_as"),
3026    ///         TooltipContent::new("autosave", tr!(autosave_tooltip))
3027    ///             .with_more(tr!(autosave_tooltip_more)),
3028    ///     ])
3029    ///     // …
3030    /// ```
3031    ///
3032    /// **Multiple calls accumulate**, like [`Self::register_fonts`] and
3033    /// [`I18nConfig::compile_in`](teksilo_i18n::I18nConfig::compile_in), so an
3034    /// application can compose its own catalogue with catalogues shipped by
3035    /// plugins, extensions or sibling crates. Assigning here instead would mean
3036    /// a contributor registering one tooltip silently deleted every tooltip the
3037    /// application had — the failure has no error and no warning, it just makes
3038    /// rich tooltips stop resolving their `[label](:key)` links.
3039    ///
3040    /// On a duplicate key the **first** registration wins; see
3041    /// [`install_tooltip_registry`](teksilo_widgets::tooltip::install_tooltip_registry).
3042    pub fn register_tooltips(
3043        mut self,
3044        contents: Vec<teksilo_widgets::tooltip::TooltipContent>,
3045    ) -> Self {
3046        self.tooltip_contents.extend(contents);
3047        self
3048    }
3049
3050    /// Register a backend event source. Widgets can
3051    /// then call `BuildContext::subscribe_event(origin, callback)` from
3052    /// inside their `build()` method to receive events on the UI thread.
3053    ///
3054    /// Only one source per application is supported. Subsequent calls
3055    /// replace the previously registered source.
3056    pub fn event_source<S: EventSource>(mut self, source: S) -> Self {
3057        self.event_source = Some(EventSourceAdapter::new(source));
3058        self
3059    }
3060
3061    /// Register an application-defined value of type `T` that any widget
3062    /// can retrieve via `BuildContext::app_state::<T>()`.
3063    ///
3064    /// Each type `T` may be registered at most once; a subsequent call
3065    /// with the same type replaces the previous value. To share multiple
3066    /// values of the same logical kind, wrap each in a distinct newtype.
3067    pub fn app_state<T: 'static>(mut self, value: T) -> Self {
3068        self.app_state_registry
3069            .insert(TypeId::of::<T>(), Box::new(value));
3070        self
3071    }
3072
3073    /// Register an app-wide [`DefaultPostRoot`](crate::DefaultPostRoot) hook that wraps every
3074    /// window's root after its `root_builder` runs.
3075    ///
3076    /// Unlike `app_state(DefaultPostRoot::new(..))` — which stores a single
3077    /// type-keyed value and so silently replaces any previously-registered
3078    /// hook — this **composes**: each registered hook runs in call order,
3079    /// each wrapping the previous one's result. So an app that installs the
3080    /// debug inspector AND the toast host (or any other post-root chrome)
3081    /// gets both wrappers, not just whichever was installed last. The
3082    /// earlier-registered hook is the innermost wrapper (it sees the raw
3083    /// user root); the latest is outermost.
3084    ///
3085    /// All framework installers that splice window-level chrome
3086    /// (`install_inspector_in_debug`, `install_toast*`) route through this,
3087    /// so their order of installation no longer matters for correctness.
3088    pub fn register_post_root(mut self, hook: crate::DefaultPostRoot) -> Self {
3089        use crate::DefaultPostRoot;
3090        let key = TypeId::of::<DefaultPostRoot>();
3091        let composed = match self.app_state_registry.remove(&key) {
3092            Some(existing) => {
3093                let existing = *existing
3094                    .downcast::<DefaultPostRoot>()
3095                    .expect("DefaultPostRoot slot held a non-DefaultPostRoot value");
3096                let prev = existing.0;
3097                let next = hook.0;
3098                DefaultPostRoot(std::rc::Rc::new(move |tree, root_id| {
3099                    let inner = prev(tree, root_id);
3100                    next(tree, inner)
3101                }))
3102            }
3103            None => hook,
3104        };
3105        self.app_state_registry.insert(key, Box::new(composed));
3106        self
3107    }
3108
3109    /// Register a composable observer that runs on every `AppEvent`,
3110    /// in addition to (never instead of) the single
3111    /// [`on_app_event`](Self::on_app_event) handler.
3112    ///
3113    /// Unlike `on_app_event` — which stores a single `Option<Box<dyn
3114    /// FnMut(&AppEvent)>>` and so silently replaces any previously
3115    /// registered handler — this **composes**: each registered observer
3116    /// runs, in call order, on every `AppEvent` delivered to the UI
3117    /// thread. So a framework extension that needs to react to
3118    /// `AppEvent`s (e.g. `teksilo::install_toast` turning
3119    /// `AppEvent::SettingsWriteFailed` into a toast) can register its
3120    /// own observer without clobbering the application's own
3121    /// `on_app_event` handler, or being clobbered by it, regardless of
3122    /// install order. Mirrors [`register_post_root`](Self::register_post_root)'s
3123    /// type-keyed `app_state` composition pattern exactly, but for
3124    /// event observation instead of post-root window chrome.
3125    ///
3126    /// See `TeksiloAppHandler::user_event` for the dispatch order: the
3127    /// `on_app_event` handler runs first, then every composed observer.
3128    pub fn register_app_event_observer(mut self, observer: impl Fn(&AppEvent) + 'static) -> Self {
3129        use crate::app_event_observers::AppEventObservers;
3130        let key = TypeId::of::<AppEventObservers>();
3131        let observer = AppEventObservers::new(observer);
3132        let composed = match self.app_state_registry.remove(&key) {
3133            Some(existing) => {
3134                let existing = *existing
3135                    .downcast::<AppEventObservers>()
3136                    .expect("AppEventObservers slot held a non-AppEventObservers value");
3137                let prev = existing.0;
3138                let next = observer.0;
3139                AppEventObservers(std::rc::Rc::new(move |event: &AppEvent| {
3140                    prev(event);
3141                    next(event);
3142                }))
3143            }
3144            None => observer,
3145        };
3146        self.app_state_registry.insert(key, Box::new(composed));
3147        self
3148    }
3149
3150    /// Install the rfd-backed native file-dialog service. Registers a
3151    /// [`FileDialogHandle`](teksilo_platform::file_dialog::FileDialogHandle)
3152    /// wrapping an
3153    /// [`RfdAsyncBackend`](teksilo_platform::file_dialog::RfdAsyncBackend)
3154    /// into the app-state registry. Reachable from any handler via
3155    /// `ctx.app_state::<FileDialogHandle>()`, or — with
3156    /// `use teksilo_platform::file_dialog::EventContextFileDialogExt;` —
3157    /// directly via `ctx.pick_file(req, |result, ctx| ...)`.
3158    ///
3159    /// Apps that ship a custom or mock backend bypass this and call
3160    /// `.app_state(FileDialogHandle::new(my_backend))` directly.
3161    #[cfg(feature = "rfd-backend")]
3162    pub fn install_file_dialog(mut self) -> Self {
3163        use teksilo_platform::file_dialog::{FileDialogHandle, RfdAsyncBackend};
3164        let handle = FileDialogHandle::new(RfdAsyncBackend::new());
3165        self.app_state_registry
3166            .insert(TypeId::of::<FileDialogHandle>(), Box::new(handle));
3167        self
3168    }
3169
3170    /// Install the external (OS) drag-and-drop service. Registers an
3171    /// [`ExternalDndHandle`](teksilo_platform::external_dnd::ExternalDndHandle)
3172    /// wrapping the platform's default backend
3173    /// ([`default_backend`](teksilo_platform::external_dnd::default_backend) —
3174    /// raw `NSDraggingDestination` on macOS, OLE on Windows, `wl_data_device`
3175    /// on Wayland, a no-op on X11) into the app-state registry.
3176    ///
3177    /// Once installed, every window is registered as an OS drop target on
3178    /// creation (and detached on close) by the window manager. Drops surface
3179    /// to widgets through the normal drag handlers (`on_drag_hover` /
3180    /// `on_drag_leave` / `on_drop`) with `payload.is_external()` true — the
3181    /// ready-made `DropZone` widget consumes them.
3182    ///
3183    /// Apps that ship a custom backend bypass this and call
3184    /// `.app_state(ExternalDndHandle::new(my_backend))` directly.
3185    pub fn install_external_dnd(mut self) -> Self {
3186        use teksilo_platform::external_dnd::{ExternalDndHandle, default_backend};
3187        let handle = ExternalDndHandle::new(default_backend());
3188        self.app_state_registry
3189            .insert(TypeId::of::<ExternalDndHandle>(), Box::new(handle));
3190        self
3191    }
3192
3193    /// Install the native (OS) menu service. Registers a
3194    /// [`NativeMenuHandle`](teksilo_platform::native_menu::NativeMenuHandle)
3195    /// wrapping the platform's default backend (a real `NSMenu` on macOS, a
3196    /// no-op elsewhere) into the app-state registry.
3197    ///
3198    /// Once installed, a [`MenuBar`](teksilo_widgets::MenuBar) built with
3199    /// `from_model(..).native_on_macos(..)` mirrors its [`MenuModel`](teksilo_widgets::MenuModel) into the
3200    /// global menu bar on macOS, and item activations route back through the
3201    /// usual `Intent`/`Action` pipeline. The global menu follows window focus
3202    /// automatically (see the `WindowEvent::Focused` arm).
3203    ///
3204    /// Apps that ship a custom backend bypass this and call
3205    /// `.app_state(NativeMenuHandle::new(my_backend))` directly.
3206    pub fn install_native_menu(mut self) -> Self {
3207        use teksilo_platform::native_menu::{NativeMenuHandle, default_backend};
3208        let handle = NativeMenuHandle::new(default_backend());
3209        self.app_state_registry
3210            .insert(TypeId::of::<NativeMenuHandle>(), Box::new(handle));
3211        self
3212    }
3213
3214    /// Register an `I18nConfig`. Constructs an
3215    /// `I18nManager` at startup, installs it on the thread-local, and
3216    /// seeds the widget tree with the resolved initial locale and layout
3217    /// direction. Without this call, `tr!`-expanded code falls back to
3218    /// returning the literal key as a placeholder.
3219    pub fn i18n(mut self, config: I18nConfig) -> Self {
3220        self.i18n = Some(config);
3221        self
3222    }
3223
3224    /// Set a fixed theme (implies `ThemeMode::Manual`).
3225    pub fn theme(mut self, theme: Theme) -> Self {
3226        self.theme = theme;
3227        self.theme_mode = ThemeMode::Manual;
3228        self
3229    }
3230
3231    /// Set how the application resolves its theme.
3232    ///
3233    /// - `ThemeMode::Manual` — use the theme set via `.theme()` (default).
3234    /// - `ThemeMode::FollowSystem` — auto-switch between light/dark built-in themes.
3235    /// - `ThemeMode::Native` — read colors from OS desktop environment config.
3236    pub fn theme_mode(mut self, mode: ThemeMode) -> Self {
3237        self.theme_mode = mode;
3238        self
3239    }
3240
3241    #[cfg(feature = "text")]
3242    pub fn typesetter(mut self, typesetter: SharedTypesetter) -> Self {
3243        self.typesetter = Some(typesetter);
3244        self
3245    }
3246
3247    /// Register additional fonts (e.g. a theme's font family) into the
3248    /// shared typesetter at startup, *before* any text is shaped — so a
3249    /// theme that sets `typography.body.family = "Roboto"` resolves
3250    /// correctly instead of silently falling back to the bundled Inter.
3251    ///
3252    /// A theme preset typically exposes a `FontRegistrar` the app passes
3253    /// here:
3254    /// ```ignore
3255    /// TeksiloAppBuilder::new()
3256    ///     .theme(material3::light())
3257    ///     .register_fonts(material3::font_registrar())
3258    ///     .run();
3259    /// ```
3260    #[cfg(feature = "text")]
3261    pub fn register_fonts(mut self, registrar: impl teksilo_text::FontRegistrar + 'static) -> Self {
3262        self.font_registrars.push(Box::new(registrar));
3263        self
3264    }
3265
3266    /// Register a handler for `AppEvent`s received from background threads.
3267    pub fn on_app_event(mut self, handler: impl FnMut(&AppEvent) + 'static) -> Self {
3268        self.app_event_handler = Some(Box::new(handler));
3269        self
3270    }
3271
3272    /// Register a router for [`AppEvent::External`] payloads that needs to
3273    /// **open, find or focus windows** — see [`ExternalCtxHandler`].
3274    ///
3275    /// [`on_app_event`](Self::on_app_event) is the hook for reacting to an event;
3276    /// this is the hook for *acting on the window set* because of one. The
3277    /// difference is not stylistic: `on_app_event` receives `&AppEvent` and
3278    /// nothing else, and `EventContext::open_window` panics on a standalone
3279    /// context, so there is no way to open a window from there at all.
3280    ///
3281    /// The handler runs against the focused window's tree (or the primary
3282    /// window's) with a real [`WindowOps`](teksilo_core::WindowOps) sink, and is
3283    /// consulted **only** for payloads that no framework router and no built-in
3284    /// downcast arm claimed — so it never has to defend against
3285    /// `CloseWindowRequest` and friends. Return `true` when the payload was
3286    /// yours.
3287    ///
3288    /// Unlike [`register_app_event_observer`](Self::register_app_event_observer),
3289    /// this is a single slot: calling it twice replaces the first router, the
3290    /// same way `on_app_event` does.
3291    ///
3292    /// ```ignore
3293    /// // A single-instance app: a second launch forwards its argv over a socket,
3294    /// // the listener posts it with `AppEventProxy::send_external`, and this
3295    /// // opens (or raises) the document window — the "document window" recipe in
3296    /// // docs/multi-window.md, driven from off the UI thread.
3297    /// .on_external_with_ctx(move |payload, ctx| {
3298    ///     let Some(req) = payload.downcast_ref::<OpenDocument>() else {
3299    ///         return false;
3300    ///     };
3301    ///     let wid = window_id_for(&req.path);
3302    ///     match ctx.find_window(&wid) {
3303    ///         Some(id) => ctx.focus_window(id),
3304    ///         None => { ctx.open_window(document_window_config(&req.path)); }
3305    ///     }
3306    ///     true
3307    /// })
3308    /// ```
3309    pub fn on_external_with_ctx(
3310        mut self,
3311        handler: impl FnMut(
3312            &(dyn std::any::Any + Send),
3313            &mut teksilo_core::widget::EventContext,
3314        ) -> bool
3315        + 'static,
3316    ) -> Self {
3317        self.external_ctx_handler = Some(Box::new(handler));
3318        self
3319    }
3320
3321    /// Register a callback that receives an `AppEventProxy` once the event loop is ready.
3322    /// Use this to hand the proxy to background threads that need to post commands.
3323    /// May be called more than once; all registered callbacks fire in order
3324    /// (e.g. `install_async` registers one to wire the executor's waker).
3325    pub fn on_ready(mut self, handler: impl FnOnce(AppEventProxy) + 'static) -> Self {
3326        self.on_ready.push(Box::new(handler));
3327        self
3328    }
3329
3330    /// Register a closure run once per event-loop turn (at the top of
3331    /// `about_to_wait`) plus a shared poll flag. Returning `true` from the
3332    /// closure means it advanced work that may have mutated UI state, which
3333    /// triggers a repaint of all windows. While `poll_source` is set the loop
3334    /// stays in [`ControlFlow::Poll`] so the closure keeps running; when it
3335    /// clears, the loop sleeps until the next event (off-thread wakes arrive
3336    /// via [`AppEventProxy`]).
3337    ///
3338    /// General-purpose and async-agnostic — `teksilo-app` only ever sees
3339    /// `FnMut`. The optional `teksilo-async` crate uses this to drive a
3340    /// main-thread executor; nothing in the core loop depends on a runtime.
3341    pub fn on_loop_tick(
3342        mut self,
3343        poll_source: std::rc::Rc<std::cell::Cell<bool>>,
3344        tick: impl FnMut() -> bool + 'static,
3345    ) -> Self {
3346        self.loop_tick = Some(Box::new(tick));
3347        self.loop_tick_poll = Some(poll_source);
3348        self
3349    }
3350
3351    /// Configure the initial window. Required — every app must open at
3352    /// least one window at startup. The single canonical entry point:
3353    /// build a [`WindowConfig`] and pass it here.
3354    ///
3355    /// ```ignore
3356    /// TeksiloAppBuilder::new()
3357    ///     .theme(teksilo_core::presets::intui::light())
3358    ///     .initial_window(
3359    ///         WindowConfig::new()
3360    ///             .title("My App")
3361    ///             .size(800, 600)
3362    ///             .root(|tree, _state| tree.add(MyRoot::new())),
3363    ///     )
3364    ///     .run();
3365    /// ```
3366    pub fn initial_window(mut self, config: WindowConfig) -> Self {
3367        self.initial_window = Some(config);
3368        self
3369    }
3370
3371    /// Open the configured settings bundle (if any) and register
3372    /// each service in the app-state registry.
3373    fn install_settings(&mut self) -> Option<teksilo_settings::OpenedSettings> {
3374        let bundle = self.settings_bundle.take()?;
3375        let paths = self.app_paths.clone().expect(
3376            "TeksiloAppBuilder::settings(...) requires .application(...) or .app_paths(...) \
3377             to be set first so persistence has a target directory.",
3378        );
3379        match bundle.open(&paths) {
3380            Ok(opened) => {
3381                self.app_state_registry.insert(
3382                    TypeId::of::<teksilo_settings::SettingsStore>(),
3383                    Box::new(opened.store.clone()),
3384                );
3385                if let Some(w) = &opened.window_state {
3386                    self.app_state_registry.insert(
3387                        TypeId::of::<teksilo_settings::WindowStateService>(),
3388                        Box::new(w.clone()),
3389                    );
3390                }
3391                // Reachable from any handler via
3392                // `ctx.app_state::<teksilo_settings::SettingsRegistry>()`,
3393                // so application code can register its own ad hoc
3394                // `SettingsFile` / `PersistedListModel` / `MruList`
3395                // handles into the very same registry a `SettingsWatcher`
3396                // event gets dispatched through — not just the two
3397                // services the bundle itself opens.
3398                self.app_state_registry.insert(
3399                    TypeId::of::<teksilo_settings::SettingsRegistry>(),
3400                    Box::new(opened.registry.clone()),
3401                );
3402                Some(opened)
3403            }
3404            Err(e) => {
3405                eprintln!("teksilo-app: failed to open settings bundle: {e}");
3406                None
3407            }
3408        }
3409    }
3410
3411    /// Open the configured telemetry bundle (if any) and register the
3412    /// resulting handles into `app_state` so the dispatch tap and the
3413    /// `TelemetryExt` accessors can reach them. Must be called *after*
3414    /// `install_settings`, because `TelemetryBundle::open` reads the
3415    /// endpoint-override key from the live `SettingsStore`.
3416    ///
3417    /// # Panics
3418    ///
3419    /// Panics if `.telemetry(...)` was called without prior
3420    /// `.application(...)` / `.app_paths(...)`, or without a
3421    /// `.settings(...)` bundle. Both are hard requirements: the
3422    /// consent file needs an `AppPaths` target, and the runtime
3423    /// endpoint-override key lives in the `SettingsStore`.
3424    /// Fail-closed by design — a misconfigured app must not silently
3425    /// skip telemetry installation.
3426    #[cfg(feature = "telemetry")]
3427    fn install_telemetry(&mut self, settings: Option<&teksilo_settings::SettingsStore>) {
3428        let Some(bundle) = self.telemetry_bundle.take() else {
3429            return;
3430        };
3431        let paths = self.app_paths.clone().expect(
3432            "TeksiloAppBuilder::telemetry(...) requires .application(...) or .app_paths(...) \
3433             to be set first so the consent file has a target directory.",
3434        );
3435        let store = settings.expect(
3436            "TeksiloAppBuilder::telemetry(...) requires .settings(...) so the runtime \
3437             endpoint-override key can be read from the SettingsStore. \
3438             Add .settings(SettingsBundle::new()) before .telemetry(...).",
3439        );
3440        match bundle.open(&paths, store) {
3441            Ok(opened) => {
3442                // Register the OpenedTelemetry under its concrete type
3443                // so widgets can access it via TelemetryExt::telemetry().
3444                self.app_state_registry.insert(
3445                    TypeId::of::<teksilo_telemetry::OpenedTelemetry>(),
3446                    Box::new(opened.clone()),
3447                );
3448                // Register the dispatch hook under the teksilo-core type.
3449                // The dispatch tap looks this up by TypeId.
3450                let session_id = generate_session_id();
3451                let tcx = teksilo_core::telemetry::TelemetryContext {
3452                    reporter: opened.reporter.clone()
3453                        as std::rc::Rc<dyn teksilo_core::telemetry::UsageReporter>,
3454                    session_id,
3455                    schema_version: opened.event_schema_version,
3456                };
3457                self.app_state_registry.insert(
3458                    TypeId::of::<teksilo_core::telemetry::TelemetryContext>(),
3459                    Box::new(tcx),
3460                );
3461            }
3462            Err(e) => {
3463                eprintln!("teksilo-app: failed to open telemetry bundle: {e}");
3464            }
3465        }
3466    }
3467
3468    /// Build a headless app for testing (no window, no GPU).
3469    pub fn build_headless(mut self) -> HeadlessApp {
3470        // Install the tooltip registry before anything else — widgets
3471        // that read from it during their first build (e.g. rich
3472        // tooltips looking up their :key) need it available.
3473        if !self.tooltip_contents.is_empty() {
3474            teksilo_widgets::tooltip::install_tooltip_registry(std::mem::take(
3475                &mut self.tooltip_contents,
3476            ));
3477        }
3478
3479        // Open settings (if a bundle was configured) and register the
3480        // services into `app_state_registry` so they're reachable from
3481        // any handler via the SettingsExt trait.
3482        let opened_settings = self.install_settings();
3483
3484        // Open telemetry (if a bundle was configured). Must come after
3485        // install_settings — TelemetryBundle reads the endpoint-override
3486        // key from the SettingsStore.
3487        #[cfg(feature = "telemetry")]
3488        self.install_telemetry(opened_settings.as_ref().map(|s| &s.store));
3489
3490        let mut tree = WidgetTree::new().with_theme(self.theme.clone());
3491
3492        #[cfg(feature = "text")]
3493        let typesetter = {
3494            let ts = self
3495                .typesetter
3496                .take()
3497                .unwrap_or_else(SharedTypesetter::new_with_default_font);
3498            // Install app/theme fonts before any text is shaped, so a
3499            // theme's `typography.*.family` resolves instead of falling
3500            // back to the bundled default.
3501            for registrar in &self.font_registrars {
3502                ts.apply_font_registrar(registrar.as_ref());
3503            }
3504            tree = tree.with_text_backend(ts.as_text_backend());
3505            // Auto-register so rich-text widgets can reach the shared
3506            // typesetter via `ctx.app_state::<SharedTypesetter>()` in
3507            // headless tests too.
3508            use std::any::TypeId;
3509            self.app_state_registry
3510                .insert(TypeId::of::<SharedTypesetter>(), Box::new(ts.clone()));
3511            ts
3512        };
3513        #[cfg(not(feature = "text"))]
3514        let _ = &mut self;
3515
3516        // Install the i18n manager (if any) and seed the tree with the
3517        // resolved initial locale and layout direction. Must happen before
3518        // the root builder runs so that any `tr!` calls inside `build()`
3519        // resolve against the correct locale on first build.
3520        let i18n_manager = self.i18n.as_ref().map(|cfg| install_i18n(&mut tree, cfg));
3521
3522        // Install the app-state registry (if any) before running the root
3523        // builder so that widgets' `build()` methods can call
3524        // `ctx.app_state::<T>()`.
3525        if !self.app_state_registry.is_empty() {
3526            let ctx = TreeAppContext::empty().with_app_state(self.app_state_registry);
3527            tree.set_app_context(std::rc::Rc::new(ctx));
3528        }
3529        #[cfg(feature = "text")]
3530        let _ = &typesetter;
3531
3532        // Build the root from the `initial_window`'s builder if one was
3533        // provided. Headless apps without an `initial_window` run with an
3534        // empty tree — tests add widgets via `tree.add(...)` directly.
3535        if let Some(mut config) = self.initial_window.take()
3536            && let Some(root_builder) = config.take_root_builder()
3537        {
3538            // Headless has no real WindowState; construct a stub so
3539            // widgets that bind against their own window signals
3540            // still get a valid handle.
3541            let stub_state = teksilo_core::WindowState::new(teksilo_core::WindowStateInit {
3542                id: crate::TeksiloWindowId::new(0),
3543                string_id: config.string_id.clone(),
3544                placement: config.initial_placement,
3545                title: config.title.clone(),
3546                size: config.size,
3547                position: config.position.unwrap_or((0, 0)),
3548                focused: true,
3549                resizable: config.resizable,
3550                always_on_top: config.always_on_top,
3551            });
3552            tree.set_window_state(stub_state.clone());
3553            root_builder(&mut tree, stub_state);
3554        }
3555
3556        HeadlessApp {
3557            tree,
3558            theme: self.theme,
3559            i18n_manager,
3560            settings: opened_settings,
3561        }
3562    }
3563
3564    /// Build and run the application with windowed rendering.
3565    pub fn run(mut self) {
3566        // Install the tooltip registry before the window manager
3567        // starts building trees — rich tooltips read from it during
3568        // their first build.
3569        if !self.tooltip_contents.is_empty() {
3570            teksilo_widgets::tooltip::install_tooltip_registry(std::mem::take(
3571                &mut self.tooltip_contents,
3572            ));
3573        }
3574
3575        // Open settings (if a bundle was configured) so the services
3576        // are present in the app_state registry when window trees
3577        // start being built. The `OpenedSettings` handle is kept on
3578        // the stack so its inner `SettingsFile` clones live long
3579        // enough to flush on shutdown.
3580        let opened_settings = self.install_settings();
3581
3582        // Open telemetry (if a bundle was configured). Must come after
3583        // install_settings — TelemetryBundle reads the endpoint-override
3584        // key from the SettingsStore.
3585        #[cfg(feature = "telemetry")]
3586        self.install_telemetry(opened_settings.as_ref().map(|s| &s.store));
3587
3588        // Construct the i18n manager (if configured) and install it on
3589        // the thread-local before any window or widget tree is created.
3590        // `WindowManager::create_window` seeds every new tree from the
3591        // thread-local, so each window inherits the manager's active
3592        // locale and layout direction on construction — no separate
3593        // post-create seeding step needed here.
3594        //
3595        // `runtime_override` entries are collected before the install
3596        // so the hot-reload watcher can be spun up after the winit
3597        // event loop exists (we need the `EventLoopProxy` as the sink
3598        // target) without a second borrow of `self.i18n`.
3599        let runtime_overrides: Vec<(LanguageIdentifier, std::path::PathBuf)> = self
3600            .i18n
3601            .as_ref()
3602            .map(|cfg| cfg.runtime_overrides().to_vec())
3603            .unwrap_or_default();
3604
3605        if let Some(cfg) = self.i18n.as_ref() {
3606            install_i18n_manager(cfg);
3607        }
3608
3609        let event_loop = winit::event_loop::EventLoop::<AppEvent>::with_user_event()
3610            .build()
3611            .expect("winit event loop creation failed");
3612        event_loop.set_control_flow(ControlFlow::Wait);
3613
3614        // Always create a proxy: it's needed by both `on_ready` (if set)
3615        // and by the event-source poster (if a source is registered). The
3616        // proxy is cheap to clone.
3617        let proxy = AppEventProxy {
3618            inner: event_loop.create_proxy(),
3619        };
3620
3621        // Register the process-wide sink for permanently-discarded
3622        // `teksilo-settings` writes (F3): a `DebouncedWriter` gave up
3623        // after `MAX_WRITE_ATTEMPTS` retries, or was dropped at teardown
3624        // with a write still failing. Previously this only reached an
3625        // `eprintln!` on the settings crate's own background I/O thread
3626        // and was otherwise invisible; this posts a typed `AppEvent`
3627        // through the event loop proxy so it reaches the UI thread like
3628        // every other backend->UI channel (see `user_event` above).
3629        let proxy_for_write_failure = proxy.inner.clone();
3630        teksilo_settings::set_write_failure_sink(std::sync::Arc::new(
3631            move |path, attempts, dropped_patches, message| {
3632                let _ = proxy_for_write_failure.send_event(AppEvent::SettingsWriteFailed {
3633                    path,
3634                    attempts,
3635                    dropped_patches,
3636                    message,
3637                });
3638            },
3639        ));
3640
3641        // Build the i18n hot-reload watcher if any `runtime_override`s
3642        // were registered. The sink posts `AppEvent::I18nReload` through
3643        // the event loop proxy; the watcher's background thread converts
3644        // file-change events into these messages. The watcher handle is
3645        // handed to `TeksiloAppHandler` which keeps it alive for the loop
3646        // lifetime. Construction failures log and fall back to no
3647        // hot-reload (the rest of i18n still works).
3648        let i18n_watcher = if runtime_overrides.is_empty() {
3649            None
3650        } else {
3651            let proxy_for_sink = proxy.inner.clone();
3652            let sink: teksilo_i18n::ReloadSink = std::sync::Arc::new(move |locale, path| {
3653                let _ = proxy_for_sink.send_event(AppEvent::I18nReload {
3654                    locale: locale.to_string(),
3655                    path,
3656                });
3657            });
3658            match teksilo_i18n::FtlFileWatcher::new(runtime_overrides, sink) {
3659                Ok(watcher) => Some(watcher),
3660                Err(e) => {
3661                    eprintln!("teksilo-app: failed to start i18n file watcher: {e}");
3662                    None
3663                }
3664            }
3665        };
3666
3667        // Build the live cross-process settings-reload watcher, mirroring
3668        // the i18n watcher immediately above: on by default whenever a
3669        // settings bundle was actually opened (`opened_settings.is_some()`),
3670        // opt-out via `.settings_watch(false)`. The sink posts
3671        // `AppEvent::SettingsReload` through the event loop proxy; the
3672        // handler (see `user_event` above) dispatches the changed path
3673        // through the app's `SettingsRegistry` (installed into `app_state`
3674        // by `install_settings`). Construction failures log and fall back
3675        // to no live reload — the rest of settings persistence still
3676        // works, peers just won't be noticed until this process happens
3677        // to touch the same key itself.
3678        let settings_watcher = if self.settings_watch_enabled && opened_settings.is_some() {
3679            self.app_paths.as_ref().and_then(|paths| {
3680                let proxy_for_sink = proxy.inner.clone();
3681                let sink: teksilo_settings::SettingsReloadSink = std::sync::Arc::new(move |path| {
3682                    let _ = proxy_for_sink.send_event(AppEvent::SettingsReload { path });
3683                });
3684                let dirs = vec![
3685                    paths.config_dir().to_path_buf(),
3686                    paths.data_dir().to_path_buf(),
3687                ];
3688                match teksilo_settings::SettingsWatcher::new(dirs, sink) {
3689                    Ok(watcher) => Some(watcher),
3690                    Err(e) => {
3691                        eprintln!("teksilo-app: failed to start settings file watcher: {e}");
3692                        None
3693                    }
3694                }
3695            })
3696        } else {
3697            None
3698        };
3699
3700        // Build the typesetter first so we can auto-register it into
3701        // the per-tree app-state registry below. This gives rich-text
3702        // widgets (and anything else that needs direct typesetter
3703        // access) a reachable handle via `ctx.app_state::<SharedTypesetter>()`
3704        // without forcing the application author to wire it manually.
3705        #[cfg(feature = "text")]
3706        let typesetter = self
3707            .typesetter
3708            .unwrap_or_else(SharedTypesetter::new_with_default_font);
3709
3710        #[cfg(feature = "text")]
3711        // Install app/theme fonts before any text is shaped.
3712        for registrar in &self.font_registrars {
3713            typesetter.apply_font_registrar(registrar.as_ref());
3714        }
3715
3716        #[cfg(feature = "text")]
3717        {
3718            use std::any::TypeId;
3719            self.app_state_registry.insert(
3720                TypeId::of::<SharedTypesetter>(),
3721                Box::new(typesetter.clone()),
3722            );
3723        }
3724
3725        // Auto-install a system clipboard handle so `RichTextEditor::editor`
3726        // (and any future clipboard-aware widget) can reach it via
3727        // `EventContext::app_state::<ClipboardHandle>()`. Behind the
3728        // `clipboard` feature because it pulls `arboard` into the build.
3729        // Falls back to `MemoryClipboard` if the OS backend fails to
3730        // initialize (headless CI, missing display, …) so the editor
3731        // still works in-process.
3732        #[cfg(feature = "clipboard")]
3733        {
3734            use std::any::TypeId;
3735            use teksilo_platform::clipboard::{ArboardClipboard, ClipboardHandle, MemoryClipboard};
3736            let handle = match ArboardClipboard::new() {
3737                Ok(backend) => ClipboardHandle::new(backend),
3738                Err(_) => ClipboardHandle::new(MemoryClipboard::new()),
3739            };
3740            self.app_state_registry
3741                .insert(TypeId::of::<ClipboardHandle>(), Box::new(handle));
3742        }
3743
3744        // Always build the per-tree app context — the poster is cheap
3745        // and lets background-work integrations (file dialogs, future
3746        // async-result features) reach the event loop without forcing
3747        // an event-source registration. Apps without an event source,
3748        // app-state registry, or background-work feature simply pay an
3749        // unused Arc<AppEventPoster> per tree.
3750        let poster: std::sync::Arc<dyn AppEventPoster> = std::sync::Arc::new(proxy.clone());
3751        let base = match self.event_source {
3752            Some(adapter) => TreeAppContext::with_source_and_poster(adapter, poster.clone()),
3753            None => TreeAppContext::empty(),
3754        };
3755        let app_context_template = Some(std::rc::Rc::new(
3756            base.with_app_state(self.app_state_registry)
3757                .with_poster(poster),
3758        ));
3759
3760        for on_ready in self.on_ready {
3761            on_ready(proxy.clone());
3762        }
3763
3764        let initial_config = self
3765            .initial_window
3766            .expect("TeksiloAppBuilder::initial_window(WindowConfig) is required");
3767
3768        let mut app = TeksiloAppHandler::new(
3769            self.theme,
3770            self.theme_mode,
3771            self.app_event_handler,
3772            initial_config,
3773            app_context_template,
3774            #[cfg(feature = "text")]
3775            typesetter,
3776            i18n_watcher,
3777            settings_watcher,
3778            proxy.clone(),
3779        );
3780        // Hand over the app's own ops-bearing external-event router, if any —
3781        // moved onto the handler after construction (like `loop_tick` below)
3782        // rather than threaded through `TeksiloAppHandler::new`'s already long
3783        // parameter list.
3784        app.external_ctx_handler = self.external_ctx_handler;
3785        // Hand over any registered loop-tick hook (e.g. the `teksilo-async`
3786        // executor poll). Async-agnostic: just a closure + a poll flag.
3787        app.loop_tick = self.loop_tick;
3788        app.loop_tick_poll = self.loop_tick_poll;
3789
3790        event_loop
3791            .run_app(&mut app)
3792            .expect("winit event loop exited with error");
3793
3794        // Flush any pending settings writes synchronously before the
3795        // process exits. The `DebouncedWriter` background threads also
3796        // flush on Drop, but doing it synchronously here also surfaces
3797        // any I/O errors to stderr before the binding goes out of
3798        // scope.
3799        if let Some(opened) = opened_settings
3800            && let Err(e) = opened.flush_all()
3801        {
3802            eprintln!("teksilo-app: settings flush on exit failed: {e}");
3803        }
3804    }
3805}
3806
3807impl Default for TeksiloAppBuilder {
3808    fn default() -> Self {
3809        Self::new()
3810    }
3811}
3812
3813/// Build an `I18nManager` from `cfg`, pre-resolve its initial locale,
3814/// and install it on the thread-local. Shared by `build_headless` and
3815/// `run` so both paths use identical setup. Returns the manager so the
3816/// headless caller can hand it to `HeadlessApp`; in the windowed `run`
3817/// path the thread-local owns it for the process lifetime.
3818fn install_i18n_manager(cfg: &I18nConfig) -> Rc<I18nManager> {
3819    let mgr = I18nManager::from_config(cfg);
3820    let initial_loc = I18nManager::resolve_initial_locale(cfg);
3821    mgr.set_locale(initial_loc);
3822    teksilo_i18n::thread_local::install(mgr.clone());
3823    mgr
3824}
3825
3826/// Headless-only helper: install the i18n manager AND seed the single
3827/// `WidgetTree` with the resolved locale and direction. The windowed
3828/// path doesn't need this because `WindowManager::create_window` reads
3829/// the thread-local and seeds each new tree at construction time; the
3830/// headless path has no WindowManager so it seeds its one tree here.
3831fn install_i18n(tree: &mut WidgetTree, cfg: &I18nConfig) -> Rc<I18nManager> {
3832    let mgr = install_i18n_manager(cfg);
3833    tree.set_locale(mgr.locale_signal().get().to_string());
3834    tree.set_layout_direction(mgr.direction_signal().get());
3835    mgr
3836}
3837
3838/// A headless app for testing (no window, no GPU).
3839pub struct HeadlessApp {
3840    pub tree: WidgetTree,
3841    pub theme: Theme,
3842    /// Active i18n manager, if `TeksiloAppBuilder::i18n(...)` was used. Tests
3843    /// can reach the bundles, version signal, and locale signal directly
3844    /// through this handle.
3845    pub i18n_manager: Option<Rc<I18nManager>>,
3846    /// Active persistence services, if `TeksiloAppBuilder::settings(...)`
3847    /// was used. Held here so the underlying `SettingsFile` clones
3848    /// (and their I/O threads) live as long as the headless app.
3849    pub settings: Option<teksilo_settings::OpenedSettings>,
3850}
3851
3852impl HeadlessApp {
3853    pub fn theme(&self) -> &Theme {
3854        &self.theme
3855    }
3856
3857    /// The active i18n manager, if `i18n(...)` was registered on the
3858    /// builder.
3859    pub fn i18n_manager(&self) -> Option<&Rc<I18nManager>> {
3860        self.i18n_manager.as_ref()
3861    }
3862
3863    /// Switch the active locale. Updates the manager (which increments the
3864    /// version signal so any `LocalizedString::to_signal()` observers
3865    /// re-resolve), then seeds the tree with the new direction (only when
3866    /// it actually changed) and triggers a composite rebuild via
3867    /// `WidgetTree::set_locale`. No-op if no `I18nConfig` was registered.
3868    pub fn set_locale(&mut self, locale: LanguageIdentifier) {
3869        let Some(mgr) = self.i18n_manager.clone() else {
3870            return;
3871        };
3872        let outcome = mgr.set_locale(locale.clone());
3873        if outcome.direction_changed {
3874            self.tree.set_layout_direction(mgr.direction_signal().get());
3875        }
3876        self.tree.set_locale(locale.to_string());
3877    }
3878}
3879
3880#[cfg(test)]
3881mod tests {
3882    use super::*;
3883    use teksilo_i18n::lit;
3884    use teksilo_tokens::Color;
3885    use teksilo_widgets::{Button, ModalContainer};
3886
3887    #[test]
3888    fn builder_accepts_theme() {
3889        let app = TeksiloAppBuilder::new()
3890            .theme(teksilo_core::presets::intui::light())
3891            .build_headless();
3892        assert_ne!(app.theme().colors.accent, Color::TRANSPARENT);
3893    }
3894
3895    #[test]
3896    fn register_post_root_composes_instead_of_clobbering() {
3897        // Regression: installing two post-root chrome wrappers (e.g. the
3898        // debug inspector AND the toast host) must run BOTH, not just the
3899        // last-installed one — `app_state(DefaultPostRoot)` is type-keyed
3900        // and silently overwrote the earlier hook, killing F12 / overflow
3901        // stripes in any app that also installed toast.
3902        use crate::DefaultPostRoot;
3903        use std::cell::RefCell;
3904        use std::rc::Rc;
3905
3906        let order: Rc<RefCell<Vec<&'static str>>> = Rc::new(RefCell::new(Vec::new()));
3907        let (o1, o2) = (order.clone(), order.clone());
3908
3909        let builder = TeksiloAppBuilder::new()
3910            .register_post_root(DefaultPostRoot::new(move |_t, id| {
3911                o1.borrow_mut().push("inspector");
3912                id
3913            }))
3914            .register_post_root(DefaultPostRoot::new(move |_t, id| {
3915                o2.borrow_mut().push("toast");
3916                id
3917            }));
3918
3919        let composed = builder
3920            .app_state_registry
3921            .get(&TypeId::of::<DefaultPostRoot>())
3922            .and_then(|b| b.downcast_ref::<DefaultPostRoot>())
3923            .expect("composed DefaultPostRoot must be present")
3924            .clone();
3925
3926        let mut tree = WidgetTree::new();
3927        let root = tree.add(Button::new(lit!("root")));
3928        let out = (composed.0)(&mut tree, root);
3929
3930        assert_eq!(
3931            *order.borrow(),
3932            vec!["inspector", "toast"],
3933            "both hooks run, earliest-registered innermost (first)"
3934        );
3935        assert_eq!(out, root, "passthrough hooks return the same root id");
3936    }
3937
3938    #[test]
3939    fn register_app_event_observer_composes_instead_of_clobbering() {
3940        // Mirrors `register_post_root_composes_instead_of_clobbering`
3941        // above: two extensions each registering their own `AppEvent`
3942        // observer (e.g. a future telemetry hook AND
3943        // `teksilo::install_toast`'s settings-write-failure toast) must
3944        // both fire, not just the last-installed one.
3945        use crate::app_event_observers::AppEventObservers;
3946        use std::cell::RefCell;
3947        use std::rc::Rc;
3948        use teksilo_core::app_event::AppEvent;
3949
3950        let order: Rc<RefCell<Vec<&'static str>>> = Rc::new(RefCell::new(Vec::new()));
3951        let (o1, o2) = (order.clone(), order.clone());
3952
3953        let builder = TeksiloAppBuilder::new()
3954            .register_app_event_observer(move |_event| {
3955                o1.borrow_mut().push("first");
3956            })
3957            .register_app_event_observer(move |_event| {
3958                o2.borrow_mut().push("second");
3959            });
3960
3961        let composed = builder
3962            .app_state_registry
3963            .get(&TypeId::of::<AppEventObservers>())
3964            .and_then(|b| b.downcast_ref::<AppEventObservers>())
3965            .expect("composed AppEventObservers must be present")
3966            .clone();
3967
3968        let event = AppEvent::BackgroundComplete {
3969            operation_id: "op".to_string(),
3970        };
3971        (composed.0)(&event);
3972
3973        assert_eq!(
3974            *order.borrow(),
3975            vec!["first", "second"],
3976            "both observers run, in registration order"
3977        );
3978    }
3979
3980    #[test]
3981    fn register_app_event_observer_does_not_suppress_on_app_event_handler() {
3982        // The composable observer slot and the single `on_app_event`
3983        // handler slot are independent storage (`app_state_registry` vs
3984        // `app_event_handler`), so registering one must never clear or
3985        // shadow the other. `TeksiloAppHandler::user_event` dispatches
3986        // both (handler first, then composed observers) — this test
3987        // proves the two slots coexist and mirrors that dispatch order
3988        // directly, since driving the real `ApplicationHandler::user_event`
3989        // requires a live winit event loop unavailable in a unit test.
3990        use crate::app_event_observers::AppEventObservers;
3991        use std::cell::RefCell;
3992        use std::rc::Rc;
3993        use teksilo_core::app_event::AppEvent;
3994
3995        let handler_fired = Rc::new(RefCell::new(false));
3996        let observer_fired = Rc::new(RefCell::new(false));
3997        let (h1, h2) = (handler_fired.clone(), observer_fired.clone());
3998
3999        let mut builder = TeksiloAppBuilder::new()
4000            .on_app_event(move |_event| {
4001                *h1.borrow_mut() = true;
4002            })
4003            .register_app_event_observer(move |_event| {
4004                *h2.borrow_mut() = true;
4005            });
4006
4007        let mut handler = builder
4008            .app_event_handler
4009            .take()
4010            .expect("on_app_event handler must survive register_app_event_observer");
4011        let observers = builder
4012            .app_state_registry
4013            .get(&TypeId::of::<AppEventObservers>())
4014            .and_then(|b| b.downcast_ref::<AppEventObservers>())
4015            .expect("registered observer must survive on_app_event")
4016            .clone();
4017
4018        let event = AppEvent::BackgroundComplete {
4019            operation_id: "op".to_string(),
4020        };
4021        // Mirrors the dispatch order in `user_event`: handler first, then
4022        // composed observers.
4023        handler(&event);
4024        (observers.0)(&event);
4025
4026        assert!(
4027            *handler_fired.borrow(),
4028            "on_app_event's handler must still fire"
4029        );
4030        assert!(
4031            *observer_fired.borrow(),
4032            "the registered observer must also fire"
4033        );
4034    }
4035
4036    /// `on_external_with_ctx` is a third, independent slot: registering it must
4037    /// not disturb `on_app_event`'s handler or the composable observers, and
4038    /// they must not disturb it. Same shape (and same limitation) as the test
4039    /// above — driving the real `ApplicationHandler::user_event` needs a live
4040    /// winit event loop, so this proves slot independence and the router's own
4041    /// claim contract; that it truly receives a window-capable `EventContext`
4042    /// is proven end-to-end by Skribisto's `scripts/automation_single_instance.py`.
4043    #[test]
4044    fn on_external_with_ctx_is_a_slot_of_its_own() {
4045        use crate::app_event_observers::AppEventObservers;
4046
4047        let builder = TeksiloAppBuilder::new()
4048            .on_external_with_ctx(|_payload, _ctx| true)
4049            .on_app_event(|_event| {})
4050            .register_app_event_observer(|_event| {});
4051
4052        assert!(
4053            builder.external_ctx_handler.is_some(),
4054            "the external router must survive a later on_app_event/observer registration"
4055        );
4056        assert!(
4057            builder.app_event_handler.is_some(),
4058            "on_app_event must survive on_external_with_ctx"
4059        );
4060        assert!(
4061            builder
4062                .app_state_registry
4063                .contains_key(&TypeId::of::<AppEventObservers>()),
4064            "observers must survive on_external_with_ctx"
4065        );
4066
4067        // Single slot, like `on_app_event`: registering twice replaces.
4068        let builder = builder.on_external_with_ctx(|_payload, _ctx| false);
4069        let mut router = builder
4070            .external_ctx_handler
4071            .expect("the second registration is the live one");
4072        // Exercise the claim contract — the `bool` `user_event` branches on to
4073        // decide whether the payload was the app's — through a real, headless
4074        // `EventContext`. `NoopWindowOps` is the same sink `window_manager`'s
4075        // own close-guard tests use; the live `WindowOpsImpl` only arrives with
4076        // a winit event loop.
4077        let mut tree = teksilo_core::WidgetTree::new();
4078        let mut claimed = true;
4079        tree.run_with_event_context(
4080            &mut teksilo_core::NoopWindowOps,
4081            |ctx: &mut teksilo_core::widget::EventContext| {
4082                claimed = router(&42i32, ctx);
4083            },
4084        );
4085        assert!(
4086            !claimed,
4087            "the replacing router's answer is the one that decides"
4088        );
4089    }
4090
4091    #[test]
4092    fn builder_with_root() {
4093        use teksilo_widgets::RectWidget;
4094        let app = TeksiloAppBuilder::new()
4095            .initial_window(
4096                WindowConfig::new()
4097                    .root(|tree, _state| tree.add(RectWidget::new().background(Color::RED))),
4098            )
4099            .build_headless();
4100        let mut tree = app.tree;
4101        tree.layout(SizeProposal::exact(200.0, 100.0));
4102        let frame = tree.render();
4103        assert!(!frame.is_empty());
4104    }
4105
4106    #[test]
4107    fn app_state_flows_through_headless_builder() {
4108        use std::rc::Rc;
4109        use teksilo_core::build_context::BuildContext;
4110        use teksilo_core::signal::Signal;
4111        use teksilo_core::widget::{LayoutContext, Widget};
4112
4113        struct AppGlobals {
4114            label: Signal<String>,
4115        }
4116
4117        #[derive(Debug)]
4118        struct GlobalsReader {
4119            observed: Signal<String>,
4120        }
4121
4122        impl Widget for GlobalsReader {
4123            fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
4124                let globals = ctx
4125                    .app_state::<Rc<AppGlobals>>()
4126                    .expect("AppGlobals not registered");
4127                self.observed.set(globals.label.get());
4128                Vec::new()
4129            }
4130
4131            fn layout_response(
4132                &self,
4133                proposal: SizeProposal,
4134                _ctx: &LayoutContext,
4135            ) -> teksilo_core::widget::LayoutResponse {
4136                proposal.resolve(0.0, 0.0).into()
4137            }
4138        }
4139
4140        let globals = Rc::new(AppGlobals {
4141            label: Signal::new("headless works".to_string()),
4142        });
4143
4144        let observed = Signal::new(String::new());
4145        let observed_for_root = observed.clone();
4146
4147        let _app = TeksiloAppBuilder::new()
4148            .app_state(globals.clone())
4149            .initial_window(WindowConfig::new().root(move |tree, _state| {
4150                tree.add(GlobalsReader {
4151                    observed: observed_for_root.clone(),
4152                })
4153            }))
4154            .build_headless();
4155
4156        assert_eq!(observed.get(), "headless works");
4157    }
4158
4159    #[test]
4160    fn auto_prefers_native_for_deferred_content_when_supported() {
4161        let request = ModalRequest::deferred(|tree| tree.add(Button::new(lit!("Deferred"))));
4162
4163        assert_eq!(
4164            resolve_modal_presentation(request.presentation, &request.content, true),
4165            ResolvedModalPresentation::NativeWindow
4166        );
4167    }
4168
4169    #[test]
4170    fn existing_widget_forces_in_tree_even_if_native_requested() {
4171        let mut tree = WidgetTree::new();
4172        let content = tree.add(Button::new(lit!("Existing")));
4173        let request = ModalRequest::in_tree(content).presentation(ModalPresentation::NativeWindow);
4174
4175        assert_eq!(
4176            resolve_modal_presentation(request.presentation, &request.content, true),
4177            ResolvedModalPresentation::InTree
4178        );
4179    }
4180
4181    #[test]
4182    fn present_in_tree_modal_request_shows_centered_overlay() {
4183        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4184        let source = tree.add(Button::new(lit!("Trigger")));
4185        let content = tree.add(Button::new(lit!("Modal content")));
4186        tree.set_dormant(content);
4187        tree.layout(SizeProposal::exact(800.0, 600.0));
4188
4189        present_in_tree_modal_request(
4190            &mut tree,
4191            source,
4192            ModalRequest::in_tree(content).presentation(ModalPresentation::InTree),
4193        );
4194        tree.layout(SizeProposal::exact(800.0, 600.0));
4195
4196        // Two overlays: the modal-panel overlay AND the dialog scrim
4197        // pushed below it by the modal-presentation pipeline.
4198        assert_eq!(tree.active_overlays().len(), 2);
4199        assert!(tree.find_by_label("Modal content").is_some());
4200    }
4201
4202    #[test]
4203    fn present_in_tree_modal_request_builds_deferred_content() {
4204        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4205        let source = tree.add(Button::new(lit!("Trigger")));
4206        tree.layout(SizeProposal::exact(800.0, 600.0));
4207
4208        present_in_tree_modal_request(
4209            &mut tree,
4210            source,
4211            ModalRequest::deferred(|tree| tree.add(Button::new(lit!("Deferred modal"))))
4212                .presentation(ModalPresentation::InTree),
4213        );
4214        tree.layout(SizeProposal::exact(800.0, 600.0));
4215
4216        // Two overlays: the modal-panel overlay AND the dialog scrim
4217        // pushed below it by the modal-presentation pipeline.
4218        assert_eq!(tree.active_overlays().len(), 2);
4219        assert!(tree.find_by_label("Deferred modal").is_some());
4220    }
4221
4222    #[test]
4223    fn present_in_tree_modal_request_mounts_scrim_below_modal() {
4224        // The scrim must be pushed BEFORE the modal so it z-orders
4225        // below the panel. `active_content_ids()` returns ids in
4226        // stack order (oldest → newest), so the first id is the
4227        // scrim and the second is the modal content.
4228        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4229        let source = tree.add(Button::new(lit!("Trigger")));
4230        let content = tree.add(Button::new(lit!("Modal content")));
4231        tree.set_dormant(content);
4232        tree.layout(SizeProposal::exact(800.0, 600.0));
4233
4234        present_in_tree_modal_request(
4235            &mut tree,
4236            source,
4237            ModalRequest::in_tree(content).presentation(ModalPresentation::InTree),
4238        );
4239
4240        let stack = tree.overlay_manager().active_content_ids();
4241        assert_eq!(stack.len(), 2, "scrim + modal");
4242        // Scrim is the first one; modal content the second.
4243        assert_eq!(stack[1], content, "modal content sits above scrim");
4244    }
4245
4246    #[test]
4247    fn dismissing_modal_cascades_to_scrim() {
4248        // The scrim's `parent_overlay` is patched to the modal id
4249        // after both are pushed. Dismissing the modal must therefore
4250        // also dismiss the scrim through the cascade walk in
4251        // `dismiss_immediate`.
4252        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4253        let source = tree.add(Button::new(lit!("Trigger")));
4254        let content = tree.add(Button::new(lit!("Modal content")));
4255        tree.set_dormant(content);
4256        tree.layout(SizeProposal::exact(800.0, 600.0));
4257
4258        present_in_tree_modal_request(
4259            &mut tree,
4260            source,
4261            ModalRequest::in_tree(content).presentation(ModalPresentation::InTree),
4262        );
4263        assert_eq!(tree.active_overlays().len(), 2);
4264
4265        // Find the modal's overlay id (the one whose content is the
4266        // modal content widget) and dismiss it.
4267        let modal_overlay = tree
4268            .overlay_manager()
4269            .find_by_content(content)
4270            .expect("modal overlay registered");
4271        tree.overlay_manager_mut().dismiss(modal_overlay);
4272
4273        assert!(
4274            tree.active_overlays().is_empty(),
4275            "scrim must cascade away with the modal",
4276        );
4277    }
4278
4279    #[test]
4280    fn scrim_uses_full_viewport_placement() {
4281        // The scrim's overlay placement determines its bounds during
4282        // `position_overlays`. It must be `FullViewport` so the dim
4283        // covers the entire window regardless of the modal's size or
4284        // position.
4285        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4286        let source = tree.add(Button::new(lit!("Trigger")));
4287        let content = tree.add(Button::new(lit!("Modal content")));
4288        tree.set_dormant(content);
4289        tree.layout(SizeProposal::exact(800.0, 600.0));
4290
4291        present_in_tree_modal_request(
4292            &mut tree,
4293            source,
4294            ModalRequest::in_tree(content).presentation(ModalPresentation::InTree),
4295        );
4296        tree.layout(SizeProposal::exact(800.0, 600.0));
4297
4298        // The scrim is at the bottom of the stack — first content id.
4299        let scrim_content_id = tree.overlay_manager().active_content_ids()[0];
4300        let scrim_bounds = tree.bounds(scrim_content_id);
4301        assert!(
4302            (scrim_bounds.width - 800.0).abs() < 0.01,
4303            "scrim spans the viewport width",
4304        );
4305        assert!(
4306            (scrim_bounds.height - 600.0).abs() < 0.01,
4307            "scrim spans the viewport height",
4308        );
4309    }
4310
4311    #[test]
4312    fn present_in_tree_modal_request_moves_focus_into_modal() {
4313        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4314        let source = tree.add(Button::new(lit!("Trigger")));
4315        tree.layout(SizeProposal::exact(800.0, 600.0));
4316        tree.focus(source);
4317
4318        present_in_tree_modal_request(
4319            &mut tree,
4320            source,
4321            ModalRequest::deferred(|tree| {
4322                tree.add(ModalContainer::new(Button::new(lit!("Continue"))))
4323            })
4324            .presentation(ModalPresentation::InTree),
4325        );
4326
4327        let continue_button = tree.find_by_label("Continue").unwrap();
4328        assert_eq!(tree.focused(), Some(continue_button));
4329    }
4330
4331    /// **A modal whose content is a text editor opens with the caret in it.**
4332    ///
4333    /// The editors are the one focusable widget family that carries no label,
4334    /// so `first_focusable_descendant` is the only thing that can find them —
4335    /// and a modal that fails to focus one opens with no caret at all, which
4336    /// reads as a broken surface rather than an unfocused one.
4337    #[test]
4338    fn present_in_tree_modal_focuses_a_rich_text_editor() {
4339        use teksilo_widgets::rich_text::RichTextEditor;
4340        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4341        let source = tree.add(Button::new(lit!("Trigger")));
4342        tree.layout(SizeProposal::exact(800.0, 600.0));
4343        tree.focus(source);
4344
4345        let doc = teksilo_text::text_document::TextDocument::new();
4346        doc.set_plain_text("hello").unwrap();
4347        present_in_tree_modal_request(
4348            &mut tree,
4349            source,
4350            ModalRequest::deferred(move |tree| {
4351                tree.add(ModalContainer::new(RichTextEditor::editor(doc)))
4352            })
4353            .presentation(ModalPresentation::InTree),
4354        );
4355
4356        let focused = tree.focused().expect("the modal moved focus into itself");
4357        assert_ne!(
4358            focused, source,
4359            "focus must leave the trigger and land inside the modal"
4360        );
4361        let name = tree.widget_type_name(focused).unwrap_or("<none>");
4362        assert!(
4363            name.contains("RichTextEditor"),
4364            "focus landed on {name}, not the editor — the modal opens caretless"
4365        );
4366    }
4367
4368    /// **A modal that opens over a text editor shows its caret.**
4369    ///
4370    /// Focus landing on the editor is not enough: the caret is gated on the
4371    /// editor's *own* `has_focus`, and `present_in_tree_modal_request` parks
4372    /// the content dormant and re-activates it in the same batch, before
4373    /// moving focus in. Those two activation edges used to be replayed in
4374    /// order *after* the focus dispatch, so the superseded `false` arrived
4375    /// last and the editor's dormancy handler wiped the focus it had just
4376    /// been granted — the dialog opened with the text visible and no caret,
4377    /// which reads as a dead surface rather than an unfocused one.
4378    ///
4379    /// Asserted on the painted frame rather than on any internal flag,
4380    /// because the caret is the whole point: a thin, full-line-height rect
4381    /// in the theme's `editor_caret` colour, at the editor's origin.
4382    #[test]
4383    fn present_in_tree_modal_paints_the_editor_caret() {
4384        use teksilo_widgets::rich_text::RichTextEditor;
4385        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4386        let source = tree.add(Button::new(lit!("Trigger")));
4387        tree.layout(SizeProposal::exact(800.0, 600.0));
4388        tree.focus(source);
4389
4390        let doc = teksilo_text::text_document::TextDocument::new();
4391        doc.set_plain_text("hello").unwrap();
4392        present_in_tree_modal_request(
4393            &mut tree,
4394            source,
4395            ModalRequest::deferred(move |tree| {
4396                tree.add(ModalContainer::new(RichTextEditor::editor(doc)))
4397            })
4398            .presentation(ModalPresentation::InTree),
4399        );
4400        tree.layout(SizeProposal::exact(800.0, 600.0));
4401
4402        let editor = tree.focused().expect("the modal moved focus into itself");
4403        let editor_bounds = tree.bounds(editor);
4404        let frame = tree.render();
4405
4406        // The caret is emitted through `Canvas::fill_rect`, which lands in the
4407        // frame as a `WidgetBackground` decoration — so identify it by shape
4408        // and colour rather than by kind.
4409        let caret_color = teksilo_core::presets::intui::light()
4410            .colors
4411            .editor_caret
4412            .to_array();
4413        let caret = frame.decorations.iter().find(|d| {
4414            d.color == caret_color && d.rect[2] > 0.0 && d.rect[2] <= 4.0 && d.rect[3] > 4.0
4415        });
4416        let caret = caret.unwrap_or_else(|| {
4417            panic!(
4418                "the modal painted no caret — {} glyphs and {} decorations, none caret-shaped: {:?}",
4419                frame.glyphs.len(),
4420                frame.decorations.len(),
4421                frame.decorations,
4422            )
4423        });
4424
4425        // ...and it sits inside the editor, not stranded at the viewport origin.
4426        assert!(
4427            caret.rect[0] >= editor_bounds.x
4428                && caret.rect[0] <= editor_bounds.x + editor_bounds.width
4429                && caret.rect[1] >= editor_bounds.y
4430                && caret.rect[1] <= editor_bounds.y + editor_bounds.height,
4431            "caret at {:?} must fall inside the editor's bounds {editor_bounds:?}",
4432            caret.rect,
4433        );
4434    }
4435
4436    /// Same, but with the editor buried under the chrome a real dialog wraps it
4437    /// in — a titled panel, a column, a fixed-size box, padding. The walk has to
4438    /// reach through all of it.
4439    #[test]
4440    fn present_in_tree_modal_focuses_an_editor_under_chrome() {
4441        use teksilo_widgets::rich_text::RichTextEditor;
4442        use teksilo_widgets::{Divider, FixedSize, Padding, Panel, TextWidget, VStack};
4443        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4444        let source = tree.add(Button::new(lit!("Trigger")));
4445        tree.layout(SizeProposal::exact(900.0, 700.0));
4446        tree.focus(source);
4447
4448        let doc = teksilo_text::text_document::TextDocument::new();
4449        doc.set_plain_text("hello").unwrap();
4450        present_in_tree_modal_request(
4451            &mut tree,
4452            source,
4453            ModalRequest::deferred(move |tree| {
4454                tree.add(ModalContainer::new(
4455                    Panel::new().corner_radius(10.0).padding(0.0).child(
4456                        VStack::new()
4457                            .spacing(0.0)
4458                            .child(
4459                                Padding::symmetric(8.0, 14.0)
4460                                    .child(TextWidget::new(lit!("Synopsis"))),
4461                            )
4462                            .child(Divider::new())
4463                            .child(
4464                                FixedSize::new().width(600.0).height(400.0).child(
4465                                    Padding::uniform(16.0).child(RichTextEditor::editor(doc)),
4466                                ),
4467                            ),
4468                    ),
4469                ))
4470            })
4471            .presentation(ModalPresentation::InTree),
4472        );
4473
4474        let focused = tree.focused().expect("the modal moved focus into itself");
4475        let name = tree.widget_type_name(focused).unwrap_or("<none>");
4476        assert!(
4477            name.contains("RichTextEditor"),
4478            "focus landed on {name}, not the editor — chrome between the modal root \
4479             and the editor is hiding it from the focus walk"
4480        );
4481    }
4482
4483    /// And with **no `ModalContainer`** — the shape an app takes when its dialog
4484    /// owns its own chrome (Skribisto's synopsis / picker panels do). The focus
4485    /// walk starts at whatever the deferred builder returned.
4486    #[test]
4487    fn present_in_tree_modal_focuses_an_editor_without_a_modal_container() {
4488        use teksilo_widgets::rich_text::RichTextEditor;
4489        use teksilo_widgets::{FixedSize, Padding, Panel, VStack};
4490        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4491        let source = tree.add(Button::new(lit!("Trigger")));
4492        tree.layout(SizeProposal::exact(900.0, 700.0));
4493        tree.focus(source);
4494
4495        let doc = teksilo_text::text_document::TextDocument::new();
4496        doc.set_plain_text("hello").unwrap();
4497        present_in_tree_modal_request(
4498            &mut tree,
4499            source,
4500            ModalRequest::deferred(move |tree| {
4501                tree.add(
4502                    Panel::new().corner_radius(10.0).padding(0.0).child(
4503                        VStack::new().spacing(0.0).child(
4504                            FixedSize::new()
4505                                .width(600.0)
4506                                .height(400.0)
4507                                .child(Padding::uniform(16.0).child(RichTextEditor::editor(doc))),
4508                        ),
4509                    ),
4510                )
4511            })
4512            .presentation(ModalPresentation::InTree),
4513        );
4514
4515        let focused = tree.focused().expect("the modal moved focus into itself");
4516        let name = tree.widget_type_name(focused).unwrap_or("<none>");
4517        assert!(
4518            name.contains("RichTextEditor"),
4519            "focus landed on {name}, not the editor"
4520        );
4521    }
4522
4523    #[test]
4524    fn present_in_tree_modal_restores_focus_to_trigger_on_dismiss() {
4525        // Regression: tabbing to a trigger, opening a modal, then
4526        // dismissing it must return keyboard focus to the trigger. The
4527        // modal overlay carries the pre-modal focus owner as its
4528        // `focus_restore`, which every dismiss path replays.
4529        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4530        let source = tree.add(Button::new(lit!("Rename")));
4531        tree.layout(SizeProposal::exact(800.0, 600.0));
4532        tree.focus(source);
4533        assert_eq!(
4534            tree.focused(),
4535            Some(source),
4536            "precondition: trigger focused"
4537        );
4538
4539        present_in_tree_modal_request(
4540            &mut tree,
4541            source,
4542            ModalRequest::deferred(|tree| {
4543                tree.add(ModalContainer::new(Button::new(lit!("Continue"))))
4544            })
4545            .presentation(ModalPresentation::InTree),
4546        );
4547
4548        // Focus moved into the modal (existing behavior).
4549        let continue_button = tree.find_by_label("Continue").unwrap();
4550        assert_eq!(tree.focused(), Some(continue_button));
4551
4552        // The modal overlay is the topmost; dismissing it must surface
4553        // the trigger as the focus_restore target.
4554        let modal_overlay = *tree
4555            .active_overlays()
4556            .last()
4557            .expect("modal overlay registered");
4558        let (_dismissed, focus_restore) = tree
4559            .overlay_manager_mut()
4560            .dismiss_with_focus_restore(modal_overlay);
4561        assert_eq!(
4562            focus_restore,
4563            Some(source),
4564            "dismissing the modal must restore focus to the trigger that opened it",
4565        );
4566    }
4567
4568    #[test]
4569    fn mouse_opened_modal_restores_pointer_modality_on_dismiss() {
4570        // Regression: a modal opened by mouse (focus_visible = false) must
4571        // not leave the trigger sporting a keyboard `:focus-visible` ring
4572        // after the user types / presses Enter inside the dialog — which
4573        // flips the global modality to keyboard. The pre-modal modality is
4574        // captured and replayed when the overlay dismisses.
4575        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4576        let source = tree.add(Button::new(lit!("Rename")));
4577        tree.layout(SizeProposal::exact(800.0, 600.0));
4578
4579        // Mouse-style entry: pointer modality, trigger focused.
4580        let focus_visible = tree.focus_visible_signal();
4581        focus_visible.set(false);
4582        tree.focus(source);
4583
4584        present_in_tree_modal_request(
4585            &mut tree,
4586            source,
4587            ModalRequest::deferred(|tree| {
4588                tree.add(ModalContainer::new(Button::new(lit!("Continue"))))
4589            })
4590            .presentation(ModalPresentation::InTree),
4591        );
4592
4593        // Keyboard input *inside* the dialog (typing the name, Enter to
4594        // accept) flips the global modality to keyboard.
4595        focus_visible.set(true);
4596
4597        // Dismiss fires the overlay's on_dismiss, which restores modality.
4598        let modal_overlay = *tree
4599            .active_overlays()
4600            .last()
4601            .expect("modal overlay registered");
4602        tree.overlay_manager_mut()
4603            .dismiss_with_focus_restore(modal_overlay);
4604
4605        assert!(
4606            !focus_visible.get(),
4607            "a mouse-opened modal must restore pointer modality on dismiss, \
4608             not leave a keyboard focus ring on the trigger",
4609        );
4610    }
4611
4612    #[test]
4613    fn keyboard_opened_modal_keeps_focus_visible_on_dismiss() {
4614        // Invariant guard for the fix above: a modal opened while in
4615        // keyboard modality must KEEP the focus ring on the trigger when it
4616        // closes — restoring the captured modality must not blanket-clear it.
4617        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4618        let source = tree.add(Button::new(lit!("Rename")));
4619        tree.layout(SizeProposal::exact(800.0, 600.0));
4620
4621        // Keyboard-style entry: keyboard modality, trigger focused.
4622        let focus_visible = tree.focus_visible_signal();
4623        focus_visible.set(true);
4624        tree.focus(source);
4625
4626        present_in_tree_modal_request(
4627            &mut tree,
4628            source,
4629            ModalRequest::deferred(|tree| {
4630                tree.add(ModalContainer::new(Button::new(lit!("Continue"))))
4631            })
4632            .presentation(ModalPresentation::InTree),
4633        );
4634
4635        // Even if a pointer event flipped modality off inside the dialog,
4636        // dismiss restores the captured (keyboard) modality.
4637        focus_visible.set(false);
4638
4639        let modal_overlay = *tree
4640            .active_overlays()
4641            .last()
4642            .expect("modal overlay registered");
4643        tree.overlay_manager_mut()
4644            .dismiss_with_focus_restore(modal_overlay);
4645
4646        assert!(
4647            focus_visible.get(),
4648            "a keyboard-opened modal must restore keyboard modality on dismiss",
4649        );
4650    }
4651
4652    /// Test content widget: a focusable container with two focusable
4653    /// button descendants. `hint` controls which (if any) the widget
4654    /// reports as its `initial_focus_hint`.
4655    #[derive(Debug)]
4656    struct TwoButtonContent {
4657        root: Option<WidgetId>,
4658        second: Option<WidgetId>,
4659        hint_to_second: bool,
4660    }
4661
4662    impl teksilo_core::Widget for TwoButtonContent {
4663        fn build(&mut self, ctx: &mut teksilo_core::BuildContext) -> Vec<WidgetId> {
4664            let first = ctx.add(Button::new(lit!("First")));
4665            let second = ctx.add(Button::new(lit!("Second")));
4666            let row = ctx.add(
4667                teksilo_widgets::HStack::new()
4668                    .add_child(first)
4669                    .add_child(second),
4670            );
4671            self.root = Some(row);
4672            self.second = Some(second);
4673            vec![row]
4674        }
4675
4676        fn layout_response(
4677            &self,
4678            proposal: teksilo_canvas::SizeProposal,
4679            ctx: &teksilo_core::LayoutContext,
4680        ) -> teksilo_core::widget::LayoutResponse {
4681            self.root
4682                .and_then(|id| ctx.child_size(id, proposal))
4683                .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
4684                .into()
4685        }
4686
4687        fn initial_focus_hint(&self) -> Option<WidgetId> {
4688            if self.hint_to_second {
4689                self.second
4690            } else {
4691                None
4692            }
4693        }
4694
4695        fn children(&self) -> Vec<WidgetId> {
4696            self.root.into_iter().collect()
4697        }
4698    }
4699
4700    #[test]
4701    fn present_in_tree_modal_consults_initial_focus_hint() {
4702        // When `focus_target` is None, the framework must consult the
4703        // content widget's `initial_focus_hint` before falling back to
4704        // `first_focusable_descendant`.
4705        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4706        let source = tree.add(Button::new(lit!("Trigger")));
4707        tree.layout(SizeProposal::exact(800.0, 600.0));
4708
4709        present_in_tree_modal_request(
4710            &mut tree,
4711            source,
4712            ModalRequest::deferred(|tree| {
4713                tree.add(TwoButtonContent {
4714                    root: None,
4715                    second: None,
4716                    hint_to_second: true,
4717                })
4718            })
4719            .presentation(ModalPresentation::InTree),
4720        );
4721        tree.layout(SizeProposal::exact(800.0, 600.0));
4722
4723        // Two "Second" labels may exist globally (source isn't one), so
4724        // find_by_label is unambiguous here.
4725        let second = tree.find_by_label("Second").unwrap();
4726        assert_eq!(
4727            tree.focused(),
4728            Some(second),
4729            "initial_focus_hint must redirect focus away from first focusable",
4730        );
4731    }
4732
4733    #[test]
4734    fn present_in_tree_modal_falls_back_to_first_focusable_without_hint() {
4735        // Baseline: content without an initial_focus_hint gets the first
4736        // focusable descendant, matching prior behavior.
4737        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4738        let source = tree.add(Button::new(lit!("Trigger")));
4739        tree.layout(SizeProposal::exact(800.0, 600.0));
4740
4741        present_in_tree_modal_request(
4742            &mut tree,
4743            source,
4744            ModalRequest::deferred(|tree| {
4745                tree.add(TwoButtonContent {
4746                    root: None,
4747                    second: None,
4748                    hint_to_second: false,
4749                })
4750            })
4751            .presentation(ModalPresentation::InTree),
4752        );
4753        tree.layout(SizeProposal::exact(800.0, 600.0));
4754
4755        let first = tree.find_by_label("First").unwrap();
4756        assert_eq!(
4757            tree.focused(),
4758            Some(first),
4759            "without focus_target or initial_focus_hint, first focusable wins",
4760        );
4761    }
4762
4763    #[test]
4764    fn present_in_tree_modal_rejects_focus_target_outside_content_subtree() {
4765        // A focus_target pointing at a widget that exists but is NOT a
4766        // descendant of content_id must be rejected. The framework falls
4767        // back to initial_focus_hint → first_focusable_descendant.
4768        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4769        let source = tree.add(Button::new(lit!("Trigger")));
4770        tree.layout(SizeProposal::exact(800.0, 600.0));
4771
4772        present_in_tree_modal_request(
4773            &mut tree,
4774            source,
4775            ModalRequest::deferred(|tree| {
4776                tree.add(TwoButtonContent {
4777                    root: None,
4778                    second: None,
4779                    hint_to_second: false,
4780                })
4781            })
4782            .presentation(ModalPresentation::InTree)
4783            .focus_target(source), // active but outside modal subtree
4784        );
4785        tree.layout(SizeProposal::exact(800.0, 600.0));
4786
4787        let first = tree.find_by_label("First").unwrap();
4788        assert_eq!(
4789            tree.focused(),
4790            Some(first),
4791            "focus_target outside content subtree must be rejected",
4792        );
4793    }
4794}