1use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
24use ftui_layout::{
25 PaneAccessibilityPreferences, PaneAffordanceMotion, PaneAnnouncement, PaneAnnouncer,
26 PaneCardinalDirection, PaneCommand, PaneCommandAcceleration, PaneCommandEffect,
27 PaneCommandResolution, PaneFocusContext, PaneFocusOrdinal, PaneId, PaneLayout,
28 PaneResizeDirection, PaneTree, Rect, SplitAxis, announce_command, resolve_pane_command,
29};
30use ftui_render::cell::{Cell, PackedRgba};
31use ftui_render::drawing::{BorderChars, Draw};
32use ftui_style::{PaneAffordanceTheme, ResolvedTheme};
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 let ctrl = m.contains(Modifiers::CTRL);
61 let alt = m.contains(Modifiers::ALT);
62 let shift = m.contains(Modifiers::SHIFT);
63 let unmodified = !ctrl && !alt && !m.contains(Modifiers::SUPER);
65
66 match key.code {
67 KeyCode::Tab if unmodified && !shift => Some(PaneCommand::FocusNext),
68 KeyCode::BackTab => Some(PaneCommand::FocusPrevious),
69
70 KeyCode::Left if ctrl && shift => Some(PaneCommand::FocusEdge(PaneCardinalDirection::Left)),
71 KeyCode::Right if ctrl && shift => {
72 Some(PaneCommand::FocusEdge(PaneCardinalDirection::Right))
73 }
74 KeyCode::Up if ctrl && shift => Some(PaneCommand::FocusEdge(PaneCardinalDirection::Up)),
75 KeyCode::Down if ctrl && shift => Some(PaneCommand::FocusEdge(PaneCardinalDirection::Down)),
76
77 KeyCode::Left if ctrl => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Left)),
78 KeyCode::Right if ctrl => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Right)),
79 KeyCode::Up if ctrl => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Up)),
80 KeyCode::Down if ctrl => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Down)),
81
82 KeyCode::Left if alt => Some(PaneCommand::MovePane(PaneCardinalDirection::Left)),
83 KeyCode::Right if alt => Some(PaneCommand::MovePane(PaneCardinalDirection::Right)),
84 KeyCode::Up if alt => Some(PaneCommand::MovePane(PaneCardinalDirection::Up)),
85 KeyCode::Down if alt => Some(PaneCommand::MovePane(PaneCardinalDirection::Down)),
86
87 KeyCode::Char('+' | '=') if alt => Some(PaneCommand::ResizeStep {
88 direction: PaneResizeDirection::Increase,
89 units: resize_units,
90 }),
91 KeyCode::Char('-' | '_') if alt => Some(PaneCommand::ResizeStep {
92 direction: PaneResizeDirection::Decrease,
93 units: resize_units,
94 }),
95
96 KeyCode::Char('s' | 'S') if alt => Some(PaneCommand::Split(SplitAxis::Horizontal)),
97 KeyCode::Char('v' | 'V') if alt => Some(PaneCommand::Split(SplitAxis::Vertical)),
98 KeyCode::Char('w' | 'W') if alt => Some(PaneCommand::Close),
99 KeyCode::Char('[') if alt => Some(PaneCommand::SwapPane(PaneFocusOrdinal::Previous)),
100 KeyCode::Char(']') if alt => Some(PaneCommand::SwapPane(PaneFocusOrdinal::Next)),
101 KeyCode::Char('z' | 'Z') if alt => Some(PaneCommand::Maximize),
102 KeyCode::Char('r' | 'R') if alt => Some(PaneCommand::Restore),
103
104 _ => None,
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum PaneKeyOutcome {
111 Unbound,
113 Handled {
115 command: PaneCommand,
117 resolution: PaneCommandResolution,
119 },
120 Failed {
123 command: PaneCommand,
125 },
126}
127
128#[derive(Debug, Clone)]
133pub struct PaneKeyboardController {
134 focus: PaneFocusContext,
135 acceleration: PaneCommandAcceleration,
136 op_seed: u64,
137 repeat_count: u16,
138 announcer: PaneAnnouncer,
139 preferences: PaneAccessibilityPreferences,
140}
141
142impl PaneKeyboardController {
143 #[must_use]
145 pub fn new(active: Option<PaneId>) -> Self {
146 Self {
147 focus: PaneFocusContext {
148 active,
149 maximized: None,
150 },
151 acceleration: PaneCommandAcceleration::default(),
152 op_seed: 0,
153 repeat_count: 0,
154 announcer: PaneAnnouncer::new(),
155 preferences: PaneAccessibilityPreferences::none(),
156 }
157 }
158
159 pub fn take_announcement(&mut self) -> Option<PaneAnnouncement> {
162 self.announcer.take()
163 }
164
165 #[must_use]
167 pub fn pending_announcement(&self) -> Option<&PaneAnnouncement> {
168 self.announcer.pending()
169 }
170
171 #[must_use]
173 pub const fn with_acceleration(mut self, acceleration: PaneCommandAcceleration) -> Self {
174 self.acceleration = acceleration;
175 self
176 }
177
178 #[must_use]
181 pub const fn with_preferences(mut self, preferences: PaneAccessibilityPreferences) -> Self {
182 self.preferences = preferences;
183 self
184 }
185
186 pub const fn set_preferences(&mut self, preferences: PaneAccessibilityPreferences) {
190 self.preferences = preferences;
191 }
192
193 #[must_use]
195 pub const fn preferences(&self) -> PaneAccessibilityPreferences {
196 self.preferences
197 }
198
199 #[must_use]
202 pub fn affordance_motion(&self) -> PaneAffordanceMotion {
203 self.preferences.affordance_motion()
204 }
205
206 #[must_use]
210 pub fn focus_ring(&self, theme: &ResolvedTheme) -> PaneFocusRing {
211 let affordance = PaneAffordanceTheme::from_resolved(theme, self.preferences.high_contrast);
212 PaneFocusRing::themed(&affordance)
213 }
214
215 #[must_use]
217 pub const fn focus(&self) -> PaneFocusContext {
218 self.focus
219 }
220
221 #[must_use]
223 pub const fn active(&self) -> Option<PaneId> {
224 self.focus.active
225 }
226
227 #[must_use]
229 pub const fn maximized(&self) -> Option<PaneId> {
230 self.focus.maximized
231 }
232
233 pub const fn set_active(&mut self, active: Option<PaneId>) {
235 self.focus.active = active;
236 }
237
238 pub fn handle_key(
242 &mut self,
243 key: &KeyEvent,
244 tree: &mut PaneTree,
245 layout: &PaneLayout,
246 ) -> PaneKeyOutcome {
247 if key.kind == KeyEventKind::Repeat {
249 self.repeat_count = self.repeat_count.saturating_add(1);
250 } else {
251 self.repeat_count = 0;
252 }
253 let resize_units = self.acceleration.units_for(self.repeat_count);
254
255 let Some(command) = key_to_pane_command(key, resize_units) else {
256 return PaneKeyOutcome::Unbound;
257 };
258
259 let resolution = resolve_pane_command(tree, layout, self.focus, command);
260 if let PaneCommandEffect::Structural(ops) = &resolution.effect {
261 for op in ops {
262 if tree.apply_operation(self.op_seed, op.clone()).is_err() {
263 return PaneKeyOutcome::Failed { command };
264 }
265 self.op_seed += 1;
266 }
267 }
268 self.focus.active = resolution.next_active;
269 self.focus.maximized = resolution.next_maximized;
270 self.announcer
271 .offer(announce_command(command, &resolution, tree));
272 PaneKeyOutcome::Handled {
273 command,
274 resolution,
275 }
276 }
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub struct PaneFocusRing {
282 pub border: BorderChars,
285 pub cell: Cell,
287}
288
289impl Default for PaneFocusRing {
290 fn default() -> Self {
291 Self {
292 border: BorderChars::DOUBLE,
293 cell: Cell::from_char(' ').with_fg(PackedRgba::rgb(0xFF, 0xD7, 0x00)),
297 }
298 }
299}
300
301impl PaneFocusRing {
302 #[must_use]
310 pub fn themed(affordance: &PaneAffordanceTheme) -> Self {
311 let rgb = affordance.focus_ring.to_rgb();
312 Self {
313 border: BorderChars::DOUBLE,
314 cell: Cell::from_char(' ').with_fg(PackedRgba::rgb(rgb.r, rgb.g, rgb.b)),
315 }
316 }
317}
318
319pub fn render_pane_focus_ring<T: Draw>(target: &mut T, rect: Rect, ring: &PaneFocusRing) {
324 target.draw_border(rect, ring.border, ring.cell);
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub struct PaneKeyHint {
330 pub keys: &'static str,
332 pub action: &'static str,
334}
335
336#[must_use]
338pub fn pane_keyboard_hints() -> &'static [PaneKeyHint] {
339 &[
340 PaneKeyHint {
341 keys: "Tab / Shift+Tab",
342 action: "focus next / previous pane",
343 },
344 PaneKeyHint {
345 keys: "Ctrl+Arrows",
346 action: "focus pane by direction",
347 },
348 PaneKeyHint {
349 keys: "Ctrl+Shift+Arrows",
350 action: "focus pane at edge",
351 },
352 PaneKeyHint {
353 keys: "Alt+Arrows",
354 action: "move pane",
355 },
356 PaneKeyHint {
357 keys: "Alt++ / Alt+-",
358 action: "grow / shrink pane",
359 },
360 PaneKeyHint {
361 keys: "Alt+s / Alt+v",
362 action: "split horizontal / vertical",
363 },
364 PaneKeyHint {
365 keys: "Alt+w",
366 action: "close pane",
367 },
368 PaneKeyHint {
369 keys: "Alt+[ / Alt+]",
370 action: "swap with previous / next pane",
371 },
372 PaneKeyHint {
373 keys: "Alt+z / Alt+r",
374 action: "maximize / restore pane",
375 },
376 ]
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use ftui_layout::{
383 PANE_TREE_SCHEMA_VERSION, PaneLeaf, PaneNodeRecord, PaneSplit, PaneSplitRatio,
384 PaneTreeSnapshot,
385 };
386 use ftui_render::buffer::Buffer;
387 use std::collections::BTreeMap;
388
389 fn pid(raw: u64) -> PaneId {
390 PaneId::new(raw).expect("non-zero id")
391 }
392
393 fn key(code: KeyCode, modifiers: Modifiers) -> KeyEvent {
394 KeyEvent {
395 code,
396 modifiers,
397 kind: KeyEventKind::Press,
398 }
399 }
400
401 fn nested() -> PaneTree {
403 let snapshot = PaneTreeSnapshot {
404 schema_version: PANE_TREE_SCHEMA_VERSION,
405 root: pid(1),
406 next_id: pid(6),
407 nodes: vec![
408 PaneNodeRecord::split(
409 pid(1),
410 None,
411 PaneSplit {
412 axis: SplitAxis::Horizontal,
413 ratio: PaneSplitRatio::new(1, 1).unwrap(),
414 first: pid(2),
415 second: pid(3),
416 },
417 ),
418 PaneNodeRecord::leaf(pid(2), Some(pid(1)), PaneLeaf::new("left")),
419 PaneNodeRecord::split(
420 pid(3),
421 Some(pid(1)),
422 PaneSplit {
423 axis: SplitAxis::Vertical,
424 ratio: PaneSplitRatio::new(1, 1).unwrap(),
425 first: pid(4),
426 second: pid(5),
427 },
428 ),
429 PaneNodeRecord::leaf(pid(4), Some(pid(3)), PaneLeaf::new("right_top")),
430 PaneNodeRecord::leaf(pid(5), Some(pid(3)), PaneLeaf::new("right_bottom")),
431 ],
432 extensions: BTreeMap::new(),
433 };
434 PaneTree::from_snapshot(snapshot).expect("valid tree")
435 }
436
437 #[test]
438 fn keymap_covers_the_full_vocabulary() {
439 let none = Modifiers::NONE;
440 let ctrl = Modifiers::CTRL;
441 let alt = Modifiers::ALT;
442 let ctrl_shift = Modifiers::CTRL | Modifiers::SHIFT;
443
444 assert_eq!(
445 key_to_pane_command(&key(KeyCode::Tab, none), 1),
446 Some(PaneCommand::FocusNext)
447 );
448 assert_eq!(
449 key_to_pane_command(&key(KeyCode::BackTab, Modifiers::SHIFT), 1),
450 Some(PaneCommand::FocusPrevious)
451 );
452 assert_eq!(
453 key_to_pane_command(&key(KeyCode::Left, ctrl), 1),
454 Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Left))
455 );
456 assert_eq!(
457 key_to_pane_command(&key(KeyCode::Down, ctrl_shift), 1),
458 Some(PaneCommand::FocusEdge(PaneCardinalDirection::Down))
459 );
460 assert_eq!(
461 key_to_pane_command(&key(KeyCode::Right, alt), 1),
462 Some(PaneCommand::MovePane(PaneCardinalDirection::Right))
463 );
464 assert_eq!(
465 key_to_pane_command(&key(KeyCode::Char('+'), alt), 4),
466 Some(PaneCommand::ResizeStep {
467 direction: PaneResizeDirection::Increase,
468 units: 4
469 })
470 );
471 assert_eq!(
472 key_to_pane_command(&key(KeyCode::Char('-'), alt), 1),
473 Some(PaneCommand::ResizeStep {
474 direction: PaneResizeDirection::Decrease,
475 units: 1
476 })
477 );
478 assert_eq!(
479 key_to_pane_command(&key(KeyCode::Char('s'), alt), 1),
480 Some(PaneCommand::Split(SplitAxis::Horizontal))
481 );
482 assert_eq!(
483 key_to_pane_command(&key(KeyCode::Char('v'), alt), 1),
484 Some(PaneCommand::Split(SplitAxis::Vertical))
485 );
486 assert_eq!(
487 key_to_pane_command(&key(KeyCode::Char('w'), alt), 1),
488 Some(PaneCommand::Close)
489 );
490 assert_eq!(
491 key_to_pane_command(&key(KeyCode::Char('['), alt), 1),
492 Some(PaneCommand::SwapPane(PaneFocusOrdinal::Previous))
493 );
494 assert_eq!(
495 key_to_pane_command(&key(KeyCode::Char('z'), alt), 1),
496 Some(PaneCommand::Maximize)
497 );
498 assert_eq!(
499 key_to_pane_command(&key(KeyCode::Char('r'), alt), 1),
500 Some(PaneCommand::Restore)
501 );
502 }
503
504 #[test]
505 fn keymap_does_not_steal_plain_or_app_keys() {
506 assert_eq!(
508 key_to_pane_command(&key(KeyCode::Char('a'), Modifiers::NONE), 1),
509 None
510 );
511 assert_eq!(
512 key_to_pane_command(&key(KeyCode::Left, Modifiers::NONE), 1),
513 None
514 );
515 assert_eq!(
517 key_to_pane_command(&key(KeyCode::Char('c'), Modifiers::CTRL), 1),
518 None
519 );
520 let mut release = key(KeyCode::Tab, Modifiers::NONE);
522 release.kind = KeyEventKind::Release;
523 assert_eq!(key_to_pane_command(&release, 1), None);
524 }
525
526 #[test]
527 fn controller_drives_pointer_free_workflow() {
528 fn run() -> (u64, Option<PaneId>, Option<PaneId>) {
531 let mut tree = nested();
532 let mut controller = PaneKeyboardController::new(Some(pid(2)));
533 let area = Rect::new(0, 0, 80, 24);
534
535 let solve = |t: &PaneTree| t.solve_layout(area).expect("solves");
536
537 let layout = solve(&tree);
539 let out =
540 controller.handle_key(&key(KeyCode::Tab, Modifiers::NONE), &mut tree, &layout);
541 assert!(matches!(out, PaneKeyOutcome::Handled { .. }));
542 assert_eq!(controller.active(), Some(pid(4)));
543
544 let layout = solve(&tree);
546 controller.handle_key(&key(KeyCode::Down, Modifiers::CTRL), &mut tree, &layout);
547 assert_eq!(controller.active(), Some(pid(5)));
548
549 let layout = solve(&tree);
551 let before = tree.nodes().count();
552 controller.handle_key(&key(KeyCode::Char('s'), Modifiers::ALT), &mut tree, &layout);
553 assert!(tree.nodes().count() > before, "split must grow the tree");
554
555 let layout = solve(&tree);
557 controller.handle_key(&key(KeyCode::Char('z'), Modifiers::ALT), &mut tree, &layout);
558 assert!(controller.maximized().is_some());
559 let layout = solve(&tree);
560 controller.handle_key(&key(KeyCode::Char('r'), Modifiers::ALT), &mut tree, &layout);
561 assert_eq!(controller.maximized(), None);
562
563 (
564 tree.state_hash(),
565 controller.active(),
566 controller.maximized(),
567 )
568 }
569
570 let first = run();
571 let second = run();
572 assert_eq!(
573 first, second,
574 "keyboard-only workflow must be deterministic"
575 );
576 }
577
578 #[test]
579 fn controller_close_updates_focus_to_survivor() {
580 let mut tree = nested();
581 let mut controller = PaneKeyboardController::new(Some(pid(2)));
582 let layout = tree.solve_layout(Rect::new(0, 0, 80, 24)).expect("solves");
583 let out =
584 controller.handle_key(&key(KeyCode::Char('w'), Modifiers::ALT), &mut tree, &layout);
585 match out {
586 PaneKeyOutcome::Handled { command, .. } => {
587 assert_eq!(command, PaneCommand::Close);
588 }
589 other => panic!("expected handled close, got {other:?}"),
590 }
591 assert_ne!(controller.active(), Some(pid(2)));
593 assert!(controller.active().is_some());
594 }
595
596 #[test]
597 fn unbound_key_does_not_touch_state() {
598 let mut tree = nested();
599 let mut controller = PaneKeyboardController::new(Some(pid(2)));
600 let layout = tree.solve_layout(Rect::new(0, 0, 80, 24)).expect("solves");
601 let before = tree.state_hash();
602 let out = controller.handle_key(
603 &key(KeyCode::Char('q'), Modifiers::NONE),
604 &mut tree,
605 &layout,
606 );
607 assert_eq!(out, PaneKeyOutcome::Unbound);
608 assert_eq!(controller.active(), Some(pid(2)));
609 assert_eq!(tree.state_hash(), before);
610 }
611
612 #[test]
613 fn repeat_accelerates_resize_units() {
614 let mut tree = nested();
615 let mut controller = PaneKeyboardController::new(Some(pid(2)));
617 let layout = tree.solve_layout(Rect::new(0, 0, 80, 24)).expect("solves");
618 let mut repeat = key(KeyCode::Char('+'), Modifiers::ALT);
619 repeat.kind = KeyEventKind::Repeat;
620 let mut last_units = 0u16;
622 for _ in 0..4 {
623 if let PaneKeyOutcome::Handled {
624 command: PaneCommand::ResizeStep { units, .. },
625 ..
626 } = controller.handle_key(&repeat, &mut tree, &layout)
627 {
628 last_units = units;
629 }
630 }
631 assert_eq!(
632 last_units,
633 PaneCommandAcceleration::default().accelerated_units
634 );
635 }
636
637 #[test]
638 fn focus_ring_draws_a_distinct_border() {
639 let mut buffer = Buffer::new(10, 6);
640 render_pane_focus_ring(
641 &mut buffer,
642 Rect::new(0, 0, 6, 4),
643 &PaneFocusRing::default(),
644 );
645 assert_eq!(
647 buffer.get(0, 0).and_then(|c| c.content.as_char()),
648 Some('╔')
649 );
650 assert_eq!(
651 buffer.get(5, 3).and_then(|c| c.content.as_char()),
652 Some('╝')
653 );
654 }
655
656 #[test]
657 fn themed_focus_ring_uses_the_affordance_color() {
658 use ftui_style::theme::themes;
659 let resolved = themes::dark().resolve(true);
660 let affordance = PaneAffordanceTheme::from_resolved(&resolved, false);
661 let ring = PaneFocusRing::themed(&affordance);
662 let want = affordance.focus_ring.to_rgb();
665 assert_eq!(ring.cell.fg, PackedRgba::rgb(want.r, want.g, want.b));
666 assert_ne!(ring.cell.fg, PaneFocusRing::default().cell.fg);
667 assert_eq!(ring.border, BorderChars::DOUBLE);
669 }
670
671 #[test]
672 fn themed_focus_ring_high_contrast_is_at_least_as_visible() {
673 use ftui_style::theme::themes;
674 let resolved = themes::solarized_light().resolve(false);
675 let normal = PaneAffordanceTheme::from_resolved(&resolved, false);
676 let high = PaneAffordanceTheme::from_resolved(&resolved, true);
677 let ratio = |c: ftui_style::color::Color| {
678 ftui_style::color::contrast_ratio(c.to_rgb(), resolved.surface.to_rgb())
679 };
680 assert!(ratio(high.focus_ring) + 1e-9 >= ratio(normal.focus_ring));
681 }
682
683 #[test]
684 fn preferences_do_not_alter_keyboard_semantics() {
685 use ftui_layout::PaneAccessibilityPreferences;
686 fn drive(
690 prefs: PaneAccessibilityPreferences,
691 ) -> (u64, Option<PaneId>, Option<PaneId>, Vec<String>) {
692 let mut tree = nested();
693 let mut controller = PaneKeyboardController::new(Some(pid(2))).with_preferences(prefs);
694 let area = Rect::new(0, 0, 80, 24);
695 let seq = [
696 key(KeyCode::Tab, Modifiers::NONE),
697 key(KeyCode::Down, Modifiers::CTRL),
698 key(KeyCode::Char('s'), Modifiers::ALT),
699 key(KeyCode::Char('z'), Modifiers::ALT),
700 key(KeyCode::Char('r'), Modifiers::ALT),
701 ];
702 let mut announcements = Vec::new();
703 for k in seq {
704 let layout = tree.solve_layout(area).expect("solves");
705 controller.handle_key(&k, &mut tree, &layout);
706 if let Some(a) = controller.take_announcement() {
707 announcements.push(a.text);
708 }
709 }
710 (
711 tree.state_hash(),
712 controller.active(),
713 controller.maximized(),
714 announcements,
715 )
716 }
717 let plain = drive(PaneAccessibilityPreferences::none());
718 let full = drive(PaneAccessibilityPreferences::all());
719 assert_eq!(plain, full, "a11y modes must not change pane semantics");
720 }
721
722 #[test]
723 fn focus_ring_honors_high_contrast_preference() {
724 use ftui_layout::PaneAccessibilityPreferences;
725 use ftui_style::theme::themes;
726 let theme = themes::dark().resolve(true);
727 let normal_aff = PaneAffordanceTheme::from_resolved(&theme, false);
728 let high_aff = PaneAffordanceTheme::from_resolved(&theme, true);
729
730 let normal_ctl = PaneKeyboardController::new(Some(pid(2)));
733 let high_ctl = PaneKeyboardController::new(Some(pid(2)))
734 .with_preferences(PaneAccessibilityPreferences::none().with_high_contrast(true));
735 assert_eq!(
736 normal_ctl.focus_ring(&theme).cell.fg,
737 PaneFocusRing::themed(&normal_aff).cell.fg
738 );
739 assert_eq!(
740 high_ctl.focus_ring(&theme).cell.fg,
741 PaneFocusRing::themed(&high_aff).cell.fg
742 );
743
744 let ratio = |c: ftui_style::color::Color| {
746 ftui_style::color::contrast_ratio(c.to_rgb(), theme.surface.to_rgb())
747 };
748 assert!(ratio(high_aff.focus_ring) + 1e-9 >= ratio(normal_aff.focus_ring));
749 }
750
751 #[test]
752 fn controller_affordance_motion_honors_reduced_motion() {
753 use ftui_layout::PaneAccessibilityPreferences;
754 let reduced = PaneKeyboardController::new(None)
755 .with_preferences(PaneAccessibilityPreferences::none().with_reduced_motion(true));
756 assert!(reduced.affordance_motion().reduced_motion);
757 let plain = PaneKeyboardController::new(None);
758 assert!(!plain.affordance_motion().reduced_motion);
759 }
760
761 #[test]
762 fn hints_cover_every_binding_family() {
763 let hints = pane_keyboard_hints();
764 assert_eq!(hints.len(), 9);
765 assert!(hints.iter().any(|h| h.action.contains("focus next")));
766 assert!(hints.iter().any(|h| h.action.contains("split")));
767 assert!(hints.iter().any(|h| h.action.contains("maximize")));
768 }
769
770 #[test]
771 fn controller_surfaces_announcements() {
772 let mut tree = nested();
773 let mut controller = PaneKeyboardController::new(Some(pid(2)));
774 let layout = tree.solve_layout(Rect::new(0, 0, 80, 24)).expect("solves");
775 controller.handle_key(&key(KeyCode::Tab, Modifiers::NONE), &mut tree, &layout);
777 let spoken = controller.take_announcement().expect("focus announces");
778 assert_eq!(spoken.text, "Focused pane right_top");
779 assert!(controller.take_announcement().is_none());
781 }
782}