Skip to main content

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}