Skip to main content

teksilo_core/window/
state.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Per-window reactive state.
5//!
6//! A [`WindowState`] is a refcounted handle to the signal-bound surface
7//! of a single window. Widgets bind against these signals
8//! (`ctx.window().placement().map(|p| ...)`) for reactive UI that
9//! stays in sync with the OS; app code writes to the signals to push
10//! state to the OS.
11//!
12//! ## Two-way sync pattern
13//!
14//! Every public signal has two writers:
15//!
16//! - **App-side writes** — `state.title().set("Hello")` or any code
17//!   that assigns through the `Signal` handle returned from the
18//!   getter. These fire the observer wired in [`WindowState::new`];
19//!   the observer pushes a [`WindowCommand`] into
20//!   `WindowStateInner::pending_os_commands`, which the app-level
21//!   window manager drains once per tick and translates into a winit
22//!   call.
23//!
24//! - **OS-side writes** — the app-level window manager calls the
25//!   private `set_*_from_os` methods on `WindowStateInner` when a
26//!   winit `WindowEvent` reports that the OS changed state. Those
27//!   setters flip the `applying_from_os` guard
28//!   before updating the signal; the observer sees the guard is set
29//!   and skips enqueuing a command. Without this guard, every
30//!   OS-initiated change would loop back into a redundant OS call —
31//!   at best wasteful, at worst a mid-animation state-drift bug
32//!   (Compose Multiplatform issues #1489, #4006).
33
34use std::cell::{Cell, RefCell};
35use std::rc::Rc;
36
37use crate::signal::{ObserverHandle, Signal};
38
39use super::command::{UserAttentionKind, WindowCommand};
40use super::id::TeksiloWindowId;
41use super::placement::WindowPlacement;
42
43/// A refcounted handle to a single window's reactive state.
44///
45/// Cloning gives you another handle to the same underlying state.
46/// Widgets should store a [`WindowState`] clone when they need to read
47/// or write window-level signals outside of a single `build()` call.
48#[derive(Clone)]
49pub struct WindowState {
50    inner: Rc<WindowStateInner>,
51}
52
53impl std::fmt::Debug for WindowState {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("WindowState")
56            .field("id", &self.inner.id)
57            .field("string_id", &self.inner.string_id)
58            .field("placement", &self.inner.placement.get())
59            .field("title", &self.inner.title.get())
60            .field("size", &self.inner.size.get())
61            .field("position", &self.inner.position.get())
62            .field("focused", &self.inner.focused.get())
63            .field("resizable", &self.inner.resizable.get())
64            .field("always_on_top", &self.inner.always_on_top.get())
65            .field(
66                "pending_commands",
67                &self.inner.pending_os_commands.borrow().len(),
68            )
69            .finish()
70    }
71}
72
73/// Initial values for a [`WindowState`] at creation time.
74///
75/// Built from the equivalent fields on `WindowConfig` by the app-level
76/// window manager, then passed to [`WindowState::new`].
77#[derive(Debug, Clone)]
78pub struct WindowStateInit {
79    pub id: TeksiloWindowId,
80    pub string_id: Option<String>,
81    pub placement: WindowPlacement,
82    pub title: String,
83    pub size: (u32, u32),
84    pub position: (i32, i32),
85    pub focused: bool,
86    pub resizable: bool,
87    pub always_on_top: bool,
88}
89
90pub(crate) struct WindowStateInner {
91    id: TeksiloWindowId,
92    string_id: Option<String>,
93
94    placement: Signal<WindowPlacement>,
95    title: Signal<String>,
96    size: Signal<(u32, u32)>,
97    position: Signal<(i32, i32)>,
98    focused: Signal<bool>,
99    resizable: Signal<bool>,
100    always_on_top: Signal<bool>,
101    /// Whether the window's title bar offers a minimise and a maximise
102    /// button, as the window manager created it; see
103    /// [`WindowState::set_caption_buttons`].
104    caption_buttons: Cell<(bool, bool)>,
105    /// Caps Lock active state, OS-driven only — no observer and no
106    /// app→OS command (the app never sets the keyboard lock). Toggled by
107    /// the window manager on each `Key::CapsLock` press; read by password
108    /// fields to show a Caps Lock warning.
109    caps_lock: Signal<bool>,
110
111    /// `true` while the Alt key is currently held down. OS-driven only —
112    /// no observer and no app→OS command. Set by the window manager from
113    /// winit's `ModifiersChanged` — winit reports modifier keys there, not
114    /// through `KeyboardInput`, and our `Key` enum has no `Alt`. Read by:
115    ///
116    /// - `MenuLabel` to show / hide mnemonic underlines while Alt is held
117    ///   (matches the Win32 `WM_CHANGEUISTATE` underlining convention).
118    /// - `MenuBar` to detect bare-Alt-tap (true → false transition with
119    ///   `other_key_pressed_during_alt == false`) which focuses the
120    ///   first trigger.
121    alt_down: Signal<bool>,
122
123    /// Sticky flag that records whether any non-Alt key was pressed
124    /// while Alt was held. Cleared on every rising edge of `alt_down`
125    /// (which the manager drives from winit's `ModifiersChanged`);
126    /// flipped to `true` by the manager
127    /// on any non-Alt `KeyDown` that arrives while `alt_down` is
128    /// `true`. Read by `MenuBar` at the Alt → release moment to
129    /// decide whether the user did a bare-Alt-tap (no other key
130    /// pressed → focus menubar) or used Alt as a modifier for a
131    /// real chord (skip).
132    ///
133    /// `Cell<bool>` rather than `Signal<bool>` — only the MenuBar
134    /// effect reads it at the transition moment, never reactively.
135    other_key_pressed_during_alt: Cell<bool>,
136
137    /// At-most-one menubar dispatcher per window. See
138    /// [`super::menubar_dispatcher`]. Wrapped in `Rc` so the slot
139    /// can be shared with the [`super::menubar_dispatcher::MenubarGuard`]
140    /// returned to the caller — when the guard drops, it clears the
141    /// slot iff it still points to the same dispatcher.
142    menubar_dispatcher: Rc<super::menubar_dispatcher::MenubarDispatcherSlot>,
143
144    /// Commands queued by observers on app-side signal writes. Drained
145    /// by the app-level window manager once per tick.
146    pending_os_commands: RefCell<Vec<WindowCommand>>,
147
148    /// A pending `xdg_activation_v1` token stashed by
149    /// [`WindowState::set_activation_token`] and consumed by the next
150    /// [`WindowState::focus`], which carries it on the emitted
151    /// [`WindowCommand::Focus`]. Only meaningful for Wayland cross-process
152    /// raises; ignored on every other platform.
153    pending_activation_token: RefCell<Option<String>>,
154
155    /// `true` while a `set_*_from_os` call is in progress. The
156    /// observers installed in [`WindowState::new`] check this flag and
157    /// do nothing when it is set — the OS already knows, there is no
158    /// command to send back.
159    applying_from_os: Cell<bool>,
160
161    /// Holds the `ObserverHandle`s returned from
162    /// [`Signal::observe`] during construction. They must stay alive
163    /// for the lifetime of the `WindowState`; dropping them would
164    /// silently unsubscribe the OS-sync observers.
165    _observer_handles: RefCell<Vec<ObserverHandle>>,
166}
167
168impl WindowState {
169    /// Construct a new state from initial values.
170    ///
171    /// Wires an observer on every signal that pushes a matching
172    /// [`WindowCommand`] onto the pending queue, guarded by
173    /// `WindowStateInner::applying_from_os`.
174    pub fn new(init: WindowStateInit) -> Self {
175        let inner = Rc::new(WindowStateInner {
176            id: init.id,
177            string_id: init.string_id,
178            placement: Signal::new(init.placement),
179            title: Signal::new(init.title),
180            size: Signal::new(init.size),
181            position: Signal::new(init.position),
182            focused: Signal::new(init.focused),
183            resizable: Signal::new(init.resizable),
184            always_on_top: Signal::new(init.always_on_top),
185            caption_buttons: Cell::new((true, true)),
186            caps_lock: Signal::new(false),
187            alt_down: Signal::new(false),
188            other_key_pressed_during_alt: Cell::new(false),
189            menubar_dispatcher: Rc::new(RefCell::new(None)),
190            pending_os_commands: RefCell::new(Vec::new()),
191            pending_activation_token: RefCell::new(None),
192            applying_from_os: Cell::new(false),
193            _observer_handles: RefCell::new(Vec::new()),
194        });
195
196        // Wire the observers. Each one queues a WindowCommand on
197        // app-side writes and silently ignores OS-originated writes.
198        let mut handles = Vec::new();
199
200        {
201            let inner_w = Rc::downgrade(&inner);
202            handles.push(inner.placement.observe(move |v| {
203                if let Some(inner) = inner_w.upgrade() {
204                    inner.enqueue_unless_from_os(WindowCommand::SetPlacement(*v));
205                }
206            }));
207        }
208        {
209            let inner_w = Rc::downgrade(&inner);
210            handles.push(inner.title.observe(move |v| {
211                if let Some(inner) = inner_w.upgrade() {
212                    inner.enqueue_unless_from_os(WindowCommand::SetTitle(v.clone()));
213                }
214            }));
215        }
216        {
217            let inner_w = Rc::downgrade(&inner);
218            handles.push(inner.size.observe(move |v| {
219                if let Some(inner) = inner_w.upgrade() {
220                    inner.enqueue_unless_from_os(WindowCommand::SetSize(v.0, v.1));
221                }
222            }));
223        }
224        {
225            let inner_w = Rc::downgrade(&inner);
226            handles.push(inner.position.observe(move |v| {
227                if let Some(inner) = inner_w.upgrade() {
228                    inner.enqueue_unless_from_os(WindowCommand::SetPosition(v.0, v.1));
229                }
230            }));
231        }
232        {
233            let inner_w = Rc::downgrade(&inner);
234            handles.push(inner.resizable.observe(move |v| {
235                if let Some(inner) = inner_w.upgrade() {
236                    inner.enqueue_unless_from_os(WindowCommand::SetResizable(*v));
237                }
238            }));
239        }
240        {
241            let inner_w = Rc::downgrade(&inner);
242            handles.push(inner.always_on_top.observe(move |v| {
243                if let Some(inner) = inner_w.upgrade() {
244                    inner.enqueue_unless_from_os(WindowCommand::SetAlwaysOnTop(*v));
245                }
246            }));
247        }
248        // `focused` has no observer: it is purely OS-driven. Writes
249        // go through `set_focused_from_os`; app code that wants to
250        // pull focus calls `focus()` instead, which emits
251        // `WindowCommand::Focus` directly.
252
253        *inner._observer_handles.borrow_mut() = handles;
254        Self { inner }
255    }
256
257    pub fn id(&self) -> TeksiloWindowId {
258        self.inner.id
259    }
260
261    pub fn string_id(&self) -> Option<&str> {
262        self.inner.string_id.as_deref()
263    }
264
265    pub fn placement(&self) -> &Signal<WindowPlacement> {
266        &self.inner.placement
267    }
268
269    pub fn title(&self) -> &Signal<String> {
270        &self.inner.title
271    }
272
273    pub fn size(&self) -> &Signal<(u32, u32)> {
274        &self.inner.size
275    }
276
277    pub fn position(&self) -> &Signal<(i32, i32)> {
278        &self.inner.position
279    }
280
281    pub fn focused(&self) -> &Signal<bool> {
282        &self.inner.focused
283    }
284
285    pub fn resizable(&self) -> &Signal<bool> {
286        &self.inner.resizable
287    }
288
289    pub fn always_on_top(&self) -> &Signal<bool> {
290        &self.inner.always_on_top
291    }
292
293    /// Whether the window's title bar offers a minimise button: `false` for
294    /// a modal dialog, which follows the window it blocks, and for a window
295    /// created with `WindowConfig::minimizable(false)`. A title bar the
296    /// application draws reads it to leave the button out.
297    #[doc(hidden)]
298    pub fn is_minimizable(&self) -> bool {
299        self.inner.caption_buttons.get().0
300    }
301
302    /// Whether the window's title bar offers a maximise button; see
303    /// [`is_minimizable`](Self::is_minimizable).
304    #[doc(hidden)]
305    pub fn is_maximizable(&self) -> bool {
306        self.inner.caption_buttons.get().1
307    }
308
309    /// Record which caption buttons the window was created with. Called by
310    /// the window manager before the window's root is built; both are
311    /// `true` until then.
312    #[doc(hidden)]
313    pub fn set_caption_buttons(&self, minimizable: bool, maximizable: bool) {
314        self.inner.caption_buttons.set((minimizable, maximizable));
315    }
316
317    /// Caps Lock active state. OS-driven only — the window manager
318    /// toggles it on each `Key::CapsLock` press. Read this (e.g. via
319    /// `ctx.window()`) to drive a Caps Lock warning on password fields.
320    pub fn caps_lock(&self) -> &Signal<bool> {
321        &self.inner.caps_lock
322    }
323
324    /// Whether the Alt key is currently held down. OS-driven only — the
325    /// window manager flips this from winit's `ModifiersChanged`, where
326    /// winit reports modifier keys. Read
327    /// this to drive mnemonic-underline visibility on menus and menubars.
328    /// See the menubar key-dispatch documentation for the full Alt-tap
329    /// / Alt+letter / mnemonic-underline contract.
330    pub fn alt_down(&self) -> &Signal<bool> {
331        &self.inner.alt_down
332    }
333
334    /// Read whether any non-Alt key has been pressed during the
335    /// current Alt-hold window. `false` means the user has not
336    /// composed a chord since pressing Alt; the next Alt-release
337    /// counts as a bare-Alt-tap. Read by the MenuBar dispatcher.
338    pub fn other_key_pressed_during_alt(&self) -> bool {
339        self.inner.other_key_pressed_during_alt.get()
340    }
341
342    /// Install (or replace) the per-window menubar key dispatcher.
343    /// Returns a [`super::menubar_dispatcher::MenubarGuard`] that
344    /// clears the slot on drop iff it still points at this exact
345    /// dispatcher (`Rc::ptr_eq`).
346    ///
347    /// At most one dispatcher is supported per window. A second
348    /// install while another is still live `debug_assert!`s and
349    /// overwrites in release. This matches the "one MenuBar per
350    /// window" invariant the framework enforces upstream.
351    pub fn install_menubar_dispatcher(
352        &self,
353        dispatcher: Rc<dyn super::menubar_dispatcher::MenubarDispatcher>,
354    ) -> super::menubar_dispatcher::MenubarGuard {
355        let slot = self.inner.menubar_dispatcher.clone();
356        {
357            let mut slot_ref = slot.borrow_mut();
358            debug_assert!(
359                slot_ref.is_none(),
360                "WindowState: a menubar dispatcher is already installed for this \
361                 window — only one keyboard-dispatching MenuBar is supported per \
362                 window because the dispatcher slot routes F10 / Alt+letter / \
363                 Alt-tap to exactly one trigger set.\n\n\
364                 \
365                 Causes typically fall into one of:\n\
366                 \
367                 1. Two MenuBar widgets are mounted in the same window. Pick \
368                    the primary one — the one that should own F10 / Alt+letter \
369                    — and call `.no_dispatcher_install()` on every secondary \
370                    MenuBar (showcase content, embedded demos, settings \
371                    previews, …). Secondary MenuBars still render and respond \
372                    to mouse + arrow-key navigation; only the window-level \
373                    keyboard dispatch is left to the primary.\n\
374                 \
375                 2. A MenuBar was added with `tree.add_boxed(MenuBar::new()…)` \
376                    from inside another MenuBar's tab / popover / submenu. Same \
377                    fix: mark the inner one with `.no_dispatcher_install()`.\n\
378                 \
379                 3. The host widget that owns the MenuBar reconstructs a fresh \
380                    `MenuBar::new()` on every rebuild without dropping its \
381                    previous `MenubarGuard` first. `MenuBar::build` already \
382                    handles its own rebuild path; if you're hand-rolling the \
383                    install (custom MenubarDispatcher impl), drop the old \
384                    `MenubarGuard` BEFORE calling this method again."
385            );
386            *slot_ref = Some(dispatcher.clone());
387        }
388        super::menubar_dispatcher::MenubarGuard {
389            slot,
390            own: dispatcher,
391        }
392    }
393
394    /// Snapshot of the currently-installed menubar dispatcher. Used by
395    /// `teksilo-app`'s key-event arm to consult the menubar BEFORE
396    /// focus-based dispatch. Returns `None` when no `MenuBar` is
397    /// mounted in this window.
398    pub fn menubar_dispatcher(
399        &self,
400    ) -> Option<Rc<dyn super::menubar_dispatcher::MenubarDispatcher>> {
401        self.inner.menubar_dispatcher.borrow().clone()
402    }
403
404    /// Request user attention (bouncing dock icon on macOS, flashing
405    /// taskbar on Windows). Queues a [`WindowCommand::RequestAttention`]
406    /// command for the next drain.
407    pub fn request_attention(&self, kind: UserAttentionKind) {
408        self.inner
409            .pending_os_commands
410            .borrow_mut()
411            .push(WindowCommand::RequestAttention(kind));
412    }
413
414    /// Focus this window — raise it above others and give it keyboard
415    /// focus. Queues a [`WindowCommand::Focus`] command for the next
416    /// drain, carrying (and clearing) any token set via
417    /// [`WindowState::set_activation_token`].
418    pub fn focus(&self) {
419        let activation_token = self.inner.pending_activation_token.borrow_mut().take();
420        self.inner
421            .pending_os_commands
422            .borrow_mut()
423            .push(WindowCommand::Focus { activation_token });
424    }
425
426    /// Stash an `xdg_activation_v1` token — an opaque string minted by the
427    /// focused requester and handed across a process boundary — to be consumed
428    /// by the next [`WindowState::focus`]. Only affects a Wayland raise;
429    /// ignored on every other platform, where `focus()` raises on its own.
430    pub fn set_activation_token(&self, token: String) {
431        *self.inner.pending_activation_token.borrow_mut() = Some(token);
432    }
433
434    /// Close this window. Queues a [`WindowCommand::Close`] command
435    /// for the next drain.
436    pub fn close(&self) {
437        self.inner
438            .pending_os_commands
439            .borrow_mut()
440            .push(WindowCommand::Close);
441    }
442
443    /// Test helper: returns the count of pending commands without
444    /// draining. Test-only to avoid exposing queue state to
445    /// application code.
446    #[cfg(test)]
447    pub(crate) fn pending_command_count(&self) -> usize {
448        self.inner.pending_os_commands.borrow().len()
449    }
450}
451
452// Framework-internal write-back API consumed by the app-level window
453// manager when a winit `WindowEvent` reports an OS-initiated state
454// change. Each method backing an observed signal flips the re-entrancy
455// guard before updating it so the observers do not push the same change
456// back out as a
457// [`WindowCommand`], which would at best duplicate work and at worst
458// cause OS↔app drift mid-animation (Compose Multiplatform #1489). The
459// keyboard-state setters below have no observer, so they need no guard.
460//
461// These are `pub` rather than `pub(crate)` because teksilo-app lives in a
462// separate crate. Application code should never call them; they read
463// like internals and have no stability guarantee. Use the public
464// signal setters instead — those fire OS commands through the normal
465// drain path.
466impl WindowState {
467    /// Drain the pending OS-command queue.
468    pub fn drain_os_commands(&self) -> Vec<WindowCommand> {
469        std::mem::take(&mut *self.inner.pending_os_commands.borrow_mut())
470    }
471
472    /// OS-originated placement write. Observers do not push back to
473    /// the OS while the guard is set.
474    pub fn set_placement_from_os(&self, p: WindowPlacement) {
475        self.inner.with_os_guard(|| self.inner.placement.set(p));
476    }
477
478    pub fn set_title_from_os(&self, title: String) {
479        self.inner.with_os_guard(|| self.inner.title.set(title));
480    }
481
482    pub fn set_size_from_os(&self, size: (u32, u32)) {
483        self.inner.with_os_guard(|| self.inner.size.set(size));
484    }
485
486    pub fn set_position_from_os(&self, position: (i32, i32)) {
487        self.inner
488            .with_os_guard(|| self.inner.position.set(position));
489    }
490
491    pub fn set_focused_from_os(&self, focused: bool) {
492        self.inner.with_os_guard(|| self.inner.focused.set(focused));
493    }
494
495    pub fn set_resizable_from_os(&self, resizable: bool) {
496        self.inner
497            .with_os_guard(|| self.inner.resizable.set(resizable));
498    }
499
500    pub fn set_always_on_top_from_os(&self, on_top: bool) {
501        self.inner
502            .with_os_guard(|| self.inner.always_on_top.set(on_top));
503    }
504
505    /// Update Caps Lock state from the OS. No observer / command is
506    /// wired (the app never drives the keyboard lock), so this writes the
507    /// signal directly. Idempotent: skips the write when unchanged to
508    /// avoid spurious repaints on auto-repeat.
509    pub fn set_caps_lock_from_os(&self, active: bool) {
510        if self.inner.caps_lock.get() != active {
511            self.inner.caps_lock.set(active);
512        }
513    }
514
515    /// Update Alt-held state from the OS. Resets the
516    /// `other_key_pressed_during_alt` flag on every Alt KeyDown
517    /// edge so each Alt-hold window starts fresh. Idempotent.
518    pub fn set_alt_from_os(&self, active: bool) {
519        if self.inner.alt_down.get() != active {
520            self.inner.alt_down.set(active);
521            if active {
522                // Fresh Alt-hold window — no other key has been
523                // pressed yet during this hold.
524                self.inner.other_key_pressed_during_alt.set(false);
525            }
526        }
527    }
528
529    /// Mark that a non-Alt key was pressed while Alt is currently
530    /// held. Sticky — only cleared by the next
531    /// [`set_alt_from_os(true)`](Self::set_alt_from_os) edge. No-op
532    /// when Alt is not held. Idempotent.
533    pub fn note_non_alt_keydown_during_alt(&self) {
534        if self.inner.alt_down.get() {
535            self.inner.other_key_pressed_during_alt.set(true);
536        }
537    }
538}
539
540impl WindowStateInner {
541    fn enqueue_unless_from_os(&self, cmd: WindowCommand) {
542        if self.applying_from_os.get() {
543            return;
544        }
545        self.pending_os_commands.borrow_mut().push(cmd);
546    }
547
548    fn with_os_guard<R>(&self, f: impl FnOnce() -> R) -> R {
549        // Set-and-restore rather than set-true-then-false: re-entry
550        // through nested signal observers stays correct.
551        let prev = self.applying_from_os.replace(true);
552        let out = f();
553        self.applying_from_os.set(prev);
554        out
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    fn init(id: u64) -> WindowStateInit {
563        WindowStateInit {
564            id: TeksiloWindowId::new(id),
565            string_id: Some("test".to_string()),
566            placement: WindowPlacement::Floating,
567            title: "Test".to_string(),
568            size: (800, 600),
569            position: (0, 0),
570            focused: false,
571            resizable: true,
572            always_on_top: false,
573        }
574    }
575
576    #[test]
577    fn app_side_write_enqueues_command() {
578        let state = WindowState::new(init(1));
579        state.placement().set(WindowPlacement::Fullscreen);
580        let cmds = state.drain_os_commands();
581        assert_eq!(
582            cmds,
583            vec![WindowCommand::SetPlacement(WindowPlacement::Fullscreen)]
584        );
585    }
586
587    #[test]
588    fn os_side_write_does_not_enqueue_command() {
589        let state = WindowState::new(init(1));
590        state.set_placement_from_os(WindowPlacement::Maximized);
591        assert_eq!(state.placement().get(), WindowPlacement::Maximized);
592        assert_eq!(state.drain_os_commands(), vec![]);
593    }
594
595    #[test]
596    fn os_side_write_still_notifies_derived_signals() {
597        let state = WindowState::new(init(1));
598        let is_fs = state.placement().map(|p| p.is_fullscreen());
599        assert!(!is_fs.get());
600        state.set_placement_from_os(WindowPlacement::Fullscreen);
601        assert!(is_fs.get());
602        // ... but no OS command was emitted.
603        assert_eq!(state.drain_os_commands(), vec![]);
604    }
605
606    #[test]
607    fn drain_is_consuming() {
608        let state = WindowState::new(init(1));
609        state.title().set("One".to_string());
610        state.title().set("Two".to_string());
611        assert_eq!(state.pending_command_count(), 2);
612        let _ = state.drain_os_commands();
613        assert_eq!(state.pending_command_count(), 0);
614    }
615
616    #[test]
617    fn focus_close_attention_do_not_depend_on_signals() {
618        let state = WindowState::new(init(1));
619        state.focus();
620        state.close();
621        state.request_attention(UserAttentionKind::Critical);
622        let cmds = state.drain_os_commands();
623        assert_eq!(
624            cmds,
625            vec![
626                WindowCommand::Focus {
627                    activation_token: None
628                },
629                WindowCommand::Close,
630                WindowCommand::RequestAttention(UserAttentionKind::Critical),
631            ]
632        );
633    }
634
635    #[test]
636    fn multiple_app_writes_of_different_fields() {
637        let state = WindowState::new(init(1));
638        state.title().set("Hello".to_string());
639        state.size().set((1200, 800));
640        state.resizable().set(false);
641        let cmds = state.drain_os_commands();
642        assert_eq!(
643            cmds,
644            vec![
645                WindowCommand::SetTitle("Hello".to_string()),
646                WindowCommand::SetSize(1200, 800),
647                WindowCommand::SetResizable(false),
648            ]
649        );
650    }
651
652    #[test]
653    fn guard_is_scoped_to_a_single_from_os_call() {
654        let state = WindowState::new(init(1));
655        // First OS-originated change: guard suppresses command.
656        state.set_size_from_os((1024, 768));
657        assert_eq!(state.drain_os_commands(), vec![]);
658        // Now an app-side write still works as normal.
659        state.size().set((640, 480));
660        assert_eq!(
661            state.drain_os_commands(),
662            vec![WindowCommand::SetSize(640, 480)]
663        );
664    }
665
666    #[test]
667    fn id_and_string_id_accessors() {
668        let state = WindowState::new(init(42));
669        assert_eq!(state.id(), TeksiloWindowId::new(42));
670        assert_eq!(state.string_id(), Some("test"));
671    }
672
673    #[test]
674    fn state_is_cloneable_and_shares_storage() {
675        let a = WindowState::new(init(1));
676        let b = a.clone();
677        a.title().set("From a".to_string());
678        assert_eq!(b.title().get(), "From a");
679        // Either handle can drain — they share the same queue.
680        let cmds = b.drain_os_commands();
681        assert_eq!(cmds.len(), 1);
682        assert_eq!(a.pending_command_count(), 0);
683    }
684
685    // --- Alt-down tracking ---
686
687    #[test]
688    fn alt_down_signal_defaults_false() {
689        let state = WindowState::new(init(1));
690        assert!(!state.alt_down().get());
691        assert!(!state.other_key_pressed_during_alt());
692    }
693
694    #[test]
695    fn set_alt_from_os_toggles_signal() {
696        let state = WindowState::new(init(1));
697        state.set_alt_from_os(true);
698        assert!(state.alt_down().get());
699        state.set_alt_from_os(false);
700        assert!(!state.alt_down().get());
701    }
702
703    #[test]
704    fn set_alt_from_os_does_not_enqueue_command() {
705        // Alt is keyboard-driven; the app cannot drive it.
706        let state = WindowState::new(init(1));
707        state.set_alt_from_os(true);
708        assert!(state.drain_os_commands().is_empty());
709    }
710
711    #[test]
712    fn alt_down_edge_resets_other_key_flag() {
713        let state = WindowState::new(init(1));
714        // Alt+letter chord: Alt down, then letter pressed.
715        state.set_alt_from_os(true);
716        state.note_non_alt_keydown_during_alt();
717        assert!(state.other_key_pressed_during_alt());
718        // Release Alt — flag persists (the consumer reads it on
719        // release to decide whether the tap is bare or chorded).
720        state.set_alt_from_os(false);
721        assert!(state.other_key_pressed_during_alt());
722        // Next Alt-down edge resets the flag so the new hold window
723        // starts fresh.
724        state.set_alt_from_os(true);
725        assert!(!state.other_key_pressed_during_alt());
726    }
727
728    #[test]
729    fn note_non_alt_keydown_is_noop_when_alt_not_held() {
730        let state = WindowState::new(init(1));
731        state.note_non_alt_keydown_during_alt();
732        assert!(!state.other_key_pressed_during_alt());
733    }
734
735    #[test]
736    fn alt_signal_observers_fire_on_transition() {
737        let state = WindowState::new(init(1));
738        let received: Rc<RefCell<Vec<bool>>> = Rc::new(RefCell::new(Vec::new()));
739        let received_w = Rc::downgrade(&received);
740        let _handle = state.alt_down().observe(move |v| {
741            if let Some(r) = received_w.upgrade() {
742                r.borrow_mut().push(*v);
743            }
744        });
745        state.set_alt_from_os(true);
746        state.set_alt_from_os(true); // idempotent — no second notify
747        state.set_alt_from_os(false);
748        assert_eq!(*received.borrow(), vec![true, false]);
749    }
750}