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>,
118
119 other_key_pressed_during_alt: Cell<bool>,
132
133 menubar_dispatcher: Rc<super::menubar_dispatcher::MenubarDispatcherSlot>,
139
140 pending_os_commands: RefCell<Vec<WindowCommand>>,
143
144 pending_activation_token: RefCell<Option<String>>,
150
151 applying_from_os: Cell<bool>,
156
157 _observer_handles: RefCell<Vec<ObserverHandle>>,
162}
163
164impl WindowState {
165 pub fn new(init: WindowStateInit) -> Self {
171 let inner = Rc::new(WindowStateInner {
172 id: init.id,
173 string_id: init.string_id,
174 placement: Signal::new(init.placement),
175 title: Signal::new(init.title),
176 size: Signal::new(init.size),
177 position: Signal::new(init.position),
178 focused: Signal::new(init.focused),
179 resizable: Signal::new(init.resizable),
180 always_on_top: Signal::new(init.always_on_top),
181 caps_lock: Signal::new(false),
182 alt_down: Signal::new(false),
183 other_key_pressed_during_alt: Cell::new(false),
184 menubar_dispatcher: Rc::new(RefCell::new(None)),
185 pending_os_commands: RefCell::new(Vec::new()),
186 pending_activation_token: RefCell::new(None),
187 applying_from_os: Cell::new(false),
188 _observer_handles: RefCell::new(Vec::new()),
189 });
190
191 let mut handles = Vec::new();
194
195 {
196 let inner_w = Rc::downgrade(&inner);
197 handles.push(inner.placement.observe(move |v| {
198 if let Some(inner) = inner_w.upgrade() {
199 inner.enqueue_unless_from_os(WindowCommand::SetPlacement(*v));
200 }
201 }));
202 }
203 {
204 let inner_w = Rc::downgrade(&inner);
205 handles.push(inner.title.observe(move |v| {
206 if let Some(inner) = inner_w.upgrade() {
207 inner.enqueue_unless_from_os(WindowCommand::SetTitle(v.clone()));
208 }
209 }));
210 }
211 {
212 let inner_w = Rc::downgrade(&inner);
213 handles.push(inner.size.observe(move |v| {
214 if let Some(inner) = inner_w.upgrade() {
215 inner.enqueue_unless_from_os(WindowCommand::SetSize(v.0, v.1));
216 }
217 }));
218 }
219 {
220 let inner_w = Rc::downgrade(&inner);
221 handles.push(inner.position.observe(move |v| {
222 if let Some(inner) = inner_w.upgrade() {
223 inner.enqueue_unless_from_os(WindowCommand::SetPosition(v.0, v.1));
224 }
225 }));
226 }
227 {
228 let inner_w = Rc::downgrade(&inner);
229 handles.push(inner.resizable.observe(move |v| {
230 if let Some(inner) = inner_w.upgrade() {
231 inner.enqueue_unless_from_os(WindowCommand::SetResizable(*v));
232 }
233 }));
234 }
235 {
236 let inner_w = Rc::downgrade(&inner);
237 handles.push(inner.always_on_top.observe(move |v| {
238 if let Some(inner) = inner_w.upgrade() {
239 inner.enqueue_unless_from_os(WindowCommand::SetAlwaysOnTop(*v));
240 }
241 }));
242 }
243 *inner._observer_handles.borrow_mut() = handles;
249 Self { inner }
250 }
251
252 pub fn id(&self) -> TeksiloWindowId {
253 self.inner.id
254 }
255
256 pub fn string_id(&self) -> Option<&str> {
257 self.inner.string_id.as_deref()
258 }
259
260 pub fn placement(&self) -> &Signal<WindowPlacement> {
261 &self.inner.placement
262 }
263
264 pub fn title(&self) -> &Signal<String> {
265 &self.inner.title
266 }
267
268 pub fn size(&self) -> &Signal<(u32, u32)> {
269 &self.inner.size
270 }
271
272 pub fn position(&self) -> &Signal<(i32, i32)> {
273 &self.inner.position
274 }
275
276 pub fn focused(&self) -> &Signal<bool> {
277 &self.inner.focused
278 }
279
280 pub fn resizable(&self) -> &Signal<bool> {
281 &self.inner.resizable
282 }
283
284 pub fn always_on_top(&self) -> &Signal<bool> {
285 &self.inner.always_on_top
286 }
287
288 pub fn caps_lock(&self) -> &Signal<bool> {
292 &self.inner.caps_lock
293 }
294
295 pub fn alt_down(&self) -> &Signal<bool> {
302 &self.inner.alt_down
303 }
304
305 pub fn other_key_pressed_during_alt(&self) -> bool {
310 self.inner.other_key_pressed_during_alt.get()
311 }
312
313 pub fn install_menubar_dispatcher(
323 &self,
324 dispatcher: Rc<dyn super::menubar_dispatcher::MenubarDispatcher>,
325 ) -> super::menubar_dispatcher::MenubarGuard {
326 let slot = self.inner.menubar_dispatcher.clone();
327 {
328 let mut slot_ref = slot.borrow_mut();
329 debug_assert!(
330 slot_ref.is_none(),
331 "WindowState: a menubar dispatcher is already installed for this \
332 window — only one keyboard-dispatching MenuBar is supported per \
333 window because the dispatcher slot routes F10 / Alt+letter / \
334 Alt-tap to exactly one trigger set.\n\n\
335 \
336 Causes typically fall into one of:\n\
337 \
338 1. Two MenuBar widgets are mounted in the same window. Pick \
339 the primary one — the one that should own F10 / Alt+letter \
340 — and call `.no_dispatcher_install()` on every secondary \
341 MenuBar (showcase content, embedded demos, settings \
342 previews, …). Secondary MenuBars still render and respond \
343 to mouse + arrow-key navigation; only the window-level \
344 keyboard dispatch is left to the primary.\n\
345 \
346 2. A MenuBar was added with `tree.add_boxed(MenuBar::new()…)` \
347 from inside another MenuBar's tab / popover / submenu. Same \
348 fix: mark the inner one with `.no_dispatcher_install()`.\n\
349 \
350 3. The host widget that owns the MenuBar reconstructs a fresh \
351 `MenuBar::new()` on every rebuild without dropping its \
352 previous `MenubarGuard` first. `MenuBar::build` already \
353 handles its own rebuild path; if you're hand-rolling the \
354 install (custom MenubarDispatcher impl), drop the old \
355 `MenubarGuard` BEFORE calling this method again."
356 );
357 *slot_ref = Some(dispatcher.clone());
358 }
359 super::menubar_dispatcher::MenubarGuard {
360 slot,
361 own: dispatcher,
362 }
363 }
364
365 pub fn menubar_dispatcher(
370 &self,
371 ) -> Option<Rc<dyn super::menubar_dispatcher::MenubarDispatcher>> {
372 self.inner.menubar_dispatcher.borrow().clone()
373 }
374
375 pub fn request_attention(&self, kind: UserAttentionKind) {
379 self.inner
380 .pending_os_commands
381 .borrow_mut()
382 .push(WindowCommand::RequestAttention(kind));
383 }
384
385 pub fn focus(&self) {
390 let activation_token = self.inner.pending_activation_token.borrow_mut().take();
391 self.inner
392 .pending_os_commands
393 .borrow_mut()
394 .push(WindowCommand::Focus { activation_token });
395 }
396
397 pub fn set_activation_token(&self, token: String) {
402 *self.inner.pending_activation_token.borrow_mut() = Some(token);
403 }
404
405 pub fn close(&self) {
408 self.inner
409 .pending_os_commands
410 .borrow_mut()
411 .push(WindowCommand::Close);
412 }
413
414 #[cfg(test)]
418 pub(crate) fn pending_command_count(&self) -> usize {
419 self.inner.pending_os_commands.borrow().len()
420 }
421}
422
423impl WindowState {
438 pub fn drain_os_commands(&self) -> Vec<WindowCommand> {
440 std::mem::take(&mut *self.inner.pending_os_commands.borrow_mut())
441 }
442
443 pub fn set_placement_from_os(&self, p: WindowPlacement) {
446 self.inner.with_os_guard(|| self.inner.placement.set(p));
447 }
448
449 pub fn set_title_from_os(&self, title: String) {
450 self.inner.with_os_guard(|| self.inner.title.set(title));
451 }
452
453 pub fn set_size_from_os(&self, size: (u32, u32)) {
454 self.inner.with_os_guard(|| self.inner.size.set(size));
455 }
456
457 pub fn set_position_from_os(&self, position: (i32, i32)) {
458 self.inner
459 .with_os_guard(|| self.inner.position.set(position));
460 }
461
462 pub fn set_focused_from_os(&self, focused: bool) {
463 self.inner.with_os_guard(|| self.inner.focused.set(focused));
464 }
465
466 pub fn set_resizable_from_os(&self, resizable: bool) {
467 self.inner
468 .with_os_guard(|| self.inner.resizable.set(resizable));
469 }
470
471 pub fn set_always_on_top_from_os(&self, on_top: bool) {
472 self.inner
473 .with_os_guard(|| self.inner.always_on_top.set(on_top));
474 }
475
476 pub fn set_caps_lock_from_os(&self, active: bool) {
481 if self.inner.caps_lock.get() != active {
482 self.inner.caps_lock.set(active);
483 }
484 }
485
486 pub fn set_alt_from_os(&self, active: bool) {
490 if self.inner.alt_down.get() != active {
491 self.inner.alt_down.set(active);
492 if active {
493 self.inner.other_key_pressed_during_alt.set(false);
496 }
497 }
498 }
499
500 pub fn note_non_alt_keydown_during_alt(&self) {
505 if self.inner.alt_down.get() {
506 self.inner.other_key_pressed_during_alt.set(true);
507 }
508 }
509}
510
511impl WindowStateInner {
512 fn enqueue_unless_from_os(&self, cmd: WindowCommand) {
513 if self.applying_from_os.get() {
514 return;
515 }
516 self.pending_os_commands.borrow_mut().push(cmd);
517 }
518
519 fn with_os_guard<R>(&self, f: impl FnOnce() -> R) -> R {
520 let prev = self.applying_from_os.replace(true);
523 let out = f();
524 self.applying_from_os.set(prev);
525 out
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 fn init(id: u64) -> WindowStateInit {
534 WindowStateInit {
535 id: TeksiloWindowId::new(id),
536 string_id: Some("test".to_string()),
537 placement: WindowPlacement::Floating,
538 title: "Test".to_string(),
539 size: (800, 600),
540 position: (0, 0),
541 focused: false,
542 resizable: true,
543 always_on_top: false,
544 }
545 }
546
547 #[test]
548 fn app_side_write_enqueues_command() {
549 let state = WindowState::new(init(1));
550 state.placement().set(WindowPlacement::Fullscreen);
551 let cmds = state.drain_os_commands();
552 assert_eq!(
553 cmds,
554 vec![WindowCommand::SetPlacement(WindowPlacement::Fullscreen)]
555 );
556 }
557
558 #[test]
559 fn os_side_write_does_not_enqueue_command() {
560 let state = WindowState::new(init(1));
561 state.set_placement_from_os(WindowPlacement::Maximized);
562 assert_eq!(state.placement().get(), WindowPlacement::Maximized);
563 assert_eq!(state.drain_os_commands(), vec![]);
564 }
565
566 #[test]
567 fn os_side_write_still_notifies_derived_signals() {
568 let state = WindowState::new(init(1));
569 let is_fs = state.placement().map(|p| p.is_fullscreen());
570 assert!(!is_fs.get());
571 state.set_placement_from_os(WindowPlacement::Fullscreen);
572 assert!(is_fs.get());
573 assert_eq!(state.drain_os_commands(), vec![]);
575 }
576
577 #[test]
578 fn drain_is_consuming() {
579 let state = WindowState::new(init(1));
580 state.title().set("One".to_string());
581 state.title().set("Two".to_string());
582 assert_eq!(state.pending_command_count(), 2);
583 let _ = state.drain_os_commands();
584 assert_eq!(state.pending_command_count(), 0);
585 }
586
587 #[test]
588 fn focus_close_attention_do_not_depend_on_signals() {
589 let state = WindowState::new(init(1));
590 state.focus();
591 state.close();
592 state.request_attention(UserAttentionKind::Critical);
593 let cmds = state.drain_os_commands();
594 assert_eq!(
595 cmds,
596 vec![
597 WindowCommand::Focus {
598 activation_token: None
599 },
600 WindowCommand::Close,
601 WindowCommand::RequestAttention(UserAttentionKind::Critical),
602 ]
603 );
604 }
605
606 #[test]
607 fn multiple_app_writes_of_different_fields() {
608 let state = WindowState::new(init(1));
609 state.title().set("Hello".to_string());
610 state.size().set((1200, 800));
611 state.resizable().set(false);
612 let cmds = state.drain_os_commands();
613 assert_eq!(
614 cmds,
615 vec![
616 WindowCommand::SetTitle("Hello".to_string()),
617 WindowCommand::SetSize(1200, 800),
618 WindowCommand::SetResizable(false),
619 ]
620 );
621 }
622
623 #[test]
624 fn guard_is_scoped_to_a_single_from_os_call() {
625 let state = WindowState::new(init(1));
626 state.set_size_from_os((1024, 768));
628 assert_eq!(state.drain_os_commands(), vec![]);
629 state.size().set((640, 480));
631 assert_eq!(
632 state.drain_os_commands(),
633 vec![WindowCommand::SetSize(640, 480)]
634 );
635 }
636
637 #[test]
638 fn id_and_string_id_accessors() {
639 let state = WindowState::new(init(42));
640 assert_eq!(state.id(), TeksiloWindowId::new(42));
641 assert_eq!(state.string_id(), Some("test"));
642 }
643
644 #[test]
645 fn state_is_cloneable_and_shares_storage() {
646 let a = WindowState::new(init(1));
647 let b = a.clone();
648 a.title().set("From a".to_string());
649 assert_eq!(b.title().get(), "From a");
650 let cmds = b.drain_os_commands();
652 assert_eq!(cmds.len(), 1);
653 assert_eq!(a.pending_command_count(), 0);
654 }
655
656 #[test]
659 fn alt_down_signal_defaults_false() {
660 let state = WindowState::new(init(1));
661 assert!(!state.alt_down().get());
662 assert!(!state.other_key_pressed_during_alt());
663 }
664
665 #[test]
666 fn set_alt_from_os_toggles_signal() {
667 let state = WindowState::new(init(1));
668 state.set_alt_from_os(true);
669 assert!(state.alt_down().get());
670 state.set_alt_from_os(false);
671 assert!(!state.alt_down().get());
672 }
673
674 #[test]
675 fn set_alt_from_os_does_not_enqueue_command() {
676 let state = WindowState::new(init(1));
678 state.set_alt_from_os(true);
679 assert!(state.drain_os_commands().is_empty());
680 }
681
682 #[test]
683 fn alt_down_edge_resets_other_key_flag() {
684 let state = WindowState::new(init(1));
685 state.set_alt_from_os(true);
687 state.note_non_alt_keydown_during_alt();
688 assert!(state.other_key_pressed_during_alt());
689 state.set_alt_from_os(false);
692 assert!(state.other_key_pressed_during_alt());
693 state.set_alt_from_os(true);
696 assert!(!state.other_key_pressed_during_alt());
697 }
698
699 #[test]
700 fn note_non_alt_keydown_is_noop_when_alt_not_held() {
701 let state = WindowState::new(init(1));
702 state.note_non_alt_keydown_during_alt();
703 assert!(!state.other_key_pressed_during_alt());
704 }
705
706 #[test]
707 fn alt_signal_observers_fire_on_transition() {
708 let state = WindowState::new(init(1));
709 let received: Rc<RefCell<Vec<bool>>> = Rc::new(RefCell::new(Vec::new()));
710 let received_w = Rc::downgrade(&received);
711 let _handle = state.alt_down().observe(move |v| {
712 if let Some(r) = received_w.upgrade() {
713 r.borrow_mut().push(*v);
714 }
715 });
716 state.set_alt_from_os(true);
717 state.set_alt_from_os(true); state.set_alt_from_os(false);
719 assert_eq!(*received.borrow(), vec![true, false]);
720 }
721}