mod search; use super::{KeyResponse, OptimisticUpdate, Screen}; use crate::text_input::InputAction; use crate::ui::{Controls, LayoutDetail, LayoutsTable}; use crate::DisplayLayout; use search::SearchState; use zellij_tile::prelude::*; #[derive(Clone)] pub struct LayoutListScreen { pub selected_layout_index: usize, retain_terminal_panes: bool, retain_plugin_panes: bool, apply_only_to_active_tab: bool, show_more_override_options: bool, search_state: SearchState, last_rows: usize, last_cols: usize, } impl Default for LayoutListScreen { fn default() -> Self { Self { selected_layout_index: 0, retain_terminal_panes: false, retain_plugin_panes: false, apply_only_to_active_tab: false, show_more_override_options: false, search_state: SearchState::new_in_search_mode(), last_rows: 0, last_cols: 0, } } } impl LayoutListScreen { pub fn with_selected_index(selected_index: usize) -> Self { Self { selected_layout_index: selected_index, retain_terminal_panes: true, retain_plugin_panes: false, apply_only_to_active_tab: false, show_more_override_options: false, search_state: SearchState::new_in_search_mode(), last_rows: 0, last_cols: 0, } } pub fn handle_key( &mut self, key: KeyWithModifier, display_layouts: &[DisplayLayout], ) -> KeyResponse { if self.search_state.is_typing() { self.handle_typing_filter_mode(key, display_layouts) } else { match key.bare_key { BareKey::Char('/') if key.has_no_modifiers() => { self.search_state.start_typing(); self.update_filter(display_layouts); KeyResponse::render() }, BareKey::Delete if key.has_no_modifiers() => { if let Some(file_name) = self.get_selected_file_name(display_layouts) { let _ = delete_layout(file_name.clone()); KeyResponse::none().with_optimistic(OptimisticUpdate::Delete(file_name)) } else { KeyResponse::none() } }, BareKey::Char('e') if key.has_no_modifiers() => { self.edit_selected_layout(display_layouts); KeyResponse::none() }, BareKey::Char('i') if key.has_no_modifiers() => { let new_screen = self.start_import_layout(); KeyResponse::new_screen(new_screen) }, BareKey::Char('n') if key.has_no_modifiers() => { let new_screen = self.start_new_layout_creation_from_session(); KeyResponse::new_screen(new_screen) }, BareKey::Char('r') if key.has_no_modifiers() => { if let Some(new_screen) = self.start_rename_layout(display_layouts) { KeyResponse::new_screen(new_screen) } else { KeyResponse::render() } }, BareKey::Up if key.has_no_modifiers() => { self.navigate_up(display_layouts); KeyResponse::render() }, BareKey::Down if key.has_no_modifiers() => { self.navigate_down(display_layouts); KeyResponse::render() }, BareKey::Enter if key.has_no_modifiers() => { self.open_selected_layout(display_layouts); KeyResponse::none() }, BareKey::Char('w') if key.has_modifiers(&[KeyModifier::Alt]) => { self.apply_selected_layout(display_layouts); KeyResponse::none() }, BareKey::Char('t') if key.has_no_modifiers() => { self.toggle_retain_options(); KeyResponse::render() }, BareKey::Char('a') if key.has_no_modifiers() => { self.toggle_target_option(); KeyResponse::render() }, BareKey::Char('m') if key.has_no_modifiers() => { if let Some(new_screen) = self.show_error_detail(display_layouts) { KeyResponse::new_screen(new_screen) } else { KeyResponse::render() } }, BareKey::Char('?') if key.has_no_modifiers() => { self.show_more_override_options = !self.show_more_override_options; KeyResponse::render() }, BareKey::Esc if key.has_no_modifiers() => { close_self(); KeyResponse::none() }, _ => KeyResponse::none(), } } } fn handle_typing_filter_mode( &mut self, key: KeyWithModifier, display_layouts: &[DisplayLayout], ) -> KeyResponse { // Search-first mode: Enter opens, Alt+W applies, Tab autocompletes, // arrows navigate, Esc exits to management mode match key.bare_key { BareKey::Esc if key.has_no_modifiers() => { // Clear text first; only exit to management mode if already empty if self.search_state.get_filter_input().is_empty() { self.clear_filter(); } else { self.search_state.get_filter_input_mut().clear(); self.update_filter(display_layouts); } return KeyResponse::render(); }, BareKey::Enter if key.has_no_modifiers() => { // Open the currently selected layout as new tab(s) self.open_selected_layout(display_layouts); return KeyResponse::none(); }, BareKey::Char('w') if key.has_modifiers(&[KeyModifier::Alt]) => { // Apply/override the currently selected layout to the session self.apply_selected_layout(display_layouts); return KeyResponse::none(); }, BareKey::Tab if key.has_no_modifiers() => { // Complete: fill input with selected match name if self.search_state.fill_input_with_selected_match() { self.update_filter(display_layouts); } return KeyResponse::render(); }, BareKey::Up if key.has_no_modifiers() => { // Navigate filtered results while typing self.navigate_up(display_layouts); return KeyResponse::render(); }, BareKey::Down if key.has_no_modifiers() => { // Navigate filtered results while typing self.navigate_down(display_layouts); return KeyResponse::render(); }, _ => {}, } // Pass remaining keys to TextInput let action = self.search_state.get_filter_input_mut().handle_key(key); match action { InputAction::Continue => { self.update_filter(display_layouts); KeyResponse::render() }, InputAction::Cancel => { // Ctrl-C - clear text first; only exit to management mode if already empty if self.search_state.get_filter_input().is_empty() { self.clear_filter(); } else { self.search_state.get_filter_input_mut().clear(); self.update_filter(display_layouts); } KeyResponse::render() }, InputAction::Submit => { // Enter handled above KeyResponse::none() }, InputAction::Complete => KeyResponse::none(), InputAction::NoAction => KeyResponse::none(), } } fn edit_selected_layout(&self, display_layouts: &[DisplayLayout]) { if let Some(file_name) = self.get_selected_file_name(display_layouts) { let _ = edit_layout(file_name, Default::default()); } } fn get_selected_file_name(&self, display_layouts: &[DisplayLayout]) -> Option { display_layouts .get(self.selected_layout_index) .and_then(|layout| layout.file_name()) } fn start_new_layout_creation_from_session(&self) -> Screen { match dump_session_layout() { Ok((session_layout, Some(session_layout_metadata))) => { Screen::NewLayoutFromSession(super::NewLayoutFromCurrentSessionScreen { name_input: crate::text_input::TextInput::empty(), session_layout, current_layout_metadata: session_layout_metadata, editing_name: false, save_current_tab_only: false, }) }, Ok((_, None)) => Screen::Error(super::ErrorScreen { message: "Failed to retrieve session layout metadata".to_string(), return_to_screen: Box::new(Screen::LayoutList(self.clone())), }), Err(error_msg) => Screen::Error(super::ErrorScreen { message: format!("Failed to dump session layout: {}", error_msg), return_to_screen: Box::new(Screen::LayoutList(self.clone())), }), } } fn start_import_layout(&self) -> Screen { Screen::ImportLayout(super::ImportLayoutScreen::new()) } fn start_rename_layout(&self, display_layouts: &[DisplayLayout]) -> Option { if let Some(layout_file_name) = self.get_selected_file_name(display_layouts) { Some(Screen::RenameLayout(super::RenameLayoutScreen::new( layout_file_name, self.selected_layout_index, ))) } else { None } } fn navigate_up(&mut self, display_layouts: &[DisplayLayout]) { if self.is_searching() { // Navigate in search results if self.search_state.get_selected_search_index() == 0 { self.search_state.set_selected_search_index( self.search_state .get_search_results() .len() .saturating_sub(1), ); } else { let new_index = self .search_state .get_selected_search_index() .saturating_sub(1); self.search_state.set_selected_search_index(new_index); } // Keep selected_layout_index synchronized with the actual position self.selected_layout_index = self .search_state .get_search_results() .get(self.search_state.get_selected_search_index()) .map(|r| r.original_index) .unwrap_or(0); } else { // Normal navigation if self.selected_layout_index == 0 { self.selected_layout_index = display_layouts.len().saturating_sub(1); } else { self.selected_layout_index -= 1; } } self.apply_only_to_active_tab = self.should_default_to_current_tab(display_layouts); } fn navigate_down(&mut self, display_layouts: &[DisplayLayout]) { if self.is_searching() { // Navigate in search results if self.search_state.get_selected_search_index() + 1 >= self.search_state.get_search_results().len() { self.search_state.set_selected_search_index(0); } else { let new_index = self.search_state.get_selected_search_index() + 1; self.search_state.set_selected_search_index(new_index); } // Keep selected_layout_index synchronized with the actual position self.selected_layout_index = self .search_state .get_search_results() .get(self.search_state.get_selected_search_index()) .map(|r| r.original_index) .unwrap_or(0); } else { // Normal navigation if self.selected_layout_index + 1 >= display_layouts.len() { self.selected_layout_index = 0; } else { self.selected_layout_index += 1; } } self.apply_only_to_active_tab = self.should_default_to_current_tab(display_layouts); } fn apply_selected_layout(&self, display_layouts: &[DisplayLayout]) { if let Some(DisplayLayout::Valid(chosen_layout)) = display_layouts.get(self.selected_layout_index) { override_layout( chosen_layout, self.retain_terminal_panes, self.retain_plugin_panes, self.apply_only_to_active_tab, Default::default(), ); close_self(); } } fn toggle_retain_options(&mut self) { // Cycle through: Terminals -> Plugins -> Both -> None -> (back to Terminals) match (self.retain_terminal_panes, self.retain_plugin_panes) { (true, false) => { // Terminals -> Plugins self.retain_terminal_panes = false; self.retain_plugin_panes = true; }, (false, true) => { // Plugins -> Both self.retain_terminal_panes = true; self.retain_plugin_panes = true; }, (true, true) => { // Both -> None self.retain_terminal_panes = false; self.retain_plugin_panes = false; }, (false, false) => { // None -> Terminals self.retain_terminal_panes = true; self.retain_plugin_panes = false; }, } } fn toggle_target_option(&mut self) { // Toggle between: All Tabs (false) <-> Current (true) self.apply_only_to_active_tab = !self.apply_only_to_active_tab; } fn show_error_detail(&self, display_layouts: &[DisplayLayout]) -> Option { if let Some(DisplayLayout::Error { name, error, error_message: _, }) = display_layouts.get(self.selected_layout_index) { Some(Screen::ErrorDetail(super::ErrorDetailScreen::new( name.clone(), error.clone(), Box::new(Screen::LayoutList(self.clone())), ))) } else { None } } fn should_default_to_current_tab(&self, display_layouts: &[DisplayLayout]) -> bool { match display_layouts.get(self.selected_layout_index) { Some(DisplayLayout::Valid(LayoutInfo::BuiltIn(_))) => true, Some(DisplayLayout::Valid(LayoutInfo::File(_, metadata))) => metadata.tabs.len() == 1, _ => false, } } fn open_selected_layout(&self, display_layouts: &[DisplayLayout]) { let selected = display_layouts.get(self.selected_layout_index); if let Some(DisplayLayout::Valid(chosen_layout)) = selected { let tab_ids = new_tabs_with_layout_info(chosen_layout); if self.should_default_to_current_tab(display_layouts) { if let Some(&tab_id) = tab_ids.first() { let layout_name = selected.unwrap().name(); rename_tab_with_id(tab_id as u64, layout_name); } } close_self(); } } pub fn render(&mut self, display_layouts: &[DisplayLayout], rows: usize, cols: usize) { show_cursor(None); // Store terminal dimensions for cursor positioning self.last_rows = rows; self.last_cols = cols; self.update_filter(display_layouts); // Calculate base coordinates using the full display_layouts (NOT filtered results) let (total_width, total_height) = self.calculate_content_dimensions(rows, cols, display_layouts); let (base_x, base_y) = self.calculate_base_coordinates(rows, cols, total_width, total_height); // In search mode, shift everything down by 1 row let base_y = if self.search_state.is_typing() || self.search_state.is_active() { base_y + 1 } else { base_y }; let layouts_to_render = self.effective_layouts(display_layouts); let (content_height, controls_y) = self.calculate_layout(rows, &display_layouts); let controls = Controls::new( self.retain_terminal_panes, self.retain_plugin_panes, self.apply_only_to_active_tab, self.show_more_override_options, self.search_state.is_typing(), self.search_state.is_active(), ); let controls_width = controls.calculate_width(cols); let (table_width, detail_x) = self.calculate_horizontal_split(cols, display_layouts, controls_width); let table_y = if self.search_state.is_active() || self.search_state.is_typing() { 1 + base_y } else { base_y }; if self.search_state.is_active() || self.search_state.is_typing() { self.search_state.render_filter_line(base_x, base_y); } let selected_index = self.effective_selected_index(); let (visible_layouts, selected_in_window, hidden_above, hidden_below) = calculate_visible_window( &layouts_to_render, selected_index, content_height.saturating_sub(1), // account for title row ); let matched_indices = self .search_state .get_matched_indices_for_visible(hidden_above, visible_layouts.len()); let has_visible_layouts = !visible_layouts.is_empty(); LayoutsTable::new( visible_layouts, selected_in_window, hidden_above, hidden_below, ) .with_matched_indices(matched_indices) .render( base_x, table_y, content_height.saturating_sub(1), table_width, ); if has_visible_layouts { if let Some(selected_layout) = display_layouts.get(self.selected_layout_index) { LayoutDetail::new(selected_layout).render( detail_x + base_x, table_y + 1, content_height.saturating_sub(1), table_width, ); } else { // Render "No layout selected" message (moved from LayoutDetail) let msg = Text::new("No layout selected").color_all(2); print_text_with_coordinates(msg, detail_x + base_x, table_y, None, None); } } controls.render(base_x, controls_y + base_y, cols); } fn calculate_layout(&self, rows: usize, display_layouts: &[DisplayLayout]) -> (usize, usize) { let rows_in_table = display_layouts.len() + 1; // 1 for the title row let controls_height = self.get_controls_height(); let filter_row_height = if self.is_searching() { 1 } else { 0 }; let search_mode_offset = if self.is_searching() { 1 } else { 0 }; let padding = 1; let mut content_height = std::cmp::max(rows_in_table, 5); if content_height + controls_height + padding + filter_row_height + search_mode_offset >= rows { content_height = rows .saturating_sub(controls_height) .saturating_sub(padding) .saturating_sub(filter_row_height) .saturating_sub(search_mode_offset) } let controls_y = content_height + padding + filter_row_height; (content_height, controls_y) } fn get_controls_height(&self) -> usize { if self.show_more_override_options { 5 } else { 3 } } fn calculate_horizontal_split( &self, cols: usize, display_layouts: &[DisplayLayout], controls_width: usize, ) -> (usize, usize) { let padding = 2; let widest_layout_name = display_layouts .iter() .map(|layout| layout.name_width()) .max() .unwrap_or(0); let widest_last_modified = display_layouts .iter() .map(|layout| layout.last_modified_width()) .max() .unwrap_or(0); let content_based_table_width = std::cmp::min( (cols / 2).saturating_sub(padding), widest_layout_name + widest_last_modified + padding, ); let controls_based_table_width = controls_width.saturating_sub(padding) / 2; let table_width = content_based_table_width.max(controls_based_table_width); let detail_x = table_width.saturating_add(padding); (table_width, detail_x) } fn calculate_content_dimensions( &self, rows: usize, cols: usize, display_layouts: &[DisplayLayout], ) -> (usize, usize) { let filter_row_height = if self.is_searching() { 1 } else { 0 }; let search_mode_offset = if self.is_searching() { 1 } else { 0 }; let (content_height, _) = self.calculate_layout(rows, display_layouts); let padding = 1; let controls_height = self.get_controls_height(); let total_height = filter_row_height + search_mode_offset + content_height + padding + controls_height; let controls = Controls::new( self.retain_terminal_panes, self.retain_plugin_panes, self.apply_only_to_active_tab, self.show_more_override_options, self.search_state.is_typing(), self.search_state.is_active(), ); let controls_width = controls.calculate_width(cols); let (table_width, detail_x) = self.calculate_horizontal_split(cols, display_layouts, controls_width); let layout_width = detail_x.saturating_add(table_width); let total_width = layout_width.max(controls_width); (total_width, total_height) } fn calculate_base_coordinates( &self, rows: usize, cols: usize, total_width: usize, total_height: usize, ) -> (usize, usize) { let base_x = if total_width < cols { (cols.saturating_sub(total_width)) / 2 } else { 0 }; let base_y = if total_height < rows { (rows.saturating_sub(total_height)) / 2 } else { 0 }; (base_x, base_y) } pub fn update_selected_index(&mut self, display_layouts: &[DisplayLayout]) { if self.selected_layout_index >= display_layouts.len() { self.selected_layout_index = display_layouts.len().saturating_sub(1); } // Refresh filter results if filtering is active if self.search_state.is_active() && !self.search_state.get_filter_input().is_empty() { self.update_filter(display_layouts); } self.apply_only_to_active_tab = self.should_default_to_current_tab(display_layouts); } fn is_searching(&self) -> bool { (self.search_state.is_active() && !self.search_state.get_search_results().is_empty()) || self.search_state.is_typing() } fn effective_selected_index(&self) -> usize { if self.is_searching() { self.search_state.get_selected_search_index() } else { self.selected_layout_index } } fn effective_layouts(&self, display_layouts: &[DisplayLayout]) -> Vec { if self.is_searching() { self.search_state .get_search_results() .iter() .map(|r| r.layout.clone()) .collect() } else { display_layouts.to_vec() } } fn update_filter(&mut self, display_layouts: &[DisplayLayout]) { let (total_width, total_height) = self.calculate_content_dimensions(self.last_rows, self.last_cols, display_layouts); let (base_x, base_y) = self.calculate_base_coordinates( self.last_rows, self.last_cols, total_width, total_height, ); // In search mode, cursor must account for the extra row offset let base_y = if self.search_state.is_typing() || self.search_state.is_active() { base_y + 1 } else { base_y }; self.search_state .update_filter(display_layouts, base_x, base_y); // Update selected_layout_index to match the selected search result if let Some(original_index) = self.search_state.get_current_selected_original_index() { self.selected_layout_index = original_index; } } fn clear_filter(&mut self) { self.search_state.clear_filter(); self.selected_layout_index = 0; } } fn calculate_visible_window( layouts: &[DisplayLayout], selected_index: usize, max_visible: usize, ) -> (Vec, usize, usize, usize) { // returns: (index_in_rendered, hidden_items_above, hidden_items_below) if layouts.is_empty() || max_visible == 0 { return (Vec::new(), 0, 0, 0); } if layouts.len() <= max_visible { return (layouts.to_vec(), selected_index, 0, 0); } let Some(selected_layout) = layouts.get(selected_index) else { return (Vec::new(), 0, 0, 0); }; let mut visible = Vec::with_capacity(max_visible); visible.push(selected_layout.clone()); let mut before = selected_index; let mut after = selected_index; let mut take_before = true; while visible.len() < max_visible { if take_before && before > 0 { before = before.saturating_sub(1); if let Some(layout) = layouts.get(before) { visible.insert(0, layout.clone()); } } else if !take_before && after + 1 < layouts.len() { after += 1; if let Some(layout) = layouts.get(after) { visible.push(layout.clone()); } } else if before > 0 { before = before.saturating_sub(1); if let Some(layout) = layouts.get(before) { visible.insert(0, layout.clone()); } } else if after + 1 < layouts.len() { after += 1; if let Some(layout) = layouts.get(after) { visible.push(layout.clone()); } } else { break; } take_before = !take_before; } let adjusted_index = selected_index.saturating_sub(before); ( visible, adjusted_index, before, layouts.len().saturating_sub(after + 1), ) }