1use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
27use ftui_layout::{
28 PaneAccessibilityPreferences, PaneAffordanceMotion, PaneAnnouncement, PaneAnnouncer,
29 PaneCardinalDirection, PaneCommand, PaneCommandAcceleration, PaneCommandEffect,
30 PaneCommandResolution, PaneFocusContext, PaneFocusOrdinal, PaneId, PaneLayout, PaneNodeKind,
31 PaneResizeDirection, PaneTree, SplitAxis, announce_command, focus_order, resolve_pane_command,
32};
33
34#[must_use]
55pub fn key_to_pane_command(key: &KeyEvent, resize_units: u16) -> Option<PaneCommand> {
56 if key.kind == KeyEventKind::Release {
57 return None;
58 }
59 let m = key.modifiers;
60 if m.contains(Modifiers::CTRL) || m.contains(Modifiers::SUPER) || m.contains(Modifiers::ALT) {
64 return None;
65 }
66 let shift = m.contains(Modifiers::SHIFT);
67
68 match key.code {
69 KeyCode::Left if shift => Some(PaneCommand::MovePane(PaneCardinalDirection::Left)),
70 KeyCode::Right if shift => Some(PaneCommand::MovePane(PaneCardinalDirection::Right)),
71 KeyCode::Up if shift => Some(PaneCommand::MovePane(PaneCardinalDirection::Up)),
72 KeyCode::Down if shift => Some(PaneCommand::MovePane(PaneCardinalDirection::Down)),
73
74 KeyCode::Left => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Left)),
75 KeyCode::Right => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Right)),
76 KeyCode::Up => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Up)),
77 KeyCode::Down => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Down)),
78
79 KeyCode::Home => Some(PaneCommand::FocusEdge(PaneCardinalDirection::Left)),
80 KeyCode::End => Some(PaneCommand::FocusEdge(PaneCardinalDirection::Right)),
81 KeyCode::PageUp => Some(PaneCommand::FocusEdge(PaneCardinalDirection::Up)),
82 KeyCode::PageDown => Some(PaneCommand::FocusEdge(PaneCardinalDirection::Down)),
83
84 KeyCode::Char('n') => Some(PaneCommand::FocusNext),
85 KeyCode::Char('p') => Some(PaneCommand::FocusPrevious),
86
87 KeyCode::Char('=' | '+') => Some(PaneCommand::ResizeStep {
88 direction: PaneResizeDirection::Increase,
89 units: resize_units,
90 }),
91 KeyCode::Char('-' | '_') => Some(PaneCommand::ResizeStep {
92 direction: PaneResizeDirection::Decrease,
93 units: resize_units,
94 }),
95
96 KeyCode::Char('s' | 'S') => Some(PaneCommand::Split(SplitAxis::Horizontal)),
97 KeyCode::Char('v' | 'V') => Some(PaneCommand::Split(SplitAxis::Vertical)),
98 KeyCode::Char('x' | 'X') | KeyCode::Delete => Some(PaneCommand::Close),
99 KeyCode::Char('[') => Some(PaneCommand::SwapPane(PaneFocusOrdinal::Previous)),
100 KeyCode::Char(']') => Some(PaneCommand::SwapPane(PaneFocusOrdinal::Next)),
101 KeyCode::Char('f' | 'F') => Some(PaneCommand::Maximize),
102 KeyCode::Escape => Some(PaneCommand::Restore),
103
104 _ => None,
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum PaneAriaRole {
111 Group,
113 Separator,
115}
116
117impl PaneAriaRole {
118 #[must_use]
120 pub const fn as_str(self) -> &'static str {
121 match self {
122 Self::Group => "group",
123 Self::Separator => "separator",
124 }
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum PaneAriaOrientation {
131 Horizontal,
133 Vertical,
135}
136
137impl PaneAriaOrientation {
138 #[must_use]
140 pub const fn as_str(self) -> &'static str {
141 match self {
142 Self::Horizontal => "horizontal",
143 Self::Vertical => "vertical",
144 }
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct PaneAriaNode {
152 pub pane_id: PaneId,
154 pub role: PaneAriaRole,
156 pub tabindex: i32,
159 pub label: String,
161 pub orientation: Option<PaneAriaOrientation>,
163 pub value_now: Option<u16>,
165 pub value_min: Option<u16>,
167 pub value_max: Option<u16>,
169 pub current: bool,
171}
172
173#[must_use]
181pub fn pane_accessibility_tree(tree: &PaneTree, focus: PaneFocusContext) -> Vec<PaneAriaNode> {
182 let order = focus_order(tree);
183 let effective_active = focus
184 .active
185 .filter(|id| order.contains(id))
186 .or_else(|| order.first().copied());
187
188 let mut nodes = Vec::new();
189 for record in tree.nodes() {
190 match &record.kind {
191 PaneNodeKind::Leaf(leaf) => {
192 let is_active = Some(record.id) == effective_active;
193 nodes.push(PaneAriaNode {
194 pane_id: record.id,
195 role: PaneAriaRole::Group,
196 tabindex: i32::from(is_active) - 1, label: format!("pane {}", leaf.surface_key),
198 orientation: None,
199 value_now: None,
200 value_min: None,
201 value_max: None,
202 current: is_active,
203 });
204 }
205 PaneNodeKind::Split(split) => {
206 let orientation = match split.axis {
209 SplitAxis::Horizontal => PaneAriaOrientation::Vertical,
210 SplitAxis::Vertical => PaneAriaOrientation::Horizontal,
211 };
212 let num = u64::from(split.ratio.numerator());
216 let den = u64::from(split.ratio.denominator());
217 let first_share = u16::try_from(num * 100 / (num + den)).unwrap_or(50);
218 nodes.push(PaneAriaNode {
219 pane_id: record.id,
220 role: PaneAriaRole::Separator,
221 tabindex: -1,
222 label: format!("{} pane divider", orientation.as_str()),
223 orientation: Some(orientation),
224 value_now: Some(first_share),
225 value_min: Some(0),
226 value_max: Some(100),
227 current: false,
228 });
229 }
230 }
231 }
232 nodes
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
237pub enum PaneWebKeyOutcome {
238 Unbound,
240 Handled {
242 command: PaneCommand,
244 resolution: PaneCommandResolution,
246 },
247 Failed {
249 command: PaneCommand,
251 },
252}
253
254#[derive(Debug, Clone)]
257pub struct PaneWebKeyboardController {
258 focus: PaneFocusContext,
259 acceleration: PaneCommandAcceleration,
260 op_seed: u64,
261 repeat_count: u16,
262 announcer: PaneAnnouncer,
263 preferences: PaneAccessibilityPreferences,
264}
265
266impl PaneWebKeyboardController {
267 #[must_use]
269 pub fn new(active: Option<PaneId>) -> Self {
270 Self {
271 focus: PaneFocusContext {
272 active,
273 maximized: None,
274 },
275 acceleration: PaneCommandAcceleration::default(),
276 op_seed: 0,
277 repeat_count: 0,
278 announcer: PaneAnnouncer::new(),
279 preferences: PaneAccessibilityPreferences::none(),
280 }
281 }
282
283 pub fn take_announcement(&mut self) -> Option<PaneAnnouncement> {
286 self.announcer.take()
287 }
288
289 #[must_use]
291 pub fn pending_announcement(&self) -> Option<&PaneAnnouncement> {
292 self.announcer.pending()
293 }
294
295 #[must_use]
297 pub const fn with_acceleration(mut self, acceleration: PaneCommandAcceleration) -> Self {
298 self.acceleration = acceleration;
299 self
300 }
301
302 #[must_use]
305 pub const fn with_preferences(mut self, preferences: PaneAccessibilityPreferences) -> Self {
306 self.preferences = preferences;
307 self
308 }
309
310 pub const fn set_preferences(&mut self, preferences: PaneAccessibilityPreferences) {
314 self.preferences = preferences;
315 }
316
317 #[must_use]
319 pub const fn preferences(&self) -> PaneAccessibilityPreferences {
320 self.preferences
321 }
322
323 #[must_use]
326 pub fn affordance_motion(&self) -> PaneAffordanceMotion {
327 self.preferences.affordance_motion()
328 }
329
330 #[must_use]
339 pub fn accessibility_dataset(&self) -> [(&'static str, bool); 3] {
340 [
341 ("data-pane-reduced-motion", self.preferences.reduced_motion),
342 ("data-pane-high-contrast", self.preferences.high_contrast),
343 ("data-pane-large-target", self.preferences.large_target),
344 ]
345 }
346
347 #[must_use]
349 pub const fn focus(&self) -> PaneFocusContext {
350 self.focus
351 }
352
353 #[must_use]
355 pub const fn active(&self) -> Option<PaneId> {
356 self.focus.active
357 }
358
359 #[must_use]
361 pub const fn maximized(&self) -> Option<PaneId> {
362 self.focus.maximized
363 }
364
365 pub const fn set_active(&mut self, active: Option<PaneId>) {
367 self.focus.active = active;
368 }
369
370 #[must_use]
372 pub fn accessibility_tree(&self, tree: &PaneTree) -> Vec<PaneAriaNode> {
373 pane_accessibility_tree(tree, self.focus)
374 }
375
376 pub fn handle_key(
380 &mut self,
381 key: &KeyEvent,
382 tree: &mut PaneTree,
383 layout: &PaneLayout,
384 ) -> PaneWebKeyOutcome {
385 if key.kind == KeyEventKind::Repeat {
386 self.repeat_count = self.repeat_count.saturating_add(1);
387 } else {
388 self.repeat_count = 0;
389 }
390 let resize_units = self.acceleration.units_for(self.repeat_count);
391
392 let Some(command) = key_to_pane_command(key, resize_units) else {
393 return PaneWebKeyOutcome::Unbound;
394 };
395
396 let resolution = resolve_pane_command(tree, layout, self.focus, command);
397 if let PaneCommandEffect::Structural(ops) = &resolution.effect {
398 for op in ops {
399 if tree.apply_operation(self.op_seed, op.clone()).is_err() {
400 return PaneWebKeyOutcome::Failed { command };
401 }
402 self.op_seed += 1;
403 }
404 }
405 self.focus.active = resolution.next_active;
406 self.focus.maximized = resolution.next_maximized;
407 self.announcer
408 .offer(announce_command(command, &resolution, tree));
409 PaneWebKeyOutcome::Handled {
410 command,
411 resolution,
412 }
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use ftui_layout::{
420 PANE_TREE_SCHEMA_VERSION, PaneLeaf, PaneNodeRecord, PaneSplit, PaneSplitRatio,
421 PaneTreeSnapshot, Rect,
422 };
423 use std::collections::BTreeMap;
424
425 fn pid(raw: u64) -> PaneId {
426 PaneId::new(raw).expect("non-zero id")
427 }
428
429 fn key(code: KeyCode, modifiers: Modifiers) -> KeyEvent {
430 KeyEvent {
431 code,
432 modifiers,
433 kind: KeyEventKind::Press,
434 }
435 }
436
437 #[test]
438 fn alt_chords_are_never_bound() {
439 assert_eq!(
442 key_to_pane_command(&key(KeyCode::Left, Modifiers::ALT), 1),
443 None
444 );
445 assert_eq!(
446 key_to_pane_command(&key(KeyCode::Right, Modifiers::ALT), 1),
447 None
448 );
449 assert_eq!(
450 key_to_pane_command(&key(KeyCode::Left, Modifiers::SHIFT | Modifiers::ALT), 1),
451 None
452 );
453 }
454
455 fn nested() -> PaneTree {
457 let snapshot = PaneTreeSnapshot {
458 schema_version: PANE_TREE_SCHEMA_VERSION,
459 root: pid(1),
460 next_id: pid(6),
461 nodes: vec![
462 PaneNodeRecord::split(
463 pid(1),
464 None,
465 PaneSplit {
466 axis: SplitAxis::Horizontal,
467 ratio: PaneSplitRatio::new(1, 1).unwrap(),
468 first: pid(2),
469 second: pid(3),
470 },
471 ),
472 PaneNodeRecord::leaf(pid(2), Some(pid(1)), PaneLeaf::new("left")),
473 PaneNodeRecord::split(
474 pid(3),
475 Some(pid(1)),
476 PaneSplit {
477 axis: SplitAxis::Vertical,
478 ratio: PaneSplitRatio::new(3, 1).unwrap(),
479 first: pid(4),
480 second: pid(5),
481 },
482 ),
483 PaneNodeRecord::leaf(pid(4), Some(pid(3)), PaneLeaf::new("right_top")),
484 PaneNodeRecord::leaf(pid(5), Some(pid(3)), PaneLeaf::new("right_bottom")),
485 ],
486 extensions: BTreeMap::new(),
487 };
488 PaneTree::from_snapshot(snapshot).expect("valid tree")
489 }
490
491 #[test]
492 fn web_keymap_is_browser_safe() {
493 for code in [
495 KeyCode::Char('w'),
496 KeyCode::Char('t'),
497 KeyCode::Char('='),
498 KeyCode::Left,
499 ] {
500 assert_eq!(key_to_pane_command(&key(code, Modifiers::CTRL), 1), None);
501 assert_eq!(key_to_pane_command(&key(code, Modifiers::SUPER), 1), None);
502 }
503 let mut release = key(KeyCode::Left, Modifiers::NONE);
505 release.kind = KeyEventKind::Release;
506 assert_eq!(key_to_pane_command(&release, 1), None);
507 }
508
509 #[test]
510 fn web_keymap_covers_the_vocabulary() {
511 let none = Modifiers::NONE;
512 let shift = Modifiers::SHIFT;
513 assert_eq!(
514 key_to_pane_command(&key(KeyCode::Left, none), 1),
515 Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Left))
516 );
517 assert_eq!(
518 key_to_pane_command(&key(KeyCode::Down, shift), 1),
519 Some(PaneCommand::MovePane(PaneCardinalDirection::Down))
520 );
521 assert_eq!(
522 key_to_pane_command(&key(KeyCode::Home, none), 1),
523 Some(PaneCommand::FocusEdge(PaneCardinalDirection::Left))
524 );
525 assert_eq!(
526 key_to_pane_command(&key(KeyCode::Char('n'), none), 1),
527 Some(PaneCommand::FocusNext)
528 );
529 assert_eq!(
530 key_to_pane_command(&key(KeyCode::Char('='), none), 3),
531 Some(PaneCommand::ResizeStep {
532 direction: PaneResizeDirection::Increase,
533 units: 3
534 })
535 );
536 assert_eq!(
537 key_to_pane_command(&key(KeyCode::Char('s'), none), 1),
538 Some(PaneCommand::Split(SplitAxis::Horizontal))
539 );
540 assert_eq!(
541 key_to_pane_command(&key(KeyCode::Delete, none), 1),
542 Some(PaneCommand::Close)
543 );
544 assert_eq!(
545 key_to_pane_command(&key(KeyCode::Char(']'), none), 1),
546 Some(PaneCommand::SwapPane(PaneFocusOrdinal::Next))
547 );
548 assert_eq!(
549 key_to_pane_command(&key(KeyCode::Char('f'), none), 1),
550 Some(PaneCommand::Maximize)
551 );
552 assert_eq!(
553 key_to_pane_command(&key(KeyCode::Escape, none), 1),
554 Some(PaneCommand::Restore)
555 );
556 }
557
558 #[test]
559 fn roving_tabindex_has_exactly_one_zero() {
560 let tree = nested();
561 let nodes = pane_accessibility_tree(&tree, PaneFocusContext::active(pid(4)));
562 let zeros: Vec<_> = nodes
563 .iter()
564 .filter(|n| n.role == PaneAriaRole::Group && n.tabindex == 0)
565 .collect();
566 assert_eq!(zeros.len(), 1, "exactly one leaf is the roving Tab stop");
567 assert_eq!(zeros[0].pane_id, pid(4));
568 assert!(zeros[0].current);
569 for n in nodes
571 .iter()
572 .filter(|n| n.role == PaneAriaRole::Group && n.pane_id != pid(4))
573 {
574 assert_eq!(n.tabindex, -1);
575 assert!(!n.current);
576 }
577 }
578
579 #[test]
580 fn roving_tabindex_defaults_to_first_pane_when_unfocused() {
581 let tree = nested();
582 let nodes = pane_accessibility_tree(&tree, PaneFocusContext::default());
583 let active: Vec<_> = nodes.iter().filter(|n| n.tabindex == 0).collect();
584 assert_eq!(active.len(), 1);
585 assert_eq!(active[0].pane_id, pid(2));
587 }
588
589 #[test]
590 fn separator_exposes_orientation_and_value() {
591 let tree = nested();
592 let nodes = pane_accessibility_tree(&tree, PaneFocusContext::active(pid(2)));
593 let root = nodes.iter().find(|n| n.pane_id == pid(1)).unwrap();
595 assert_eq!(root.role, PaneAriaRole::Separator);
596 assert_eq!(root.orientation, Some(PaneAriaOrientation::Vertical));
597 assert_eq!(root.value_now, Some(50));
598 let inner = nodes.iter().find(|n| n.pane_id == pid(3)).unwrap();
600 assert_eq!(inner.orientation, Some(PaneAriaOrientation::Horizontal));
601 assert_eq!(inner.value_now, Some(75));
602 }
603
604 #[test]
605 fn controller_drives_pointer_free_web_workflow() {
606 fn run() -> (u64, Option<PaneId>, usize) {
607 let mut tree = nested();
608 let mut controller = PaneWebKeyboardController::new(Some(pid(2)));
609 let area = Rect::new(0, 0, 80, 24);
610 let solve = |t: &PaneTree| t.solve_layout(area).expect("solves");
611
612 let layout = solve(&tree);
614 controller.handle_key(&key(KeyCode::Right, Modifiers::NONE), &mut tree, &layout);
615 assert_eq!(controller.active(), Some(pid(4)));
616
617 let layout = solve(&tree);
619 let before = tree.nodes().count();
620 controller.handle_key(
621 &key(KeyCode::Char('s'), Modifiers::NONE),
622 &mut tree,
623 &layout,
624 );
625 assert!(tree.nodes().count() > before);
626
627 let layout = solve(&tree);
629 controller.handle_key(
630 &key(KeyCode::Char('f'), Modifiers::NONE),
631 &mut tree,
632 &layout,
633 );
634 assert!(controller.maximized().is_some());
635 let layout = solve(&tree);
636 controller.handle_key(&key(KeyCode::Escape, Modifiers::NONE), &mut tree, &layout);
637 assert_eq!(controller.maximized(), None);
638
639 let aria = controller.accessibility_tree(&tree);
641 let zeros = aria.iter().filter(|n| n.tabindex == 0).count();
642 (tree.state_hash(), controller.active(), zeros)
643 }
644
645 let first = run();
646 let second = run();
647 assert_eq!(first, second, "web keyboard workflow must be deterministic");
648 assert_eq!(first.2, 1, "roving invariant holds after the workflow");
649 }
650
651 #[test]
652 fn unbound_key_does_not_touch_state() {
653 let mut tree = nested();
654 let mut controller = PaneWebKeyboardController::new(Some(pid(2)));
655 let layout = tree.solve_layout(Rect::new(0, 0, 80, 24)).expect("solves");
656 let before = tree.state_hash();
657 let out = controller.handle_key(
658 &key(KeyCode::Char('q'), Modifiers::NONE),
659 &mut tree,
660 &layout,
661 );
662 assert_eq!(out, PaneWebKeyOutcome::Unbound);
663 assert_eq!(tree.state_hash(), before);
664 assert_eq!(controller.active(), Some(pid(2)));
665 }
666
667 #[test]
668 fn controller_surfaces_announcements_and_coalesces_resize() {
669 let mut tree = nested();
670 let mut controller = PaneWebKeyboardController::new(Some(pid(2)));
671 let area = Rect::new(0, 0, 80, 24);
672
673 let layout = tree.solve_layout(area).expect("solves");
675 controller.handle_key(&key(KeyCode::Right, Modifiers::NONE), &mut tree, &layout);
676 assert_eq!(
677 controller
678 .take_announcement()
679 .expect("focus announces")
680 .text,
681 "Focused pane right_top"
682 );
683
684 controller.set_active(Some(pid(2)));
687 for _ in 0..3 {
688 let layout = tree.solve_layout(area).expect("solves");
689 controller.handle_key(
690 &key(KeyCode::Char('='), Modifiers::NONE),
691 &mut tree,
692 &layout,
693 );
694 }
695 let spoken = controller.take_announcement().expect("resize announces");
696 assert_eq!(
697 spoken.category,
698 ftui_layout::PaneAnnouncementCategory::Resize
699 );
700 assert!(controller.take_announcement().is_none());
702 }
703
704 #[test]
705 fn web_preferences_do_not_alter_keyboard_semantics() {
706 use ftui_layout::PaneAccessibilityPreferences;
707 fn drive(
711 prefs: PaneAccessibilityPreferences,
712 ) -> (u64, Option<PaneId>, Option<PaneId>, Vec<String>) {
713 let mut tree = nested();
714 let mut controller =
715 PaneWebKeyboardController::new(Some(pid(2))).with_preferences(prefs);
716 let area = Rect::new(0, 0, 80, 24);
717 let seq = [
718 key(KeyCode::Char('n'), Modifiers::NONE), key(KeyCode::Down, Modifiers::NONE), key(KeyCode::Char('s'), Modifiers::NONE), key(KeyCode::Char('f'), Modifiers::NONE), key(KeyCode::Escape, Modifiers::NONE), ];
724 let mut announcements = Vec::new();
725 for k in seq {
726 let layout = tree.solve_layout(area).expect("solves");
727 controller.handle_key(&k, &mut tree, &layout);
728 if let Some(a) = controller.take_announcement() {
729 announcements.push(a.text);
730 }
731 }
732 (
733 tree.state_hash(),
734 controller.active(),
735 controller.maximized(),
736 announcements,
737 )
738 }
739 let plain = drive(PaneAccessibilityPreferences::none());
740 let full = drive(PaneAccessibilityPreferences::all());
741 assert_eq!(plain, full, "a11y modes must not change pane semantics");
742 }
743
744 #[test]
745 fn accessibility_dataset_reflects_preferences() {
746 use ftui_layout::PaneAccessibilityPreferences;
747 let plain = PaneWebKeyboardController::new(None);
749 assert_eq!(
750 plain.accessibility_dataset(),
751 [
752 ("data-pane-reduced-motion", false),
753 ("data-pane-high-contrast", false),
754 ("data-pane-large-target", false),
755 ]
756 );
757 let prefs = PaneAccessibilityPreferences::none()
759 .with_high_contrast(true)
760 .with_large_target(true);
761 let controller = PaneWebKeyboardController::new(None).with_preferences(prefs);
762 let ds = controller.accessibility_dataset();
763 assert_eq!(ds[0], ("data-pane-reduced-motion", false));
764 assert_eq!(ds[1], ("data-pane-high-contrast", true));
765 assert_eq!(ds[2], ("data-pane-large-target", true));
766 }
767
768 #[test]
769 fn web_affordance_motion_honors_reduced_motion() {
770 use ftui_layout::PaneAccessibilityPreferences;
771 let reduced = PaneWebKeyboardController::new(None)
772 .with_preferences(PaneAccessibilityPreferences::none().with_reduced_motion(true));
773 assert!(reduced.affordance_motion().reduced_motion);
774 assert!(
775 !PaneWebKeyboardController::new(None)
776 .affordance_motion()
777 .reduced_motion
778 );
779 }
780}