Another project
1use std::sync::Arc;
2
3use bone_text::Shaper;
4
5use crate::a11y::{AccessNode, AccessTreeBuilder, Role};
6use crate::focus::FocusManager;
7use crate::hit_test::{HitFrame, HitItem, HitState, Interaction, Sense, ZLayer};
8use crate::hotkey::{ActionId, HotkeyScopes, HotkeyTable, KeyChord};
9use crate::input::{InputSnapshot, KeyEvent};
10use crate::layout::{LayoutDirection, LayoutRect};
11use crate::strings::{Locale, StringTable};
12use crate::theme::Theme;
13use crate::widget_id::WidgetId;
14
15#[derive(Clone, Debug, PartialEq)]
16pub struct InteractDeclaration {
17 pub id: WidgetId,
18 pub rect: LayoutRect,
19 pub sense: Sense,
20 pub z: ZLayer,
21 pub disabled: bool,
22 pub focusable: bool,
23 pub active: bool,
24 pub a11y: Option<AccessNode>,
25}
26
27impl InteractDeclaration {
28 #[must_use]
29 pub const fn new(id: WidgetId, rect: LayoutRect, sense: Sense) -> Self {
30 Self {
31 id,
32 rect,
33 sense,
34 z: ZLayer::BASE,
35 disabled: false,
36 focusable: false,
37 active: false,
38 a11y: None,
39 }
40 }
41
42 #[must_use]
43 pub fn at_z(self, z: ZLayer) -> Self {
44 Self { z, ..self }
45 }
46
47 #[must_use]
48 pub fn disabled(self, disabled: bool) -> Self {
49 Self { disabled, ..self }
50 }
51
52 #[must_use]
53 pub fn focusable(self, focusable: bool) -> Self {
54 Self { focusable, ..self }
55 }
56
57 #[must_use]
58 pub fn active(self, active: bool) -> Self {
59 Self { active, ..self }
60 }
61
62 #[must_use]
63 pub fn a11y(self, node: AccessNode) -> Self {
64 Self {
65 a11y: Some(node),
66 ..self
67 }
68 }
69}
70
71pub struct FrameCtx<'a> {
72 theme: Arc<Theme>,
73 pub input: &'a mut InputSnapshot,
74 pub focus: &'a mut FocusManager,
75 pub hotkeys: &'a HotkeyTable,
76 pub strings: &'a StringTable,
77 pub hits: &'a mut HitFrame,
78 pub previous: &'a HitState,
79 pub a11y: &'a mut AccessTreeBuilder,
80 pub shaper: &'a mut Shaper,
81}
82
83impl<'a> FrameCtx<'a> {
84 #[must_use]
85 #[allow(
86 clippy::too_many_arguments,
87 reason = "FrameCtx threads every per-frame subsystem; bundling them obscures lifetimes"
88 )]
89 pub fn new(
90 theme: Arc<Theme>,
91 input: &'a mut InputSnapshot,
92 focus: &'a mut FocusManager,
93 hotkeys: &'a HotkeyTable,
94 strings: &'a StringTable,
95 hits: &'a mut HitFrame,
96 previous: &'a HitState,
97 a11y: &'a mut AccessTreeBuilder,
98 shaper: &'a mut Shaper,
99 ) -> Self {
100 focus.begin_frame();
101 focus.observe_input(input);
102 a11y.begin_frame();
103 Self {
104 theme,
105 input,
106 focus,
107 hotkeys,
108 strings,
109 hits,
110 previous,
111 a11y,
112 shaper,
113 }
114 }
115
116 #[must_use]
117 pub fn theme(&self) -> &Theme {
118 &self.theme
119 }
120
121 #[must_use]
122 pub fn locale(&self) -> Locale {
123 self.strings.locale()
124 }
125
126 #[must_use]
127 pub fn direction(&self) -> LayoutDirection {
128 self.strings.direction()
129 }
130
131 #[must_use]
132 pub fn is_focused(&self, id: WidgetId) -> bool {
133 self.focus.focused() == Some(id)
134 }
135
136 pub fn request_focus(&mut self, id: WidgetId) {
137 self.focus.request_focus(id);
138 }
139
140 pub fn interact(&mut self, declaration: InteractDeclaration) -> Interaction {
141 if !declaration.disabled {
142 self.focus.register_focusable(declaration.id);
143 if declaration.focusable {
144 self.focus.register_tab_stop(declaration.id);
145 }
146 if declaration
147 .a11y
148 .as_ref()
149 .is_some_and(|node| node.role == Role::TextInput)
150 {
151 self.focus.register_text_input(declaration.id);
152 }
153 }
154 self.hits.push(HitItem {
155 id: declaration.id,
156 rect: declaration.rect,
157 sense: declaration.sense,
158 z: declaration.z,
159 disabled: declaration.disabled,
160 active: declaration.active,
161 });
162 if let Some(node) = declaration.a11y {
163 self.a11y.push(declaration.id, declaration.rect, node);
164 }
165 let interaction = self.previous.interaction(declaration.id);
166 if interaction.click() && declaration.focusable && !declaration.disabled {
167 self.focus.request_focus(declaration.id);
168 }
169 interaction
170 }
171
172 pub fn block_pointer(&mut self, id: WidgetId, rect: LayoutRect) {
173 self.hits.push(HitItem {
174 id,
175 rect,
176 sense: Sense::INTERACTIVE,
177 z: ZLayer::BASE,
178 disabled: false,
179 active: false,
180 });
181 }
182
183 pub fn theme_scope<R>(
184 &mut self,
185 modify: impl FnOnce(&Theme) -> Theme,
186 body: impl FnOnce(&mut FrameCtx<'a>) -> R,
187 ) -> R {
188 let outer = Arc::clone(&self.theme);
189 self.theme = Arc::new(modify(&self.theme));
190 let result = body(self);
191 self.theme = outer;
192 result
193 }
194
195 pub fn dispatch_hotkeys(&mut self, scopes: &HotkeyScopes) -> Vec<ActionId> {
196 let table = self.hotkeys;
197 let pending = std::mem::take(&mut self.input.keys_pressed);
198 let (matched, remaining) = pending.into_iter().fold(
199 (Vec::new(), Vec::new()),
200 |(mut matched, mut remaining), event: KeyEvent| {
201 match table.dispatch(KeyChord::from(event), scopes) {
202 Some(action) => matched.push(action),
203 None => remaining.push(event),
204 }
205 (matched, remaining)
206 },
207 );
208 self.input.keys_pressed = remaining;
209 matched
210 }
211}
212
213impl Drop for FrameCtx<'_> {
214 fn drop(&mut self) {
215 self.focus.end_frame();
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use core::num::NonZeroU32;
222 use core::time::Duration;
223 use std::sync::Arc;
224
225 use super::{FrameCtx, InteractDeclaration};
226 use crate::a11y::AccessTreeBuilder;
227 use crate::focus::FocusManager;
228 use crate::hit_test::{HitFrame, HitState, Sense, resolve};
229 use crate::hotkey::{
230 ActionId, HotkeyBinding, HotkeyScope, HotkeyScopes, HotkeyTable, KeyChord,
231 };
232 use crate::input::{
233 FrameInstant, InputSnapshot, KeyChar, KeyCode, KeyEvent, ModifierMask, PointerButton,
234 PointerButtonMask, PointerSample,
235 };
236 use crate::layout::{LayoutDirection, LayoutPos, LayoutPx, LayoutRect, LayoutSize};
237 use crate::strings::{Locale, StringTable};
238 use crate::theme::{Theme, ThemeMode};
239 use crate::widget_id::{WidgetId, WidgetKey};
240
241 fn global_scope() -> HotkeyScopes {
242 HotkeyScopes::from_outer_to_inner([HotkeyScope::Global])
243 }
244
245 fn rect() -> LayoutRect {
246 LayoutRect::new(
247 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
248 LayoutSize::new(LayoutPx::new(50.0), LayoutPx::new(50.0)),
249 )
250 }
251
252 fn id(name: &'static str) -> WidgetId {
253 WidgetId::ROOT.child(WidgetKey::new(name))
254 }
255
256 fn action(n: u32) -> ActionId {
257 let Some(nz) = NonZeroU32::new(n) else {
258 panic!("test action id must be non-zero");
259 };
260 ActionId::new(nz)
261 }
262
263 #[test]
264 fn interact_registers_tab_stop_when_focusable() {
265 let theme = Arc::new(Theme::light());
266 let mut focus = FocusManager::new();
267 let hotkeys = HotkeyTable::new();
268 let mut hits = HitFrame::new();
269 let prev = HitState::new();
270 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
271 let mut shaper = bone_text::Shaper::new();
272 let mut a11y = AccessTreeBuilder::new();
273 {
274 let mut frame = FrameCtx::new(
275 theme,
276 &mut input,
277 &mut focus,
278 &hotkeys,
279 StringTable::empty(),
280 &mut hits,
281 &prev,
282 &mut a11y,
283 &mut shaper,
284 );
285 let _ = frame.interact(
286 InteractDeclaration::new(id("button"), rect(), Sense::INTERACTIVE).focusable(true),
287 );
288 }
289 assert_eq!(focus.tab_stops().len(), 1);
290 assert!(hits.items().iter().any(|item| item.id == id("button")));
291 }
292
293 #[test]
294 fn dispatch_hotkeys_consumes_pressed_chord() {
295 let chord = KeyChord::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL);
296 let Ok(table) = HotkeyTable::try_from_bindings(vec![HotkeyBinding::new(
297 chord,
298 HotkeyScope::Global,
299 action(42),
300 )]) else {
301 panic!("registration must succeed");
302 };
303 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
304 input.keys_pressed.push(KeyEvent::new(
305 KeyCode::Char(KeyChar::from_char('s')),
306 ModifierMask::CTRL,
307 ));
308 let mut focus = FocusManager::new();
309 let mut hits = HitFrame::new();
310 let prev = HitState::new();
311 let mut shaper = bone_text::Shaper::new();
312 let mut a11y = AccessTreeBuilder::new();
313 let actions = {
314 let mut frame = FrameCtx::new(
315 Arc::new(Theme::light()),
316 &mut input,
317 &mut focus,
318 &table,
319 StringTable::empty(),
320 &mut hits,
321 &prev,
322 &mut a11y,
323 &mut shaper,
324 );
325 frame.dispatch_hotkeys(&global_scope())
326 };
327 assert_eq!(actions, vec![action(42)]);
328 assert!(input.keys_pressed.is_empty());
329 }
330
331 #[test]
332 fn dispatch_hotkeys_leaves_unmatched_keys() {
333 let table = HotkeyTable::new();
334 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
335 let event = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL);
336 input.keys_pressed.push(event);
337 let mut focus = FocusManager::new();
338 let mut hits = HitFrame::new();
339 let prev = HitState::new();
340 let mut shaper = bone_text::Shaper::new();
341 let mut a11y = AccessTreeBuilder::new();
342 let actions = {
343 let mut frame = FrameCtx::new(
344 Arc::new(Theme::light()),
345 &mut input,
346 &mut focus,
347 &table,
348 StringTable::empty(),
349 &mut hits,
350 &prev,
351 &mut a11y,
352 &mut shaper,
353 );
354 frame.dispatch_hotkeys(&global_scope())
355 };
356 assert!(actions.is_empty());
357 assert_eq!(input.keys_pressed, vec![event]);
358 }
359
360 #[test]
361 fn end_to_end_press_release_routes_through_resolve() {
362 let theme = Arc::new(Theme::light());
363 let mut focus = FocusManager::new();
364 let hotkeys = HotkeyTable::new();
365 let mut hits = HitFrame::new();
366 let mut state = HitState::new();
367 let mut press = InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(0)));
368 press.pointer = Some(PointerSample::new(LayoutPos::new(
369 LayoutPx::new(10.0),
370 LayoutPx::new(10.0),
371 )));
372 press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
373
374 let mut shaper = bone_text::Shaper::new();
375 let mut a11y = AccessTreeBuilder::new();
376 {
377 let mut frame = FrameCtx::new(
378 theme.clone(),
379 &mut press,
380 &mut focus,
381 &hotkeys,
382 StringTable::empty(),
383 &mut hits,
384 &state,
385 &mut a11y,
386 &mut shaper,
387 );
388 let _ = frame.interact(
389 InteractDeclaration::new(id("btn"), rect(), Sense::INTERACTIVE).focusable(true),
390 );
391 }
392 state = resolve(&state, &hits, &press, focus.focused());
393 assert!(state.interaction(id("btn")).pressed());
394
395 hits.clear();
396 let mut release =
397 InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(40)));
398 release.pointer = Some(PointerSample::new(LayoutPos::new(
399 LayoutPx::new(15.0),
400 LayoutPx::new(15.0),
401 )));
402 release.buttons_released = PointerButtonMask::just(PointerButton::Primary);
403
404 {
405 let mut frame = FrameCtx::new(
406 theme.clone(),
407 &mut release,
408 &mut focus,
409 &hotkeys,
410 StringTable::empty(),
411 &mut hits,
412 &state,
413 &mut a11y,
414 &mut shaper,
415 );
416 let _ = frame.interact(
417 InteractDeclaration::new(id("btn"), rect(), Sense::INTERACTIVE).focusable(true),
418 );
419 }
420 state = resolve(&state, &hits, &release, focus.focused());
421 assert!(state.interaction(id("btn")).click());
422
423 hits.clear();
424 let mut idle = InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(80)));
425 {
426 let mut frame = FrameCtx::new(
427 theme,
428 &mut idle,
429 &mut focus,
430 &hotkeys,
431 StringTable::empty(),
432 &mut hits,
433 &state,
434 &mut a11y,
435 &mut shaper,
436 );
437 let _ = frame.interact(
438 InteractDeclaration::new(id("btn"), rect(), Sense::INTERACTIVE).focusable(true),
439 );
440 }
441 assert_eq!(
442 focus.focused(),
443 Some(id("btn")),
444 "click on focusable widget auto-focuses next frame",
445 );
446 }
447
448 #[test]
449 fn theme_scope_swaps_theme_for_body_and_restores_after() {
450 let mut focus = FocusManager::new();
451 let hotkeys = HotkeyTable::new();
452 let mut hits = HitFrame::new();
453 let prev = HitState::new();
454 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
455 let mut shaper = bone_text::Shaper::new();
456 let mut a11y = AccessTreeBuilder::new();
457 let mut frame = FrameCtx::new(
458 Arc::new(Theme::light()),
459 &mut input,
460 &mut focus,
461 &hotkeys,
462 StringTable::empty(),
463 &mut hits,
464 &prev,
465 &mut a11y,
466 &mut shaper,
467 );
468 assert_eq!(frame.theme().mode, ThemeMode::Light);
469 let inner_mode = frame.theme_scope(|_| Theme::dark(), |frame| frame.theme().mode);
470 assert_eq!(inner_mode, ThemeMode::Dark);
471 assert_eq!(frame.theme().mode, ThemeMode::Light);
472 }
473
474 #[test]
475 fn nested_theme_scopes_unwind_lifo() {
476 let mut focus = FocusManager::new();
477 let hotkeys = HotkeyTable::new();
478 let mut hits = HitFrame::new();
479 let prev = HitState::new();
480 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
481 let mut shaper = bone_text::Shaper::new();
482 let mut a11y = AccessTreeBuilder::new();
483 let mut frame = FrameCtx::new(
484 Arc::new(Theme::light()),
485 &mut input,
486 &mut focus,
487 &hotkeys,
488 StringTable::empty(),
489 &mut hits,
490 &prev,
491 &mut a11y,
492 &mut shaper,
493 );
494 let modes = frame.theme_scope(
495 |_| Theme::dark(),
496 |frame| {
497 let outer_mode = frame.theme().mode;
498 let inner_mode = frame.theme_scope(|_| Theme::light(), |frame| frame.theme().mode);
499 let after_inner = frame.theme().mode;
500 (outer_mode, inner_mode, after_inner)
501 },
502 );
503 assert_eq!(modes, (ThemeMode::Dark, ThemeMode::Light, ThemeMode::Dark));
504 assert_eq!(frame.theme().mode, ThemeMode::Light);
505 }
506
507 #[test]
508 fn theme_scope_observes_modified_accent_via_clone() {
509 let mut focus = FocusManager::new();
510 let hotkeys = HotkeyTable::new();
511 let mut hits = HitFrame::new();
512 let prev = HitState::new();
513 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
514 let mut shaper = bone_text::Shaper::new();
515 let mut a11y = AccessTreeBuilder::new();
516 let mut frame = FrameCtx::new(
517 Arc::new(Theme::light()),
518 &mut input,
519 &mut focus,
520 &hotkeys,
521 StringTable::empty(),
522 &mut hits,
523 &prev,
524 &mut a11y,
525 &mut shaper,
526 );
527 let outer_accent = frame.theme().colors.accent_solid();
528 let outer_ring = frame.theme().colors.focus_ring();
529 let (inner_accent, inner_ring) = frame.theme_scope(
530 |t| {
531 let mut next = t.clone();
532 next.colors.accent = t.colors.danger;
533 next
534 },
535 |frame| {
536 (
537 frame.theme().colors.accent_solid(),
538 frame.theme().colors.focus_ring(),
539 )
540 },
541 );
542 assert_ne!(inner_accent, outer_accent);
543 assert_eq!(inner_ring, inner_accent);
544 assert_eq!(frame.theme().colors.accent_solid(), outer_accent);
545 assert_eq!(frame.theme().colors.focus_ring(), outer_ring);
546 }
547
548 #[test]
549 fn hot_swap_between_frames_rebinds_token_reads() {
550 let mut focus = FocusManager::new();
551 let hotkeys = HotkeyTable::new();
552 let strings = StringTable::empty();
553 let prev = HitState::new();
554 let read_tokens = |theme: Arc<Theme>,
555 focus: &mut FocusManager,
556 hits: &mut HitFrame,
557 input: &mut InputSnapshot,
558 a11y: &mut AccessTreeBuilder,
559 shaper: &mut bone_text::Shaper| {
560 let frame = FrameCtx::new(
561 theme, input, focus, &hotkeys, strings, hits, &prev, a11y, shaper,
562 );
563 (
564 frame.theme().mode,
565 frame.theme().colors.text_primary(),
566 frame.theme().colors.surface(crate::theme::SurfaceLevel::L0),
567 )
568 };
569
570 let mut hits_a = HitFrame::new();
571 let mut input_a = InputSnapshot::idle(FrameInstant::ZERO);
572 let mut shaper_a = bone_text::Shaper::new();
573 let mut a11y_a = AccessTreeBuilder::new();
574 let (mode_a, text_a, surface_a) = read_tokens(
575 Arc::new(Theme::light()),
576 &mut focus,
577 &mut hits_a,
578 &mut input_a,
579 &mut a11y_a,
580 &mut shaper_a,
581 );
582
583 let mut hits_b = HitFrame::new();
584 let mut input_b = InputSnapshot::idle(FrameInstant::ZERO);
585 let mut shaper_b = bone_text::Shaper::new();
586 let mut a11y_b = AccessTreeBuilder::new();
587 let (mode_b, text_b, surface_b) = read_tokens(
588 Arc::new(Theme::dark()),
589 &mut focus,
590 &mut hits_b,
591 &mut input_b,
592 &mut a11y_b,
593 &mut shaper_b,
594 );
595
596 assert_eq!(mode_a, ThemeMode::Light);
597 assert_eq!(mode_b, ThemeMode::Dark);
598 assert_ne!(text_a, text_b);
599 assert_ne!(surface_a, surface_b);
600 assert!(surface_a.relative_luminance() > surface_b.relative_luminance());
601 assert!(text_a.relative_luminance() < text_b.relative_luminance());
602 }
603
604 #[test]
605 fn locale_and_direction_follow_string_table() {
606 let theme = Arc::new(Theme::light());
607 let mut focus = FocusManager::new();
608 let hotkeys = HotkeyTable::new();
609 let mut hits = HitFrame::new();
610 let prev = HitState::new();
611 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
612 let strings = StringTable::for_locale(Locale::ArXb);
613 let mut shaper = bone_text::Shaper::new();
614 let mut a11y = AccessTreeBuilder::new();
615 let frame = FrameCtx::new(
616 theme,
617 &mut input,
618 &mut focus,
619 &hotkeys,
620 &strings,
621 &mut hits,
622 &prev,
623 &mut a11y,
624 &mut shaper,
625 );
626 assert_eq!(frame.locale(), Locale::ArXb);
627 assert_eq!(frame.direction(), LayoutDirection::Rtl);
628 }
629}