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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech
use teksilo_canvas::{Rect, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::{Prop, Signal};
use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
/// Wraps an arbitrary widget so it can drive a popover.
///
/// `PopoverButton` and `PopoverIconButton` cover the two stock triggers; this
/// is the third case — a trigger that is *not* a button, such as a table
/// header's filter glyph or a tag chip. It supplies what those two get from
/// `Button`/`IconButton`: an activate route (pointer, Enter/Space, and the
/// AT `Click` action), the `has_popup` / `expanded` disclosure annotations, and
/// the arena-level `enabled` gate.
///
/// ```ignore
/// PopoverWidget::new(OverlayTrigger::around(my_glyph))
/// .content(my_panel)
/// .placement(OverlayPlacement::BelowPreferred)
/// ```
pub struct OverlayTrigger {
child_id: Option<WidgetId>,
pending_child: Option<PendingChild>,
pending_handlers: Option<HandlerSet>,
name: Option<String>,
/// Optional `has_popup` hint surfaced on this trigger's a11y
/// node. Same role as Button's equivalent — used by Popover
/// for the ARIA disclosure pattern.
has_popup: Option<teksilo_core::accesskit::HasPopup>,
/// Optional signal reporting whether the owned popup is
/// currently visible. Published via `set_expanded`.
expanded_signal: Option<Signal<bool>>,
/// Enabled state, wired into the arena on this trigger's node so
/// a disabled custom trigger greys out (via `effective_enabled`),
/// reports `disabled` to AT, and has its pointer/key dispatch
/// gated — the same treatment a stock `Button` gets. Default
/// `Prop::Static(true)`.
enabled: Prop<bool>,
/// Installed by [`crate::popover_widget::PopoverTrigger::with_on_activate`]. Routed onto the child
/// in `build` as pointer-tap, Enter/Space and the AT `Click` action, so a
/// custom trigger is reachable exactly the ways a `Button` trigger is.
on_activate: Option<std::rc::Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
}
impl OverlayTrigger {
pub(crate) fn new(child: Box<dyn Widget>, handlers: HandlerSet) -> Self {
Self::from_pending(PendingChild::Deferred(child), handlers)
}
pub(crate) fn from_id(id: WidgetId, handlers: HandlerSet) -> Self {
Self::from_pending(PendingChild::Id(id), handlers)
}
fn from_pending(pending: PendingChild, handlers: HandlerSet) -> Self {
Self {
child_id: None,
pending_child: Some(pending),
pending_handlers: Some(handlers),
name: None,
has_popup: None,
expanded_signal: None,
enabled: Prop::Static(true),
on_activate: None,
}
}
/// Wrap any widget as a popover trigger.
pub fn around(widget: impl Widget + 'static) -> Self {
Self::from_pending(PendingChild::Deferred(Box::new(widget)), HandlerSet::new())
}
/// [`around`](Self::around) for a widget already inserted by id.
pub fn around_id(id: WidgetId) -> Self {
Self::from_pending(PendingChild::Id(id), HandlerSet::new())
}
/// Set the trigger's accessible name.
pub fn named(self, name: impl Into<String>) -> Self {
self.name(name)
}
/// Whether an activate handler is already installed.
pub fn has_on_activate(&self) -> bool {
self.on_activate.is_some()
}
/// Install the popover's open/close handler. Routed onto the wrapped widget
/// as pointer-tap, Enter/Space and the AT `Click` action.
pub fn on_activate(
mut self,
f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static,
) -> Self {
self.on_activate = Some(std::rc::Rc::new(f));
self
}
/// Set the trigger's enabled state (static or reactive). When
/// `false`, the trigger child greys out, reports `disabled` to
/// AT, and stops accepting pointer/key dispatch — via the arena's
/// `enabled_when` cascade onto this node.
pub(crate) fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
self.enabled = enabled.into();
self
}
pub(crate) fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub(crate) fn has_popup(mut self, kind: teksilo_core::accesskit::HasPopup) -> Self {
self.has_popup = Some(kind);
self
}
pub(crate) fn expanded_when(mut self, signal: Signal<bool>) -> Self {
self.expanded_signal = Some(signal);
self
}
}
impl std::fmt::Debug for OverlayTrigger {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OverlayTrigger")
.field("name", &self.name)
.finish()
}
}
impl Widget for OverlayTrigger {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
// Wire enabled into the arena on this trigger node. The child is
// a descendant, so `arena.is_enabled` (ancestor walk) gates its
// dispatch, `effective_enabled` greys it out, and the a11y walker
// marks it disabled — with no per-trigger bool snapshot.
let self_id = ctx.self_id();
ctx.enabled_when(self_id, self.enabled.clone());
if let Some(pending) = self.pending_child.take() {
self.child_id = Some(match pending {
PendingChild::Id(id) => id,
PendingChild::Deferred(w) => ctx.add_boxed(w),
});
}
// Attach handlers to the CHILD, not to ourselves. The child is
// the hit-test target and the first node in the bubble pass —
// if it has its own gesture arena (e.g. a real `Button`, which
// unconditionally wires `on_tap` for InteractionState
// tracking), it consumes the tap before any ancestor can see
// it. Routing the overlay-opening handlers onto the child's
// *external* bucket means they fire alongside the child's own
// handlers when the gesture arena emits `Tap`.
//
// For non-interactive triggers (test `FixedLeaf`, `Panel`,
// etc.) `ensure_gesture_arena` lazily installs a recognizer
// for the external `on_tap`, so the same path works.
let mut handlers = self.pending_handlers.take();
if let Some(activate) = self.on_activate.clone() {
let set = handlers.take().unwrap_or_default();
let tap = activate.clone();
let key = activate.clone();
let act = activate;
handlers = Some(
set.on_tap(move |_pos, ctx| tap(ctx))
.on_key(move |event, ctx| match event {
teksilo_core::event::WidgetEvent::KeyDown {
key: teksilo_core::event::Key::Enter | teksilo_core::event::Key::Space,
..
} => {
key(ctx);
teksilo_core::event::EventResponse::Handled
}
_ => teksilo_core::event::EventResponse::Ignored,
})
.on_access_action(move |action, ctx| {
if action == teksilo_core::accesskit::Action::Click {
act(ctx);
teksilo_core::event::EventResponse::Handled
} else {
teksilo_core::event::EventResponse::Ignored
}
}),
);
}
if let Some(handlers) = handlers {
if let Some(child_id) = self.child_id {
ctx.apply_handlers(child_id, handlers);
} else {
// No child — keep handlers on self so they aren't lost.
ctx.apply_self_handlers(handlers);
}
}
// Register the expanded_signal so flips trigger an a11y
// refresh on this trigger node.
if let Some(ref expanded_signal) = self.expanded_signal {
let registry = ctx.binding_registry();
expanded_signal.bind_to(
self_id,
registry,
teksilo_core::binding::BindingLevel::RepaintOnly,
);
}
self.children()
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
self.child_id
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or_else(|| proposal.resolve(0.0, 0.0))
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(teksilo_core::accesskit::Role::Button);
if let Some(name) = &self.name {
builder.set_name(name.as_str());
}
if let Some(kind) = self.has_popup {
builder.set_has_popup(kind);
}
if let Some(ref signal) = self.expanded_signal {
builder.set_expanded(signal.get());
}
}
fn children(&self) -> Vec<WidgetId> {
self.child_id.into_iter().collect()
}
}