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