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