use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use std::path::PathBuf; use zellij_tile::prelude::*; #[derive(Default)] pub struct NewSessionInfo { name: String, layout_list: LayoutList, entering_new_session_info: EnteringState, pub is_welcome_screen: bool, pub new_session_folder: Option, } #[derive(Eq, PartialEq)] enum EnteringState { EnteringName, EnteringLayoutSearch, } impl Default for EnteringState { fn default() -> Self { EnteringState::EnteringName } } impl NewSessionInfo { pub fn name(&self) -> &str { &self.name } pub fn layout_search_term(&self) -> &str { &self.layout_list.layout_search_term } pub fn entering_new_session_name(&self) -> bool { self.entering_new_session_info == EnteringState::EnteringName } pub fn entering_layout_search_term(&self) -> bool { self.entering_new_session_info == EnteringState::EnteringLayoutSearch } pub fn add_char(&mut self, character: char) { match self.entering_new_session_info { EnteringState::EnteringName => { self.name.push(character); }, EnteringState::EnteringLayoutSearch => { self.layout_list.layout_search_term.push(character); self.update_layout_search_term(); }, } } pub fn handle_backspace(&mut self) { match self.entering_new_session_info { EnteringState::EnteringName => { self.name.pop(); }, EnteringState::EnteringLayoutSearch => { self.layout_list.layout_search_term.pop(); self.update_layout_search_term(); }, } } pub fn handle_break(&mut self) { match self.entering_new_session_info { EnteringState::EnteringName => { self.name.clear(); }, EnteringState::EnteringLayoutSearch => { self.layout_list.layout_search_term.clear(); self.entering_new_session_info = EnteringState::EnteringName; self.update_layout_search_term(); }, } } pub fn handle_key(&mut self, key: KeyWithModifier) { match key.bare_key { BareKey::Backspace if key.has_no_modifiers() => { self.handle_backspace(); }, BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Ctrl]) => { self.handle_break(); }, BareKey::Esc if key.has_no_modifiers() => { self.handle_break(); }, BareKey::Char(character) if key.has_no_modifiers() => { self.add_char(character); }, BareKey::Up if key.has_no_modifiers() => { self.move_selection_up(); }, BareKey::Down if key.has_no_modifiers() => { self.move_selection_down(); }, _ => {}, } } pub fn handle_selection(&mut self, current_session_name: &Option) { match self.entering_new_session_info { EnteringState::EnteringLayoutSearch => { let new_session_layout: Option = self.selected_layout_info(); let new_session_name = if self.name.is_empty() { None } else { Some(self.name.as_str()) }; if new_session_name != current_session_name.as_ref().map(|s| s.as_str()) { match new_session_layout { Some(new_session_layout) => { let cwd = self.new_session_folder.as_ref().map(|c| PathBuf::from(c)); switch_session_with_layout(new_session_name, new_session_layout, cwd); if self.is_welcome_screen { // the welcome screen has done its job and now we need to quit this temporary // session so as not to leave garbage sessions behind quit_zellij(); } else { hide_self(); } }, None => { switch_session(new_session_name); if self.is_welcome_screen { // the welcome screen has done its job and now we need to quit this temporary // session so as not to leave garbage sessions behind quit_zellij(); } else { hide_self(); } }, } } self.name.clear(); self.layout_list.clear_selection(); hide_self(); }, EnteringState::EnteringName => { self.entering_new_session_info = EnteringState::EnteringLayoutSearch; }, } } pub fn update_layout_list(&mut self, layout_info: Vec) { self.layout_list.update_layout_list(layout_info); } pub fn layout_list(&self, max_rows: usize) -> Vec<(LayoutInfo, bool)> { // bool - is_selected let rtr = range_to_render( max_rows, self.layout_count(), Some(self.layout_list.selected_layout_index), ); self.layout_list .layout_list .iter() .enumerate() .map(|(i, l)| (l.clone(), i == self.layout_list.selected_layout_index)) .take(rtr.1) .skip(rtr.0) .collect() } pub fn layouts_to_render(&self, max_rows: usize) -> Vec<(LayoutInfo, Vec, bool)> { // (layout_info, // search_indices, // is_selected) if self.is_searching() { self.layout_search_results(max_rows) .into_iter() .map(|(layout_search_result, is_selected)| { ( layout_search_result.layout_info, layout_search_result.indices, is_selected, ) }) .collect() } else { self.layout_list(max_rows) .into_iter() .map(|(layout_info, is_selected)| (layout_info, vec![], is_selected)) .collect() } } pub fn layout_search_results(&self, max_rows: usize) -> Vec<(LayoutSearchResult, bool)> { // bool - is_selected let rtr = range_to_render( max_rows, self.layout_list.layout_search_results.len(), Some(self.layout_list.selected_layout_index), ); self.layout_list .layout_search_results .iter() .enumerate() .map(|(i, l)| (l.clone(), i == self.layout_list.selected_layout_index)) .take(rtr.1) .skip(rtr.0) .collect() } pub fn is_searching(&self) -> bool { !self.layout_list.layout_search_term.is_empty() } pub fn layout_count(&self) -> usize { self.layout_list.layout_list.len() } pub fn selected_layout_info(&self) -> Option { self.layout_list.selected_layout_info() } fn update_layout_search_term(&mut self) { self.layout_list.update_search_term(); } fn move_selection_up(&mut self) { self.layout_list.move_selection_up(); } fn move_selection_down(&mut self) { self.layout_list.move_selection_down(); } pub fn get_layout_list_clone(&self) -> LayoutList { self.layout_list.clone() } } #[derive(Default, Clone)] pub struct LayoutList { pub layout_list: Vec, pub layout_search_results: Vec, pub selected_layout_index: usize, pub layout_search_term: String, } impl LayoutList { pub fn update_layout_list(&mut self, layout_list: Vec) { let old_layout_length = self.layout_list.len(); self.layout_list = layout_list; if old_layout_length != self.layout_list.len() { // honestly, this is just the UX choice that sucks the least... self.clear_selection(); } } pub fn selected_layout_info(&self) -> Option { if !self.layout_search_term.is_empty() { self.layout_search_results .get(self.selected_layout_index) .map(|l| l.layout_info.clone()) } else { self.layout_list.get(self.selected_layout_index).cloned() } } pub fn clear_selection(&mut self) { self.selected_layout_index = 0; } pub fn max_index(&self) -> usize { if self.layout_search_term.is_empty() { self.layout_list.len().saturating_sub(1) } else { self.layout_search_results.len().saturating_sub(1) } } pub fn move_selection_up(&mut self) { let max_index = self.max_index(); if self.selected_layout_index > 0 { self.selected_layout_index -= 1; } else { self.selected_layout_index = max_index; } } pub fn move_selection_down(&mut self) { let max_index = self.max_index(); if self.selected_layout_index < max_index { self.selected_layout_index += 1; } else { self.selected_layout_index = 0; } } pub fn update_search_term(&mut self) { if self.layout_search_term.is_empty() { self.clear_selection(); self.layout_search_results = vec![]; } else { let mut matches = vec![]; let matcher = SkimMatcherV2::default().use_cache(true); for layout_info in &self.layout_list { if let Some((score, indices)) = matcher.fuzzy_indices(&layout_info.name(), &self.layout_search_term) { matches.push(LayoutSearchResult { layout_info: layout_info.clone(), score, indices, }); } } matches.sort_by(|a, b| b.score.cmp(&a.score)); self.layout_search_results = matches; self.clear_selection(); } } pub fn layouts_to_render(&self, max_rows: usize) -> Vec<(LayoutInfo, Vec, bool)> { if !self.layout_search_term.is_empty() { self.layout_search_results_to_render(max_rows) } else { self.layout_list_to_render(max_rows) } } fn layout_list_to_render(&self, max_rows: usize) -> Vec<(LayoutInfo, Vec, bool)> { let range_to_render = range_to_render( max_rows, self.layout_list.len(), Some(self.selected_layout_index), ); self.layout_list .iter() .enumerate() .map(|(i, l)| (l.clone(), vec![], i == self.selected_layout_index)) .take(range_to_render.1) .skip(range_to_render.0) .collect() } fn layout_search_results_to_render( &self, max_rows: usize, ) -> Vec<(LayoutInfo, Vec, bool)> { let range_to_render = range_to_render( max_rows, self.layout_search_results.len(), Some(self.selected_layout_index), ); self.layout_search_results .iter() .enumerate() .map(|(i, l)| { ( l.layout_info.clone(), l.indices.clone(), i == self.selected_layout_index, ) }) .take(range_to_render.1) .skip(range_to_render.0) .collect() } } pub fn range_to_render( table_rows: usize, results_len: usize, selected_index: Option, ) -> (usize, usize) { if table_rows <= results_len { let row_count_to_render = table_rows.saturating_sub(1); // 1 for the title let first_row_index_to_render = selected_index .unwrap_or(0) .saturating_sub(row_count_to_render / 2); let last_row_index_to_render = first_row_index_to_render + row_count_to_render; (first_row_index_to_render, last_row_index_to_render) } else { let first_row_index_to_render = 0; let last_row_index_to_render = results_len; (first_row_index_to_render, last_row_index_to_render) } } #[derive(Clone)] pub struct LayoutSearchResult { pub layout_info: LayoutInfo, pub score: i64, pub indices: Vec, } #[cfg(test)] mod tests { use super::*; fn make_layout(name: &str) -> LayoutInfo { LayoutInfo::BuiltIn(name.to_string()) } fn make_layout_list(names: &[&str]) -> LayoutList { let mut ll = LayoutList::default(); ll.layout_list = names.iter().map(|n| make_layout(n)).collect(); ll } // --------------------------------------------------------------- // Section 6: Layout List Navigation // --------------------------------------------------------------- #[test] fn test_6_1_layout_navigation_wraps_down() { let mut ll = make_layout_list(&["a", "b", "c"]); ll.selected_layout_index = 2; ll.move_selection_down(); assert_eq!(ll.selected_layout_index, 0); } #[test] fn test_6_2_layout_navigation_wraps_up() { let mut ll = make_layout_list(&["a", "b", "c"]); ll.selected_layout_index = 0; ll.move_selection_up(); assert_eq!(ll.selected_layout_index, 2); } #[test] fn test_6_3_layout_search_filters_results() { let mut ll = make_layout_list(&["default", "compact", "development"]); ll.layout_search_term = "dev".to_string(); ll.update_search_term(); // "development" should match let matched_names: Vec<&str> = ll .layout_search_results .iter() .map(|r| r.layout_info.name()) .collect(); assert!(matched_names.contains(&"development")); // "default" and "compact" should not match "dev" well enough // (though "default" starts with "de" so it might fuzzy-match — check) // The key assertion is that "development" is present and is the best match. // With SkimMatcherV2, "default" may also match "dev" (d, e from "default"). // So we just verify "development" is the top result. assert_eq!( ll.layout_search_results[0].layout_info.name(), "development" ); } #[test] fn test_6_4_selected_layout_info_returns_correct_layout() { let ll = make_layout_list(&["a", "b", "c"]); // selected_layout_index defaults to 0, set to 1 let mut ll = ll; ll.selected_layout_index = 1; let info = ll.selected_layout_info(); assert!(info.is_some()); assert_eq!(info.unwrap().name(), "b"); } #[test] fn test_6_5_layout_viewport_windowing() { let mut ll = make_layout_list(&[ "layout-0", "layout-1", "layout-2", "layout-3", "layout-4", "layout-5", "layout-6", "layout-7", "layout-8", "layout-9", ]); ll.selected_layout_index = 5; let rendered = ll.layouts_to_render(5); // Should return at most 5-1=4 entries (range_to_render subtracts 1 for title) assert!(rendered.len() <= 5); // The selected layout should be within the visible window let selected_visible = rendered.iter().any(|(_, _, is_selected)| *is_selected); assert!(selected_visible); } // --------------------------------------------------------------- // Section 7: Viewport Scrolling (range_to_render tests) // --------------------------------------------------------------- #[test] fn test_7_1_all_results_fit_in_viewport() { // When table_rows > results_len, all results are shown let (start, end) = range_to_render(10, 5, None); assert_eq!(start, 0); assert_eq!(end, 5); } #[test] fn test_7_2_viewport_scrolls_to_keep_selection_centered() { // table_rows=6, results_len=20, selected=10 // row_count_to_render = 6-1 = 5, half = 2 // first = 10-2 = 8, last = 8+5 = 13 let (start, end) = range_to_render(6, 20, Some(10)); assert_eq!(start, 8); assert_eq!(end, 13); } #[test] fn test_7_3_viewport_clamps_at_bottom() { // table_rows=6, results_len=20, selected=19 // row_count_to_render = 5, half = 2 // first = 19-2 = 17, last = 17+5 = 22 > 20 // Note: range_to_render does NOT clamp — it returns (17, 22) // The actual clamping happens in the caller via .take().skip() let (start, end) = range_to_render(6, 20, Some(19)); assert_eq!(start, 17); assert_eq!(end, 22); } #[test] fn test_7_4_viewport_clamps_at_top() { // table_rows=6, results_len=20, selected=0 // row_count_to_render = 5, half = 2 // first = 0.saturating_sub(2) = 0, last = 0+5 = 5 let (start, end) = range_to_render(6, 20, Some(0)); assert_eq!(start, 0); assert_eq!(end, 5); } }