1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech
//! MenuContext — shared navigation state for the menu hierarchy.
//!
//! Created by `MenuBar` during `build()` and passed to `MenuOverlayHost` and
//! `MenuBarTrigger` widgets. Coordinates opening/closing menus, focus transfer,
//! and Left/Right arrow navigation between top-level menus.
use std::cell::RefCell;
use std::rc::Rc;
use teksilo_core::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
use teksilo_core::signal::Signal;
use teksilo_core::widget::EventContext;
use teksilo_core::widget_id::WidgetId;
struct MenuContextInner {
trigger_ids: Vec<WidgetId>,
/// The overlay host widget IDs (MenuOverlayHost — shown as overlay content).
content_ids: Vec<WidgetId>,
/// The inner focusable widget IDs (MenuList inside the host — receives focus).
focus_ids: Vec<WidgetId>,
}
/// Shared state for a menu bar and its dropdowns.
#[derive(Clone)]
pub(crate) struct MenuContext {
inner: Rc<RefCell<MenuContextInner>>,
/// Which top-level menu index is open (None = all closed).
/// Used by MenuBarTrigger to derive bg_color / text_color.
pub open_index: Signal<Option<usize>>,
}
impl MenuContext {
pub fn new(open_index: Signal<Option<usize>>) -> Self {
Self {
inner: Rc::new(RefCell::new(MenuContextInner {
trigger_ids: Vec::new(),
content_ids: Vec::new(),
focus_ids: Vec::new(),
})),
open_index,
}
}
pub fn register(
&self,
index: usize,
trigger_id: WidgetId,
content_id: WidgetId,
focus_id: WidgetId,
) {
let mut inner = self.inner.borrow_mut();
let existing_focus_id = inner.focus_ids.get(index).copied();
while inner.trigger_ids.len() <= index {
inner.trigger_ids.push(trigger_id);
}
while inner.content_ids.len() <= index {
inner.content_ids.push(content_id);
}
while inner.focus_ids.len() <= index {
inner.focus_ids.push(focus_id);
}
inner.trigger_ids[index] = trigger_id;
inner.content_ids[index] = content_id;
if existing_focus_id.is_none() {
inner.focus_ids[index] = focus_id;
}
}
/// Set the focus target for a menu index (called by MenuOverlayHost::build
/// after the inner MenuList is created).
pub fn set_focus_id(&self, index: usize, focus_id: WidgetId) {
let mut inner = self.inner.borrow_mut();
while inner.focus_ids.len() <= index {
inner.focus_ids.push(focus_id);
}
inner.focus_ids[index] = focus_id;
}
pub fn count(&self) -> usize {
self.inner.borrow().trigger_ids.len()
}
/// Open the menu at `index`. Dismisses any currently open overlays,
/// activates the content widget, shows the overlay, and focuses it.
pub fn open_at(&self, index: usize, ctx: &mut EventContext) {
let inner = self.inner.borrow();
if index >= inner.content_ids.len() {
return;
}
let content_id = inner.content_ids[index];
let trigger_id = inner.trigger_ids[index];
let focus_id = inner.focus_ids[index];
drop(inner);
ctx.dismiss_all_except_hosts();
self.open_index.set(Some(index));
// Build this menu's content if it has never been opened, before the
// overlay below is measured against it and focus moves into it. Set
// `open_index` first — that is the signal the deferred host reveals on.
ctx.materialize_now(content_id);
ctx.activate(content_id);
ctx.show_overlay(OverlayRequest {
content_id,
anchor: trigger_id,
placement: OverlayPlacement::BelowPreferred,
dismiss: DismissBehavior::EscapeOrClickOutside,
layer: OverlayLayer::InTree,
parent_overlay: None,
on_dismiss: None,
fade_duration: None,
});
// Focus the inner MenuList (not the host) so it receives key events
// first. ArrowLeft/Escape bubble from MenuList → host for cross-menu nav.
ctx.request_focus(focus_id);
}
/// Close all menus and reset the open_index signal.
/// Restores focus to the currently open menu trigger if any.
pub fn close(&self, ctx: &mut EventContext) {
let current_index = self.open_index.get();
self.open_index.set(None);
ctx.dismiss_all_except_hosts();
// Restore focus to the trigger that was open
if let Some(index) = current_index
&& let Some(trigger_id) = self.trigger_id(index)
{
ctx.request_focus(trigger_id);
}
}
/// Navigate to the menu at `(current + delta) % count`, wrapping around.
/// Properly manages focus between menu triggers.
pub fn navigate(&self, delta: i32, ctx: &mut EventContext) {
let count = self.count();
if count == 0 {
return;
}
let current_index = self.open_index.get();
let current = current_index.unwrap_or(0) as i32;
let next = ((current + delta).rem_euclid(count as i32)) as usize;
// If we're navigating from an open menu, close it first and restore focus to trigger
if let Some(current_index) = current_index
&& let Some(trigger_id) = self.trigger_id(current_index)
{
ctx.request_focus(trigger_id);
}
// Open the next menu
self.open_at(next, ctx);
}
/// Get the trigger widget ID for an index (for focus return).
pub fn trigger_id(&self, index: usize) -> Option<WidgetId> {
self.inner.borrow().trigger_ids.get(index).copied()
}
}
impl std::fmt::Debug for MenuContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MenuContext")
.field("count", &self.count())
.finish()
}
}