use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
use ftui_layout::{
PaneAccessibilityPreferences, PaneAffordanceMotion, PaneAnnouncement, PaneAnnouncer,
PaneCardinalDirection, PaneCommand, PaneCommandAcceleration, PaneCommandEffect,
PaneCommandResolution, PaneFocusContext, PaneFocusOrdinal, PaneId, PaneLayout,
PaneResizeDirection, PaneTree, Rect, SplitAxis, announce_command, resolve_pane_command,
};
use ftui_render::cell::{Cell, PackedRgba};
use ftui_render::drawing::{BorderChars, Draw};
use ftui_style::{PaneAffordanceTheme, ResolvedTheme};
#[must_use]
pub fn key_to_pane_command(key: &KeyEvent, resize_units: u16) -> Option<PaneCommand> {
if key.kind == KeyEventKind::Release {
return None;
}
let m = key.modifiers;
let ctrl = m.contains(Modifiers::CTRL);
let alt = m.contains(Modifiers::ALT);
let shift = m.contains(Modifiers::SHIFT);
let unmodified = !ctrl && !alt && !m.contains(Modifiers::SUPER);
match key.code {
KeyCode::Tab if unmodified && !shift => Some(PaneCommand::FocusNext),
KeyCode::BackTab => Some(PaneCommand::FocusPrevious),
KeyCode::Left if ctrl && shift => Some(PaneCommand::FocusEdge(PaneCardinalDirection::Left)),
KeyCode::Right if ctrl && shift => {
Some(PaneCommand::FocusEdge(PaneCardinalDirection::Right))
}
KeyCode::Up if ctrl && shift => Some(PaneCommand::FocusEdge(PaneCardinalDirection::Up)),
KeyCode::Down if ctrl && shift => Some(PaneCommand::FocusEdge(PaneCardinalDirection::Down)),
KeyCode::Left if ctrl => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Left)),
KeyCode::Right if ctrl => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Right)),
KeyCode::Up if ctrl => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Up)),
KeyCode::Down if ctrl => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Down)),
KeyCode::Left if alt => Some(PaneCommand::MovePane(PaneCardinalDirection::Left)),
KeyCode::Right if alt => Some(PaneCommand::MovePane(PaneCardinalDirection::Right)),
KeyCode::Up if alt => Some(PaneCommand::MovePane(PaneCardinalDirection::Up)),
KeyCode::Down if alt => Some(PaneCommand::MovePane(PaneCardinalDirection::Down)),
KeyCode::Char('+' | '=') if alt => Some(PaneCommand::ResizeStep {
direction: PaneResizeDirection::Increase,
units: resize_units,
}),
KeyCode::Char('-' | '_') if alt => Some(PaneCommand::ResizeStep {
direction: PaneResizeDirection::Decrease,
units: resize_units,
}),
KeyCode::Char('s' | 'S') if alt => Some(PaneCommand::Split(SplitAxis::Horizontal)),
KeyCode::Char('v' | 'V') if alt => Some(PaneCommand::Split(SplitAxis::Vertical)),
KeyCode::Char('w' | 'W') if alt => Some(PaneCommand::Close),
KeyCode::Char('[') if alt => Some(PaneCommand::SwapPane(PaneFocusOrdinal::Previous)),
KeyCode::Char(']') if alt => Some(PaneCommand::SwapPane(PaneFocusOrdinal::Next)),
KeyCode::Char('z' | 'Z') if alt => Some(PaneCommand::Maximize),
KeyCode::Char('r' | 'R') if alt => Some(PaneCommand::Restore),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaneKeyOutcome {
Unbound,
Handled {
command: PaneCommand,
resolution: PaneCommandResolution,
},
Failed {
command: PaneCommand,
},
}
#[derive(Debug, Clone)]
pub struct PaneKeyboardController {
focus: PaneFocusContext,
acceleration: PaneCommandAcceleration,
op_seed: u64,
repeat_count: u16,
announcer: PaneAnnouncer,
preferences: PaneAccessibilityPreferences,
}
impl PaneKeyboardController {
#[must_use]
pub fn new(active: Option<PaneId>) -> Self {
Self {
focus: PaneFocusContext {
active,
maximized: None,
},
acceleration: PaneCommandAcceleration::default(),
op_seed: 0,
repeat_count: 0,
announcer: PaneAnnouncer::new(),
preferences: PaneAccessibilityPreferences::none(),
}
}
pub fn take_announcement(&mut self) -> Option<PaneAnnouncement> {
self.announcer.take()
}
#[must_use]
pub fn pending_announcement(&self) -> Option<&PaneAnnouncement> {
self.announcer.pending()
}
#[must_use]
pub const fn with_acceleration(mut self, acceleration: PaneCommandAcceleration) -> Self {
self.acceleration = acceleration;
self
}
#[must_use]
pub const fn with_preferences(mut self, preferences: PaneAccessibilityPreferences) -> Self {
self.preferences = preferences;
self
}
pub const fn set_preferences(&mut self, preferences: PaneAccessibilityPreferences) {
self.preferences = preferences;
}
#[must_use]
pub const fn preferences(&self) -> PaneAccessibilityPreferences {
self.preferences
}
#[must_use]
pub fn affordance_motion(&self) -> PaneAffordanceMotion {
self.preferences.affordance_motion()
}
#[must_use]
pub fn focus_ring(&self, theme: &ResolvedTheme) -> PaneFocusRing {
let affordance = PaneAffordanceTheme::from_resolved(theme, self.preferences.high_contrast);
PaneFocusRing::themed(&affordance)
}
#[must_use]
pub const fn focus(&self) -> PaneFocusContext {
self.focus
}
#[must_use]
pub const fn active(&self) -> Option<PaneId> {
self.focus.active
}
#[must_use]
pub const fn maximized(&self) -> Option<PaneId> {
self.focus.maximized
}
pub const fn set_active(&mut self, active: Option<PaneId>) {
self.focus.active = active;
}
pub fn handle_key(
&mut self,
key: &KeyEvent,
tree: &mut PaneTree,
layout: &PaneLayout,
) -> PaneKeyOutcome {
if key.kind == KeyEventKind::Repeat {
self.repeat_count = self.repeat_count.saturating_add(1);
} else {
self.repeat_count = 0;
}
let resize_units = self.acceleration.units_for(self.repeat_count);
let Some(command) = key_to_pane_command(key, resize_units) else {
return PaneKeyOutcome::Unbound;
};
let resolution = resolve_pane_command(tree, layout, self.focus, command);
if let PaneCommandEffect::Structural(ops) = &resolution.effect {
for op in ops {
if tree.apply_operation(self.op_seed, op.clone()).is_err() {
return PaneKeyOutcome::Failed { command };
}
self.op_seed += 1;
}
}
self.focus.active = resolution.next_active;
self.focus.maximized = resolution.next_maximized;
self.announcer
.offer(announce_command(command, &resolution, tree));
PaneKeyOutcome::Handled {
command,
resolution,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PaneFocusRing {
pub border: BorderChars,
pub cell: Cell,
}
impl Default for PaneFocusRing {
fn default() -> Self {
Self {
border: BorderChars::DOUBLE,
cell: Cell::from_char(' ').with_fg(PackedRgba::rgb(0xFF, 0xD7, 0x00)),
}
}
}
impl PaneFocusRing {
#[must_use]
pub fn themed(affordance: &PaneAffordanceTheme) -> Self {
let rgb = affordance.focus_ring.to_rgb();
Self {
border: BorderChars::DOUBLE,
cell: Cell::from_char(' ').with_fg(PackedRgba::rgb(rgb.r, rgb.g, rgb.b)),
}
}
}
pub fn render_pane_focus_ring<T: Draw>(target: &mut T, rect: Rect, ring: &PaneFocusRing) {
target.draw_border(rect, ring.border, ring.cell);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PaneKeyHint {
pub keys: &'static str,
pub action: &'static str,
}
#[must_use]
pub fn pane_keyboard_hints() -> &'static [PaneKeyHint] {
&[
PaneKeyHint {
keys: "Tab / Shift+Tab",
action: "focus next / previous pane",
},
PaneKeyHint {
keys: "Ctrl+Arrows",
action: "focus pane by direction",
},
PaneKeyHint {
keys: "Ctrl+Shift+Arrows",
action: "focus pane at edge",
},
PaneKeyHint {
keys: "Alt+Arrows",
action: "move pane",
},
PaneKeyHint {
keys: "Alt++ / Alt+-",
action: "grow / shrink pane",
},
PaneKeyHint {
keys: "Alt+s / Alt+v",
action: "split horizontal / vertical",
},
PaneKeyHint {
keys: "Alt+w",
action: "close pane",
},
PaneKeyHint {
keys: "Alt+[ / Alt+]",
action: "swap with previous / next pane",
},
PaneKeyHint {
keys: "Alt+z / Alt+r",
action: "maximize / restore pane",
},
]
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_layout::{
PANE_TREE_SCHEMA_VERSION, PaneLeaf, PaneNodeRecord, PaneSplit, PaneSplitRatio,
PaneTreeSnapshot,
};
use ftui_render::buffer::Buffer;
use std::collections::BTreeMap;
fn pid(raw: u64) -> PaneId {
PaneId::new(raw).expect("non-zero id")
}
fn key(code: KeyCode, modifiers: Modifiers) -> KeyEvent {
KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press,
}
}
fn nested() -> PaneTree {
let snapshot = PaneTreeSnapshot {
schema_version: PANE_TREE_SCHEMA_VERSION,
root: pid(1),
next_id: pid(6),
nodes: vec![
PaneNodeRecord::split(
pid(1),
None,
PaneSplit {
axis: SplitAxis::Horizontal,
ratio: PaneSplitRatio::new(1, 1).unwrap(),
first: pid(2),
second: pid(3),
},
),
PaneNodeRecord::leaf(pid(2), Some(pid(1)), PaneLeaf::new("left")),
PaneNodeRecord::split(
pid(3),
Some(pid(1)),
PaneSplit {
axis: SplitAxis::Vertical,
ratio: PaneSplitRatio::new(1, 1).unwrap(),
first: pid(4),
second: pid(5),
},
),
PaneNodeRecord::leaf(pid(4), Some(pid(3)), PaneLeaf::new("right_top")),
PaneNodeRecord::leaf(pid(5), Some(pid(3)), PaneLeaf::new("right_bottom")),
],
extensions: BTreeMap::new(),
};
PaneTree::from_snapshot(snapshot).expect("valid tree")
}
#[test]
fn keymap_covers_the_full_vocabulary() {
let none = Modifiers::NONE;
let ctrl = Modifiers::CTRL;
let alt = Modifiers::ALT;
let ctrl_shift = Modifiers::CTRL | Modifiers::SHIFT;
assert_eq!(
key_to_pane_command(&key(KeyCode::Tab, none), 1),
Some(PaneCommand::FocusNext)
);
assert_eq!(
key_to_pane_command(&key(KeyCode::BackTab, Modifiers::SHIFT), 1),
Some(PaneCommand::FocusPrevious)
);
assert_eq!(
key_to_pane_command(&key(KeyCode::Left, ctrl), 1),
Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Left))
);
assert_eq!(
key_to_pane_command(&key(KeyCode::Down, ctrl_shift), 1),
Some(PaneCommand::FocusEdge(PaneCardinalDirection::Down))
);
assert_eq!(
key_to_pane_command(&key(KeyCode::Right, alt), 1),
Some(PaneCommand::MovePane(PaneCardinalDirection::Right))
);
assert_eq!(
key_to_pane_command(&key(KeyCode::Char('+'), alt), 4),
Some(PaneCommand::ResizeStep {
direction: PaneResizeDirection::Increase,
units: 4
})
);
assert_eq!(
key_to_pane_command(&key(KeyCode::Char('-'), alt), 1),
Some(PaneCommand::ResizeStep {
direction: PaneResizeDirection::Decrease,
units: 1
})
);
assert_eq!(
key_to_pane_command(&key(KeyCode::Char('s'), alt), 1),
Some(PaneCommand::Split(SplitAxis::Horizontal))
);
assert_eq!(
key_to_pane_command(&key(KeyCode::Char('v'), alt), 1),
Some(PaneCommand::Split(SplitAxis::Vertical))
);
assert_eq!(
key_to_pane_command(&key(KeyCode::Char('w'), alt), 1),
Some(PaneCommand::Close)
);
assert_eq!(
key_to_pane_command(&key(KeyCode::Char('['), alt), 1),
Some(PaneCommand::SwapPane(PaneFocusOrdinal::Previous))
);
assert_eq!(
key_to_pane_command(&key(KeyCode::Char('z'), alt), 1),
Some(PaneCommand::Maximize)
);
assert_eq!(
key_to_pane_command(&key(KeyCode::Char('r'), alt), 1),
Some(PaneCommand::Restore)
);
}
#[test]
fn keymap_does_not_steal_plain_or_app_keys() {
assert_eq!(
key_to_pane_command(&key(KeyCode::Char('a'), Modifiers::NONE), 1),
None
);
assert_eq!(
key_to_pane_command(&key(KeyCode::Left, Modifiers::NONE), 1),
None
);
assert_eq!(
key_to_pane_command(&key(KeyCode::Char('c'), Modifiers::CTRL), 1),
None
);
let mut release = key(KeyCode::Tab, Modifiers::NONE);
release.kind = KeyEventKind::Release;
assert_eq!(key_to_pane_command(&release, 1), None);
}
#[test]
fn controller_drives_pointer_free_workflow() {
fn run() -> (u64, Option<PaneId>, Option<PaneId>) {
let mut tree = nested();
let mut controller = PaneKeyboardController::new(Some(pid(2)));
let area = Rect::new(0, 0, 80, 24);
let solve = |t: &PaneTree| t.solve_layout(area).expect("solves");
let layout = solve(&tree);
let out =
controller.handle_key(&key(KeyCode::Tab, Modifiers::NONE), &mut tree, &layout);
assert!(matches!(out, PaneKeyOutcome::Handled { .. }));
assert_eq!(controller.active(), Some(pid(4)));
let layout = solve(&tree);
controller.handle_key(&key(KeyCode::Down, Modifiers::CTRL), &mut tree, &layout);
assert_eq!(controller.active(), Some(pid(5)));
let layout = solve(&tree);
let before = tree.nodes().count();
controller.handle_key(&key(KeyCode::Char('s'), Modifiers::ALT), &mut tree, &layout);
assert!(tree.nodes().count() > before, "split must grow the tree");
let layout = solve(&tree);
controller.handle_key(&key(KeyCode::Char('z'), Modifiers::ALT), &mut tree, &layout);
assert!(controller.maximized().is_some());
let layout = solve(&tree);
controller.handle_key(&key(KeyCode::Char('r'), Modifiers::ALT), &mut tree, &layout);
assert_eq!(controller.maximized(), None);
(
tree.state_hash(),
controller.active(),
controller.maximized(),
)
}
let first = run();
let second = run();
assert_eq!(
first, second,
"keyboard-only workflow must be deterministic"
);
}
#[test]
fn controller_close_updates_focus_to_survivor() {
let mut tree = nested();
let mut controller = PaneKeyboardController::new(Some(pid(2)));
let layout = tree.solve_layout(Rect::new(0, 0, 80, 24)).expect("solves");
let out =
controller.handle_key(&key(KeyCode::Char('w'), Modifiers::ALT), &mut tree, &layout);
match out {
PaneKeyOutcome::Handled { command, .. } => {
assert_eq!(command, PaneCommand::Close);
}
other => panic!("expected handled close, got {other:?}"),
}
assert_ne!(controller.active(), Some(pid(2)));
assert!(controller.active().is_some());
}
#[test]
fn unbound_key_does_not_touch_state() {
let mut tree = nested();
let mut controller = PaneKeyboardController::new(Some(pid(2)));
let layout = tree.solve_layout(Rect::new(0, 0, 80, 24)).expect("solves");
let before = tree.state_hash();
let out = controller.handle_key(
&key(KeyCode::Char('q'), Modifiers::NONE),
&mut tree,
&layout,
);
assert_eq!(out, PaneKeyOutcome::Unbound);
assert_eq!(controller.active(), Some(pid(2)));
assert_eq!(tree.state_hash(), before);
}
#[test]
fn repeat_accelerates_resize_units() {
let mut tree = nested();
let mut controller = PaneKeyboardController::new(Some(pid(2)));
let layout = tree.solve_layout(Rect::new(0, 0, 80, 24)).expect("solves");
let mut repeat = key(KeyCode::Char('+'), Modifiers::ALT);
repeat.kind = KeyEventKind::Repeat;
let mut last_units = 0u16;
for _ in 0..4 {
if let PaneKeyOutcome::Handled {
command: PaneCommand::ResizeStep { units, .. },
..
} = controller.handle_key(&repeat, &mut tree, &layout)
{
last_units = units;
}
}
assert_eq!(
last_units,
PaneCommandAcceleration::default().accelerated_units
);
}
#[test]
fn focus_ring_draws_a_distinct_border() {
let mut buffer = Buffer::new(10, 6);
render_pane_focus_ring(
&mut buffer,
Rect::new(0, 0, 6, 4),
&PaneFocusRing::default(),
);
assert_eq!(
buffer.get(0, 0).and_then(|c| c.content.as_char()),
Some('╔')
);
assert_eq!(
buffer.get(5, 3).and_then(|c| c.content.as_char()),
Some('╝')
);
}
#[test]
fn themed_focus_ring_uses_the_affordance_color() {
use ftui_style::theme::themes;
let resolved = themes::dark().resolve(true);
let affordance = PaneAffordanceTheme::from_resolved(&resolved, false);
let ring = PaneFocusRing::themed(&affordance);
let want = affordance.focus_ring.to_rgb();
assert_eq!(ring.cell.fg, PackedRgba::rgb(want.r, want.g, want.b));
assert_ne!(ring.cell.fg, PaneFocusRing::default().cell.fg);
assert_eq!(ring.border, BorderChars::DOUBLE);
}
#[test]
fn themed_focus_ring_high_contrast_is_at_least_as_visible() {
use ftui_style::theme::themes;
let resolved = themes::solarized_light().resolve(false);
let normal = PaneAffordanceTheme::from_resolved(&resolved, false);
let high = PaneAffordanceTheme::from_resolved(&resolved, true);
let ratio = |c: ftui_style::color::Color| {
ftui_style::color::contrast_ratio(c.to_rgb(), resolved.surface.to_rgb())
};
assert!(ratio(high.focus_ring) + 1e-9 >= ratio(normal.focus_ring));
}
#[test]
fn preferences_do_not_alter_keyboard_semantics() {
use ftui_layout::PaneAccessibilityPreferences;
fn drive(
prefs: PaneAccessibilityPreferences,
) -> (u64, Option<PaneId>, Option<PaneId>, Vec<String>) {
let mut tree = nested();
let mut controller = PaneKeyboardController::new(Some(pid(2))).with_preferences(prefs);
let area = Rect::new(0, 0, 80, 24);
let seq = [
key(KeyCode::Tab, Modifiers::NONE),
key(KeyCode::Down, Modifiers::CTRL),
key(KeyCode::Char('s'), Modifiers::ALT),
key(KeyCode::Char('z'), Modifiers::ALT),
key(KeyCode::Char('r'), Modifiers::ALT),
];
let mut announcements = Vec::new();
for k in seq {
let layout = tree.solve_layout(area).expect("solves");
controller.handle_key(&k, &mut tree, &layout);
if let Some(a) = controller.take_announcement() {
announcements.push(a.text);
}
}
(
tree.state_hash(),
controller.active(),
controller.maximized(),
announcements,
)
}
let plain = drive(PaneAccessibilityPreferences::none());
let full = drive(PaneAccessibilityPreferences::all());
assert_eq!(plain, full, "a11y modes must not change pane semantics");
}
#[test]
fn focus_ring_honors_high_contrast_preference() {
use ftui_layout::PaneAccessibilityPreferences;
use ftui_style::theme::themes;
let theme = themes::dark().resolve(true);
let normal_aff = PaneAffordanceTheme::from_resolved(&theme, false);
let high_aff = PaneAffordanceTheme::from_resolved(&theme, true);
let normal_ctl = PaneKeyboardController::new(Some(pid(2)));
let high_ctl = PaneKeyboardController::new(Some(pid(2)))
.with_preferences(PaneAccessibilityPreferences::none().with_high_contrast(true));
assert_eq!(
normal_ctl.focus_ring(&theme).cell.fg,
PaneFocusRing::themed(&normal_aff).cell.fg
);
assert_eq!(
high_ctl.focus_ring(&theme).cell.fg,
PaneFocusRing::themed(&high_aff).cell.fg
);
let ratio = |c: ftui_style::color::Color| {
ftui_style::color::contrast_ratio(c.to_rgb(), theme.surface.to_rgb())
};
assert!(ratio(high_aff.focus_ring) + 1e-9 >= ratio(normal_aff.focus_ring));
}
#[test]
fn controller_affordance_motion_honors_reduced_motion() {
use ftui_layout::PaneAccessibilityPreferences;
let reduced = PaneKeyboardController::new(None)
.with_preferences(PaneAccessibilityPreferences::none().with_reduced_motion(true));
assert!(reduced.affordance_motion().reduced_motion);
let plain = PaneKeyboardController::new(None);
assert!(!plain.affordance_motion().reduced_motion);
}
#[test]
fn hints_cover_every_binding_family() {
let hints = pane_keyboard_hints();
assert_eq!(hints.len(), 9);
assert!(hints.iter().any(|h| h.action.contains("focus next")));
assert!(hints.iter().any(|h| h.action.contains("split")));
assert!(hints.iter().any(|h| h.action.contains("maximize")));
}
#[test]
fn controller_surfaces_announcements() {
let mut tree = nested();
let mut controller = PaneKeyboardController::new(Some(pid(2)));
let layout = tree.solve_layout(Rect::new(0, 0, 80, 24)).expect("solves");
controller.handle_key(&key(KeyCode::Tab, Modifiers::NONE), &mut tree, &layout);
let spoken = controller.take_announcement().expect("focus announces");
assert_eq!(spoken.text, "Focused pane right_top");
assert!(controller.take_announcement().is_none());
}
}