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