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}