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