Skip to main content

ftui_runtime/
pane_keymap.rs

1//! Terminal keyboard bindings for the pane workspace (bd-21pbi.2).
2//!
3//! This is the terminal host's binding layer over the canonical, host-agnostic
4//! command model in `ftui_layout::pane_command` (bd-21pbi.1). It provides:
5//!
6//! - [`key_to_pane_command`]: the default terminal keymap, mapping raw
7//!   `KeyEvent`s to canonical [`PaneCommand`]s. It binds only modifier-qualified
8//!   keys (and `Tab`/`BackTab`) so it coexists with application shortcuts and
9//!   never steals unrelated plain keys.
10//! - [`PaneKeyboardController`]: a reusable host helper that translates a key,
11//!   resolves it against a live `PaneTree`, applies any structural operations,
12//!   and tracks focus/maximize state — so pointer-free pane workflows are fully
13//!   operable from a few lines of host glue.
14//! - [`render_pane_focus_ring`]: a stable focus indicator (a distinct border on
15//!   the active pane), generic over the render target so it works with both a
16//!   `Frame` and a bare `Buffer`.
17//! - [`pane_keyboard_hints`]: discoverable shortcut hints for a help panel.
18//!
19//! The keymap is the terminal half of the cross-host parity guarantee: it emits
20//! the same `PaneCommand` stream a conformant web binding (bd-21pbi.3) would for
21//! equivalent intent, so both hosts reach identical pane state.
22
23use 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/// Translate a raw terminal key event into a canonical [`PaneCommand`].
35///
36/// Returns `None` for key release events and for any key not in the default
37/// pane keymap (so the host passes those through to the application). The
38/// keymap is intentionally modifier-qualified to avoid stealing plain keys:
39///
40/// | Key | Command |
41/// |-----|---------|
42/// | `Tab` / `Shift+Tab` | `FocusNext` / `FocusPrevious` |
43/// | `Ctrl+Arrow` | `FocusDirectional` |
44/// | `Ctrl+Shift+Arrow` | `FocusEdge` |
45/// | `Alt+Arrow` | `MovePane` |
46/// | `Alt++` / `Alt+-` | `ResizeStep` grow / shrink |
47/// | `Alt+s` / `Alt+v` | `Split` horizontal / vertical |
48/// | `Alt+w` | `Close` |
49/// | `Alt+[` / `Alt+]` | `SwapPane` previous / next |
50/// | `Alt+z` / `Alt+r` | `Maximize` / `Restore` |
51///
52/// `resize_units` is the snap-step count for `ResizeStep` (computed by the
53/// caller via [`PaneCommandAcceleration`]); it is ignored for other commands.
54#[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    // Plain = no Ctrl/Alt/Super (Shift may still be present, e.g. for BackTab).
64    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/// Outcome of feeding one key event to a [`PaneKeyboardController`].
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum PaneKeyOutcome {
111    /// The key is not a pane binding; the host should handle it normally.
112    Unbound,
113    /// The command was resolved (and any structural operations applied).
114    Handled {
115        /// The command the key mapped to.
116        command: PaneCommand,
117        /// The full resolution (effect + resulting focus state).
118        resolution: PaneCommandResolution,
119    },
120    /// The command resolved to structural operations but one failed to apply
121    /// (e.g. an invalid topology); focus state is left unchanged.
122    Failed {
123        /// The command that failed.
124        command: PaneCommand,
125    },
126}
127
128/// A reusable terminal pane keyboard host: translates keys to commands,
129/// resolves them against a live tree, applies structural operations, and tracks
130/// focus + maximize state. Hosts own the `PaneTree`; this owns the focus state
131/// and a monotonic operation counter.
132#[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    /// Create a controller focused on `active` (or unfocused if `None`).
144    #[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    /// Take the pending accessibility announcement (for a status line / screen
160    /// reader), coalesced and de-duplicated. Call once per render.
161    pub fn take_announcement(&mut self) -> Option<PaneAnnouncement> {
162        self.announcer.take()
163    }
164
165    /// Peek the pending accessibility announcement without consuming it.
166    #[must_use]
167    pub fn pending_announcement(&self) -> Option<&PaneAnnouncement> {
168        self.announcer.pending()
169    }
170
171    /// Override the resize acceleration policy.
172    #[must_use]
173    pub const fn with_acceleration(mut self, acceleration: PaneCommandAcceleration) -> Self {
174        self.acceleration = acceleration;
175        self
176    }
177
178    /// Set the accessibility preferences (reduced-motion / high-contrast /
179    /// large-target) at construction time (bd-21pbi.5).
180    #[must_use]
181    pub const fn with_preferences(mut self, preferences: PaneAccessibilityPreferences) -> Self {
182        self.preferences = preferences;
183        self
184    }
185
186    /// Update the accessibility preferences at runtime (e.g. when the user
187    /// toggles a mode). Never affects focus/command semantics — only the
188    /// presentation helpers below.
189    pub const fn set_preferences(&mut self, preferences: PaneAccessibilityPreferences) {
190        self.preferences = preferences;
191    }
192
193    /// The active accessibility preferences.
194    #[must_use]
195    pub const fn preferences(&self) -> PaneAccessibilityPreferences {
196        self.preferences
197    }
198
199    /// The affordance micro-animation policy implied by the current
200    /// preferences (motion is stepped under reduced-motion).
201    #[must_use]
202    pub fn affordance_motion(&self) -> PaneAffordanceMotion {
203        self.preferences.affordance_motion()
204    }
205
206    /// Build a focus ring for the active pane from `theme`, honoring the
207    /// high-contrast preference. The ring color is contrast-clamped against the
208    /// pane surface (AAA when high-contrast is on, AA otherwise).
209    #[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    /// The current focus context (active + maximized pane).
216    #[must_use]
217    pub const fn focus(&self) -> PaneFocusContext {
218        self.focus
219    }
220
221    /// The currently active (focused) pane.
222    #[must_use]
223    pub const fn active(&self) -> Option<PaneId> {
224        self.focus.active
225    }
226
227    /// The currently maximized pane, if any.
228    #[must_use]
229    pub const fn maximized(&self) -> Option<PaneId> {
230        self.focus.maximized
231    }
232
233    /// Set the active pane (e.g. when a pointer click changes focus).
234    pub const fn set_active(&mut self, active: Option<PaneId>) {
235        self.focus.active = active;
236    }
237
238    /// Translate, resolve, and apply one key event. Structural commands mutate
239    /// `tree`; focus/maximize commands update internal state. The `layout` must
240    /// be the solved layout of `tree` (used for spatial focus + move).
241    pub fn handle_key(
242        &mut self,
243        key: &KeyEvent,
244        tree: &mut PaneTree,
245        layout: &PaneLayout,
246    ) -> PaneKeyOutcome {
247        // Sustained key repeat accelerates resize stepping.
248        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/// A stable focus indicator: a distinct border drawn around the active pane.
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub struct PaneFocusRing {
282    /// Border glyph set (default: double-line, visually distinct from pane
283    /// content borders).
284    pub border: BorderChars,
285    /// Cell whose fg/bg/attrs style the border glyphs.
286    pub cell: Cell,
287}
288
289impl Default for PaneFocusRing {
290    fn default() -> Self {
291        Self {
292            border: BorderChars::DOUBLE,
293            // Gold border: high-contrast and stable across themes. Used when no
294            // theme is wired in; prefer [`PaneFocusRing::themed`] when a theme is
295            // available so the ring respects palette + high-contrast preferences.
296            cell: Cell::from_char(' ').with_fg(PackedRgba::rgb(0xFF, 0xD7, 0x00)),
297        }
298    }
299}
300
301impl PaneFocusRing {
302    /// Build a focus ring whose color is the theme's contrast-guaranteed pane
303    /// focus-ring affordance (bd-3bfbp).
304    ///
305    /// The supplied [`PaneAffordanceTheme`] has already been contrast-clamped
306    /// against the pane surface (AA by default, AAA when built in the
307    /// high-contrast profile), so the ring stays visible across every theme
308    /// without per-call tuning.
309    #[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
319/// Draw a focus ring around `rect` on any render target (`Frame` or `Buffer`).
320///
321/// Visually stable: it draws a deterministic border and never depends on
322/// animation or timing, so the active pane is unambiguous after any command.
323pub 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/// One discoverable keyboard hint for a help panel.
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub struct PaneKeyHint {
330    /// Human-readable key combination, e.g. `"Ctrl+Arrows"`.
331    pub keys: &'static str,
332    /// What the binding does.
333    pub action: &'static str,
334}
335
336/// The canonical, discoverable pane keyboard shortcut hints, in display order.
337#[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    /// Horizontal root: left(2) | vertical(top(4)/bottom(5)). Focus order [2,4,5].
402    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        // Plain letters and plain arrows are left for the application.
507        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        // Ctrl+C (common app binding) is not a pane command.
516        assert_eq!(
517            key_to_pane_command(&key(KeyCode::Char('c'), Modifiers::CTRL), 1),
518            None
519        );
520        // Release events are ignored.
521        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        // A keyboard-only session: focus, split, focus by direction, close,
529        // maximize/restore — no pointer events at all.
530        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            // Tab -> focus next (2 -> 4).
538            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            // Ctrl+Down -> focus bottom (4 -> 5).
545            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            // Alt+s -> split the active leaf (structural; tree grows).
550            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            // Alt+z then Alt+r -> maximize then restore.
556            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        // Active pane must now be a surviving leaf, not the closed one.
592        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        // Make left(2) the active pane so its enclosing split is the root.
616        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        // Drive enough repeats to cross the acceleration threshold (default 3).
621        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        // Double-line top-left corner glyph.
646        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        // The ring's fg must equal the theme's contrast-clamped focus-ring color,
663        // not the hardcoded gold default.
664        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        // Glyph set is unchanged (still the distinct double-line border).
668        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        // Identical key stream, two controllers: one with no a11y modes, one with
687        // all of them. Focus/maximize/topology and announcements must be byte
688        // identical — a11y modes are presentation-only.
689        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        // The controller wires its high-contrast preference straight into the
731        // themed ring: each ring's color matches the affordance for its profile.
732        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        // And high contrast is never less visible than the default profile.
745        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        // Tab -> focus next (2 -> 4) announces the newly focused pane.
776        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        // Nothing pending after taking.
780        assert!(controller.take_announcement().is_none());
781    }
782}