1use par_term_config::TabId;
14
15use super::window_state::WindowState;
16use crate::pane::PaneId;
17
18pub(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_id: TabId,
32 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 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 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 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 self.focus_state.needs_redraw = true;
122 self.request_redraw();
123 true
124 }
125
126 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 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 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 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 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 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 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 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}