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