use par_term_config::TabId;
use super::window_state::WindowState;
use crate::pane::PaneId;
pub(crate) const PANE_HINT_LETTERS: &[char] = &[
'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p',
'z', 'x', 'c', 'v', 'b', 'n', 'm',
];
pub(crate) fn pane_hint_labels(count: usize) -> Vec<String> {
let n = PANE_HINT_LETTERS.len();
if count <= n {
return PANE_HINT_LETTERS[..count]
.iter()
.map(|c| c.to_string())
.collect();
}
(0..count.min(n * n))
.map(|i| {
let mut s = String::with_capacity(2);
s.push(PANE_HINT_LETTERS[i / n]);
s.push(PANE_HINT_LETTERS[i % n]);
s
})
.collect()
}
#[derive(Default)]
pub(crate) enum PaneHintSelectState {
#[default]
Idle,
Selecting {
tab_id: TabId,
assignments: Vec<(String, PaneId)>,
typed: String,
},
}
impl PaneHintSelectState {
pub(crate) fn is_active(&self) -> bool {
matches!(self, Self::Selecting { .. })
}
}
pub(crate) const PANE_HINT_BANNER: &str = "Pane hints: type a letter · Esc cancels";
impl WindowState {
pub(crate) fn pane_hint_banner(&self) -> Option<&'static str> {
self.pane_hint_select
.is_active()
.then_some(PANE_HINT_BANNER)
}
pub fn enter_pane_hint_select(&mut self) {
let Some(tab_id) = self.tab_manager.active_tab_id() else {
return;
};
let Some(tab) = self.tab_manager.get_tab(tab_id) else {
return;
};
if !tab.has_multiple_panes() {
self.show_toast("Pane hints: the tab has only one pane");
return;
}
let Some(pm) = tab.pane_manager() else {
return;
};
let panes = pm.all_panes();
let assignments: Vec<(String, PaneId)> = pane_hint_labels(panes.len())
.into_iter()
.zip(panes.iter())
.map(|(label, pane)| (label, pane.id))
.collect();
if assignments.len() < 2 {
return;
}
self.pane_hint_select = PaneHintSelectState::Selecting {
tab_id,
assignments,
typed: String::new(),
};
self.focus_state.needs_redraw = true;
self.request_redraw();
}
fn exit_pane_hint_select(&mut self) {
if self.pane_hint_select.is_active() {
self.pane_hint_select = PaneHintSelectState::Idle;
self.focus_state.needs_redraw = true;
self.request_redraw();
}
}
pub(crate) fn handle_pane_hint_select_key(&mut self, event: &winit::event::KeyEvent) -> bool {
if event.state != winit::event::ElementState::Pressed {
return false;
}
if let winit::keyboard::Key::Character(ch) = &event.logical_key {
self.resolve_pane_hint_select(ch.chars().next())
} else {
self.resolve_pane_hint_select(None)
}
}
pub(crate) fn resolve_pane_hint_select(&mut self, typed: Option<char>) -> bool {
let PaneHintSelectState::Selecting {
tab_id,
assignments,
typed: mut so_far,
} = std::mem::replace(&mut self.pane_hint_select, PaneHintSelectState::Idle)
else {
return false;
};
if let Some(c) = typed {
so_far.push(c.to_ascii_lowercase());
}
if typed.is_some()
&& !assignments.iter().any(|(label, _)| *label == so_far)
&& assignments
.iter()
.any(|(label, _)| label.starts_with(&so_far))
{
self.pane_hint_select = PaneHintSelectState::Selecting {
tab_id,
assignments,
typed: so_far,
};
self.focus_state.needs_redraw = true;
self.request_redraw();
return true;
}
if typed.is_some()
&& let Some((_, pane_id)) = assignments.iter().find(|(label, _)| *label == so_far)
&& let pane_id = *pane_id
&& self.tab_manager.active_tab_id() == Some(tab_id)
&& let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
&& let Some(pm) = tab.pane_manager_mut()
{
pm.focus_pane(pane_id);
self.after_user_pane_focus();
}
self.focus_state.needs_redraw = true;
self.request_redraw();
true
}
pub(crate) fn cancel_pane_hint_select_if_stale(&mut self) {
let stale = match &self.pane_hint_select {
PaneHintSelectState::Idle => false,
PaneHintSelectState::Selecting { tab_id, .. } => {
self.tab_manager.active_tab_id() != Some(*tab_id)
}
};
if stale {
self.exit_pane_hint_select();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::pane::{Pane, PaneNode, SplitDirection};
use crate::tab::Tab;
use par_term_terminal::TerminalManager;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tokio::sync::RwLock;
fn test_runtime() -> Arc<tokio::runtime::Runtime> {
Arc::new(
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build test runtime"),
)
}
fn stub_pane(id: PaneId, marker: &str) -> Pane {
let terminal = TerminalManager::new_with_scrollback(80, 24, 100)
.expect("stub terminal creation without a shell");
Pane::new_wrapping_terminal(
id,
Arc::new(RwLock::new(terminal)),
Some(marker.to_string()),
Arc::new(AtomicBool::new(false)),
)
}
fn window_with_two_panes() -> WindowState {
let mut state = WindowState::new(Config::default(), test_runtime());
let mut tab = Tab::new_stub(1, 1);
let pm = tab
.pane_manager_mut()
.expect("a stub tab always has a pane manager");
pm.set_root(PaneNode::split(
SplitDirection::Vertical,
0.5,
PaneNode::leaf(stub_pane(1, "/one")),
PaneNode::leaf(stub_pane(2, "/two")),
));
pm.focus_pane(1);
state.tab_manager.push_tab_for_test(tab);
state
}
fn focused_pane_id(state: &WindowState) -> Option<PaneId> {
state
.tab_manager
.active_tab()
.and_then(|t| t.pane_manager())
.and_then(|pm| pm.focused_pane_id())
}
#[test]
fn arming_draws_badges_and_a_letter_focuses_its_pane() {
let mut state = window_with_two_panes();
state.enter_pane_hint_select();
assert!(
state.pane_hint_select.is_active(),
"mode arms on a multi-pane tab"
);
state.resolve_pane_hint_select(Some('s'));
assert!(
!state.pane_hint_select.is_active(),
"a match resolves the mode"
);
assert_eq!(focused_pane_id(&state), Some(2), "'s' focuses pane 2");
}
#[test]
fn uppercase_letters_match_their_pane() {
let mut state = window_with_two_panes();
state.enter_pane_hint_select();
state.resolve_pane_hint_select(Some('A'));
assert_eq!(focused_pane_id(&state), Some(1), "'A' matches pane 1's 'a'");
assert!(!state.pane_hint_select.is_active());
}
#[test]
fn escape_or_a_non_matching_key_cancels_without_changing_focus() {
let mut state = window_with_two_panes();
state.enter_pane_hint_select();
state.resolve_pane_hint_select(Some('z'));
assert!(
!state.pane_hint_select.is_active(),
"a miss cancels the mode"
);
assert_eq!(focused_pane_id(&state), Some(1), "a miss keeps focus");
state.enter_pane_hint_select();
state.resolve_pane_hint_select(None);
assert!(
!state.pane_hint_select.is_active(),
"Escape cancels the mode"
);
assert_eq!(focused_pane_id(&state), Some(1));
}
#[test]
fn a_letter_for_a_pane_that_no_longer_exists_resolves_without_panicking() {
let mut state = window_with_two_panes();
state.enter_pane_hint_select();
let tab = state
.tab_manager
.get_tab_mut(1)
.and_then(|t| t.pane_manager_mut())
.expect("stub tab has a pane manager");
let Ok(remap) = tab.insert_subtree_at(
1,
PaneNode::leaf(stub_pane(9, "/new")),
SplitDirection::Vertical,
0.5,
) else {
panic!("inserting into pane 1 of a two-pane tab succeeds");
};
let _ = remap;
state.resolve_pane_hint_select(Some('s'));
assert!(!state.pane_hint_select.is_active());
}
#[test]
fn a_single_pane_tab_does_not_arm_the_mode() {
let mut state = WindowState::new(Config::default(), test_runtime());
state.tab_manager.push_tab_for_test(Tab::new_stub(1, 1));
state.enter_pane_hint_select();
assert!(!state.pane_hint_select.is_active(), "nothing to choose");
assert_eq!(
state.last_toast_text(),
Some("Pane hints: the tab has only one pane")
);
}
#[test]
fn the_armed_mode_explains_itself() {
let mut state = window_with_two_panes();
assert_eq!(state.pane_hint_banner(), None);
state.enter_pane_hint_select();
assert_eq!(state.pane_hint_banner(), Some(PANE_HINT_BANNER));
state.resolve_pane_hint_select(None);
assert_eq!(state.pane_hint_banner(), None);
}
#[test]
fn leaving_the_tab_cancels_the_mode() {
let mut state = window_with_two_panes();
state.enter_pane_hint_select();
state.tab_manager.push_tab_for_test(Tab::new_stub(2, 2));
state.tab_manager.switch_to(2);
state.cancel_pane_hint_select_if_stale();
assert!(
!state.pane_hint_select.is_active(),
"switching tabs cancels the mode"
);
}
#[test]
fn letters_are_deterministic_per_layout() {
for _ in 0..3 {
let mut state = window_with_two_panes();
state.enter_pane_hint_select();
let PaneHintSelectState::Selecting { assignments, .. } = &state.pane_hint_select else {
panic!("mode arms");
};
assert_eq!(
assignments,
&vec![
("a".to_string(), PaneId::from(1u64)),
("s".to_string(), PaneId::from(2u64))
],
);
}
}
#[test]
fn labels_are_single_up_to_26_and_all_double_beyond() {
let few = pane_hint_labels(3);
assert_eq!(few, vec!["a", "s", "d"]);
let full = pane_hint_labels(26);
assert!(full.iter().all(|l| l.len() == 1));
let many = pane_hint_labels(30);
assert_eq!(many.len(), 30);
assert!(many.iter().all(|l| l.len() == 2));
assert_eq!(&many[..3], &["aa", "as", "ad"]);
for (i, a) in many.iter().enumerate() {
for b in &many[i + 1..] {
assert!(!b.starts_with(a.as_str()) && !a.starts_with(b.as_str()));
}
}
}
#[test]
fn two_letter_labels_resolve_over_two_key_presses() {
let mut state = WindowState::new(Config::default(), test_runtime());
let mut tab = Tab::new_stub(1, 1);
let pm = tab.pane_manager_mut().expect("pm");
let mut node = PaneNode::leaf(stub_pane(30, "/30"));
for id in (1..30).rev() {
node = PaneNode::split(
SplitDirection::Vertical,
0.5,
PaneNode::leaf(stub_pane(id, "/p")),
node,
);
}
pm.set_root(node);
pm.focus_pane(1);
state.tab_manager.push_tab_for_test(tab);
state.enter_pane_hint_select();
assert!(state.resolve_pane_hint_select(Some('s')));
assert!(state.pane_hint_select.is_active(), "one letter is a prefix");
state.resolve_pane_hint_select(Some('S'));
assert!(!state.pane_hint_select.is_active());
assert_eq!(focused_pane_id(&state), Some(28));
state.enter_pane_hint_select();
state.resolve_pane_hint_select(Some('1'));
assert!(!state.pane_hint_select.is_active(), "not a prefix: cancel");
assert_eq!(focused_pane_id(&state), Some(28));
}
}