Skip to main content

par_term/app/
pane_hint_select.rs

1//! Built-in pane-hint selection mode (tmux `display-panes` style).
2//!
3//! Invoking the `select_pane_hint` keybinding arms the mode: a letter badge
4//! (letter on a circle background) is drawn centered in every pane of the
5//! focused tab; typing a pane's letter focuses that pane and exits the mode;
6//! Escape or any non-matching key cancels without changing focus.
7//!
8//! Mode-stack contract (docs/plans/2026-09-24-overlay-plugin-design.md):
9//! badges render above every plugin overlay and, while active, the mode
10//! captures keys regardless of overlay focus. It is built into par-term, not
11//! a plugin.
12
13use par_term_config::TabId;
14
15use super::window_state::WindowState;
16use crate::pane::PaneId;
17
18/// Home-row letter pool for pane badges. Assigned in pane-tree order, so the
19/// same layout always yields the same letters.
20pub(crate) const PANE_HINT_LETTERS: &[char] = &[
21    'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p',
22    'z', 'x', 'c', 'v', 'b', 'n', 'm',
23];
24
25#[derive(Default)]
26pub(crate) enum PaneHintSelectState {
27    #[default]
28    Idle,
29    Selecting {
30        /// Tab whose panes the badges describe; leaving that tab cancels.
31        tab_id: TabId,
32        /// Letter → pane, in tree order at arm time.
33        assignments: Vec<(char, PaneId)>,
34    },
35}
36
37impl PaneHintSelectState {
38    pub(crate) fn is_active(&self) -> bool {
39        matches!(self, Self::Selecting { .. })
40    }
41}
42
43impl WindowState {
44    /// Arm the pane-hint selection mode for the focused tab. Does nothing for
45    /// tabs without a multi-pane layout — there is nothing to choose.
46    pub fn enter_pane_hint_select(&mut self) {
47        let Some(tab_id) = self.tab_manager.active_tab_id() else {
48            return;
49        };
50        let Some(tab) = self.tab_manager.get_tab(tab_id) else {
51            return;
52        };
53        if !tab.has_multiple_panes() {
54            return;
55        }
56        let Some(pm) = tab.pane_manager() else {
57            return;
58        };
59        let assignments: Vec<(char, PaneId)> = pm
60            .all_panes()
61            .iter()
62            .zip(PANE_HINT_LETTERS.iter())
63            .map(|(pane, &letter)| (letter, pane.id))
64            .collect();
65        if assignments.len() < 2 {
66            return;
67        }
68        self.pane_hint_select = PaneHintSelectState::Selecting {
69            tab_id,
70            assignments,
71        };
72        self.focus_state.needs_redraw = true;
73        self.request_redraw();
74    }
75
76    fn exit_pane_hint_select(&mut self) {
77        if self.pane_hint_select.is_active() {
78            self.pane_hint_select = PaneHintSelectState::Idle;
79            self.focus_state.needs_redraw = true;
80            self.request_redraw();
81        }
82    }
83
84    /// Handle a key press while the mode is armed. Returns true if the key
85    /// was consumed. Any press resolves the mode: a matching letter focuses
86    /// its pane, anything else (including Escape) cancels.
87    pub(crate) fn handle_pane_hint_select_key(&mut self, event: &winit::event::KeyEvent) -> bool {
88        if event.state != winit::event::ElementState::Pressed {
89            return false;
90        }
91        if let winit::keyboard::Key::Character(ch) = &event.logical_key {
92            self.resolve_pane_hint_select(ch.chars().next())
93        } else {
94            self.resolve_pane_hint_select(None)
95        }
96    }
97
98    /// Resolve the armed mode against a typed character (lowercase matched;
99    /// `None` = non-character key such as Escape — always a cancel).
100    /// `pub(crate)` so the ui-test chord injector can drive the mode's
101    /// resolution the way a real key event would (winit `KeyEvent`s cannot
102    /// be fabricated in tests).
103    pub(crate) fn resolve_pane_hint_select(&mut self, typed: Option<char>) -> bool {
104        let PaneHintSelectState::Selecting {
105            tab_id,
106            assignments,
107        } = std::mem::replace(&mut self.pane_hint_select, PaneHintSelectState::Idle)
108        else {
109            return false;
110        };
111
112        if let Some(c) = typed.map(|c| c.to_ascii_lowercase())
113            && let Some(&(_, pane_id)) = assignments.iter().find(|(letter, _)| *letter == c)
114            && self.tab_manager.active_tab_id() == Some(tab_id)
115            && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
116            && let Some(pm) = tab.pane_manager_mut()
117        {
118            pm.focus_pane(pane_id);
119        }
120        // Matching letter or not, the mode ends here; redraw either way.
121        self.focus_state.needs_redraw = true;
122        self.request_redraw();
123        true
124    }
125
126    /// Cancel the mode when its tab is no longer the active one (switch or
127    /// close) — called from the tab-change path.
128    pub(crate) fn cancel_pane_hint_select_if_stale(&mut self) {
129        let stale = match &self.pane_hint_select {
130            PaneHintSelectState::Idle => false,
131            PaneHintSelectState::Selecting { tab_id, .. } => {
132                self.tab_manager.active_tab_id() != Some(*tab_id)
133            }
134        };
135        if stale {
136            self.exit_pane_hint_select();
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::config::Config;
145    use crate::pane::{Pane, PaneNode, SplitDirection};
146    use crate::tab::Tab;
147    use par_term_terminal::TerminalManager;
148    use std::sync::Arc;
149    use std::sync::atomic::AtomicBool;
150    use tokio::sync::RwLock;
151
152    fn test_runtime() -> Arc<tokio::runtime::Runtime> {
153        Arc::new(
154            tokio::runtime::Builder::new_current_thread()
155                .enable_all()
156                .build()
157                .expect("build test runtime"),
158        )
159    }
160
161    /// A pane whose terminal has no shell spawned, so it runs without a PTY
162    /// on every supported platform. `working_directory` carries the marker.
163    fn stub_pane(id: PaneId, marker: &str) -> Pane {
164        let terminal = TerminalManager::new_with_scrollback(80, 24, 100)
165            .expect("stub terminal creation without a shell");
166        Pane::new_wrapping_terminal(
167            id,
168            Arc::new(RwLock::new(terminal)),
169            Some(marker.to_string()),
170            Arc::new(AtomicBool::new(false)),
171        )
172    }
173
174    /// A `WindowState` whose active tab holds two shell-less panes (ids 1
175    /// and 2, focused on 1) — the smallest layout the mode targets.
176    fn window_with_two_panes() -> WindowState {
177        let mut state = WindowState::new(Config::default(), test_runtime());
178
179        let mut tab = Tab::new_stub(1, 1);
180        let pm = tab
181            .pane_manager_mut()
182            .expect("a stub tab always has a pane manager");
183        pm.set_root(PaneNode::split(
184            SplitDirection::Vertical,
185            0.5,
186            PaneNode::leaf(stub_pane(1, "/one")),
187            PaneNode::leaf(stub_pane(2, "/two")),
188        ));
189        pm.focus_pane(1);
190
191        state.tab_manager.push_tab_for_test(tab);
192        state
193    }
194
195    fn focused_pane_id(state: &WindowState) -> Option<PaneId> {
196        state
197            .tab_manager
198            .active_tab()
199            .and_then(|t| t.pane_manager())
200            .and_then(|pm| pm.focused_pane_id())
201    }
202
203    #[test]
204    fn arming_draws_badges_and_a_letter_focuses_its_pane() {
205        let mut state = window_with_two_panes();
206
207        state.enter_pane_hint_select();
208        assert!(
209            state.pane_hint_select.is_active(),
210            "mode arms on a multi-pane tab"
211        );
212
213        // Home-row letters in tree order: pane 1 gets 'a', pane 2 gets 's'.
214        state.resolve_pane_hint_select(Some('s'));
215
216        assert!(
217            !state.pane_hint_select.is_active(),
218            "a match resolves the mode"
219        );
220        assert_eq!(focused_pane_id(&state), Some(2), "'s' focuses pane 2");
221    }
222
223    #[test]
224    fn uppercase_letters_match_their_pane() {
225        let mut state = window_with_two_panes();
226        state.enter_pane_hint_select();
227        state.resolve_pane_hint_select(Some('A'));
228        assert_eq!(focused_pane_id(&state), Some(1), "'A' matches pane 1's 'a'");
229        assert!(!state.pane_hint_select.is_active());
230    }
231
232    #[test]
233    fn escape_or_a_non_matching_key_cancels_without_changing_focus() {
234        let mut state = window_with_two_panes();
235        state.enter_pane_hint_select();
236
237        // 'z' is not in the two-pane assignment set ('a', 's'); Escape is a
238        // non-character key — both must cancel, leaving focus untouched.
239        state.resolve_pane_hint_select(Some('z'));
240        assert!(
241            !state.pane_hint_select.is_active(),
242            "a miss cancels the mode"
243        );
244        assert_eq!(focused_pane_id(&state), Some(1), "a miss keeps focus");
245
246        state.enter_pane_hint_select();
247        state.resolve_pane_hint_select(None);
248        assert!(
249            !state.pane_hint_select.is_active(),
250            "Escape cancels the mode"
251        );
252        assert_eq!(focused_pane_id(&state), Some(1));
253    }
254
255    #[test]
256    fn a_letter_for_a_pane_that_no_longer_exists_resolves_without_panicking() {
257        // Badges resolve from live pane state each frame, but the key press
258        // resolves against arm-time assignments. A pane closed while the
259        // mode was armed names an id no lookup can return — the resolution
260        // must be a no-op focus, not a panic.
261        let mut state = window_with_two_panes();
262        state.enter_pane_hint_select();
263
264        let tab = state
265            .tab_manager
266            .get_tab_mut(1)
267            .and_then(|t| t.pane_manager_mut())
268            .expect("stub tab has a pane manager");
269        let Ok(remap) = tab.insert_subtree_at(
270            1,
271            PaneNode::leaf(stub_pane(9, "/new")),
272            SplitDirection::Vertical,
273            0.5,
274        ) else {
275            panic!("inserting into pane 1 of a two-pane tab succeeds");
276        };
277        let _ = remap;
278
279        state.resolve_pane_hint_select(Some('s'));
280        assert!(!state.pane_hint_select.is_active());
281    }
282
283    #[test]
284    fn a_single_pane_tab_does_not_arm_the_mode() {
285        let mut state = WindowState::new(Config::default(), test_runtime());
286        state.tab_manager.push_tab_for_test(Tab::new_stub(1, 1));
287
288        state.enter_pane_hint_select();
289        assert!(!state.pane_hint_select.is_active(), "nothing to choose");
290    }
291
292    #[test]
293    fn leaving_the_tab_cancels_the_mode() {
294        let mut state = window_with_two_panes();
295        state.enter_pane_hint_select();
296
297        state.tab_manager.push_tab_for_test(Tab::new_stub(2, 2));
298        state.tab_manager.switch_to(2);
299        // The about-to-wait sweep is what observes the switch.
300        state.cancel_pane_hint_select_if_stale();
301        assert!(
302            !state.pane_hint_select.is_active(),
303            "switching tabs cancels the mode"
304        );
305    }
306
307    #[test]
308    fn letters_are_deterministic_per_layout() {
309        // Tree order drives assignment, so the same layout must always arm
310        // with the same letters — 'a' for pane 1, 's' for pane 2.
311        for _ in 0..3 {
312            let mut state = window_with_two_panes();
313            state.enter_pane_hint_select();
314            let PaneHintSelectState::Selecting { assignments, .. } = &state.pane_hint_select else {
315                panic!("mode arms");
316            };
317            assert_eq!(
318                assignments,
319                &vec![('a', PaneId::from(1u64)), ('s', PaneId::from(2u64))],
320            );
321        }
322    }
323}