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