teksilo_app/window_manager.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Multi-window management.
5//!
6//! The `WindowManager` maintains a collection of active windows, each with its
7//! own `WidgetTree`, `PlatformWindow`, and translation state. It routes events
8//! by winit `WindowId`, broadcasts environment changes to all windows, and
9//! handles modal dialog blocking.
10
11use std::collections::HashMap;
12use std::rc::Rc;
13
14use teksilo_core::Theme;
15use teksilo_core::event_source::TreeAppContext;
16use teksilo_core::signal::Prop;
17use teksilo_core::{
18 CloseBlockedCallback, CloseGuard, CloseResponse, DecorationsMode, PlatformTitleBarHost,
19 TitleBarHostCallbacks, UserAttentionKind, WidgetId, WidgetTree, WindowCommand, WindowPlacement,
20 WindowState, WindowStateInit,
21};
22use teksilo_platform::AccessibilityPreferences;
23use teksilo_platform::PlatformWindow;
24use teksilo_platform::create_title_bar_host;
25use teksilo_platform::event_translation::TranslationState;
26use teksilo_tokens::{ColorSchemePreference, ColorTokens};
27#[allow(unused_imports)]
28use winit::raw_window_handle::HasWindowHandle;
29use winit::window::WindowLevel;
30
31use crate::app::{AppEventProxy, CloseWindowRequest, ThemeMode};
32
33use crate::window_config::{TeksiloWindowId, WindowConfig};
34
35/// A queued window closure awaiting the next
36/// [`process_pending`](WindowManager::process_pending) tick.
37#[derive(Debug, Clone, Copy)]
38struct PendingClose {
39 /// Which window to close.
40 id: TeksiloWindowId,
41 /// `true` = close unconditionally (the window's close guard is
42 /// skipped). `false` = consult the window's close guard first; a
43 /// [`CloseResponse::Veto`](teksilo_core::CloseResponse) keeps the
44 /// window open.
45 force: bool,
46}
47
48/// Pure close-verdict logic used by
49/// [`WindowManager::evaluate_close_guard`]. Returns `true` if the close
50/// should proceed, `false` to veto.
51///
52/// Precedence:
53/// 1. [`can_close`](teksilo_core::WindowConfig::can_close) sugar — a
54/// `Some(false)` signal vetoes and fires `on_close_blocked` (the
55/// only side effect here).
56/// 2. the [`on_close_requested`](teksilo_core::WindowConfig::on_close_requested)
57/// guard's [`CloseResponse`].
58/// 3. no guard configured → close.
59///
60/// Factored out of `evaluate_close_guard` so the decision can be
61/// unit-tested headlessly with a `NoopWindowOps`-backed `EventContext`,
62/// without standing up a winit event loop.
63fn close_verdict(
64 can_close: &Option<Prop<bool>>,
65 on_close_blocked: &Option<CloseBlockedCallback>,
66 close_guard: &Option<CloseGuard>,
67 ctx: &mut teksilo_core::widget::EventContext,
68) -> bool {
69 if let Some(may_close) = can_close
70 && !may_close.get()
71 {
72 if let Some(on_blocked) = on_close_blocked {
73 on_blocked(ctx);
74 }
75 return false;
76 }
77 if let Some(guard) = close_guard {
78 return matches!(guard(ctx), CloseResponse::Close);
79 }
80 true
81}
82
83/// Decide whether a `WindowEvent::Ime` carrying an (already-classified) preedit
84/// is a redundant *consecutive* empty preedit that the event loop should drop
85/// without dispatching or redrawing, updating the per-window `last_empty` flag
86/// in the process. The first empty preedit after any non-empty IME activity is
87/// meaningful (it clears an active composition); only the second-and-later
88/// consecutive empties are skipped. Extracted from the `WindowEvent::Ime` arm so
89/// the state machine is unit-testable without a winit event loop.
90pub(crate) fn ime_should_skip_empty_preedit(last_empty: &mut bool, empty_preedit: bool) -> bool {
91 let repeat = empty_preedit && *last_empty;
92 *last_empty = empty_preedit;
93 repeat
94}
95
96/// Where keyboard focus lands when a freshly built window is shown, or `None`
97/// to leave the window with nothing focused.
98///
99/// The two window kinds get deliberately different policies:
100///
101/// * **Modal** — focus something, always: an explicitly requested target, else
102/// the root's [`Widget::initial_focus_hint`](teksilo_core::widget::Widget::initial_focus_hint),
103/// else the first focusable descendant. A dialog you must Tab into before you
104/// can answer it is broken.
105/// * **Plain window** — an *explicit* hint only. There is no
106/// `first_focusable_descendant` fallback on purpose: auto-focusing the first
107/// focusable of every window would drop the caret into whatever search box or
108/// text field happens to come first in tree order, changing behavior for every
109/// existing app window. A window that wants directed focus opts in by
110/// overriding `initial_focus_hint` — e.g. a launcher pointing at its
111/// recent-projects list so **Enter opens the highlighted entry with no Tab
112/// first**. (Before this, only modals could direct focus at all, so a plain
113/// window opened with nothing focused and its first keystroke went nowhere.)
114///
115/// The hint lookup walks descendants, so it still resolves when the opting-in
116/// widget sits deep under the window chrome (title bar, resize frame, post-root
117/// wrapper).
118///
119/// Extracted from `create_window` so the policy is unit-testable without a winit
120/// event loop.
121fn initial_window_focus(
122 tree: &WidgetTree,
123 root_id: WidgetId,
124 is_modal: bool,
125 modal_focus_target: Option<WidgetId>,
126) -> Option<WidgetId> {
127 if is_modal {
128 modal_focus_target
129 .filter(|id| tree.is_active(*id))
130 .or_else(|| tree.widget_initial_focus_hint(root_id))
131 .or_else(|| tree.first_focusable_descendant(root_id))
132 } else {
133 tree.widget_initial_focus_hint(root_id)
134 }
135}
136
137/// Per-window state managed by the WindowManager.
138pub(crate) struct ManagedWindow {
139 pub teksilo_id: TeksiloWindowId,
140 pub string_id: Option<String>,
141 pub tree: WidgetTree,
142 /// Reactive per-window state shared with the `WidgetTree` and
143 /// accessible from handlers via
144 /// [`EventContext::window`](teksilo_core::widget::EventContext::window).
145 pub state: WindowState,
146 pub platform_window: PlatformWindow,
147 pub translation_state: TranslationState,
148 pub current_modifiers: winit::keyboard::ModifiersState,
149 pub modal: bool,
150 pub parent: Option<TeksiloWindowId>,
151 /// Size-to-content mode from `WindowConfig::size_to_content`. When not
152 /// `Off`, the redraw path measures the content's intrinsic size after
153 /// layout and resizes the OS window to fit — the native-modal analogue of
154 /// the in-tree overlay's size-to-content (used for dialogs / MessageBox).
155 pub size_to_content: teksilo_core::window::SizeToContent,
156 /// Last height (logical px) applied by the size-to-content auto-resize.
157 /// Guards against a measure → resize → re-measure oscillation: a resize is
158 /// only issued when the freshly-measured target differs from this.
159 pub last_autosize_height: Option<u32>,
160 /// Custom-chrome host, if the window opted in via
161 /// `WindowConfig::custom_chrome(true)` and the platform supports it.
162 /// The same `Rc` is also stored on the `WidgetTree` so the root-builder
163 /// closure can hand it to a `TitleBar` widget.
164 pub title_bar_host: Option<Rc<dyn PlatformTitleBarHost>>,
165 /// Tracks `WindowEvent::Focused`. On Linux/X11, Linux/Wayland, and
166 /// Windows this also fires on minimize (no separate minimize event
167 /// exists in winit 0.30). We assume focused on creation — winit
168 /// may not send `Focused(true)` for the initial window on every
169 /// platform, and parking animations before the user has even seen
170 /// the window would be wrong.
171 pub focused: bool,
172 /// Tracks `WindowEvent::Occluded`. macOS-only in winit 0.30 —
173 /// stays `false` on every other platform. Combined with `focused`
174 /// to decide whether the widget tree's animation scheduler should
175 /// run: `active = focused && !occluded`.
176 pub occluded: bool,
177 /// Caps Lock active state, toggled on each `Key::CapsLock` press
178 /// (winit 0.30 delivers Caps Lock as a discrete key, not via
179 /// `ModifiersState`). Pushed to `state.caps_lock` so password fields
180 /// can warn. Starts `false`; the OS lock state at launch is not
181 /// observable on winit 0.30, so it can desync if Caps Lock was
182 /// already on before the app gained focus.
183 pub caps_lock_active: bool,
184 /// Last OS-IME enablement applied to the winit window (`None` = never
185 /// set, forces the first apply). The post-dispatch reconcile compares
186 /// the focused node's IME descriptor against this and calls
187 /// `set_ime_allowed` only on change — repeated `set_ime_allowed(true)`
188 /// can cancel an active composition on some platforms.
189 pub ime_allowed: Option<bool>,
190 /// Last OS-IME purpose applied to the winit window. Re-applied whenever
191 /// it changes while IME is enabled.
192 pub ime_purpose: Option<teksilo_core::ImePurpose>,
193 /// Whether the previous `WindowEvent::Ime` was an empty `Preedit("")`.
194 /// Some Linux IME backends (ibus / fcitx via winit) flood empty preedits
195 /// while a field is focused; the first clears any active composition, but
196 /// every consecutive repeat is a no-op that would still wake a full
197 /// layout+render pass. This lets the event loop skip the repeats entirely
198 /// (neither dispatch nor redraw). Reset by any non-empty-preedit IME event.
199 pub last_ime_preedit_empty: bool,
200 /// RAII handles for the auto-save observers wired to
201 /// `state.{size, position, placement}` when a
202 /// `WindowStateService` is registered. Dropped when the window
203 /// is removed from `WindowManager::windows`.
204 pub _persist_handles: Vec<teksilo_core::ObserverHandle>,
205 /// Optional close guard taken from
206 /// [`WindowConfig::on_close_requested`](teksilo_core::WindowConfig::on_close_requested).
207 /// Consulted by [`process_pending`](WindowManager::process_pending)
208 /// before a *guarded* close tears this window down; a
209 /// [`CloseResponse::Veto`] cancels the close. `None` = no guard, the
210 /// window closes immediately. This is strictly per-window — closing
211 /// one window never consults another's guard.
212 pub close_guard: Option<CloseGuard>,
213 /// Optional reactive "may this window close?" signal from
214 /// [`WindowConfig::can_close`](teksilo_core::WindowConfig::can_close).
215 /// When present and `false`, a guarded close is vetoed and
216 /// [`on_close_blocked`](Self::on_close_blocked) fires. Evaluated
217 /// before [`close_guard`](Self::close_guard).
218 pub can_close: Option<Prop<bool>>,
219 /// Optional notification fired when [`can_close`](Self::can_close)
220 /// blocks a close, from
221 /// [`WindowConfig::on_close_blocked`](teksilo_core::WindowConfig::on_close_blocked).
222 pub on_close_blocked: Option<CloseBlockedCallback>,
223 /// Optional teardown hook from
224 /// [`WindowConfig::on_removed`](teksilo_core::WindowConfig::on_removed).
225 /// Invoked once by [`close_window`](WindowManager::close_window),
226 /// after this window is fully gone from every `WindowManager`
227 /// registry — see that method's doc comment for why.
228 pub on_removed: Option<teksilo_core::WindowRemovedCallback>,
229 /// Glyph-atlas content version last uploaded to THIS window's
230 /// renderer (`AtlasInfo::version`). Each window compares this
231 /// against the shared bridge's current version on its own redraw
232 /// and re-uploads when behind — the version model replaces
233 /// consume-once dirty semantics so several windows all converge on
234 /// the same atlas content. `0` = nothing uploaded yet; stays `0`
235 /// (unused) when the `text` feature is off.
236 pub atlas_uploaded_version: u64,
237}
238
239/// Manages multiple application windows.
240///
241/// Each window owns its own `WidgetTree` and `PlatformWindow`. The manager
242/// routes events by winit `WindowId`, broadcasts environment changes, and
243/// handles modal dialog blocking.
244pub struct WindowManager {
245 windows: HashMap<winit::window::WindowId, ManagedWindow>,
246 teksilo_to_winit: HashMap<TeksiloWindowId, winit::window::WindowId>,
247 /// Stable string-id → id lookup, populated whenever a config carries
248 /// `id(...)`. Used by `WindowOps::find_window`.
249 string_to_id: HashMap<String, TeksiloWindowId>,
250 /// Next allocatable `TeksiloWindowId`. Bumped by `alloc_id`; never
251 /// reused after a window closes.
252 next_id: u64,
253 /// Pending close requests, drained once per tick in
254 /// [`process_pending`](Self::process_pending). Each entry records
255 /// whether the close is *guarded* (consults the window's close
256 /// guard, and may be vetoed) or *forced* (unconditional). See
257 /// [`PendingClose`].
258 pending_closes: Vec<PendingClose>,
259 theme: Theme,
260 #[cfg(feature = "text")]
261 typesetter: Option<teksilo_text::SharedTypesetter>,
262 /// Windows that are blocked by a modal child.
263 modal_blocked: HashMap<TeksiloWindowId, TeksiloWindowId>,
264 /// OS-level accessibility preferences, queried once at startup.
265 a11y_prefs: AccessibilityPreferences,
266 /// User-controlled global text-scale factor (`1.0` = 100 %). Seeded from
267 /// `teksilo_settings::TEXT_SCALE_KEY` before the first window opens and
268 /// applied to every tree created afterwards; updated at runtime via
269 /// [`set_text_scale`](Self::set_text_scale).
270 user_text_scale: f32,
271 /// How the app resolves its theme (Manual, FollowSystem, Native).
272 theme_mode: ThemeMode,
273 /// Per-tree app context shared with every window's WidgetTree when an
274 /// event source is registered on the TeksiloAppBuilder. Each window
275 /// receives a clone of this Rc so subscriptions land in a single
276 /// shared `subscription_callbacks` map.
277 app_context_template: Option<Rc<TreeAppContext>>,
278 /// Event-loop proxy used to construct `TitleBarHostCallbacks` when a
279 /// window opts into custom chrome. Installed by `TeksiloAppHandler::new`
280 /// after the proxy is minted in `TeksiloAppBuilder::run`. `None` during
281 /// tests or the headless path, in which case the host's `close()`
282 /// callback is a no-op (`TitleBarHostCallbacks::noop`).
283 event_proxy: Option<AppEventProxy>,
284 /// One-shot callbacks awaiting a `request_activation_token` result, keyed by
285 /// the requesting window. Fired (and removed) when the matching
286 /// `WindowEvent::ActivationTokenDone` arrives — hands a freshly-minted
287 /// xdg-activation token to a child process or an IPC peer. Run on the UI
288 /// thread.
289 pending_token_callbacks: HashMap<winit::window::WindowId, Box<dyn FnOnce(Option<String>)>>,
290}
291
292impl WindowManager {
293 pub fn new(theme: Theme) -> Self {
294 let a11y_prefs = AccessibilityPreferences::query();
295 Self {
296 windows: HashMap::new(),
297 teksilo_to_winit: HashMap::new(),
298 string_to_id: HashMap::new(),
299 next_id: 1,
300 pending_closes: Vec::new(),
301 theme,
302 #[cfg(feature = "text")]
303 typesetter: None,
304 modal_blocked: HashMap::new(),
305 a11y_prefs,
306 user_text_scale: 1.0,
307 theme_mode: ThemeMode::Manual,
308 app_context_template: None,
309 event_proxy: None,
310 pending_token_callbacks: HashMap::new(),
311 }
312 }
313
314 /// Store a one-shot callback fired when this window's pending
315 /// `request_activation_token` resolves via `WindowEvent::ActivationTokenDone`.
316 pub(crate) fn store_activation_token_callback(
317 &mut self,
318 id: winit::window::WindowId,
319 cb: Box<dyn FnOnce(Option<String>)>,
320 ) {
321 self.pending_token_callbacks.insert(id, cb);
322 }
323
324 /// Take the pending activation-token callback for `id`, if any.
325 pub(crate) fn take_activation_token_callback(
326 &mut self,
327 id: winit::window::WindowId,
328 ) -> Option<Box<dyn FnOnce(Option<String>)>> {
329 self.pending_token_callbacks.remove(&id)
330 }
331
332 /// Set the theme mode (called by TeksiloAppHandler during initialization).
333 pub fn set_theme_mode(&mut self, mode: ThemeMode) {
334 self.theme_mode = mode;
335 }
336
337 /// Install the event-loop proxy (called by TeksiloAppHandler once the
338 /// proxy is available). Enables `TitleBarHostCallbacks::request_close`
339 /// to post `CloseWindowRequest` back through the event loop.
340 pub fn set_event_proxy(&mut self, proxy: AppEventProxy) {
341 self.event_proxy = Some(proxy);
342 }
343
344 /// Install the per-tree app context template that every newly created
345 /// window's WidgetTree should adopt. Called by TeksiloAppHandler when the
346 /// application registered an event source on the builder.
347 pub fn set_app_context_template(&mut self, template: Rc<TreeAppContext>) {
348 self.app_context_template = Some(template);
349 }
350
351 /// The shared per-tree app context, if any, used by TeksiloAppHandler to
352 /// look up subscription callbacks when delivering
353 /// `AppEvent::SubscriptionEvent` to the UI thread.
354 pub(crate) fn app_context_template(&self) -> Option<&Rc<TreeAppContext>> {
355 self.app_context_template.as_ref()
356 }
357
358 /// Get the current theme mode.
359 pub fn theme_mode(&self) -> ThemeMode {
360 self.theme_mode
361 }
362
363 /// Recompute and broadcast the theme from the **current OS appearance**,
364 /// per the active theme mode. A no-op under `Manual`. Under `Native` it
365 /// adopts the OS's actual colours (GNOME/KDE/Cinnamon on Linux); under
366 /// `FollowSystem` it picks the built-in light/dark preset. Both results
367 /// carry the id `"system"`.
368 ///
369 /// `os_dark_hint` is the OS light/dark state as reported by winit (e.g.
370 /// from a `WindowEvent::ThemeChanged`), used as the authoritative source on
371 /// platforms where Teksilo's own OS-colour query is unimplemented
372 /// (macOS / Windows, where `query_os_theme_colors()` returns
373 /// `NoPreference`). On Linux the query reports a real scheme and the hint
374 /// is unused. Pass `None` when no winit signal is available (e.g. a runtime
375 /// "follow system" request) — the current window's reported theme is used
376 /// where possible, otherwise it resolves to light.
377 pub fn apply_os_theme(&mut self, os_dark_hint: Option<bool>) {
378 // Fall back to a window's currently-reported winit theme when the
379 // caller didn't supply a hint (so picking "System" on macOS/Windows
380 // adopts the right light/dark immediately, not just on the next toggle).
381 let hint = os_dark_hint.or_else(|| {
382 self.windows
383 .values()
384 .next()
385 .and_then(|m| m.platform_window.window().theme())
386 .map(|t| matches!(t, winit::window::Theme::Dark))
387 });
388 let theme = match self.theme_mode {
389 ThemeMode::Manual => return,
390 ThemeMode::FollowSystem => {
391 let dark = match teksilo_platform::os_theme::query_color_scheme() {
392 ColorSchemePreference::Dark => true,
393 ColorSchemePreference::Light => false,
394 ColorSchemePreference::NoPreference => hint.unwrap_or(false),
395 };
396 if dark {
397 teksilo_core::presets::intui::dark()
398 } else {
399 teksilo_core::presets::intui::light()
400 }
401 .with_id("system")
402 }
403 ThemeMode::Native => {
404 let os = teksilo_platform::os_theme::query_os_theme_colors();
405 match os.color_scheme {
406 // Real OS scheme (Linux): adopt the OS's actual colours.
407 ColorSchemePreference::Dark => Theme {
408 colors: ColorTokens::from_os_colors(&os),
409 ..teksilo_core::presets::intui::dark()
410 },
411 ColorSchemePreference::Light => Theme {
412 colors: ColorTokens::from_os_colors(&os),
413 ..teksilo_core::presets::intui::light()
414 },
415 // No OS-colour support (macOS/Windows): follow the winit
416 // light/dark hint using the built-in presets.
417 ColorSchemePreference::NoPreference => {
418 if hint.unwrap_or(false) {
419 teksilo_core::presets::intui::dark()
420 } else {
421 teksilo_core::presets::intui::light()
422 }
423 }
424 }
425 .with_id("system")
426 }
427 };
428 self.set_theme(theme);
429 }
430
431 #[cfg(feature = "text")]
432 pub fn set_typesetter(&mut self, typesetter: teksilo_text::SharedTypesetter) {
433 self.typesetter = Some(typesetter);
434 }
435
436 fn alloc_id(&mut self) -> TeksiloWindowId {
437 let id = TeksiloWindowId::new(self.next_id);
438 self.next_id += 1;
439 id
440 }
441
442 /// Create a new window synchronously. Allocates an id, constructs
443 /// the winit surface, builds the widget tree, and registers
444 /// everything in the windows map before returning.
445 ///
446 /// The returned id is immediately usable — it can be passed to
447 /// `find_window`, `focus_window`, or `close_window_by_id`, and
448 /// state writes through `WindowState` are applied at the next
449 /// `drain_window_commands` tick.
450 pub fn create_window(
451 &mut self,
452 mut config: WindowConfig,
453 target: &winit::event_loop::ActiveEventLoop,
454 ) -> TeksiloWindowId {
455 // If a `WindowStateService` is registered AND this window has
456 // a stable `id(...)`, restore the saved geometry — sanitized
457 // against the current monitor — into `config` before any
458 // winit attribute is built. See `window_persist` for the
459 // exact policy.
460 let persist_service: Option<teksilo_settings::WindowStateService> =
461 self.app_context_template.as_ref().and_then(|t| {
462 t.app_state::<teksilo_settings::WindowStateService>()
463 .cloned()
464 });
465 // Restoring and persisting are separate decisions (see
466 // `WindowConfig::restore_geometry`): a window can save its geometry
467 // without reading the saved value back. That is what lets an app whose
468 // windows share one geometry slot open the *first* window where the user
469 // left it while letting the OS place any window opened alongside it —
470 // instead of stacking them all on the same pixel — yet still have the
471 // last-moved window be the one that reopens.
472 if let Some(svc) = persist_service.as_ref()
473 && config.restore_geometry
474 {
475 crate::window_persist::apply_restored_geometry(&mut config, svc, target);
476 }
477
478 let teksilo_id = self.alloc_id();
479 // Drain the close-guard fields out of the config before any of
480 // its other fields are consumed below; they move onto the
481 // `ManagedWindow` and live for the window's lifetime.
482 let close_guard = config.take_close_guard();
483 let can_close = config.take_can_close();
484 let on_close_blocked = config.take_close_blocked();
485 let on_removed = config.take_on_removed();
486 let state = WindowState::new(WindowStateInit {
487 id: teksilo_id,
488 string_id: config.string_id.clone(),
489 placement: config.initial_placement,
490 title: config.title.clone(),
491 size: config.size,
492 position: config.position.unwrap_or((0, 0)),
493 focused: true,
494 resizable: config.resizable,
495 always_on_top: config.always_on_top,
496 });
497 if let Some(sid) = &config.string_id {
498 self.string_to_id.insert(sid.clone(), teksilo_id);
499 }
500 let wants_custom_chrome = config.decorations.wants_custom_chrome_host();
501 let is_modal = config.is_modal();
502 let modal_parent = config.modal_parent();
503 let modal_focus_target = config.modal_focus_target();
504
505 // Center modal windows over their parent when the caller did not
506 // request a specific position. Approximates the modal's outer
507 // rect with its inner (client) size — close enough visually
508 // since decoration thickness is small relative to the dialog.
509 // No-op on Wayland (compositor owns positioning).
510 if config.position.is_none()
511 && let Some(parent_id) = modal_parent
512 && let Some(parent_winit) = self.winit_id_for_teksilo(parent_id)
513 && let Some(parent_managed) = self.windows.get(&parent_winit)
514 {
515 let parent_window = parent_managed.platform_window.window();
516 if let Ok(parent_outer_pos) = parent_window.outer_position() {
517 let parent_sf = parent_window.scale_factor();
518 let parent_outer_size = parent_window.outer_size();
519 let p_x = parent_outer_pos.x as f64 / parent_sf;
520 let p_y = parent_outer_pos.y as f64 / parent_sf;
521 let p_w = parent_outer_size.width as f64 / parent_sf;
522 let p_h = parent_outer_size.height as f64 / parent_sf;
523 let m_w = config.size.0 as f64;
524 let m_h = config.size.1 as f64;
525 let x = (p_x + (p_w - m_w) / 2.0).round() as i32;
526 let y = (p_y + (p_h - m_h) / 2.0).round() as i32;
527 config.position = Some((x, y));
528 }
529 }
530
531 let mut window_attrs = winit::window::Window::default_attributes()
532 .with_title(&config.title)
533 .with_inner_size(winit::dpi::LogicalSize::new(config.size.0, config.size.1))
534 .with_resizable(config.resizable)
535 .with_visible(false); // Must be invisible for AccessKit adapter creation
536
537 if let Some((min_w, min_h)) = config.min_size {
538 window_attrs =
539 window_attrs.with_min_inner_size(winit::dpi::LogicalSize::new(min_w, min_h));
540 }
541 if let Some((max_w, max_h)) = config.max_size {
542 window_attrs =
543 window_attrs.with_max_inner_size(winit::dpi::LogicalSize::new(max_w, max_h));
544 }
545 if let Some((x, y)) = config.position {
546 window_attrs = window_attrs.with_position(winit::dpi::LogicalPosition::new(x, y));
547 }
548 if matches!(config.decorations, DecorationsMode::None) {
549 window_attrs = window_attrs.with_decorations(false);
550 }
551 if let Some(icon) = &config.icon {
552 if icon.is_valid() {
553 match winit::window::Icon::from_rgba(icon.rgba.clone(), icon.width, icon.height) {
554 Ok(platform_icon) => {
555 window_attrs = window_attrs.with_window_icon(Some(platform_icon));
556 }
557 Err(e) => eprintln!(
558 "teksilo-app: failed to build window icon ({}×{}): {e}",
559 icon.width, icon.height
560 ),
561 }
562 } else {
563 eprintln!(
564 "teksilo-app: window icon buffer size ({}) does not match {}×{}×4 ({}); \
565 dropping icon, window will open with platform default",
566 icon.rgba.len(),
567 icon.width,
568 icon.height,
569 icon.expected_len()
570 );
571 }
572 }
573
574 // When the application opts into custom chrome, suppress the
575 // server-side decorations on platforms where they're entirely
576 // client-drawn (Wayland, X11). On Windows we keep
577 // `with_decorations(true)` because the M4 recipe relies on the native
578 // frame still being present (DwmExtendFrameIntoClientArea +
579 // WM_NCCALCSIZE), and on macOS the M3 recipe sets the relevant
580 // attributes via `WindowAttributesExtMacOS` — neither needs the toggle
581 // here.
582 //
583 // The window system has to be predicted from the environment rather
584 // than read off a handle, because this decision is made *before* the
585 // window exists. `active_window_system` mirrors winit's own precedence
586 // exactly so the two cannot disagree.
587 //
588 // X11 additionally requires a window manager that implements
589 // `_NET_WM_MOVERESIZE`: without server-side decorations that is the
590 // only way the window can be moved or resized, so shipping a
591 // borderless window to a WM that lacks it would strand the user. The
592 // probe is cached per process and `X11Host::new` consults the same
593 // answer, so the decoration flag and the host can't diverge.
594 #[cfg(all(unix, not(target_os = "macos")))]
595 if wants_custom_chrome {
596 let suppress_decorations = match teksilo_platform::active_window_system() {
597 teksilo_platform::WindowSystem::Wayland => true,
598 teksilo_platform::WindowSystem::X11 => {
599 teksilo_platform::x11::capabilities().supports_custom_chrome()
600 }
601 teksilo_platform::WindowSystem::Unknown => false,
602 };
603 if suppress_decorations {
604 window_attrs = window_attrs.with_decorations(false);
605 }
606 }
607
608 // macOS custom chrome: let the widget tree paint under the titlebar
609 // region while keeping the native traffic-light cluster on top. See
610 // `title_bar_host/macos.rs` for how the traffic-light inset is
611 // measured and exposed through `reserved_leading_inset`.
612 #[cfg(target_os = "macos")]
613 if wants_custom_chrome {
614 use winit::platform::macos::WindowAttributesExtMacOS;
615 window_attrs = window_attrs
616 .with_titlebar_transparent(true)
617 .with_fullsize_content_view(true)
618 .with_title_hidden(true);
619 }
620
621 // Z-order for modals comes from the parent relationship below
622 // (`with_owner_window` on Win32; `with_parent_window` on X11/Wayland;
623 // `attach_child_window` on macOS), not from `WindowLevel::AlwaysOnTop`.
624 // Setting TOPMOST on a Win32 owned window is redundant and disrupts
625 // the message pump (paint events stop arriving until the user forces
626 // a redraw via focus change or resize). Only honour the explicit
627 // `always_on_top` config flag here.
628 if config.always_on_top {
629 window_attrs = window_attrs.with_window_level(WindowLevel::AlwaysOnTop);
630 }
631
632 // Parent-window attachment. Independent of the modal flag so
633 // non-modal parented windows (popover-as-window, inspector
634 // palettes, floating tool panels — the coming multi-window
635 // cases) take the same path.
636 //
637 // Win32: use `with_owner_window` (CreateWindowEx's hwndOwner) —
638 // winit documents this as "for dialog boxes". Produces an owned
639 // WS_POPUP/WS_OVERLAPPED that floats above its owner, gets its
640 // own paint/input messages, and tracks its owner's minimize
641 // state. We do NOT use `with_parent_window` here: on Win32 winit
642 // calls `SetParent`, making the dialog a `WS_CHILD` clipped
643 // inside the owner's client area — wrong for dialogs in every
644 // way (paint, input, movement, z-order).
645 //
646 // X11 / Wayland: `with_parent_window` is correct — winit wires
647 // it through `WM_TRANSIENT_FOR` / `xdg_toplevel.set_parent`,
648 // both of which match dialog semantics.
649 //
650 // macOS: skip here and defer to `attach_child_window` after
651 // `PlatformWindow::new_with_a11y`. AppKit's
652 // `-[NSWindow addChildWindow:ordered:]` orders the child
653 // front (making it visible), which would race with the
654 // AccessKit adapter that requires a hidden window at
655 // construction.
656 #[cfg(target_os = "windows")]
657 if let Some(parent_id) = modal_parent
658 && let Some(parent_winit) = self.winit_id_for_teksilo(parent_id)
659 && let Some(parent_managed) = self.windows.get(&parent_winit)
660 && let Ok(parent_handle) = parent_managed.platform_window.window().window_handle()
661 && let winit::raw_window_handle::RawWindowHandle::Win32(win32) = parent_handle.as_raw()
662 {
663 use winit::platform::windows::WindowAttributesExtWindows;
664 window_attrs = window_attrs.with_owner_window(win32.hwnd.get());
665 }
666
667 #[cfg(all(unix, not(target_os = "macos")))]
668 if let Some(parent_id) = modal_parent
669 && let Some(parent_winit) = self.winit_id_for_teksilo(parent_id)
670 && let Some(parent_managed) = self.windows.get(&parent_winit)
671 && let Ok(parent_handle) = parent_managed.platform_window.window().window_handle()
672 {
673 // SAFETY: the parent window is managed by the WindowManager
674 // and remains alive for the lifetime of the child.
675 window_attrs = unsafe { window_attrs.with_parent_window(Some(parent_handle.as_raw())) };
676 }
677
678 // Opt-in: consume a startup activation token from the environment so a
679 // window spawned by another instance's "open in new window" comes up
680 // focused on Wayland. No-op off Wayland/X11 or when the env is unset.
681 if config.activate_from_env {
682 window_attrs =
683 teksilo_platform::window_activation::apply_creation_token(window_attrs, target);
684 }
685
686 let window = target
687 .create_window(window_attrs)
688 .expect("winit window creation failed");
689 let winit_id = window.id();
690 let scale_factor = window.scale_factor();
691
692 let mut translation_state = TranslationState::new();
693 translation_state.set_scale_factor(scale_factor);
694
695 // Resolve the initial theme from ThemeMode before building the tree
696 let initial_theme = match self.theme_mode {
697 ThemeMode::Manual => self.theme.clone(),
698 // OS-following modes carry the id "system" so a `ThemeSwitcher`
699 // recognizes the active theme as "follow OS", not a fixed pick.
700 ThemeMode::FollowSystem => match window.theme() {
701 Some(winit::window::Theme::Dark) => teksilo_core::presets::intui::dark(),
702 _ => teksilo_core::presets::intui::light(),
703 }
704 .with_id("system"),
705 ThemeMode::Native => {
706 let os = teksilo_platform::os_theme::query_os_theme_colors();
707 let base = if os.color_scheme.is_dark() {
708 teksilo_core::presets::intui::dark()
709 } else {
710 teksilo_core::presets::intui::light()
711 };
712 Theme {
713 colors: ColorTokens::from_os_colors(&os),
714 ..base
715 }
716 .with_id("system")
717 }
718 };
719 // Update the shared theme so all subsequent windows use the same base
720 if self.theme_mode != ThemeMode::Manual {
721 self.theme = initial_theme.clone();
722 }
723
724 // Create with AccessKit adapter (shows window after adapter is ready)
725 let mut pw = pollster::block_on(PlatformWindow::new_with_a11y(window, target));
726
727 // macOS-only: the parent-child attach was deferred out of the
728 // winit builder above to avoid the AppKit auto-show that races
729 // with AccessKit adapter creation. Wire it now that the child
730 // is visible.
731 #[cfg(target_os = "macos")]
732 if let Some(parent_id) = modal_parent
733 && let Some(parent_winit) = self.winit_id_for_teksilo(parent_id)
734 && let Some(parent_managed) = self.windows.get(&parent_winit)
735 {
736 teksilo_platform::attach_child_window(
737 parent_managed.platform_window.window(),
738 pw.window(),
739 );
740 }
741
742 if is_modal {
743 // No `set_window_level(AlwaysOnTop)` here — see the comment
744 // on `with_window_level` above. The owner relationship
745 // already keeps the modal ordered above its parent.
746 teksilo_platform::window_activation::raise(pw.window(), None);
747 }
748
749 // Construct the platform title bar host if custom chrome was
750 // requested. On unsupported platforms (X11, no host backend) the
751 // factory logs a warning and returns `Unsupported`; we silently
752 // continue with native decorations and leave the host slot empty.
753 let title_bar_host: Option<Rc<dyn PlatformTitleBarHost>> = if wants_custom_chrome {
754 let callbacks = match self.event_proxy.clone() {
755 Some(proxy) => {
756 let close_proxy = proxy.clone();
757 let post_proxy = proxy;
758 TitleBarHostCallbacks {
759 request_close: Rc::new(move || {
760 close_proxy.send_external(CloseWindowRequest { teksilo_id });
761 }),
762 // Used by the Windows backend to post
763 // `TitleBarSyntheticEvent` / `TitleBarHoverEvent`
764 // back through `AppEvent::External`. Wayland
765 // and macOS construct the host but never call
766 // this closure.
767 post_external: Rc::new(move |payload| {
768 post_proxy.send_external_boxed(payload);
769 }),
770 teksilo_id,
771 }
772 }
773 // Headless / test path: no event loop proxy is installed, so
774 // the host's close() becomes a silent no-op. Real windowed
775 // runs always install a proxy via `set_event_proxy`.
776 None => TitleBarHostCallbacks {
777 teksilo_id,
778 ..TitleBarHostCallbacks::noop()
779 },
780 };
781 create_title_bar_host(pw.window_arc(), callbacks).ok()
782 } else {
783 None
784 };
785
786 let mut tree = WidgetTree::new().with_theme(initial_theme);
787 // Surface the window's HiDPI device scale to widgets that bridge to a
788 // device-pixel OS resource (e.g. a `WebView` subview). Refreshed on
789 // `ScaleFactorChanged`; the tree is otherwise fully logical.
790 tree.set_device_scale_factor(scale_factor as f32);
791
792 // Seed the tree from the active i18n manager (if any). Without
793 // this, `WidgetTree::new()` defaults to `LayoutDirection::LeftToRight`
794 // and an empty locale — so a windowed app started in an RTL
795 // locale (via `TeksiloAppBuilder::i18n(...)` with Arabic/Hebrew as
796 // initial) would lay out its first window as LTR until the
797 // user manually triggered a locale switch. New windows created
798 // mid-session also benefit: they inherit the active locale
799 // and direction instead of reverting to the default.
800 //
801 // The seeding must happen BEFORE `root_builder` runs so any
802 // `tr!` calls inside `build()` see the correct locale on
803 // first build, and BEFORE the first layout pass so the tree's
804 // `layout_direction` field already matches `m.direction_signal()`.
805 if let Some((loc, dir)) = teksilo_i18n::thread_local::with_active(|m| {
806 (
807 m.locale_signal().get().to_string(),
808 m.direction_signal().get(),
809 )
810 }) {
811 tree.set_layout_direction(dir);
812 tree.set_locale(loc);
813 }
814
815 if let Some(template) = self.app_context_template.as_ref() {
816 tree.set_app_context(template.clone());
817 }
818 if let Some(ref host) = title_bar_host {
819 tree.set_title_bar_host(host.clone());
820 }
821 tree.set_accessibility_preferences(
822 self.a11y_prefs.high_contrast,
823 self.a11y_prefs.reduced_motion,
824 self.a11y_prefs.text_scale_factor,
825 );
826 if (self.user_text_scale - 1.0).abs() > f32::EPSILON {
827 tree.set_user_text_scale(self.user_text_scale);
828 }
829
830 #[cfg_attr(not(feature = "text"), allow(unused_mut))]
831 let mut primed_atlas_version: u64 = 0;
832 #[cfg(feature = "text")]
833 {
834 if let Some(ref typesetter) = self.typesetter {
835 typesetter.set_scale_factor(scale_factor as f32);
836 tree = tree.with_text_backend(typesetter.as_text_backend());
837
838 // Prime the new window's GPU atlas from the shared
839 // typesetter. The versioned path in
840 // `handle_redraw_requested` only uploads when this
841 // window's `atlas_uploaded_version` lags the bridge; a
842 // window created after the atlas already contains every
843 // glyph it needs (e.g. reopening a modal with the same
844 // labels) would otherwise render text against an empty
845 // per-window atlas texture. Read-only access on purpose:
846 // calling `atlas_info` here would consume the pending
847 // text-activity flag and the eviction-epoch delta that
848 // belong to the creating window's in-flight redraw.
849 let (w, h, pixels, version) = {
850 let bridge = typesetter.bridge().borrow();
851 let service = bridge.service();
852 (
853 service.atlas_width(),
854 service.atlas_height(),
855 service.atlas_pixels().to_vec(),
856 bridge.atlas_version(),
857 )
858 };
859 if w > 0 && h > 0 {
860 pw.renderer_mut().upload_atlas(w, h, &pixels);
861 primed_atlas_version = version;
862 }
863 }
864 }
865
866 // Attach this window's state to the tree so widgets can bind
867 // against its own window signals via `ctx.window()`.
868 tree.set_window_state(state.clone());
869
870 if let Some(root_builder) = config.take_root_builder() {
871 let mut root_id = root_builder(&mut tree, state.clone());
872
873 // Apply post-root wrapping: per-window override takes
874 // precedence; otherwise fall back to the app-wide
875 // `DefaultPostRoot` registered via `app_state` (e.g. the
876 // debug inspector's shell wrapper). The wrapped id becomes
877 // the window's effective root for modal-focus lookup, since
878 // the wrapper still descends into the user's tree.
879 //
880 // The app-wide default is intentionally skipped for modal
881 // windows: it installs app-level chrome (the toast host, the
882 // inspector shell) that should belong to the primary window,
883 // not to transient native-window modals (dialogs, message
884 // boxes, wizards). Without this guard the shared toast
885 // registry would render every live toast in the modal too,
886 // anchored to the wrong window. An app that genuinely wants
887 // chrome on a specific modal can still set a per-window
888 // override on that window's `WindowConfig`.
889 if let Some(post_root) = config.take_post_root_builder() {
890 root_id = post_root(&mut tree, root_id);
891 } else if !is_modal
892 && let Some(default_post_root) = self
893 .app_context_template
894 .as_ref()
895 .and_then(|t| t.app_state::<crate::DefaultPostRoot>().cloned())
896 {
897 root_id = (default_post_root.0)(&mut tree, root_id);
898 }
899
900 if let Some(id) = initial_window_focus(&tree, root_id, is_modal, modal_focus_target) {
901 tree.focus(id);
902 }
903 }
904
905 // Apply non-placement post-creation tweaks that winit can't
906 // express at builder time.
907 if config.initial_placement.is_maximized() {
908 pw.window().set_maximized(true);
909 }
910 if config.initial_placement.is_fullscreen() {
911 pw.window()
912 .set_fullscreen(Some(winit::window::Fullscreen::Borderless(None)));
913 }
914 if config.initial_placement.is_minimized() {
915 pw.window().set_minimized(true);
916 }
917
918 // Handle modal blocking
919 if let Some(parent_id) = modal_parent {
920 self.modal_blocked.insert(parent_id, teksilo_id);
921 }
922
923 // Install the auto-save observers if persistence is wired and
924 // this window opted in by carrying a stable id. The handles
925 // outlive the function via `ManagedWindow._persist_handles`.
926 let persist_handles = match (&persist_service, &config.string_id) {
927 (Some(svc), Some(label)) => {
928 crate::window_persist::install_persist_observers(&state, svc.clone(), label.clone())
929 }
930 _ => Vec::new(),
931 };
932
933 let managed = ManagedWindow {
934 teksilo_id,
935 string_id: config.string_id,
936 tree,
937 state,
938 platform_window: pw,
939 translation_state,
940 current_modifiers: winit::keyboard::ModifiersState::empty(),
941 modal: is_modal,
942 parent: modal_parent,
943 size_to_content: config.size_to_content,
944 last_autosize_height: None,
945 title_bar_host,
946 focused: true,
947 occluded: false,
948 caps_lock_active: false,
949 ime_allowed: None,
950 ime_purpose: None,
951 last_ime_preedit_empty: false,
952 _persist_handles: persist_handles,
953 close_guard,
954 can_close,
955 on_close_blocked,
956 on_removed,
957 atlas_uploaded_version: primed_atlas_version,
958 };
959
960 self.windows.insert(winit_id, managed);
961 self.teksilo_to_winit.insert(teksilo_id, winit_id);
962
963 // Register the window as an OS drop target if external drag-and-drop
964 // was installed (no-op otherwise). Runs on the main thread, as macOS
965 // requires for view manipulation.
966 self.attach_external_dnd(teksilo_id, winit_id);
967
968 teksilo_id
969 }
970
971 /// Register the just-created window as an OS drop target via the installed
972 /// [`ExternalDndHandle`](teksilo_platform::external_dnd::ExternalDndHandle).
973 /// No-op if the app did not call `install_external_dnd`, or if the window
974 /// or poster handle can't be resolved.
975 fn attach_external_dnd(&self, teksilo_id: TeksiloWindowId, winit_id: winit::window::WindowId) {
976 use teksilo_platform::external_dnd::ExternalDndHandle;
977 let Some(template) = self.app_context_template.as_ref() else {
978 return;
979 };
980 let Some(handle) = template.app_state::<ExternalDndHandle>().cloned() else {
981 return;
982 };
983 let Some(poster) = template.poster().cloned() else {
984 return;
985 };
986 let Some(managed) = self.windows.get(&winit_id) else {
987 return;
988 };
989 if let Some(parent) =
990 teksilo_core::raw_handle::ParentHandle::from_window(managed.platform_window.window())
991 {
992 handle.attach(teksilo_id, parent, poster);
993 // Seed the backend's scale factor. X11 needs it to report drop
994 // positions in window-logical coordinates (its protocol is
995 // physical-pixel only, with no per-window DPI to query); every
996 // other backend ignores it. Kept current by the
997 // `ScaleFactorChanged` arm in `app.rs`.
998 handle.set_scale_factor(teksilo_id, managed.platform_window.window().scale_factor());
999 }
1000 }
1001
1002 /// Close a window by its TeksiloWindowId.
1003 ///
1004 /// The single choke point every close funnels through:
1005 /// [`process_pending`](Self::process_pending) calls this both for a
1006 /// forced close ([`queue_close`](Self::queue_close) /
1007 /// `close_window_by_id`) and for a guarded one
1008 /// ([`request_close`](Self::request_close)) once its guard has
1009 /// passed. That makes this the one place to fire
1010 /// [`WindowConfig::on_removed`](teksilo_core::WindowConfig::on_removed)
1011 /// so both paths are covered by a single call site instead of two.
1012 pub fn close_window(&mut self, teksilo_id: TeksiloWindowId) {
1013 // Purge any pending file-dialog callbacks owned by the
1014 // soon-to-close window before its tree is dropped — see
1015 // `teksilo_platform::file_dialog::FileDialogHandle::purge_window`.
1016 // A worker-thread future that resolves after this point will
1017 // still arrive at the dispatcher; deliver finds no pending
1018 // entry and silently drops.
1019 #[cfg(feature = "file-dialog")]
1020 {
1021 if let Some(handle) = self
1022 .app_context_template
1023 .as_ref()
1024 .and_then(|t| t.app_state::<teksilo_platform::file_dialog::FileDialogHandle>())
1025 {
1026 handle.purge_window(teksilo_id);
1027 }
1028 }
1029 // Purge any pending async completions owned by the closing window so a
1030 // late-arriving `spawn_local_with` result never touches a torn-down
1031 // tree (mirrors the file-dialog purge above; teksilo-core type, so no
1032 // feature gate).
1033 if let Some(handle) = self
1034 .app_context_template
1035 .as_ref()
1036 .and_then(|t| t.app_state::<teksilo_core::AsyncCompletionHandle>())
1037 {
1038 handle.purge_window(teksilo_id);
1039 }
1040 // Drop any context-bearing subscription callbacks (subscribe_event_with_ctx)
1041 // owned by the closing window, so the shared TreeAppContext map doesn't
1042 // retain inert closures per closed window (the tree is dropped without a
1043 // per-widget destroy pass). A late backend event then finds nothing.
1044 if let Some(template) = self.app_context_template.as_ref() {
1045 template.purge_ctx_subscriptions_for_window(teksilo_id);
1046 }
1047 // Revoke the window's OS drop-target registration (drops the platform
1048 // guard — RevokeDragDrop / removeFromSuperview / data-device teardown).
1049 if let Some(handle) = self
1050 .app_context_template
1051 .as_ref()
1052 .and_then(|t| t.app_state::<teksilo_platform::external_dnd::ExternalDndHandle>())
1053 {
1054 handle.detach(teksilo_id);
1055 }
1056 // Forget this window's native (OS) menu + its activation map.
1057 if let Some(handle) = self
1058 .app_context_template
1059 .as_ref()
1060 .and_then(|t| t.app_state::<teksilo_platform::native_menu::NativeMenuHandle>())
1061 {
1062 handle.clear_window(teksilo_id);
1063 }
1064 // Drop any web-view event callbacks owned by this window so a late
1065 // backend event can't route into a torn-down tree.
1066 #[cfg(feature = "web-view")]
1067 if let Some(registry) = self
1068 .app_context_template
1069 .as_ref()
1070 .and_then(|t| t.app_state::<teksilo_webview::WebViewRegistry>())
1071 {
1072 registry.purge_window(teksilo_id);
1073 }
1074 // Stashed here (rather than invoked inline) so the hook can run
1075 // AFTER `managed` — and with it the tree, platform window, and
1076 // this whole `if let` block's own cleanup — is completely gone.
1077 // See `WindowConfig::on_removed` for why "after" is load-bearing.
1078 let mut removed_hook: Option<(teksilo_core::WindowRemovedCallback, Option<String>)> = None;
1079 if let Some(winit_id) = self.teksilo_to_winit.remove(&teksilo_id)
1080 && let Some(mut managed) = self.windows.remove(&winit_id)
1081 {
1082 // If this window is involved in an in-flight app-originated OS
1083 // drag, abort it before the tree is dropped — otherwise the
1084 // app-global typed-payload stash would leak and a later genuine
1085 // external drop could be misrecovered as the stale payload.
1086 managed.tree.abort_outbound_drag();
1087 if let Some(sid) = managed.string_id.as_deref() {
1088 self.string_to_id.remove(sid);
1089 }
1090 // Unblock parent if this was a modal
1091 if managed.modal
1092 && let Some(parent_id) = managed.parent
1093 {
1094 self.modal_blocked.remove(&parent_id);
1095 }
1096 if let Some(hook) = managed.on_removed.take() {
1097 removed_hook = Some((hook, managed.string_id.clone()));
1098 }
1099 }
1100 // Also remove any modal children blocking this window
1101 self.modal_blocked.remove(&teksilo_id);
1102
1103 // Fire the teardown hook last, once every registry above
1104 // (`windows`, `teksilo_to_winit`, `string_to_id`, `modal_blocked`)
1105 // no longer mentions this window — `remaining_windows` below is
1106 // `self.windows.len()` read at this point, so it already excludes
1107 // the window being removed.
1108 if let Some((hook, string_id)) = removed_hook {
1109 hook(&teksilo_core::WindowRemovedEvent {
1110 id: teksilo_id,
1111 string_id,
1112 remaining_windows: self.windows.len(),
1113 });
1114 }
1115 }
1116
1117 /// Queue an **unconditional** window closure (processed in the next
1118 /// event loop tick). The window's close guard is *not* consulted —
1119 /// use this for explicit programmatic closes and framework-internal
1120 /// teardown (modal dismissals, `close_window_by_id`). For a
1121 /// *guarded* close that a window's
1122 /// [`on_close_requested`](teksilo_core::WindowConfig::on_close_requested)
1123 /// can veto, use [`request_close`](Self::request_close).
1124 pub fn queue_close(&mut self, teksilo_id: TeksiloWindowId) {
1125 self.pending_closes.push(PendingClose {
1126 id: teksilo_id,
1127 force: true,
1128 });
1129 }
1130
1131 /// Queue a **guarded** window closure (processed in the next event
1132 /// loop tick). Before tearing the window down,
1133 /// [`process_pending`](Self::process_pending) consults the window's
1134 /// close guard (from
1135 /// [`WindowConfig::on_close_requested`](teksilo_core::WindowConfig::on_close_requested)
1136 /// / [`can_close`](teksilo_core::WindowConfig::can_close)); a
1137 /// [`CloseResponse::Veto`](teksilo_core::CloseResponse) keeps the
1138 /// window open. Used for the interactive close gestures: the OS
1139 /// close button, a custom-chrome close button, and
1140 /// [`EventContext::close_window`](teksilo_core::widget::EventContext::close_window).
1141 pub fn request_close(&mut self, teksilo_id: TeksiloWindowId) {
1142 self.pending_closes.push(PendingClose {
1143 id: teksilo_id,
1144 force: false,
1145 });
1146 }
1147
1148 /// Route a Windows-side synthetic title-bar tap. The wndproc
1149 /// posts a `TitleBarSyntheticEvent` when `WM_NCLBUTTONUP`
1150 /// fires on a button rect that the OS treated as non-client; we
1151 /// resolve the matching `WidgetId` via the host and synthesise a
1152 /// primary-button tap so the widget's normal `on_tap` handler
1153 /// runs. No-op on platforms that never produce these events.
1154 pub fn route_title_bar_synthetic_tap(
1155 &mut self,
1156 teksilo_id: TeksiloWindowId,
1157 target: teksilo_core::ControlTarget,
1158 ) {
1159 let Some(winit_id) = self.teksilo_to_winit.get(&teksilo_id).copied() else {
1160 return;
1161 };
1162 let Some(managed) = self.windows.get_mut(&winit_id) else {
1163 return;
1164 };
1165 let Some(host) = managed.title_bar_host.as_ref() else {
1166 return;
1167 };
1168 let Some(button_id) = host.title_bar_widget_id(target) else {
1169 return;
1170 };
1171 managed.tree.synthesise_tap(button_id);
1172 }
1173
1174 /// Route a Windows-side synthetic title-bar hover entered/leave.
1175 /// Delegates to the host's `set_button_hover`, which writes the
1176 /// signal `WindowControls` registered for the matching button.
1177 /// No-op on platforms that don't intercept non-client hover.
1178 pub fn route_title_bar_synthetic_hover(
1179 &mut self,
1180 teksilo_id: TeksiloWindowId,
1181 target: teksilo_core::ControlTarget,
1182 entered: bool,
1183 ) {
1184 let Some(winit_id) = self.teksilo_to_winit.get(&teksilo_id).copied() else {
1185 return;
1186 };
1187 let Some(managed) = self.windows.get(&winit_id) else {
1188 return;
1189 };
1190 let Some(host) = managed.title_bar_host.as_ref() else {
1191 return;
1192 };
1193 host.set_button_hover(target, entered);
1194 }
1195
1196 /// Drain the app→OS command queue on every window and translate
1197 /// each [`WindowCommand`] into the appropriate winit call. Called
1198 /// once per event-loop tick after event dispatch.
1199 ///
1200 /// Observers on [`WindowState`] signals emit commands when app
1201 /// code writes through them. OS-originated writes go through the
1202 /// `*_from_os` setters on the state, which flip the re-entrancy
1203 /// guard so the same observers do not fire an echo back out — so
1204 /// the queue only contains genuine app→OS directives.
1205 pub fn drain_window_commands(&mut self) {
1206 // Collect (winit_id, cmd) pairs first so the borrow on
1207 // `self.windows` is released before we touch platform_window.
1208 let mut batch: Vec<(winit::window::WindowId, TeksiloWindowId, WindowCommand)> = Vec::new();
1209 for (winit_id, managed) in self.windows.iter() {
1210 for cmd in managed.state.drain_os_commands() {
1211 batch.push((*winit_id, managed.teksilo_id, cmd));
1212 }
1213 }
1214 for (winit_id, teksilo_id, cmd) in batch {
1215 // `Close` is the one command that needs to mutate
1216 // `self.windows` — queue it for the tick-end close drain
1217 // instead of running it inline. `WindowState::close()` is an
1218 // explicit programmatic close, so it bypasses the close
1219 // guard (forced); interactive gestures go through
1220 // `request_close` instead.
1221 if matches!(cmd, WindowCommand::Close) {
1222 self.queue_close(teksilo_id);
1223 continue;
1224 }
1225 let Some(managed) = self.windows.get(&winit_id) else {
1226 continue;
1227 };
1228 apply_window_command(managed.platform_window.window(), cmd);
1229 }
1230 }
1231
1232 /// Process pending window closures. Called from the event loop
1233 /// each tick. Creation does not need a drain path — `open_window`
1234 /// from handler code goes through [`WindowOpsImpl`] and calls
1235 /// [`create_window`](Self::create_window) synchronously inside the
1236 /// same dispatch.
1237 ///
1238 /// A *forced* close ([`queue_close`](Self::queue_close)) tears the
1239 /// window down immediately. A *guarded* close
1240 /// ([`request_close`](Self::request_close)) first runs the window's
1241 /// close guard via `evaluate_close_guard`; a
1242 /// [`CloseResponse::Veto`](teksilo_core::CloseResponse) keeps the
1243 /// window open. Guards are strictly per-window, so this is correct
1244 /// for multi-window apps: each pending close consults only its own
1245 /// window's guard.
1246 pub fn process_pending(&mut self, target: &winit::event_loop::ActiveEventLoop) {
1247 let closes = std::mem::take(&mut self.pending_closes);
1248 for pending in closes {
1249 if pending.force || self.evaluate_close_guard(pending.id, target) {
1250 self.close_window(pending.id);
1251 }
1252 // Vetoed guarded close → the window stays open. The app may
1253 // have opened a confirmation dialog from inside the guard;
1254 // its "close anyway" button calls
1255 // `EventContext::close_window_forced`, which re-queues this
1256 // window as a forced close on a later tick.
1257 }
1258 }
1259
1260 /// Run the close guard for `teksilo_id` (if it declared one) and
1261 /// return whether the close should proceed.
1262 ///
1263 /// A window with no guard returns `true` without building an
1264 /// `EventContext`. Otherwise the guard runs with a real
1265 /// [`EventContext`](teksilo_core::widget::EventContext) for the
1266 /// window's own tree (so it can open a confirmation dialog, set
1267 /// signals, fire intents…), and the verdict is:
1268 ///
1269 /// 1. [`can_close`](teksilo_core::WindowConfig::can_close) sugar: a
1270 /// `false` signal vetoes and fires
1271 /// [`on_close_blocked`](teksilo_core::WindowConfig::on_close_blocked).
1272 /// 2. otherwise the
1273 /// [`on_close_requested`](teksilo_core::WindowConfig::on_close_requested)
1274 /// guard's [`CloseResponse`].
1275 /// 3. otherwise `true` (close).
1276 ///
1277 /// Strictly per-window — only `teksilo_id`'s own guard is consulted.
1278 fn evaluate_close_guard(
1279 &mut self,
1280 teksilo_id: TeksiloWindowId,
1281 target: &winit::event_loop::ActiveEventLoop,
1282 ) -> bool {
1283 let Some(&winit_id) = self.teksilo_to_winit.get(&teksilo_id) else {
1284 // Unknown / already-gone window — let `close_window` no-op.
1285 return true;
1286 };
1287 // Fast path: no guard configured → close immediately, without
1288 // paying for an EventContext.
1289 match self.windows.get(&winit_id) {
1290 Some(managed) if managed.close_guard.is_none() && managed.can_close.is_none() => {
1291 return true;
1292 }
1293 None => return true,
1294 _ => {}
1295 }
1296 // Take the window out of the map so we can borrow `&mut self`
1297 // for `WindowOpsImpl` while still holding the window's tree.
1298 let Some(mut managed) = self.take_managed(winit_id) else {
1299 return true;
1300 };
1301
1302 #[cfg(not(target_os = "macos"))]
1303 let current_handle = managed
1304 .platform_window
1305 .window()
1306 .window_handle()
1307 .ok()
1308 .map(|h| h.as_raw());
1309 let current_arc = Some(managed.platform_window.window_arc());
1310
1311 // Clone the guard handles so the dispatch closure captures them
1312 // disjointly from `managed.tree`, which `run_with_event_context`
1313 // borrows mutably.
1314 let close_guard = managed.close_guard.clone();
1315 let can_close = managed.can_close.clone();
1316 let on_close_blocked = managed.on_close_blocked.clone();
1317
1318 let mut should_close = true;
1319 {
1320 let mut ops = WindowOpsImpl::new(
1321 self,
1322 target,
1323 teksilo_id,
1324 #[cfg(not(target_os = "macos"))]
1325 current_handle,
1326 current_arc,
1327 );
1328 managed.tree.run_with_event_context(&mut ops, |ctx| {
1329 should_close = close_verdict(&can_close, &on_close_blocked, &close_guard, ctx);
1330 });
1331 }
1332
1333 self.reinsert_managed(winit_id, managed);
1334 should_close
1335 }
1336
1337 /// Get a mutable ManagedWindow for a winit WindowId.
1338 pub(crate) fn get_by_winit_mut(
1339 &mut self,
1340 id: winit::window::WindowId,
1341 ) -> Option<&mut ManagedWindow> {
1342 self.windows.get_mut(&id)
1343 }
1344
1345 /// Temporarily remove a managed window from the map. Used by
1346 /// `TeksiloAppHandler::dispatch_in_window` so the handler's
1347 /// `&mut tree` borrow does not collide with
1348 /// [`WindowOpsImpl`]'s `&mut WindowManager` borrow.
1349 /// The caller must pair this with
1350 /// [`reinsert_managed`](Self::reinsert_managed) before the
1351 /// enclosing winit event returns.
1352 pub(crate) fn take_managed(&mut self, id: winit::window::WindowId) -> Option<ManagedWindow> {
1353 self.windows.remove(&id)
1354 }
1355
1356 /// Re-insert a `ManagedWindow` previously extracted via
1357 /// [`take_managed`](Self::take_managed).
1358 pub(crate) fn reinsert_managed(&mut self, id: winit::window::WindowId, managed: ManagedWindow) {
1359 self.windows.insert(id, managed);
1360 }
1361
1362 /// Winit ids of every window whose tree has post-mount actions queued
1363 /// (via `BuildContext::run_after_mount`) waiting to run. Drained by
1364 /// `TeksiloAppHandler::process_pending_mount_actions`. Modal-blocked
1365 /// windows are excluded so their actions (e.g. a WebView opening a native
1366 /// engine subview) stay queued until the modal closes — a native surface
1367 /// must not appear over a modal-blocked parent.
1368 pub(crate) fn winit_ids_with_pending_mount_actions(&self) -> Vec<winit::window::WindowId> {
1369 self.windows
1370 .iter()
1371 .filter(|(_, m)| m.tree.has_pending_mount_actions() && !self.is_blocked(m.teksilo_id))
1372 .map(|(id, _)| *id)
1373 .collect()
1374 }
1375
1376 /// `pub(crate)` access to the windows map used by
1377 /// [`WindowOpsImpl`].
1378 pub(crate) fn windows_map(&self) -> &HashMap<winit::window::WindowId, ManagedWindow> {
1379 &self.windows
1380 }
1381
1382 /// `pub(crate)` access to the teksilo→winit id map used by
1383 /// [`WindowOpsImpl`].
1384 pub(crate) fn teksilo_to_winit_map(
1385 &self,
1386 ) -> &HashMap<TeksiloWindowId, winit::window::WindowId> {
1387 &self.teksilo_to_winit
1388 }
1389
1390 pub(crate) fn get_by_teksilo_mut(&mut self, id: TeksiloWindowId) -> Option<&mut ManagedWindow> {
1391 let winit_id = self.teksilo_to_winit.get(&id).copied()?;
1392 self.windows.get_mut(&winit_id)
1393 }
1394
1395 /// Get the TeksiloWindowId for a winit WindowId.
1396 pub fn teksilo_id_for_winit(&self, id: winit::window::WindowId) -> Option<TeksiloWindowId> {
1397 self.windows.get(&id).map(|w| w.teksilo_id)
1398 }
1399
1400 /// Find a window by its string ID.
1401 pub fn find_window(&self, string_id: &str) -> Option<TeksiloWindowId> {
1402 self.string_to_id.get(string_id).copied()
1403 }
1404
1405 /// Whether a window is blocked by a modal child.
1406 pub fn is_blocked(&self, teksilo_id: TeksiloWindowId) -> bool {
1407 self.modal_blocked.contains_key(&teksilo_id)
1408 }
1409
1410 pub fn blocking_modal_child(&self, teksilo_id: TeksiloWindowId) -> Option<TeksiloWindowId> {
1411 self.modal_blocked.get(&teksilo_id).copied()
1412 }
1413
1414 pub fn refocus_modal_child(&self, blocked_parent: TeksiloWindowId) {
1415 let Some(child_id) = self.blocking_modal_child(blocked_parent) else {
1416 return;
1417 };
1418 let Some(child_winit) = self.winit_id_for_teksilo(child_id) else {
1419 return;
1420 };
1421 let Some(child) = self.windows.get(&child_winit) else {
1422 return;
1423 };
1424
1425 // Re-surface the modal relative to its owner via focus alone. The
1426 // cross-platform `window_activation::raise` helper raises on
1427 // X11/Windows/macOS and degrades to an attention request on Wayland
1428 // (where raising an existing window without a token is a no-op). Do NOT
1429 // call `set_window_level(AlwaysOnTop)`: it floats the modal above *all*
1430 // windows (every app) for its lifetime, and on a Win32 owned window it
1431 // also stalls the message pump (paint events stop until a focus/resize
1432 // forces a redraw) — exactly the failure the creation path documents
1433 // and avoids. The owner / transient-parent relationship already keeps
1434 // the modal above its parent.
1435 teksilo_platform::window_activation::raise(child.platform_window.window(), None);
1436 child.platform_window.request_redraw();
1437 }
1438
1439 /// Broadcast a theme change to all windows.
1440 ///
1441 /// `ThemeMode` is `App`-level state, so a user-driven theme set under
1442 /// `FollowSystem`/`Native` is last-writer-wins against the next OS theme
1443 /// event (`handle_theme_changed`). The default `Manual` mode ignores OS
1444 /// events, so an app that wants user theme choices to stick should stay on
1445 /// `Manual` (the default).
1446 pub fn set_theme(&mut self, theme: Theme) {
1447 self.theme = theme.clone();
1448 for managed in self.windows.values_mut() {
1449 managed.tree.set_theme(theme.clone());
1450 }
1451 }
1452
1453 /// Broadcast a user text-scale change to all windows. Stores the factor so
1454 /// windows created later inherit it, then re-scales every existing tree's
1455 /// text without rebuilding.
1456 pub fn set_text_scale(&mut self, factor: f32) {
1457 self.user_text_scale = factor;
1458 for managed in self.windows.values_mut() {
1459 managed.tree.set_user_text_scale(factor);
1460 }
1461 }
1462
1463 /// Seed the user text-scale factor before the first window opens. Called by
1464 /// `TeksiloAppHandler` after reading `teksilo_settings::TEXT_SCALE_KEY`, so
1465 /// every initially-created tree starts at the persisted scale.
1466 pub fn set_initial_text_scale(&mut self, factor: f32) {
1467 self.user_text_scale = factor;
1468 }
1469
1470 /// Re-query the OS accessibility preferences ("increase contrast", "reduce
1471 /// motion", text scale) and, if they changed since startup / the last
1472 /// refresh, apply them to every open window's tree. Lets a runtime toggle
1473 /// of these settings take effect without restarting the app (WCAG / EN
1474 /// 301 549 §11.7). Driven event-first — from `WindowEvent::Focused` when a
1475 /// window gains focus — so there is no idle polling wakeup. Returns `true`
1476 /// if anything changed (so the caller can request a redraw).
1477 pub fn refresh_accessibility_preferences(&mut self) -> bool {
1478 let fresh = AccessibilityPreferences::query();
1479 if fresh == self.a11y_prefs {
1480 return false;
1481 }
1482 let hc = fresh.high_contrast;
1483 let rm = fresh.reduced_motion;
1484 let ts = fresh.text_scale_factor;
1485 self.a11y_prefs = fresh;
1486 for managed in self.windows.values_mut() {
1487 managed.tree.set_accessibility_preferences(hc, rm, ts);
1488 }
1489 true
1490 }
1491
1492 /// Broadcast a locale switch to all windows. Updates the i18n manager
1493 /// (incrementing the version signal) and seeds each tree with the new
1494 /// locale and layout direction. No-op if no `I18nConfig` was registered.
1495 pub fn set_locale(&mut self, locale: teksilo_i18n::LanguageIdentifier) {
1496 let Some((outcome, new_dir)) = teksilo_i18n::thread_local::with_active(|mgr| {
1497 let outcome = mgr.set_locale(locale.clone());
1498 (outcome, mgr.direction_signal().get())
1499 }) else {
1500 return;
1501 };
1502 for managed in self.windows.values_mut() {
1503 if outcome.direction_changed {
1504 managed.tree.set_layout_direction(new_dir);
1505 }
1506 managed.tree.set_locale(locale.to_string());
1507 }
1508 }
1509
1510 /// Get the current shared theme.
1511 pub fn theme(&self) -> &Theme {
1512 &self.theme
1513 }
1514
1515 /// Get the OS-level accessibility preferences (queried at startup).
1516 pub fn accessibility_preferences(&self) -> &AccessibilityPreferences {
1517 &self.a11y_prefs
1518 }
1519
1520 /// Get the TeksiloWindowId of the first (primary) window.
1521 /// Falls back to a synthetic ID when no windows are open yet.
1522 pub fn primary_window_id(&self) -> TeksiloWindowId {
1523 self.teksilo_to_winit
1524 .keys()
1525 .copied()
1526 .min_by_key(|id| id.raw())
1527 .unwrap_or(TeksiloWindowId::new(0))
1528 }
1529
1530 /// Number of active windows.
1531 pub fn window_count(&self) -> usize {
1532 self.windows.len()
1533 }
1534
1535 /// Whether no windows remain (app should exit).
1536 pub fn is_empty(&self) -> bool {
1537 self.windows.is_empty()
1538 }
1539
1540 /// Iterate over all managed windows.
1541 pub(crate) fn iter(&self) -> impl Iterator<Item = &ManagedWindow> {
1542 self.windows.values()
1543 }
1544
1545 /// Mutably iterate over all managed windows. During a redraw the
1546 /// current window is taken out of the map (`take_managed`), so this
1547 /// yields every OTHER window — which is exactly what the glyph-atlas
1548 /// eviction recovery wants when broadcasting paint invalidation.
1549 pub(crate) fn iter_mut(&mut self) -> impl Iterator<Item = &mut ManagedWindow> {
1550 self.windows.values_mut()
1551 }
1552
1553 /// Iterate the `TeksiloWindowId` of every managed window. Feeds the
1554 /// automation `list_windows` tool (debug-only `automation` feature),
1555 /// which pairs each id with its `string_id` label and current title.
1556 #[allow(dead_code)]
1557 pub(crate) fn teksilo_ids(&self) -> impl Iterator<Item = TeksiloWindowId> + '_ {
1558 self.windows.values().map(|m| m.teksilo_id)
1559 }
1560
1561 /// Get the winit WindowId for a TeksiloWindowId.
1562 pub fn winit_id_for_teksilo(
1563 &self,
1564 teksilo_id: TeksiloWindowId,
1565 ) -> Option<winit::window::WindowId> {
1566 self.teksilo_to_winit.get(&teksilo_id).copied()
1567 }
1568
1569 /// Get the platform title bar host for a window, if the window opted
1570 /// into custom chrome via `WindowConfig::custom_chrome(true)` and the
1571 /// platform supports it. Returns `None` for windows that use native
1572 /// decorations or run on a window system without custom chrome support
1573 /// (currently X11).
1574 pub fn title_bar_host(
1575 &self,
1576 teksilo_id: TeksiloWindowId,
1577 ) -> Option<Rc<dyn PlatformTitleBarHost>> {
1578 let winit_id = self.teksilo_to_winit.get(&teksilo_id).copied()?;
1579 self.windows
1580 .get(&winit_id)
1581 .and_then(|w| w.title_bar_host.clone())
1582 }
1583
1584 /// Request redraw on all windows.
1585 pub fn request_redraw_all(&self) {
1586 for managed in self.windows.values() {
1587 managed.platform_window.request_redraw();
1588 }
1589 }
1590
1591 /// Request redraw only on windows whose next frame deadline has been
1592 /// reached (`<= now`). Used at the animation `ResumeTimeReached` wake so
1593 /// that a single animating window (e.g. a blinking caret) does NOT force a
1594 /// redraw of every other window.
1595 ///
1596 /// The blanket `request_redraw_all()` here was a cross-window over-redraw:
1597 /// a wasted-power bug on every platform (an inactive, non-animating window
1598 /// repainted at 60 Hz), and on Windows a *correctness* bug — winit services
1599 /// only one window's `RedrawRequested` per event-loop iteration, so an
1600 /// inactive window flooded with redraws it never wins is starved of its own
1601 /// pending repaint and freezes on its last active frame (caret stuck,
1602 /// colours not desaturated). Targeting only due windows removes both.
1603 pub fn request_redraw_due(&self, now: std::time::Instant) {
1604 for managed in self.windows.values() {
1605 if managed
1606 .tree
1607 .next_timer_deadline()
1608 .is_some_and(|deadline| deadline <= now)
1609 {
1610 managed.platform_window.request_redraw();
1611 }
1612 }
1613 }
1614
1615 /// Reconcile every window's reactive (`Signal`-bound) state and
1616 /// request redraw ONLY on the ones that come out of that with pending
1617 /// layout or paint work (`WidgetTree::needs_render`). Returns how many
1618 /// windows were poked, purely so a caller can log/trace it.
1619 ///
1620 /// # The problem
1621 ///
1622 /// A `Signal` mutation made by a handler in one window's dispatch
1623 /// (e.g. writing to an app-level `Signal` a *sibling* window's widget
1624 /// also reads) is supposed to make that sibling dirty too — that's the
1625 /// whole point of sharing a `Signal` across windows. But only the
1626 /// dispatching window's own event-handling path calls
1627 /// `request_redraw()` on itself (see the `WindowEvent::CursorMoved` /
1628 /// `MouseInput` / `KeyboardInput` arms); nothing tells winit to
1629 /// schedule a `RedrawRequested` for the sibling, so it shows a stale
1630 /// frame until the user focuses it (which finally earns it a paint).
1631 ///
1632 /// # Why this can't just check `needs_render()`
1633 ///
1634 /// A `Signal` write only advances a change generation on the signal
1635 /// itself (a deliberately lazy design — a signal has no reference
1636 /// back into any `WidgetTree`'s arena to mark node-level dirty bits
1637 /// synchronously). That generation is only compared against what
1638 /// each window's `BindingRegistry` last acted on, and walked into
1639 /// `arena.needs_layout` / `needs_paint` — i.e. into what
1640 /// `needs_render()` actually reads — by `WidgetTree`'s internal
1641 /// `process_state_changes` step, which today runs *only* at the top
1642 /// of that tree's own `layout()`. A window's own
1643 /// `layout()` runs *only* as part of handling its own
1644 /// `RedrawRequested` (see `handle_redraw_requested` in `app.rs`). So a
1645 /// sibling window that never redraws never reconciles its bindings
1646 /// either — its `needs_render()` reads `false` forever, not just
1647 /// "until it happens to repaint", because nothing ever performed the
1648 /// walk that would make it `true`. Checking `needs_render()` without
1649 /// reconciling first would make this method a permanent no-op for
1650 /// exactly the case it exists to fix.
1651 ///
1652 /// That's why a window's `tree.layout()` is called here first, at
1653 /// its OWN current size (`proposal_changed` stays `false`), whenever
1654 /// [`WidgetTree::needs_reconcile`] says reconciling could change the
1655 /// answer. This is the only place in the framework doing this
1656 /// specific reconciliation for a plain app-level `Signal` (the
1657 /// theme/locale/text-scale/follow-system broadcasts sidestep the
1658 /// whole problem by mutating each tree directly through
1659 /// `&mut self.windows`, not through a shared `Signal`, so they
1660 /// always know synchronously that every window needs a redraw).
1661 ///
1662 /// # Why the reconcile is gated rather than unconditional
1663 ///
1664 /// It was unconditional at first, on the grounds that `layout()`
1665 /// short-circuits before the per-node geometry recursion when
1666 /// nothing is dirty. That undersells the cost: `layout_with_ops`
1667 /// runs a dozen per-frame passes *before* it gets near that
1668 /// short-circuit — pending animations, the frame tick, the animation
1669 /// scheduler tick, drag ticks, the whole of `process_state_changes`,
1670 /// tooltips, delayed / pointer-leave / auto-dismiss overlays and
1671 /// overlay fades — and it ran all of them for every open window on
1672 /// every dispatched event, including a fast mouse-move stream.
1673 ///
1674 /// `needs_reconcile()` is cheap enough to ask instead (`u64`
1675 /// comparisons over unique bound sources, no arena walk) and answers
1676 /// precisely the question this sweep is for. It is safe to skip the
1677 /// rest because everything in that list has its own scheduling path
1678 /// — the timing-driven passes all feed `WidgetTree::next_timer_deadline`,
1679 /// which `request_redraw_due` polls — see `needs_reconcile`'s own doc
1680 /// for the case-by-case argument.
1681 ///
1682 /// The `needs_render()` check stays OUTSIDE the gate on purpose: a
1683 /// window can need a repaint for reasons that never involved a
1684 /// binding (a handler called `request_rebuild`, an event dirtied a
1685 /// node directly), and skipping the reconcile must not also skip
1686 /// poking it.
1687 ///
1688 /// # Reconciling here does not rob the window of its own reconcile
1689 ///
1690 /// Load-bearing, and it was not always true. Dirty tracking used to
1691 /// be a `bool` living on the `Signal`, which the first registry to
1692 /// flush both read AND cleared — so this very sweep was the thing
1693 /// that broke shared-`Signal` fan-out: whichever window it happened
1694 /// to visit first consumed the change and every later window's
1695 /// reconcile found nothing, permanently. `Signal` now exposes a
1696 /// monotone generation and each `BindingRegistry` remembers what it
1697 /// last acted on (see `teksilo_core::binding::BindingGroup`), so
1698 /// visiting windows in any order — this method iterates a `HashMap`,
1699 /// so the order is arbitrary and varies run to run — delivers the
1700 /// change to all of them.
1701 ///
1702 /// One known limitation: this uses `WidgetTree::layout` (a
1703 /// `NoopWindowOps` sink), not `layout_with_ops`, so a handler that
1704 /// this reconcile pass happens to run (a data-driven rebuild reacting
1705 /// to the very state change being reconciled) cannot synchronously
1706 /// open a window from here. That is acceptable for a background
1707 /// reconciliation pass — the window still opens correctly the next
1708 /// time this sibling performs its own real redraw with a real
1709 /// `WindowOps` sink, exactly the way an app that never called this
1710 /// method at all would have behaved.
1711 ///
1712 /// # Why `needs_render()`, not `needs_redraw()`
1713 ///
1714 /// Deliberately narrower than the broader `needs_redraw()`, which
1715 /// also reports `true` while a per-frame shader animation is merely
1716 /// *running*, with no dirty paint or layout at all. Treating "an
1717 /// animation is running" as a reason to force an extra redraw here —
1718 /// on top of whatever other window's event just got dispatched —
1719 /// would effectively re-couple that animating window's frame rate to
1720 /// the dispatch rate of every OTHER window's input (a fast mouse-move
1721 /// stream can exceed 60 Hz), defeating the 60 Hz `WaitUntil` pacing
1722 /// those animations already get from `next_timer_deadline` /
1723 /// `request_redraw_due` and reintroducing the exact uncapped
1724 /// free-running redraw behaviour that pacing was written to remove.
1725 pub fn request_redraw_needing_render(&mut self) -> usize {
1726 let mut poked = 0;
1727 for managed in self.windows.values_mut() {
1728 if managed.tree.needs_reconcile() {
1729 let size = managed.platform_window.surface_size();
1730 let sf = managed.platform_window.scale_factor() as f32;
1731 let proposal =
1732 teksilo_canvas::SizeProposal::exact(size.0 as f32 / sf, size.1 as f32 / sf);
1733 managed.tree.layout(proposal);
1734 }
1735 if managed.tree.needs_render() {
1736 managed.platform_window.request_redraw();
1737 poked += 1;
1738 }
1739 }
1740 poked
1741 }
1742
1743 /// Drain pending modal requests from all windows.
1744 pub fn drain_pending_modal_requests(
1745 &mut self,
1746 ) -> Vec<(TeksiloWindowId, Vec<teksilo_core::QueuedModalRequest>)> {
1747 let mut all_requests = Vec::new();
1748 for managed in self.windows.values_mut() {
1749 let requests = managed.tree.drain_pending_modal_requests();
1750 if !requests.is_empty() {
1751 all_requests.push((managed.teksilo_id, requests));
1752 }
1753 }
1754 all_requests
1755 }
1756
1757 /// Drain native modal-window dismiss requests from all windows.
1758 pub fn drain_pending_modal_dismissals(&mut self) -> Vec<TeksiloWindowId> {
1759 let mut windows_to_close = Vec::new();
1760 for managed in self.windows.values_mut() {
1761 if managed.tree.drain_pending_modal_dismissal() && managed.modal {
1762 windows_to_close.push(managed.teksilo_id);
1763 }
1764 }
1765 windows_to_close
1766 }
1767
1768 /// Drain per-tree close-window requests raised by handlers via
1769 /// [`EventContext::close_window`](teksilo_core::widget::EventContext::close_window)
1770 /// (a *guarded* close) and
1771 /// [`EventContext::close_window_forced`](teksilo_core::widget::EventContext::close_window_forced)
1772 /// (a *forced* close that bypasses the window's close guard).
1773 /// Returns `true` when at least one window was queued for closing.
1774 ///
1775 /// A forced request wins over a guarded one for the same window in
1776 /// the same drain — if a handler called both, the window closes
1777 /// unconditionally.
1778 pub fn drain_close_window_requests(&mut self) -> bool {
1779 let mut guarded: Vec<TeksiloWindowId> = Vec::new();
1780 let mut forced: Vec<TeksiloWindowId> = Vec::new();
1781 for managed in self.windows.values_mut() {
1782 // Drain both flags so neither lingers to a later tick.
1783 let wants_guarded = managed.tree.take_close_window_request();
1784 let wants_forced = managed.tree.take_force_close_request();
1785 if wants_forced {
1786 forced.push(managed.teksilo_id);
1787 } else if wants_guarded {
1788 guarded.push(managed.teksilo_id);
1789 }
1790 }
1791 let any = !guarded.is_empty() || !forced.is_empty();
1792 for id in forced {
1793 self.queue_close(id);
1794 }
1795 for id in guarded {
1796 self.request_close(id);
1797 }
1798 any
1799 }
1800
1801 /// Drain per-tree locale-switch requests raised by handlers via
1802 /// [`EventContext::set_locale`](teksilo_core::widget::EventContext::set_locale),
1803 /// parse each one to a `LanguageIdentifier`, and route it through
1804 /// [`WindowManager::set_locale`] so the `I18nManager` (active locale,
1805 /// version signal, RTL direction) and every tree stay in sync.
1806 /// Invalid or unsupported locale strings are logged and dropped.
1807 ///
1808 /// Returns `true` if any request was drained, so the caller can repaint
1809 /// every window — the fan-out marks non-originating windows dirty but
1810 /// only the window that received the triggering event gets its own
1811 /// `request_redraw`.
1812 pub fn drain_pending_locale_requests(&mut self) -> bool {
1813 let mut requests: Vec<String> = Vec::new();
1814 for managed in self.windows.values_mut() {
1815 if let Some(loc) = managed.tree.take_pending_locale_request() {
1816 requests.push(loc);
1817 }
1818 }
1819 let had_requests = !requests.is_empty();
1820 for loc_str in requests {
1821 match loc_str.parse::<teksilo_i18n::LanguageIdentifier>() {
1822 Ok(loc) => self.set_locale(loc),
1823 Err(e) => {
1824 eprintln!("teksilo-app: invalid locale `{loc_str}` requested by handler: {e}")
1825 }
1826 }
1827 }
1828 had_requests
1829 }
1830
1831 /// Drain per-tree theme-switch requests raised by handlers via
1832 /// [`EventContext::set_theme`](teksilo_core::widget::EventContext::set_theme)
1833 /// and route each through [`WindowManager::set_theme`] so the new theme is
1834 /// applied to *every* window, not just the one whose handler requested it.
1835 ///
1836 /// Returns `true` if any request was drained (same repaint rationale as
1837 /// [`WindowManager::drain_pending_locale_requests`]). If several windows
1838 /// raised a request in the same tick, each is applied in turn — the last
1839 /// wins, matching the locale path.
1840 pub fn drain_pending_theme_requests(&mut self) -> bool {
1841 let mut requests: Vec<Theme> = Vec::new();
1842 for managed in self.windows.values_mut() {
1843 if let Some(theme) = managed.tree.take_pending_theme_request() {
1844 requests.push(theme);
1845 }
1846 }
1847 let had_requests = !requests.is_empty();
1848 for theme in requests {
1849 self.set_theme(theme);
1850 }
1851 if had_requests {
1852 // An explicit theme pick disables OS-following so a later OS
1853 // light/dark change won't override the chosen theme. (The
1854 // internal `apply_os_theme` path calls `set_theme` directly, not
1855 // through this drain, so it is unaffected.)
1856 self.theme_mode = ThemeMode::Manual;
1857 }
1858 had_requests
1859 }
1860
1861 /// Drain per-tree "follow OS theme" requests raised by handlers via
1862 /// [`EventContext::follow_system_theme`](teksilo_core::widget::EventContext::follow_system_theme).
1863 /// Switches the app to [`ThemeMode::Native`] and recomputes the theme from
1864 /// the current OS colours, fanning it to every window. Returns `true` if
1865 /// any window requested it (so the caller schedules a repaint).
1866 pub fn drain_pending_follow_system_requests(&mut self) -> bool {
1867 let mut requested = false;
1868 for managed in self.windows.values_mut() {
1869 if managed.tree.take_pending_follow_system_request() {
1870 requested = true;
1871 }
1872 }
1873 if requested {
1874 self.theme_mode = ThemeMode::Native;
1875 // No winit hint at request time; apply_os_theme falls back to the
1876 // current window's reported theme.
1877 self.apply_os_theme(None);
1878 }
1879 requested
1880 }
1881
1882 /// Drain per-tree text-scale requests raised by handlers via
1883 /// [`EventContext::set_text_scale`](teksilo_core::widget::EventContext::set_text_scale)
1884 /// and route each through [`WindowManager::set_text_scale`] so the new
1885 /// factor is applied to *every* window. Returns `true` if any request was
1886 /// drained (so the caller schedules a repaint). Last writer wins if several
1887 /// windows requested in the same tick.
1888 pub fn drain_pending_text_scale_requests(&mut self) -> bool {
1889 let mut requests: Vec<f32> = Vec::new();
1890 for managed in self.windows.values_mut() {
1891 if let Some(scale) = managed.tree.take_pending_text_scale_request() {
1892 requests.push(scale);
1893 }
1894 }
1895 let had_requests = !requests.is_empty();
1896 for scale in requests {
1897 self.set_text_scale(scale);
1898 }
1899 had_requests
1900 }
1901}
1902
1903/// Translate a [`WindowCommand`] into the appropriate winit call.
1904///
1905/// `Close` is handled elsewhere (see [`WindowManager::drain_window_commands`]).
1906fn apply_window_command(win: &winit::window::Window, cmd: WindowCommand) {
1907 use winit::window::{Fullscreen, UserAttentionType, WindowLevel};
1908 match cmd {
1909 WindowCommand::SetPlacement(p) => match p {
1910 WindowPlacement::Floating => {
1911 win.set_minimized(false);
1912 win.set_fullscreen(None);
1913 win.set_maximized(false);
1914 }
1915 WindowPlacement::Maximized => {
1916 win.set_minimized(false);
1917 win.set_fullscreen(None);
1918 win.set_maximized(true);
1919 }
1920 WindowPlacement::Fullscreen => {
1921 win.set_minimized(false);
1922 win.set_fullscreen(Some(Fullscreen::Borderless(None)));
1923 }
1924 WindowPlacement::Minimized => {
1925 win.set_minimized(true);
1926 }
1927 },
1928 WindowCommand::SetTitle(title) => win.set_title(&title),
1929 WindowCommand::SetSize(w, h) => {
1930 let _ = win.request_inner_size(winit::dpi::LogicalSize::new(w, h));
1931 }
1932 WindowCommand::SetPosition(x, y) => {
1933 win.set_outer_position(winit::dpi::LogicalPosition::new(x, y));
1934 }
1935 WindowCommand::SetResizable(r) => win.set_resizable(r),
1936 WindowCommand::SetAlwaysOnTop(on) => {
1937 win.set_window_level(if on {
1938 WindowLevel::AlwaysOnTop
1939 } else {
1940 WindowLevel::Normal
1941 });
1942 }
1943 WindowCommand::RequestAttention(kind) => {
1944 let winit_kind = match kind {
1945 UserAttentionKind::Critical => UserAttentionType::Critical,
1946 UserAttentionKind::Informational => UserAttentionType::Informational,
1947 };
1948 win.request_user_attention(Some(winit_kind));
1949 }
1950 WindowCommand::Focus { activation_token } => {
1951 teksilo_platform::window_activation::raise(win, activation_token.as_deref());
1952 }
1953 WindowCommand::Close => {
1954 // Handled in drain_window_commands; unreachable here.
1955 }
1956 }
1957}
1958
1959/// App-level implementation of [`teksilo_core::WindowOps`] handed into
1960/// every `dispatch_event_with_ops` call.
1961///
1962/// Holds `&mut WindowManager` plus `&ActiveEventLoop` so
1963/// [`open_window`](teksilo_core::WindowOps::open_window) can create the
1964/// winit-level window synchronously before returning. Constructed by
1965/// `TeksiloAppHandler::dispatch_in_window` after temporarily removing
1966/// the dispatching window from `WindowManager::windows`; the removed
1967/// tree is borrowed mutably for the handler run.
1968pub struct WindowOpsImpl<'a> {
1969 wm: &'a mut WindowManager,
1970 event_loop: &'a winit::event_loop::ActiveEventLoop,
1971 /// Current (dispatching) window's id. Kept for diagnostics and
1972 /// future modal-parent self-reference logic.
1973 current_id: TeksiloWindowId,
1974 /// Current window's raw handle, captured before removal so a
1975 /// modal whose parent is the current window can still attach.
1976 #[cfg(not(target_os = "macos"))]
1977 current_handle: Option<winit::raw_window_handle::RawWindowHandle>,
1978 /// `Arc<Window>` of the current (dispatching) window. On macOS
1979 /// it is needed for `addChildWindow:ordered:`. On every platform
1980 /// it backs `current_parent_handle()` so native-dialog
1981 /// integrations can extract both window and display handles even
1982 /// while the dispatching window is temporarily out of
1983 /// `WindowManager::windows`.
1984 current_window_arc: Option<std::sync::Arc<winit::window::Window>>,
1985}
1986
1987impl<'a> WindowOpsImpl<'a> {
1988 pub fn new(
1989 wm: &'a mut WindowManager,
1990 event_loop: &'a winit::event_loop::ActiveEventLoop,
1991 current_id: TeksiloWindowId,
1992 #[cfg(not(target_os = "macos"))] current_handle: Option<
1993 winit::raw_window_handle::RawWindowHandle,
1994 >,
1995 current_window_arc: Option<std::sync::Arc<winit::window::Window>>,
1996 ) -> Self {
1997 Self {
1998 wm,
1999 event_loop,
2000 current_id,
2001 #[cfg(not(target_os = "macos"))]
2002 current_handle,
2003 current_window_arc,
2004 }
2005 }
2006}
2007
2008impl teksilo_core::WindowOps for WindowOpsImpl<'_> {
2009 fn open_window(&mut self, config: teksilo_core::WindowConfig) -> TeksiloWindowId {
2010 let _ = self.current_id;
2011 #[cfg(not(target_os = "macos"))]
2012 let _ = self.current_handle;
2013 #[cfg(target_os = "macos")]
2014 let _ = &self.current_window_arc;
2015 self.wm.create_window(config, self.event_loop)
2016 }
2017
2018 fn find_window(&self, string_id: &str) -> Option<TeksiloWindowId> {
2019 self.wm.find_window(string_id)
2020 }
2021
2022 fn window_state(&self, id: TeksiloWindowId) -> Option<teksilo_core::WindowState> {
2023 let winit_id = self.wm.teksilo_to_winit_map().get(&id).copied()?;
2024 self.wm
2025 .windows_map()
2026 .get(&winit_id)
2027 .map(|m| m.state.clone())
2028 }
2029
2030 fn windows(&self) -> Vec<teksilo_core::WindowState> {
2031 self.wm
2032 .windows_map()
2033 .values()
2034 .map(|m| m.state.clone())
2035 .collect()
2036 }
2037
2038 fn focus_window(&mut self, id: TeksiloWindowId) {
2039 if let Some(winit_id) = self.wm.teksilo_to_winit_map().get(&id).copied()
2040 && let Some(managed) = self.wm.windows_map().get(&winit_id)
2041 {
2042 teksilo_platform::window_activation::raise(managed.platform_window.window(), None);
2043 }
2044 }
2045
2046 fn request_activation_token(
2047 &mut self,
2048 id: TeksiloWindowId,
2049 cb: Box<dyn FnOnce(Option<String>)>,
2050 ) {
2051 let Some(winit_id) = self.wm.teksilo_to_winit_map().get(&id).copied() else {
2052 cb(None);
2053 return;
2054 };
2055 // Immutable borrow to issue the request, released before we mutate the
2056 // callback map below.
2057 let issued = match self.wm.windows_map().get(&winit_id) {
2058 Some(managed) => teksilo_platform::window_activation::request_activation_token(
2059 managed.platform_window.window(),
2060 ),
2061 None => false,
2062 };
2063 if issued {
2064 self.wm.store_activation_token_callback(winit_id, cb);
2065 } else {
2066 cb(None);
2067 }
2068 }
2069
2070 fn request_activation_token_self(&mut self, cb: Box<dyn FnOnce(Option<String>)>) {
2071 // The current window is temporarily out of `wm.windows` during its own
2072 // dispatch, so use the captured Arc rather than an id lookup.
2073 let Some(arc) = self.current_window_arc.clone() else {
2074 cb(None);
2075 return;
2076 };
2077 if teksilo_platform::window_activation::request_activation_token(&arc) {
2078 self.wm.store_activation_token_callback(arc.id(), cb);
2079 } else {
2080 cb(None);
2081 }
2082 }
2083
2084 fn close_window_by_id(&mut self, id: TeksiloWindowId) {
2085 self.wm.queue_close(id);
2086 }
2087
2088 fn current_parent_handle(&self) -> Option<teksilo_core::raw_handle::ParentHandle> {
2089 // Always extract from `current_window_arc` because the
2090 // dispatching window is temporarily out of `wm.windows_map()`
2091 // during event delivery.
2092 let arc = self.current_window_arc.as_ref()?;
2093 teksilo_core::raw_handle::ParentHandle::from_window(arc.as_ref())
2094 }
2095
2096 fn set_ime_cursor_area(&mut self, area: teksilo_canvas::Rect) {
2097 // Applied directly to the in-flight window (out of `wm.windows_map()`
2098 // during dispatch). Repositioning the candidate area is idempotent —
2099 // unlike `set_ime_allowed`, it never cancels an active composition —
2100 // so no dedup is needed; text widgets only report it on caret moves.
2101 if let Some(arc) = self.current_window_arc.as_ref() {
2102 arc.set_ime_cursor_area(
2103 winit::dpi::LogicalPosition::new(area.x, area.y),
2104 winit::dpi::LogicalSize::new(area.width.max(1.0), area.height.max(1.0)),
2105 );
2106 }
2107 }
2108
2109 fn begin_os_drag(
2110 &mut self,
2111 data: teksilo_core::OutboundDragData,
2112 image: Option<teksilo_core::DragImageData>,
2113 ) -> bool {
2114 use teksilo_platform::external_dnd::ExternalDndHandle;
2115 // Outbound drag is wired only if the app installed the external-DnD
2116 // service. Without it, decline so the framework keeps the in-app drag
2117 // alive.
2118 let Some(handle) = self
2119 .wm
2120 .app_context_template()
2121 .and_then(|t| t.app_state::<ExternalDndHandle>().cloned())
2122 else {
2123 return false;
2124 };
2125 handle.begin_drag(self.current_id, &data, image.as_ref())
2126 }
2127
2128 fn cancel_os_drag(&mut self) {
2129 use teksilo_platform::external_dnd::ExternalDndHandle;
2130 if let Some(handle) = self
2131 .wm
2132 .app_context_template()
2133 .and_then(|t| t.app_state::<ExternalDndHandle>().cloned())
2134 {
2135 handle.cancel_drag(self.current_id);
2136 }
2137 }
2138}
2139
2140#[cfg(test)]
2141mod initial_focus_tests {
2142 use super::*;
2143 use std::cell::Cell;
2144 use std::rc::Rc;
2145 use teksilo_canvas::SizeProposal;
2146 use teksilo_core::build_context::BuildContext;
2147 use teksilo_core::widget::Widget;
2148 use teksilo_i18n::lit;
2149 use teksilo_widgets::Button;
2150 use teksilo_widgets::primitives::VStack;
2151
2152 /// A root holding two focusable buttons, optionally pointing its
2153 /// `initial_focus_hint` at the SECOND one — so a passing test cannot be
2154 /// explained by "it happened to pick the first focusable anyway".
2155 /// `second_out` republishes that id to the test.
2156 #[derive(Debug)]
2157 struct Root {
2158 hint_to_second: bool,
2159 second: Option<WidgetId>,
2160 second_out: Rc<Cell<Option<WidgetId>>>,
2161 root: Option<WidgetId>,
2162 }
2163
2164 impl Widget for Root {
2165 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2166 let first = ctx.add(Button::new(lit!("First")));
2167 let second = ctx.add(Button::new(lit!("Second")));
2168 self.second = Some(second);
2169 self.second_out.set(Some(second));
2170 let col = ctx.add(VStack::new().add_child(first).add_child(second));
2171 self.root = Some(col);
2172 vec![col]
2173 }
2174
2175 fn initial_focus_hint(&self) -> Option<WidgetId> {
2176 if self.hint_to_second {
2177 self.second
2178 } else {
2179 None
2180 }
2181 }
2182
2183 fn layout_response(
2184 &self,
2185 proposal: SizeProposal,
2186 ctx: &teksilo_core::LayoutContext,
2187 ) -> teksilo_core::widget::LayoutResponse {
2188 self.root
2189 .and_then(|id| ctx.child_size(id, proposal))
2190 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
2191 .into()
2192 }
2193 }
2194
2195 /// Build a headless tree whose root is a `Root`, and return the policy's
2196 /// choice plus the id it should (or should not) have picked.
2197 fn focus_for(hint: bool, is_modal: bool) -> (Option<WidgetId>, Option<WidgetId>) {
2198 let second_out: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
2199 let mut tree = WidgetTree::new();
2200 let root = tree.add(Root {
2201 hint_to_second: hint,
2202 second: None,
2203 second_out: second_out.clone(),
2204 root: None,
2205 });
2206 tree.layout(SizeProposal::exact(400.0, 300.0));
2207 (
2208 initial_window_focus(&tree, root, is_modal, None),
2209 second_out.get(),
2210 )
2211 }
2212
2213 #[test]
2214 fn a_plain_window_honours_an_explicit_focus_hint() {
2215 // The feature: a window root can direct its own initial focus (the
2216 // Launcher points at its recents list so Enter opens the highlighted
2217 // project without a Tab). Must land on the SECOND button — the hint —
2218 // not merely on the first focusable.
2219 let (focused, second) = focus_for(true, false);
2220 assert!(
2221 second.is_some(),
2222 "precondition: the root exposes a hint target"
2223 );
2224 assert_eq!(
2225 focused, second,
2226 "a plain window must focus the widget its root's initial_focus_hint names"
2227 );
2228 }
2229
2230 #[test]
2231 fn a_plain_window_without_a_hint_focuses_nothing() {
2232 // The guard rail: NO `first_focusable_descendant` fallback for plain
2233 // windows. Falling back would silently drop the caret into whatever
2234 // control comes first in tree order — every existing app window would
2235 // change behavior.
2236 let (focused, _) = focus_for(false, false);
2237 assert_eq!(
2238 focused, None,
2239 "a plain window with no hint must open with nothing focused, \
2240 not steal focus onto its first focusable widget"
2241 );
2242 }
2243
2244 #[test]
2245 fn a_modal_still_falls_back_to_its_first_focusable() {
2246 // Unchanged modal policy: a dialog always focuses something, so it can
2247 // be answered from the keyboard immediately.
2248 let (focused, second) = focus_for(false, true);
2249 assert!(
2250 focused.is_some() && focused != second,
2251 "a modal with no hint falls back to its FIRST focusable (not the second)"
2252 );
2253 }
2254
2255 #[test]
2256 fn a_modal_prefers_its_hint_over_the_first_focusable() {
2257 let (focused, second) = focus_for(true, true);
2258 assert_eq!(
2259 focused, second,
2260 "a modal's hint outranks the first focusable"
2261 );
2262 }
2263}
2264
2265#[cfg(test)]
2266mod close_guard_tests {
2267 use super::*;
2268 use std::cell::Cell;
2269 use teksilo_core::signal::Signal;
2270 use teksilo_core::widget::EventContext;
2271 use teksilo_core::{CloseResponse, NoopWindowOps, WidgetTree};
2272
2273 /// Drive `close_verdict` inside a real (headless) `EventContext` and
2274 /// return its boolean verdict. Mirrors the `run_with_event_context`
2275 /// path `process_pending` uses, but with a `NoopWindowOps` sink and
2276 /// no winit event loop.
2277 fn verdict(
2278 can_close: Option<Prop<bool>>,
2279 on_blocked: Option<CloseBlockedCallback>,
2280 guard: Option<CloseGuard>,
2281 ) -> bool {
2282 let mut tree = WidgetTree::new();
2283 let mut out = true;
2284 tree.run_with_event_context(&mut NoopWindowOps, |ctx: &mut EventContext| {
2285 out = close_verdict(&can_close, &on_blocked, &guard, ctx);
2286 });
2287 out
2288 }
2289
2290 #[test]
2291 fn no_guard_closes() {
2292 assert!(verdict(None, None, None));
2293 }
2294
2295 #[test]
2296 fn guard_close_proceeds() {
2297 let guard: CloseGuard = Rc::new(|_ctx| CloseResponse::Close);
2298 assert!(verdict(None, None, Some(guard)));
2299 }
2300
2301 #[test]
2302 fn guard_veto_keeps_window_open() {
2303 let guard: CloseGuard = Rc::new(|_ctx| CloseResponse::Veto);
2304 assert!(!verdict(None, None, Some(guard)));
2305 }
2306
2307 #[test]
2308 fn can_close_false_vetoes_and_fires_blocked() {
2309 let fired = Rc::new(Cell::new(false));
2310 let flag = fired.clone();
2311 let on_blocked: CloseBlockedCallback = Rc::new(move |_ctx| flag.set(true));
2312
2313 let should_close = verdict(Some(Prop::from(Signal::new(false))), Some(on_blocked), None);
2314
2315 assert!(!should_close, "can_close == false must veto");
2316 assert!(fired.get(), "on_close_blocked must fire on a vetoed close");
2317 }
2318
2319 #[test]
2320 fn can_close_false_does_not_consult_guard() {
2321 // The guard would close, but the sugar signal short-circuits to a
2322 // veto before the guard is ever consulted.
2323 let guard_ran = Rc::new(Cell::new(false));
2324 let gflag = guard_ran.clone();
2325 let guard: CloseGuard = Rc::new(move |_ctx| {
2326 gflag.set(true);
2327 CloseResponse::Close
2328 });
2329
2330 let should_close = verdict(Some(Prop::from(Signal::new(false))), None, Some(guard));
2331
2332 assert!(!should_close, "can_close == false wins over the guard");
2333 assert!(
2334 !guard_ran.get(),
2335 "the guard must not run once can_close has vetoed"
2336 );
2337 }
2338
2339 #[test]
2340 fn can_close_true_falls_through_to_guard() {
2341 // A permissive sugar signal does not auto-close: the explicit
2342 // guard still gets the final say (here it vetoes).
2343 let guard: CloseGuard = Rc::new(|_ctx| CloseResponse::Veto);
2344 let should_close = verdict(Some(Prop::from(Signal::new(true))), None, Some(guard));
2345 assert!(!should_close, "can_close == true still consults the guard");
2346 }
2347
2348 #[test]
2349 fn can_close_true_without_guard_closes() {
2350 // on_close_blocked is set but must NOT fire when the signal is true.
2351 let fired = Rc::new(Cell::new(false));
2352 let flag = fired.clone();
2353 let on_blocked: CloseBlockedCallback = Rc::new(move |_ctx| flag.set(true));
2354
2355 let should_close = verdict(Some(Prop::from(Signal::new(true))), Some(on_blocked), None);
2356
2357 assert!(should_close, "a permissive signal with no guard closes");
2358 assert!(!fired.get(), "on_close_blocked must not fire when allowed");
2359 }
2360
2361 #[test]
2362 fn queue_close_is_forced_request_close_is_guarded() {
2363 let mut wm = WindowManager::new(teksilo_core::presets::intui::light());
2364 let a = TeksiloWindowId::new(1);
2365 let b = TeksiloWindowId::new(2);
2366 wm.queue_close(a);
2367 wm.request_close(b);
2368
2369 assert_eq!(wm.pending_closes.len(), 2);
2370 let forced = wm.pending_closes.iter().find(|p| p.id == a).unwrap();
2371 let guarded = wm.pending_closes.iter().find(|p| p.id == b).unwrap();
2372 assert!(forced.force, "queue_close must enqueue a forced close");
2373 assert!(!guarded.force, "request_close must enqueue a guarded close");
2374 }
2375
2376 /// Guard rail matching `close_window_on_an_unknown_id_is_a_harmless_no_op`
2377 /// below: with no windows open, `request_redraw_needing_render` must
2378 /// not panic and must report that it poked nothing. The substantive
2379 /// claim this method rests on — that reconciling a tree's reactive
2380 /// state before checking `needs_render()` is what actually surfaces a
2381 /// cross-window `Signal` mutation — is covered at the `WidgetTree`
2382 /// level in `teksilo_core::widget_tree::cross_window_redraw_signal_tests`,
2383 /// since a real `ManagedWindow` needs a real `PlatformWindow` this
2384 /// crate's headless tests cannot stand up.
2385 #[test]
2386 fn request_redraw_needing_render_on_an_empty_manager_pokes_nothing() {
2387 let mut wm = WindowManager::new(teksilo_core::presets::intui::light());
2388 assert_eq!(wm.request_redraw_needing_render(), 0);
2389 }
2390
2391 /// `close_window` — the single choke point both `queue_close` (forced)
2392 /// and `request_close` (once guarded) funnel through, and the one
2393 /// place that fires `WindowConfig::on_removed` — must be a no-op for
2394 /// a `TeksiloWindowId` it has never seen (already closed, or never
2395 /// existed). Constructing a real `ManagedWindow` needs an actual
2396 /// `PlatformWindow` (a live winit window + wgpu surface), which is
2397 /// not available in a headless unit test — see `on_removed`'s own
2398 /// round-trip test in `teksilo_core::window::config` for the part of
2399 /// this feature that IS testable in isolation. This test instead
2400 /// pins down the guard-rail: no window, no hook to misfire, no panic.
2401 #[test]
2402 fn close_window_on_an_unknown_id_is_a_harmless_no_op() {
2403 let mut wm = WindowManager::new(teksilo_core::presets::intui::light());
2404 assert_eq!(wm.window_count(), 0);
2405
2406 wm.close_window(TeksiloWindowId::new(42));
2407
2408 assert_eq!(wm.window_count(), 0, "still no windows — nothing to remove");
2409 }
2410}
2411
2412#[cfg(test)]
2413mod ime_dedup_tests {
2414 use super::ime_should_skip_empty_preedit;
2415
2416 #[test]
2417 fn consecutive_empty_preedits_are_skipped_after_the_first() {
2418 let mut last_empty = false;
2419
2420 // A real composition: non-empty preedits never skip and keep the flag low.
2421 assert!(!ime_should_skip_empty_preedit(&mut last_empty, false));
2422 assert!(!ime_should_skip_empty_preedit(&mut last_empty, false));
2423 assert!(!last_empty);
2424
2425 // First empty preedit (winit's synthetic clear before commit, or the
2426 // start of a flood) is meaningful — dispatched, not skipped.
2427 assert!(!ime_should_skip_empty_preedit(&mut last_empty, true));
2428 assert!(last_empty);
2429
2430 // Every consecutive empty preedit after it is a redundant no-op — skipped.
2431 assert!(ime_should_skip_empty_preedit(&mut last_empty, true));
2432 assert!(ime_should_skip_empty_preedit(&mut last_empty, true));
2433
2434 // A non-empty preedit (or any non-empty IME event) resets the run, so the
2435 // NEXT empty preedit is again treated as meaningful.
2436 assert!(!ime_should_skip_empty_preedit(&mut last_empty, false));
2437 assert!(!last_empty);
2438 assert!(!ime_should_skip_empty_preedit(&mut last_empty, true));
2439 assert!(ime_should_skip_empty_preedit(&mut last_empty, true));
2440 }
2441}