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