1use 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#[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#[derive(Debug, Clone)]
78pub struct WindowStateInit {
79 pub id: TeksiloWindowId,
80 pub string_id: Option<String>,
81 pub placement: WindowPlacement,
82 pub title: String,
83 pub size: (u32, u32),
84 pub position: (i32, i32),
85 pub focused: bool,
86 pub resizable: bool,
87 pub always_on_top: bool,
88}
89
90pub(crate) struct WindowStateInner {
91 id: TeksiloWindowId,
92 string_id: Option<String>,
93
94 placement: Signal<WindowPlacement>,
95 title: Signal<String>,
96 size: Signal<(u32, u32)>,
97 position: Signal<(i32, i32)>,
98 focused: Signal<bool>,
99 resizable: Signal<bool>,
100 always_on_top: Signal<bool>,
101 caps_lock: Signal<bool>,
106
107 alt_down: Signal<bool>,
117
118 other_key_pressed_during_alt: Cell<bool>,
130
131 menubar_dispatcher: Rc<super::menubar_dispatcher::MenubarDispatcherSlot>,
137
138 pending_os_commands: RefCell<Vec<WindowCommand>>,
141
142 pending_activation_token: RefCell<Option<String>>,
148
149 applying_from_os: Cell<bool>,
154
155 _observer_handles: RefCell<Vec<ObserverHandle>>,
160}
161
162impl WindowState {
163 pub fn new(init: WindowStateInit) -> Self {
169 let inner = Rc::new(WindowStateInner {
170 id: init.id,
171 string_id: init.string_id,
172 placement: Signal::new(init.placement),
173 title: Signal::new(init.title),
174 size: Signal::new(init.size),
175 position: Signal::new(init.position),
176 focused: Signal::new(init.focused),
177 resizable: Signal::new(init.resizable),
178 always_on_top: Signal::new(init.always_on_top),
179 caps_lock: Signal::new(false),
180 alt_down: Signal::new(false),
181 other_key_pressed_during_alt: Cell::new(false),
182 menubar_dispatcher: Rc::new(RefCell::new(None)),
183 pending_os_commands: RefCell::new(Vec::new()),
184 pending_activation_token: RefCell::new(None),
185 applying_from_os: Cell::new(false),
186 _observer_handles: RefCell::new(Vec::new()),
187 });
188
189 let mut handles = Vec::new();
192
193 {
194 let inner_w = Rc::downgrade(&inner);
195 handles.push(inner.placement.observe(move |v| {
196 if let Some(inner) = inner_w.upgrade() {
197 inner.enqueue_unless_from_os(WindowCommand::SetPlacement(*v));
198 }
199 }));
200 }
201 {
202 let inner_w = Rc::downgrade(&inner);
203 handles.push(inner.title.observe(move |v| {
204 if let Some(inner) = inner_w.upgrade() {
205 inner.enqueue_unless_from_os(WindowCommand::SetTitle(v.clone()));
206 }
207 }));
208 }
209 {
210 let inner_w = Rc::downgrade(&inner);
211 handles.push(inner.size.observe(move |v| {
212 if let Some(inner) = inner_w.upgrade() {
213 inner.enqueue_unless_from_os(WindowCommand::SetSize(v.0, v.1));
214 }
215 }));
216 }
217 {
218 let inner_w = Rc::downgrade(&inner);
219 handles.push(inner.position.observe(move |v| {
220 if let Some(inner) = inner_w.upgrade() {
221 inner.enqueue_unless_from_os(WindowCommand::SetPosition(v.0, v.1));
222 }
223 }));
224 }
225 {
226 let inner_w = Rc::downgrade(&inner);
227 handles.push(inner.resizable.observe(move |v| {
228 if let Some(inner) = inner_w.upgrade() {
229 inner.enqueue_unless_from_os(WindowCommand::SetResizable(*v));
230 }
231 }));
232 }
233 {
234 let inner_w = Rc::downgrade(&inner);
235 handles.push(inner.always_on_top.observe(move |v| {
236 if let Some(inner) = inner_w.upgrade() {
237 inner.enqueue_unless_from_os(WindowCommand::SetAlwaysOnTop(*v));
238 }
239 }));
240 }
241 *inner._observer_handles.borrow_mut() = handles;
247 Self { inner }
248 }
249
250 pub fn id(&self) -> TeksiloWindowId {
251 self.inner.id
252 }
253
254 pub fn string_id(&self) -> Option<&str> {
255 self.inner.string_id.as_deref()
256 }
257
258 pub fn placement(&self) -> &Signal<WindowPlacement> {
259 &self.inner.placement
260 }
261
262 pub fn title(&self) -> &Signal<String> {
263 &self.inner.title
264 }
265
266 pub fn size(&self) -> &Signal<(u32, u32)> {
267 &self.inner.size
268 }
269
270 pub fn position(&self) -> &Signal<(i32, i32)> {
271 &self.inner.position
272 }
273
274 pub fn focused(&self) -> &Signal<bool> {
275 &self.inner.focused
276 }
277
278 pub fn resizable(&self) -> &Signal<bool> {
279 &self.inner.resizable
280 }
281
282 pub fn always_on_top(&self) -> &Signal<bool> {
283 &self.inner.always_on_top
284 }
285
286 pub fn caps_lock(&self) -> &Signal<bool> {
290 &self.inner.caps_lock
291 }
292
293 pub fn alt_down(&self) -> &Signal<bool> {
299 &self.inner.alt_down
300 }
301
302 pub fn other_key_pressed_during_alt(&self) -> bool {
307 self.inner.other_key_pressed_during_alt.get()
308 }
309
310 pub fn install_menubar_dispatcher(
320 &self,
321 dispatcher: Rc<dyn super::menubar_dispatcher::MenubarDispatcher>,
322 ) -> super::menubar_dispatcher::MenubarGuard {
323 let slot = self.inner.menubar_dispatcher.clone();
324 {
325 let mut slot_ref = slot.borrow_mut();
326 debug_assert!(
327 slot_ref.is_none(),
328 "WindowState: a menubar dispatcher is already installed for this \
329 window — only one keyboard-dispatching MenuBar is supported per \
330 window because the dispatcher slot routes F10 / Alt+letter / \
331 Alt-tap to exactly one trigger set.\n\n\
332 \
333 Causes typically fall into one of:\n\
334 \
335 1. Two MenuBar widgets are mounted in the same window. Pick \
336 the primary one — the one that should own F10 / Alt+letter \
337 — and call `.no_dispatcher_install()` on every secondary \
338 MenuBar (showcase content, embedded demos, settings \
339 previews, …). Secondary MenuBars still render and respond \
340 to mouse + arrow-key navigation; only the window-level \
341 keyboard dispatch is left to the primary.\n\
342 \
343 2. A MenuBar was added with `tree.add_boxed(MenuBar::new()…)` \
344 from inside another MenuBar's tab / popover / submenu. Same \
345 fix: mark the inner one with `.no_dispatcher_install()`.\n\
346 \
347 3. The host widget that owns the MenuBar reconstructs a fresh \
348 `MenuBar::new()` on every rebuild without dropping its \
349 previous `MenubarGuard` first. `MenuBar::build` already \
350 handles its own rebuild path; if you're hand-rolling the \
351 install (custom MenubarDispatcher impl), drop the old \
352 `MenubarGuard` BEFORE calling this method again."
353 );
354 *slot_ref = Some(dispatcher.clone());
355 }
356 super::menubar_dispatcher::MenubarGuard {
357 slot,
358 own: dispatcher,
359 }
360 }
361
362 pub fn menubar_dispatcher(
367 &self,
368 ) -> Option<Rc<dyn super::menubar_dispatcher::MenubarDispatcher>> {
369 self.inner.menubar_dispatcher.borrow().clone()
370 }
371
372 pub fn request_attention(&self, kind: UserAttentionKind) {
376 self.inner
377 .pending_os_commands
378 .borrow_mut()
379 .push(WindowCommand::RequestAttention(kind));
380 }
381
382 pub fn focus(&self) {
387 let activation_token = self.inner.pending_activation_token.borrow_mut().take();
388 self.inner
389 .pending_os_commands
390 .borrow_mut()
391 .push(WindowCommand::Focus { activation_token });
392 }
393
394 pub fn set_activation_token(&self, token: String) {
399 *self.inner.pending_activation_token.borrow_mut() = Some(token);
400 }
401
402 pub fn close(&self) {
405 self.inner
406 .pending_os_commands
407 .borrow_mut()
408 .push(WindowCommand::Close);
409 }
410
411 #[cfg(test)]
415 pub(crate) fn pending_command_count(&self) -> usize {
416 self.inner.pending_os_commands.borrow().len()
417 }
418}
419
420impl WindowState {
433 pub fn drain_os_commands(&self) -> Vec<WindowCommand> {
435 std::mem::take(&mut *self.inner.pending_os_commands.borrow_mut())
436 }
437
438 pub fn set_placement_from_os(&self, p: WindowPlacement) {
441 self.inner.with_os_guard(|| self.inner.placement.set(p));
442 }
443
444 pub fn set_title_from_os(&self, title: String) {
445 self.inner.with_os_guard(|| self.inner.title.set(title));
446 }
447
448 pub fn set_size_from_os(&self, size: (u32, u32)) {
449 self.inner.with_os_guard(|| self.inner.size.set(size));
450 }
451
452 pub fn set_position_from_os(&self, position: (i32, i32)) {
453 self.inner
454 .with_os_guard(|| self.inner.position.set(position));
455 }
456
457 pub fn set_focused_from_os(&self, focused: bool) {
458 self.inner.with_os_guard(|| self.inner.focused.set(focused));
459 }
460
461 pub fn set_resizable_from_os(&self, resizable: bool) {
462 self.inner
463 .with_os_guard(|| self.inner.resizable.set(resizable));
464 }
465
466 pub fn set_always_on_top_from_os(&self, on_top: bool) {
467 self.inner
468 .with_os_guard(|| self.inner.always_on_top.set(on_top));
469 }
470
471 pub fn set_caps_lock_from_os(&self, active: bool) {
476 if self.inner.caps_lock.get() != active {
477 self.inner.caps_lock.set(active);
478 }
479 }
480
481 pub fn set_alt_from_os(&self, active: bool) {
485 if self.inner.alt_down.get() != active {
486 self.inner.alt_down.set(active);
487 if active {
488 self.inner.other_key_pressed_during_alt.set(false);
491 }
492 }
493 }
494
495 pub fn note_non_alt_keydown_during_alt(&self) {
500 if self.inner.alt_down.get() {
501 self.inner.other_key_pressed_during_alt.set(true);
502 }
503 }
504}
505
506impl WindowStateInner {
507 fn enqueue_unless_from_os(&self, cmd: WindowCommand) {
508 if self.applying_from_os.get() {
509 return;
510 }
511 self.pending_os_commands.borrow_mut().push(cmd);
512 }
513
514 fn with_os_guard<R>(&self, f: impl FnOnce() -> R) -> R {
515 let prev = self.applying_from_os.replace(true);
518 let out = f();
519 self.applying_from_os.set(prev);
520 out
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 fn init(id: u64) -> WindowStateInit {
529 WindowStateInit {
530 id: TeksiloWindowId::new(id),
531 string_id: Some("test".to_string()),
532 placement: WindowPlacement::Floating,
533 title: "Test".to_string(),
534 size: (800, 600),
535 position: (0, 0),
536 focused: false,
537 resizable: true,
538 always_on_top: false,
539 }
540 }
541
542 #[test]
543 fn app_side_write_enqueues_command() {
544 let state = WindowState::new(init(1));
545 state.placement().set(WindowPlacement::Fullscreen);
546 let cmds = state.drain_os_commands();
547 assert_eq!(
548 cmds,
549 vec![WindowCommand::SetPlacement(WindowPlacement::Fullscreen)]
550 );
551 }
552
553 #[test]
554 fn os_side_write_does_not_enqueue_command() {
555 let state = WindowState::new(init(1));
556 state.set_placement_from_os(WindowPlacement::Maximized);
557 assert_eq!(state.placement().get(), WindowPlacement::Maximized);
558 assert_eq!(state.drain_os_commands(), vec![]);
559 }
560
561 #[test]
562 fn os_side_write_still_notifies_derived_signals() {
563 let state = WindowState::new(init(1));
564 let is_fs = state.placement().map(|p| p.is_fullscreen());
565 assert!(!is_fs.get());
566 state.set_placement_from_os(WindowPlacement::Fullscreen);
567 assert!(is_fs.get());
568 assert_eq!(state.drain_os_commands(), vec![]);
570 }
571
572 #[test]
573 fn drain_is_consuming() {
574 let state = WindowState::new(init(1));
575 state.title().set("One".to_string());
576 state.title().set("Two".to_string());
577 assert_eq!(state.pending_command_count(), 2);
578 let _ = state.drain_os_commands();
579 assert_eq!(state.pending_command_count(), 0);
580 }
581
582 #[test]
583 fn focus_close_attention_do_not_depend_on_signals() {
584 let state = WindowState::new(init(1));
585 state.focus();
586 state.close();
587 state.request_attention(UserAttentionKind::Critical);
588 let cmds = state.drain_os_commands();
589 assert_eq!(
590 cmds,
591 vec![
592 WindowCommand::Focus {
593 activation_token: None
594 },
595 WindowCommand::Close,
596 WindowCommand::RequestAttention(UserAttentionKind::Critical),
597 ]
598 );
599 }
600
601 #[test]
602 fn multiple_app_writes_of_different_fields() {
603 let state = WindowState::new(init(1));
604 state.title().set("Hello".to_string());
605 state.size().set((1200, 800));
606 state.resizable().set(false);
607 let cmds = state.drain_os_commands();
608 assert_eq!(
609 cmds,
610 vec![
611 WindowCommand::SetTitle("Hello".to_string()),
612 WindowCommand::SetSize(1200, 800),
613 WindowCommand::SetResizable(false),
614 ]
615 );
616 }
617
618 #[test]
619 fn guard_is_scoped_to_a_single_from_os_call() {
620 let state = WindowState::new(init(1));
621 state.set_size_from_os((1024, 768));
623 assert_eq!(state.drain_os_commands(), vec![]);
624 state.size().set((640, 480));
626 assert_eq!(
627 state.drain_os_commands(),
628 vec![WindowCommand::SetSize(640, 480)]
629 );
630 }
631
632 #[test]
633 fn id_and_string_id_accessors() {
634 let state = WindowState::new(init(42));
635 assert_eq!(state.id(), TeksiloWindowId::new(42));
636 assert_eq!(state.string_id(), Some("test"));
637 }
638
639 #[test]
640 fn state_is_cloneable_and_shares_storage() {
641 let a = WindowState::new(init(1));
642 let b = a.clone();
643 a.title().set("From a".to_string());
644 assert_eq!(b.title().get(), "From a");
645 let cmds = b.drain_os_commands();
647 assert_eq!(cmds.len(), 1);
648 assert_eq!(a.pending_command_count(), 0);
649 }
650
651 #[test]
654 fn alt_down_signal_defaults_false() {
655 let state = WindowState::new(init(1));
656 assert!(!state.alt_down().get());
657 assert!(!state.other_key_pressed_during_alt());
658 }
659
660 #[test]
661 fn set_alt_from_os_toggles_signal() {
662 let state = WindowState::new(init(1));
663 state.set_alt_from_os(true);
664 assert!(state.alt_down().get());
665 state.set_alt_from_os(false);
666 assert!(!state.alt_down().get());
667 }
668
669 #[test]
670 fn set_alt_from_os_does_not_enqueue_command() {
671 let state = WindowState::new(init(1));
673 state.set_alt_from_os(true);
674 assert!(state.drain_os_commands().is_empty());
675 }
676
677 #[test]
678 fn alt_down_edge_resets_other_key_flag() {
679 let state = WindowState::new(init(1));
680 state.set_alt_from_os(true);
682 state.note_non_alt_keydown_during_alt();
683 assert!(state.other_key_pressed_during_alt());
684 state.set_alt_from_os(false);
687 assert!(state.other_key_pressed_during_alt());
688 state.set_alt_from_os(true);
691 assert!(!state.other_key_pressed_during_alt());
692 }
693
694 #[test]
695 fn note_non_alt_keydown_is_noop_when_alt_not_held() {
696 let state = WindowState::new(init(1));
697 state.note_non_alt_keydown_during_alt();
698 assert!(!state.other_key_pressed_during_alt());
699 }
700
701 #[test]
702 fn alt_signal_observers_fire_on_transition() {
703 let state = WindowState::new(init(1));
704 let received: Rc<RefCell<Vec<bool>>> = Rc::new(RefCell::new(Vec::new()));
705 let received_w = Rc::downgrade(&received);
706 let _handle = state.alt_down().observe(move |v| {
707 if let Some(r) = received_w.upgrade() {
708 r.borrow_mut().push(*v);
709 }
710 });
711 state.set_alt_from_os(true);
712 state.set_alt_from_os(true); state.set_alt_from_os(false);
714 assert_eq!(*received.borrow(), vec![true, false]);
715 }
716}