diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-01-13 11:27:07 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-13 11:27:07 +0100 |
| commit | 4a27ceea0d99b036ca607e05714cef15689d7c26 (patch) | |
| tree | 22c0148718a82082bdb196c5e867697f514cc541 /default-plugins/layout-manager/src | |
| parent | docs(CONTRIBUTING): Describe rust toolchain versioning policy (#4585) (diff) | |
| download | sl0ppij-4a27ceea0d99b036ca607e05714cef15689d7c26.tar.gz sl0ppij-4a27ceea0d99b036ca607e05714cef15689d7c26.tar.xz sl0ppij-4a27ceea0d99b036ca607e05714cef15689d7c26.zip | |
Layout manager (#4601)
* ad plugin override_layout command
* watch available layouts and report to plugin thread
* Event::AvailableLayoutInfo
* save layout command
* refactor save_layout
* fix api and permissions
* delete_layout API command
* edit layout in place API
* edit layout response through api
* propagate layout parsing errors
* generate random name plugin api
* dump stringified layout API
* synchronicity dump_session_layout and include LayoutMetadata in sync response
* parse_layout plugin api
* fix: default to default layout dir in plugin api
* rename_layout plugin command API
* initial implementation of multi-tab override
* apply to multiple tabs
* allow applying to only the active tab
* add tab/pane names to layout parsing metadata
* indicate is_builtin_plugin in PaneMetadata
* get_layout_dir plugin API command
* consolidate (and fix) is_builtin_plugin logic
* fix bad ret
* allow dumping the layout for just a specific tab in the session
* get focused pane and tab sync api
* color last substring variants
* add plugin
* update assets
* fix tests
* add e2e tests for new plugin apis
* update ui with new shortcut
* rustfmt
* docs(changelog): add PR
Diffstat (limited to 'default-plugins/layout-manager/src')
| -rw-r--r-- | default-plugins/layout-manager/src/errors.rs | 155 | ||||
| -rw-r--r-- | default-plugins/layout-manager/src/main.rs | 295 | ||||
| -rw-r--r-- | default-plugins/layout-manager/src/screens/import_layout.rs | 331 | ||||
| -rw-r--r-- | default-plugins/layout-manager/src/screens/layout_list/mod.rs | 689 | ||||
| -rw-r--r-- | default-plugins/layout-manager/src/screens/layout_list/search.rs | 187 | ||||
| -rw-r--r-- | default-plugins/layout-manager/src/screens/mod.rs | 79 | ||||
| -rw-r--r-- | default-plugins/layout-manager/src/screens/new_layout_from_session.rs | 352 | ||||
| -rw-r--r-- | default-plugins/layout-manager/src/screens/rename_layout.rs | 156 | ||||
| -rw-r--r-- | default-plugins/layout-manager/src/text_input.rs | 1007 | ||||
| -rw-r--r-- | default-plugins/layout-manager/src/ui/layout_detail_view.rs | 345 | ||||
| -rw-r--r-- | default-plugins/layout-manager/src/ui/layout_table.rs | 452 | ||||
| -rw-r--r-- | default-plugins/layout-manager/src/ui/mod.rs | 9 | ||||
| -rw-r--r-- | default-plugins/layout-manager/src/ui/multi_line_error_message.rs | 26 | ||||
| -rw-r--r-- | default-plugins/layout-manager/src/ui/text_utils.rs | 195 |
14 files changed, 4278 insertions, 0 deletions
diff --git a/default-plugins/layout-manager/src/errors.rs b/default-plugins/layout-manager/src/errors.rs new file mode 100644 index 00000000..a86af8bf --- /dev/null +++ b/default-plugins/layout-manager/src/errors.rs @@ -0,0 +1,155 @@ +use crate::screens::{KeyResponse, Screen}; +use crate::ui::{truncate_line_with_ansi, wrap_text_to_width, ErrorMessage, MultiLineErrorMessage}; +use zellij_tile::prelude::*; + +/// Format a layout parsing error into a detailed error string +pub fn format_kdl_error(error: LayoutParsingError) -> String { + match error { + LayoutParsingError::KdlError { + mut kdl_error, + file_name, + source_code, + } => { + use miette::{GraphicalReportHandler, NamedSource, Report}; + + kdl_error.help_message = + Some("https://zellij.dev/documentation/creating-a-layout.html".to_owned()); + let report: Report = kdl_error.into(); + let report = report.with_source_code(NamedSource::new(file_name, source_code)); + + let handler = GraphicalReportHandler::new(); + let mut output = String::new(); + handler.render_report(&mut output, report.as_ref()).unwrap(); + output + }, + LayoutParsingError::SyntaxError => { + format!("Failed to deserialize KDL node. \nPossible reasons:\n{}\n{}\n{}\n{}", + "- Missing `;` after a node name, eg. { node; another_node; }", + "- Missing quotations (\") around an argument node eg. { first_node \"argument_node\"; }", + "- Missing an equal sign (=) between node arguments on a title line. eg. argument=\"value\"", + "- Found an extraneous equal sign (=) between node child arguments and their values. eg. { argument=\"value\" }") + }, + } +} + +#[derive(Clone)] +pub struct ErrorScreen { + pub message: String, + pub return_to_screen: Box<Screen>, +} + +impl ErrorScreen { + pub fn handle_key(&mut self, _key: KeyWithModifier) -> KeyResponse { + KeyResponse::new_screen((*self.return_to_screen).clone()) + } + + pub fn render(&self, rows: usize, cols: usize) { + if self.message.chars().count() > cols.saturating_sub(4) { + let max_width = cols.saturating_sub(4); + let max_rows = rows.saturating_sub(4); + let lines = wrap_text_to_width(&self.message, max_width); + let base_x = 2; + let base_y = rows.saturating_sub(4 + lines.len()) / 2; + MultiLineErrorMessage::new(lines).render(base_x, base_y, max_rows); + } else { + let desired_width = self.message.chars().count(); + let base_y = rows.saturating_sub(5) / 2; + let base_x = cols.saturating_sub(desired_width) / 2; + ErrorMessage::new(&self.message).render(base_x, base_y); + } + } +} + +#[derive(Clone)] +pub struct ErrorDetailScreen { + pub layout_name: String, + pub detailed_error: String, + pub return_to_screen: Box<Screen>, +} + +impl ErrorDetailScreen { + pub fn new(layout_name: String, detailed_error: String, return_to_screen: Box<Screen>) -> Self { + Self { + layout_name, + detailed_error, + return_to_screen, + } + } + + pub fn handle_key(&mut self, _key: KeyWithModifier) -> KeyResponse { + KeyResponse::new_screen((*self.return_to_screen).clone()) + } + + pub fn render(&self, rows: usize, cols: usize) { + // Header: show layout name + let header = format!("Error in layout: {}", self.layout_name); + let header_text = Text::new(&header).error_color_all(); + print_text_with_coordinates(header_text, 1, 0, None, None); + + // Calculate available space for error content + let header_height = 2; // Header + gap + let available_rows = rows.saturating_sub(header_height); + let available_cols = cols.saturating_sub(2); // Padding on sides + + // Render error lines with middle truncation + let error_lines: Vec<&str> = self.detailed_error.lines().collect(); + let total_lines = error_lines.len(); + + if total_lines <= available_rows { + // All lines fit, show them all + for (i, line) in error_lines.iter().enumerate() { + let truncated = truncate_line_with_ansi(line, available_cols); + print!("\u{1b}[{};{}H{}", header_height + i + 1, 2, truncated); + } + } else { + // Need to truncate middle - show beginning, indicator, and end + let omitted_indicator_lines = 1; // Reserve 1 line for "... X lines omitted ..." + let lines_for_content = available_rows.saturating_sub(omitted_indicator_lines); + + // Split content space: 60% for beginning, 40% for end + let beginning_lines = (lines_for_content as f32 * 0.6).ceil() as usize; + let end_lines = lines_for_content.saturating_sub(beginning_lines); + + let omitted_count = total_lines.saturating_sub(beginning_lines + end_lines); + + let mut current_row = 0; + + // Render beginning lines + for line in error_lines.iter().take(beginning_lines) { + let truncated = truncate_line_with_ansi(line, available_cols); + print!( + "\u{1b}[{};{}H{}", + header_height + current_row + 1, + 2, + truncated + ); + current_row += 1; + } + + // Render omission indicator + let indicator = format!("... {} lines omitted ...", omitted_count); + let indicator_text = Text::new(&indicator).color_range(0, ..); + print_text_with_coordinates( + indicator_text, + (cols.saturating_sub(indicator.chars().count())) / 2, + header_height + current_row, + None, + None, + ); + current_row += 1; + + // Render end lines + let start_index = total_lines.saturating_sub(end_lines); + for line in error_lines.iter().skip(start_index) { + let truncated = truncate_line_with_ansi(line, available_cols); + print!( + "\u{1b}[{};{}H{}", + header_height + current_row + 1, + 2, + truncated + ); + current_row += 1; + } + } + } +} diff --git a/default-plugins/layout-manager/src/main.rs b/default-plugins/layout-manager/src/main.rs new file mode 100644 index 00000000..0966f4ec --- /dev/null +++ b/default-plugins/layout-manager/src/main.rs @@ -0,0 +1,295 @@ +mod errors; +mod screens; +mod text_input; +mod ui; + +use errors::format_kdl_error; +use screens::{KeyResponse, OptimisticUpdate, Screen}; +use std::collections::BTreeMap; +use ui::{get_last_modified_string, get_layout_display_info}; +use zellij_tile::prelude::*; + +#[derive(Clone)] +pub enum DisplayLayout { + Valid(LayoutInfo), + Error { + name: String, + error: String, + error_message: String, + }, +} + +impl DisplayLayout { + fn name(&self) -> String { + match self { + DisplayLayout::Valid(info) => match info { + LayoutInfo::BuiltIn(name) => name.clone(), + LayoutInfo::File(path, _) => path.split('/').last().unwrap_or(path).to_string(), + LayoutInfo::Url(url) => url.split('/').last().unwrap_or(url).to_string(), + LayoutInfo::Stringified(_) => "raw".to_string(), + }, + DisplayLayout::Error { name, .. } => name.clone(), + } + } + fn file_name(&self) -> Option<String> { + match self { + DisplayLayout::Valid(LayoutInfo::File(file_name, _)) => Some(file_name.clone()), + DisplayLayout::Error { name, .. } => Some(name.clone()), + _ => None, + } + } + fn is_error(&self) -> bool { + match self { + DisplayLayout::Error { .. } => true, + _ => false, + } + } + fn is_builtin(&self) -> bool { + matches!(self, DisplayLayout::Valid(LayoutInfo::BuiltIn(..))) + } + + fn builtin_sort_priority(&self) -> usize { + let name = self.name().to_lowercase(); + match name.as_str() { + "default" => 0, + "compact" => 1, + _ => 2, + } + } + + fn get_update_time(&self) -> Option<i64> { + match self { + DisplayLayout::Valid(LayoutInfo::File(_, metadata)) => { + metadata.update_time.parse::<i64>().ok() + }, + _ => None, + } + } + + fn compare_builtin_layouts(&self, other: &DisplayLayout) -> std::cmp::Ordering { + match self + .builtin_sort_priority() + .cmp(&other.builtin_sort_priority()) + { + std::cmp::Ordering::Equal => { + self.name().to_lowercase().cmp(&other.name().to_lowercase()) + }, + priority_order => priority_order, + } + } + + fn compare_for_display(&self, other: &DisplayLayout) -> std::cmp::Ordering { + match (self.is_builtin(), other.is_builtin()) { + (false, false) => { + // Sort by update_time (newest first) + match (self.get_update_time(), other.get_update_time()) { + (Some(self_time), Some(other_time)) => { + // Reverse comparison for descending order + other_time.cmp(&self_time) + }, + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => { + // Fallback to alphabetical + self.name().to_lowercase().cmp(&other.name().to_lowercase()) + }, + } + }, + (false, true) => std::cmp::Ordering::Less, + (true, false) => std::cmp::Ordering::Greater, + (true, true) => self.compare_builtin_layouts(other), + } + } + + fn name_width(&self) -> usize { + self.name().chars().count() + } + fn last_modified_width(&self) -> usize { + let display_info = get_layout_display_info(&self); + get_last_modified_string(display_info.1, self.is_builtin()) + .chars() + .count() + } +} + +#[derive(Default)] +struct State { + display_layouts: Vec<DisplayLayout>, + screen: Screen, +} + +impl State { + fn handle_key_event(&mut self, key: KeyWithModifier) -> KeyResponse { + match &mut self.screen { + Screen::LayoutList(layout_list_screen) => { + layout_list_screen.handle_key(key, &self.display_layouts) + }, + Screen::NewLayoutFromSession(new_layout_from_session_screen) => { + new_layout_from_session_screen.handle_key(key) + }, + Screen::ImportLayout(import_layout_screen) => import_layout_screen.handle_key(key), + Screen::RenameLayout(rename_layout_screen) => rename_layout_screen.handle_key(key), + Screen::Error(ref mut error_screen) => error_screen.handle_key(key), + Screen::ErrorDetail(ref mut error_detail_screen) => error_detail_screen.handle_key(key), + } + } + + fn apply_key_response(&mut self, response: KeyResponse) -> bool { + let mut should_render = false; + + if let Some(update) = response.optimistic_update { + self.apply_optimistic_update(update); + should_render = true; + } + + if let Some(new_screen) = response.new_screen { + self.screen = new_screen; + } + + should_render || response.should_render + } + + fn apply_optimistic_update(&mut self, update: OptimisticUpdate) { + match update { + OptimisticUpdate::Delete(file_name) => { + self.optimistic_delete_layout(&file_name); + }, + OptimisticUpdate::Rename { old_name, new_name } => { + self.optimistic_rename_layout(&old_name, &new_name); + }, + OptimisticUpdate::Add { name, metadata } => { + self.optimistic_add_layout(name, metadata); + }, + } + } + + fn optimistic_add_layout(&mut self, name: String, metadata: LayoutMetadata) { + let placeholder = DisplayLayout::Valid(LayoutInfo::File(name, metadata)); + + self.display_layouts.push(placeholder); + self.display_layouts + .sort_by(|a, b| a.compare_for_display(b)); + + if let Screen::LayoutList(ref mut screen) = self.screen { + screen.update_selected_index(&self.display_layouts); + } + } + + fn optimistic_delete_layout(&mut self, file_name: &str) { + self.display_layouts + .retain(|layout| layout.file_name() != Some(file_name.to_string())); + + if let Screen::LayoutList(ref mut screen) = self.screen { + screen.update_selected_index(&self.display_layouts); + } + } + + fn optimistic_rename_layout(&mut self, old_name: &str, new_name: &str) { + for layout in &mut self.display_layouts { + match layout { + DisplayLayout::Valid(LayoutInfo::File(file_name, _)) => { + if file_name == old_name { + let _ = std::mem::replace(file_name, new_name.to_string()); + break; + } + }, + DisplayLayout::Error { ref mut name, .. } => { + if name == old_name { + let _ = std::mem::replace(name, new_name.to_string()); + break; + } + }, + _ => {}, + } + } + + self.display_layouts + .sort_by(|a, b| a.compare_for_display(b)); + + if let Screen::LayoutList(ref mut screen) = self.screen { + screen.update_selected_index(&self.display_layouts); + } + } +} + +register_plugin!(State); + +impl ZellijPlugin for State { + fn load(&mut self, _configuration: BTreeMap<String, String>) { + subscribe(&[ + EventType::PastedText, + EventType::SessionUpdate, + EventType::Key, + EventType::AvailableLayoutInfo, + EventType::PermissionRequestResult, + ]); + rename_plugin_pane(get_plugin_ids().plugin_id, "Layout Manager"); + } + + fn update(&mut self, event: Event) -> bool { + let mut should_render = false; + + match event { + Event::PastedText(text) => { + if let Screen::ImportLayout(ref mut import_screen) = self.screen { + import_screen.handle_pasted_text(text); + should_render = true; + } + }, + Event::AvailableLayoutInfo(available_layouts, layouts_with_errors) => { + // Convert valid layouts to DisplayLayout::Valid + let mut display_layouts: Vec<DisplayLayout> = available_layouts + .into_iter() + .map(DisplayLayout::Valid) + .collect(); + + // Convert error layouts to DisplayLayout::Error + for layout_error in &layouts_with_errors { + let stringified_error = format_kdl_error(layout_error.error.clone()); + let error_message = match &layout_error.error { + LayoutParsingError::KdlError { kdl_error, .. } => { + format!("Layout Error: {}", kdl_error.error_message) + }, + LayoutParsingError::SyntaxError => "KDL parsing error".to_owned(), + }; + + display_layouts.push(DisplayLayout::Error { + name: layout_error.layout_name.clone(), + error_message, + error: stringified_error, + }); + } + + display_layouts.sort_by(|a, b| a.compare_for_display(b)); + + self.display_layouts = display_layouts; + if let Screen::LayoutList(ref mut screen) = self.screen { + screen.update_selected_index(&self.display_layouts); + } + should_render = true; + }, + Event::Key(key) => { + let response = self.handle_key_event(key); + should_render = self.apply_key_response(response); + }, + _ => {}, + } + + should_render + } + + fn render(&mut self, rows: usize, cols: usize) { + match &mut self.screen { + Screen::LayoutList(layout_list_screen) => { + layout_list_screen.render(&self.display_layouts, rows, cols) + }, + Screen::NewLayoutFromSession(new_layout_from_session_screen) => { + new_layout_from_session_screen.render(rows, cols) + }, + Screen::ImportLayout(import_layout_screen) => import_layout_screen.render(rows, cols), + Screen::RenameLayout(rename_layout_screen) => rename_layout_screen.render(rows, cols), + Screen::Error(ref error_screen) => error_screen.render(rows, cols), + Screen::ErrorDetail(ref error_detail_screen) => error_detail_screen.render(rows, cols), + } + } +} diff --git a/default-plugins/layout-manager/src/screens/import_layout.rs b/default-plugins/layout-manager/src/screens/import_layout.rs new file mode 100644 index 00000000..8bf1a3a1 --- /dev/null +++ b/default-plugins/layout-manager/src/screens/import_layout.rs @@ -0,0 +1,331 @@ +use super::{KeyResponse, LayoutListScreen, OptimisticUpdate, Screen}; +use crate::errors::{format_kdl_error, ErrorScreen}; +use crate::text_input::{InputAction, TextInput}; +use crate::ui::{truncate_with_ellipsis_start, LayoutDetail, Title}; +use crate::{DisplayLayout, LayoutInfo}; +use zellij_tile::prelude::*; + +#[derive(Clone)] +pub struct ImportLayoutScreen { + pub name_input: TextInput, + pub pasted_text: Option<String>, + pub parsed_metadata: Option<LayoutMetadata>, + pub parse_error: Option<String>, + pub editing_name: bool, +} + +impl ImportLayoutScreen { + pub fn new() -> Self { + Self { + name_input: TextInput::empty(), + pasted_text: None, + parsed_metadata: None, + parse_error: None, + editing_name: false, + } + } + + pub fn handle_pasted_text(&mut self, text: String) { + self.pasted_text = Some(text.clone()); + + match parse_layout(&text) { + Ok(parsed_metadata) => { + self.parsed_metadata = Some(parsed_metadata); + self.parse_error = None; + }, + Err(e) => { + self.parsed_metadata = None; + self.parse_error = Some(format_kdl_error(e)); + }, + } + } + + pub fn handle_key(&mut self, key: KeyWithModifier) -> KeyResponse { + // Special Esc handling based on edit state + if key.bare_key == BareKey::Esc && key.has_no_modifiers() { + return self.handle_escape_key(); + } + + if self.editing_name { + self.handle_editing_mode_key(key) + } else { + self.handle_non_editing_mode_key(key) + } + } + + fn handle_escape_key(&mut self) -> KeyResponse { + if self.editing_name { + self.editing_name = false; + KeyResponse::render() + } else { + KeyResponse::new_screen(self.cancel_import()) + } + } + + fn handle_editing_mode_key(&mut self, key: KeyWithModifier) -> KeyResponse { + let action = self.name_input.handle_key(key); + + match action { + InputAction::Continue => KeyResponse::render(), + InputAction::Submit => self.exit_editing_mode(), + InputAction::Cancel => self.exit_editing_mode(), + InputAction::Complete => KeyResponse::none(), + InputAction::NoAction => KeyResponse::none(), + } + } + + fn handle_non_editing_mode_key(&mut self, key: KeyWithModifier) -> KeyResponse { + match key.bare_key { + BareKey::Enter if key.has_no_modifiers() => self.attempt_save_layout(), + BareKey::Char('r') if key.has_no_modifiers() => self.enter_editing_mode(), + _ => KeyResponse::none(), + } + } + + fn exit_editing_mode(&mut self) -> KeyResponse { + self.editing_name = false; + KeyResponse::render() + } + + fn enter_editing_mode(&mut self) -> KeyResponse { + self.editing_name = true; + KeyResponse::render() + } + + fn cancel_import(&self) -> Screen { + Screen::LayoutList(LayoutListScreen::default()) + } + + fn attempt_save_layout(&self) -> KeyResponse { + // Check if we have a pasted layout + let Some(pasted_text) = &self.pasted_text else { + return KeyResponse::new_screen( + self.create_error_screen("Please paste a layout first"), + ); + }; + + // Check if the layout is valid + if self.parse_error.is_some() { + return KeyResponse::new_screen( + self.create_error_screen("Cannot save an invalid layout"), + ); + } + + // Get the parsed metadata for optimistic update + let Some(metadata) = &self.parsed_metadata else { + return KeyResponse::new_screen( + self.create_error_screen("Cannot save layout without metadata"), + ); + }; + + let layout_name = self.determine_layout_name(); + + // Capture optimistic update BEFORE the API call + let optimistic = OptimisticUpdate::Add { + name: layout_name.clone(), + metadata: metadata.clone(), + }; + + match self.save_layout(&layout_name, pasted_text) { + Ok(()) => KeyResponse::new_screen(Screen::LayoutList(LayoutListScreen::default())) + .with_optimistic(optimistic), + Err(error_msg) => KeyResponse::new_screen(self.create_error_screen(&error_msg)), + } + } + + fn create_error_screen(&self, message: &str) -> Screen { + Screen::Error(ErrorScreen { + message: message.to_string(), + return_to_screen: Box::new(Screen::ImportLayout(self.clone())), + }) + } + + fn determine_layout_name(&self) -> String { + let input_text = self.name_input.get_text(); + if input_text.is_empty() { + generate_random_name() + } else { + input_text.to_string() + } + } + + fn save_layout(&self, layout_name: &str, layout_content: &str) -> Result<(), String> { + save_layout(layout_name.to_owned(), layout_content.to_owned(), false) + .map_err(|err| format!("Failed to save layout '{}': {}", layout_name, err))?; + + eprintln!("Successfully imported layout: {}", layout_name); + Ok(()) + } + + fn description_text(&self) -> &str { + "Paste your layout here." + } + + fn max_description_width(&self) -> usize { + self.description_text().chars().count() + } + + fn render_description(&self, x: usize, y: usize, _width: usize) { + let text = Text::new(self.description_text()); + print_text_with_coordinates(text, x, y, None, None); + } + + fn save_as_line_text(&self, max_width: Option<usize>) -> (String, usize) { + // Returns (text, cursor_position_in_line) + let input_text = self.name_input.get_text(); + let cursor_pos = self.name_input.get_cursor_position(); + + let display_name = if input_text.is_empty() && !self.editing_name { + "RANDOM" + } else { + input_text + }; + + let text_suffix = if self.editing_name { + "" // No hint when editing + } else { + " (<r> Rename)" // Show rename hint when not editing + }; + + let mut text = format!("Save as: {}{}", display_name, text_suffix); + let mut cursor_position_in_line = 9 + cursor_pos; + + if let Some(max_width) = max_width { + if text.chars().count() > max_width { + let truncated_display_name = truncate_with_ellipsis_start( + display_name, + max_width.saturating_sub(9 + text_suffix.chars().count()), + ); + text = format!("Save as: {}{}", truncated_display_name, text_suffix); + let truncated_len = truncated_display_name.chars().count(); + cursor_position_in_line = 9 + cursor_pos.min(truncated_len); + } + } + + (text, cursor_position_in_line) + } + + fn render_save_as_line(&self, x: usize, y: usize, width: usize) { + let (text, _) = self.save_as_line_text(Some(width)); + let colored = Text::new(&text).color_substring(3, "<r>"); + print_text_with_coordinates(colored, x, y, None, None); + } + + fn help_text(&self) -> (&str, &[&str]) { + // Only Enter and Esc - NO <r> here (it's in the save-as line) + ("<Enter> - Save, <Esc> - Back", &["<Enter>", "<Esc>"]) + } + + fn render_help_text(&self, x: usize, y: usize, _width: usize) { + let (text, items_to_color) = self.help_text(); + let mut text_obj = Text::new(text); + for item in items_to_color { + text_obj = text_obj.color_substring(3, item); + } + print_text_with_coordinates(text_obj, x, y, None, None); + } + + fn render_title(&self, x: usize, y: usize, width: usize) { + let title = Text::new("Import Layout").color_all(2); + print_text_with_coordinates(title, x, y, Some(width), None); + } + + pub fn render(&self, rows: usize, cols: usize) { + // Only render modal if we have a valid pasted layout + if let Some(metadata) = &self.parsed_metadata { + let display_layout = + DisplayLayout::Valid(LayoutInfo::File("imported".to_string(), metadata.clone())); + + let layout_detail = LayoutDetail::new(&display_layout); + + let desired_ui_width = std::cmp::max( + self.help_text().0.chars().count(), + self.save_as_line_text(None).0.chars().count(), + ); + + let actual_ui_width = std::cmp::min(desired_ui_width, cols); + + // Calculate layout detail dimensions + let available_cols_for_details = actual_ui_width.saturating_sub(2); + let desired_details_height = + layout_detail.calculate_required_height(available_cols_for_details); + + let desired_ui_height = 6 + desired_details_height; + let base_y = rows.saturating_sub(desired_ui_height) / 2; + let base_x = cols.saturating_sub(actual_ui_width) / 2; + + // Update cursor BEFORE rendering + let save_as_line_y = base_y + 2; + self.update_cursor_position(base_x, save_as_line_y, actual_ui_width); + + // Render components + self.render_title(base_x, base_y, actual_ui_width); + self.render_save_as_line(base_x, save_as_line_y, actual_ui_width); + + let details_y = save_as_line_y + 2; + + let actual_details_height = if rows < desired_ui_height { + rows.saturating_sub(details_y + 2) + } else { + desired_details_height + }; + + layout_detail.render( + base_x + 2, + details_y, + actual_details_height, + available_cols_for_details, + ); + + // Render help text + let help_y = details_y + actual_details_height + 1; + self.render_help_text(base_x, help_y, actual_ui_width); + } else if let Some(error) = &self.parse_error { + // Show error if parse failed + Title::new("Import Layout").render(0, 0); + self.render_parse_error(error, 2); + + let help_y = rows.saturating_sub(2); + self.render_esc_cancel_help(0, help_y); + } else { + // Calculate desired width + let desired_ui_width = std::cmp::max( + self.help_text().0.chars().count(), + self.max_description_width(), + ); + + let actual_ui_width = std::cmp::min(desired_ui_width, cols); + let actual_ui_height = std::cmp::min(6, rows); // 6 - content rows plus padding + + let base_y = rows.saturating_sub(actual_ui_height) / 2; + let base_x = cols.saturating_sub(actual_ui_width) / 2; + + Title::new("Import Layout").render(base_x, base_y); + self.render_description(base_x, base_y + 2, cols); + + let help_y = base_y + 4; + self.render_esc_cancel_help(base_x, help_y); + } + } + fn render_esc_cancel_help(&self, x: usize, y: usize) { + let help_text = "<Esc> - Cancel"; + let help_text = Text::new(help_text).color_substring(3, "<Esc>"); + print_text_with_coordinates(help_text, x, y, None, None); + } + + fn render_parse_error(&self, error: &str, y: usize) { + // Render error message using ANSI escape sequences + for (i, line) in error.lines().enumerate() { + print!("\u{1b}[{};{}H{}", y + i + 1, 1, line); + } + } + + pub fn update_cursor_position(&self, base_x: usize, cursor_y: usize, max_width: usize) { + if self.editing_name { + let (_, cursor_position_in_line) = self.save_as_line_text(Some(max_width)); + show_cursor(Some((base_x + cursor_position_in_line, cursor_y))) + } else { + show_cursor(None) + } + } +} diff --git a/default-plugins/layout-manager/src/screens/layout_list/mod.rs b/default-plugins/layout-manager/src/screens/layout_list/mod.rs new file mode 100644 index 00000000..e643657b --- /dev/null +++ b/default-plugins/layout-manager/src/screens/layout_list/mod.rs @@ -0,0 +1,689 @@ +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(), + 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(), + 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::Tab if key.has_no_modifiers() => { + 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() => { + self.clear_filter(); + KeyResponse::render() + }, + _ => KeyResponse::none(), + } + } + } + + fn handle_typing_filter_mode( + &mut self, + key: KeyWithModifier, + display_layouts: &[DisplayLayout], + ) -> KeyResponse { + // Special handling for Enter and Esc + match key.bare_key { + BareKey::Esc if key.has_no_modifiers() => { + self.clear_filter(); + return KeyResponse::render(); + }, + BareKey::Enter if key.has_no_modifiers() => { + if self.search_state.get_filter_input().is_empty() + || self.search_state.get_search_results().is_empty() + { + self.clear_filter(); + } else { + self.search_state.stop_typing(); + show_cursor(None); + } + return KeyResponse::render(); + }, + _ => {}, + } + + // Pass all 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 or Esc - clear filter + self.clear_filter(); + 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<String> { + 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<Screen> { + 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(), + ); + } + } + + 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<Screen> { + 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]) { + if let Some(DisplayLayout::Valid(chosen_layout)) = + display_layouts.get(self.selected_layout_index) + { + new_tabs_with_layout_info(chosen_layout); + } + } + + 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); + + 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 padding = 1; + let mut content_height = std::cmp::max(rows_in_table, 5); + if content_height + controls_height + padding + filter_row_height >= rows { + content_height = rows + .saturating_sub(controls_height) + .saturating_sub(padding) + .saturating_sub(filter_row_height) + } + 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 (content_height, _) = self.calculate_layout(rows, display_layouts); + let padding = 1; + let controls_height = self.get_controls_height(); + let total_height = filter_row_height + 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<DisplayLayout> { + 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, + ); + + 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<DisplayLayout>, 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), + ) +} diff --git a/default-plugins/layout-manager/src/screens/layout_list/search.rs b/default-plugins/layout-manager/src/screens/layout_list/search.rs new file mode 100644 index 00000000..1414d6a3 --- /dev/null +++ b/default-plugins/layout-manager/src/screens/layout_list/search.rs @@ -0,0 +1,187 @@ +use crate::text_input::TextInput; +use crate::DisplayLayout; +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; +use zellij_tile::prelude::*; + +#[derive(Clone)] +pub struct SearchResult { + pub layout: DisplayLayout, + pub original_index: usize, + pub score: i64, + pub matched_indices: Vec<usize>, +} + +#[derive(Clone)] +pub struct SearchState { + filter_active: bool, + filter_input: TextInput, + typing_filter: bool, + search_results: Vec<SearchResult>, + selected_search_index: usize, +} + +impl Default for SearchState { + fn default() -> Self { + Self { + filter_active: false, + filter_input: TextInput::empty(), + typing_filter: false, + search_results: Vec::new(), + selected_search_index: 0, + } + } +} + +impl SearchState { + pub fn new() -> Self { + Self::default() + } + + pub fn is_active(&self) -> bool { + self.filter_active + } + + pub fn is_typing(&self) -> bool { + self.typing_filter + } + + pub fn start_typing(&mut self) { + self.typing_filter = true; + } + + pub fn stop_typing(&mut self) { + self.typing_filter = false; + } + + pub fn get_filter_input(&self) -> &TextInput { + &self.filter_input + } + + pub fn get_filter_input_mut(&mut self) -> &mut TextInput { + &mut self.filter_input + } + + pub fn get_search_results(&self) -> &[SearchResult] { + &self.search_results + } + + pub fn get_selected_search_index(&self) -> usize { + self.selected_search_index + } + + pub fn set_selected_search_index(&mut self, index: usize) { + self.selected_search_index = index; + } + + pub fn update_filter( + &mut self, + display_layouts: &[DisplayLayout], + base_x: usize, + base_y: usize, + ) { + let filter_prompt = "Filter:"; + let filter_text = self.filter_input.get_text(); + + // Clear results if filter is empty + if filter_text.is_empty() && !self.typing_filter { + self.search_results.clear(); + self.selected_search_index = 0; + return; + } + + // Perform fuzzy matching + let matcher = SkimMatcherV2::default(); + + let mut results: Vec<SearchResult> = display_layouts + .iter() + .enumerate() + .filter_map(|(index, layout)| { + let name = layout.name(); + matcher + .fuzzy_indices(&name, filter_text) + .map(|(score, indices)| SearchResult { + layout: layout.clone(), + original_index: index, + score, + matched_indices: indices, + }) + }) + .collect(); + + // Sort by score descending (best matches first) + results.sort_by(|a, b| b.score.cmp(&a.score)); + self.search_results = results; + + if self.search_results.is_empty() { + self.filter_active = false; + } else { + self.filter_active = true; + } + + // Keep selection in bounds + if self.selected_search_index >= self.search_results.len() { + self.selected_search_index = self.search_results.len().saturating_sub(1); + } + + // Show cursor when typing + if self.typing_filter { + let cursor_pos = self.filter_input.get_cursor_position(); + show_cursor(Some(( + filter_prompt.chars().count() + 1 + cursor_pos + base_x, + base_y, + ))); + } else { + show_cursor(None); + } + } + + pub fn clear_filter(&mut self) { + self.filter_active = false; + self.typing_filter = false; + self.filter_input.clear(); + self.search_results.clear(); + self.selected_search_index = 0; + show_cursor(None); + } + + pub fn get_current_selected_original_index(&self) -> Option<usize> { + self.search_results + .get(self.selected_search_index) + .map(|result| result.original_index) + } + + pub fn render_filter_line(&self, base_x: usize, base_y: usize) { + let filter_text_str = self.filter_input.get_text(); + let filter_text = if self.typing_filter { + let mut filter_line = + Text::new(format!("Filter: {}", filter_text_str)).color_substring(2, "Filter:"); + if !filter_text_str.is_empty() { + filter_line = filter_line.color_last_substring(3, filter_text_str) + } + filter_line + } else { + Text::new(format!("Filter: {} (<Esc> - clear)", filter_text_str)) + .color_substring(3, "<Esc>") + .color_substring(2, "Filter:") + }; + print_text_with_coordinates(filter_text, base_x, base_y, None, None); + } + + pub fn get_matched_indices_for_visible( + &self, + hidden_above: usize, + visible_count: usize, + ) -> Vec<Option<Vec<usize>>> { + if !self.filter_active || self.search_results.is_empty() { + return vec![None; visible_count]; + } + + self.search_results + .iter() + .skip(hidden_above) + .take(visible_count) + .map(|result| Some(result.matched_indices.clone())) + .collect() + } +} diff --git a/default-plugins/layout-manager/src/screens/mod.rs b/default-plugins/layout-manager/src/screens/mod.rs new file mode 100644 index 00000000..d9d617be --- /dev/null +++ b/default-plugins/layout-manager/src/screens/mod.rs @@ -0,0 +1,79 @@ +mod import_layout; +mod layout_list; +mod new_layout_from_session; +mod rename_layout; + +use zellij_tile::prelude::LayoutMetadata; + +pub use import_layout::ImportLayoutScreen; +pub use layout_list::LayoutListScreen; +pub use new_layout_from_session::NewLayoutFromCurrentSessionScreen; +pub use rename_layout::RenameLayoutScreen; + +// Re-export error types from errors module for convenience +pub use crate::errors::{ErrorDetailScreen, ErrorScreen}; + +#[derive(Clone)] +pub enum Screen { + LayoutList(LayoutListScreen), + NewLayoutFromSession(NewLayoutFromCurrentSessionScreen), + ImportLayout(ImportLayoutScreen), + RenameLayout(RenameLayoutScreen), + Error(ErrorScreen), + ErrorDetail(ErrorDetailScreen), +} + +impl Default for Screen { + fn default() -> Self { + Screen::LayoutList(LayoutListScreen::default()) + } +} + +/// Optimistic state updates to apply before Zellij confirms +#[derive(Clone, Debug)] +pub enum OptimisticUpdate { + Delete(String), // file_name + Rename { + old_name: String, + new_name: String, + }, + Add { + name: String, + metadata: LayoutMetadata, + }, +} + +/// Response from screen key handlers +#[derive(Default)] +pub struct KeyResponse { + pub should_render: bool, + pub new_screen: Option<Screen>, + pub optimistic_update: Option<OptimisticUpdate>, +} + +impl KeyResponse { + pub fn render() -> Self { + KeyResponse { + should_render: true, + new_screen: None, + optimistic_update: None, + } + } + + pub fn new_screen(screen: Screen) -> Self { + KeyResponse { + should_render: true, + new_screen: Some(screen), + optimistic_update: None, + } + } + + pub fn with_optimistic(mut self, update: OptimisticUpdate) -> Self { + self.optimistic_update = Some(update); + self + } + + pub fn none() -> Self { + KeyResponse::default() + } +} diff --git a/default-plugins/layout-manager/src/screens/new_layout_from_session.rs b/default-plugins/layout-manager/src/screens/new_layout_from_session.rs new file mode 100644 index 00000000..fbf6d63b --- /dev/null +++ b/default-plugins/layout-manager/src/screens/new_layout_from_session.rs @@ -0,0 +1,352 @@ +use super::{ErrorScreen, KeyResponse, LayoutListScreen, OptimisticUpdate, Screen}; +use crate::text_input::{InputAction, TextInput}; +use crate::ui::{truncate_with_ellipsis_start, LayoutDetail}; +use crate::DisplayLayout; +use zellij_tile::prelude::*; + +#[derive(Clone)] +pub struct NewLayoutFromCurrentSessionScreen { + pub name_input: TextInput, + pub session_layout: String, + pub current_layout_metadata: LayoutMetadata, + pub editing_name: bool, + pub save_current_tab_only: bool, +} + +impl NewLayoutFromCurrentSessionScreen { + pub fn handle_key(&mut self, key: KeyWithModifier) -> KeyResponse { + // Special Esc handling based on edit state + if key.bare_key == BareKey::Esc && key.has_no_modifiers() { + return self.handle_escape_key(); + } + + if self.editing_name { + self.handle_editing_mode_key(key) + } else { + self.handle_non_editing_mode_key(key) + } + } + + fn handle_escape_key(&mut self) -> KeyResponse { + if self.editing_name { + self.editing_name = false; + KeyResponse::render() + } else { + KeyResponse::new_screen(self.cancel_save()) + } + } + + fn handle_editing_mode_key(&mut self, key: KeyWithModifier) -> KeyResponse { + let action = self.name_input.handle_key(key); + + match action { + InputAction::Continue => KeyResponse::render(), + InputAction::Submit => self.exit_editing_mode(), + InputAction::Cancel => self.exit_editing_mode(), + InputAction::Complete => KeyResponse::none(), + InputAction::NoAction => KeyResponse::none(), + } + } + + fn handle_non_editing_mode_key(&mut self, key: KeyWithModifier) -> KeyResponse { + match key.bare_key { + BareKey::Enter if key.has_no_modifiers() => self.attempt_save_current_session(), + BareKey::Tab if key.has_no_modifiers() => self.handle_tab_toggle(), + BareKey::Char('u') if key.has_no_modifiers() => self.handle_update_layout(), + BareKey::Char('r') if key.has_no_modifiers() => self.enter_editing_mode(), + _ => KeyResponse::none(), + } + } + + fn exit_editing_mode(&mut self) -> KeyResponse { + self.editing_name = false; + KeyResponse::render() + } + + fn enter_editing_mode(&mut self) -> KeyResponse { + self.editing_name = true; + KeyResponse::render() + } + + fn handle_tab_toggle(&mut self) -> KeyResponse { + self.toggle_tab_target(); + if let Some(new_screen) = self.update_layout_from_session() { + KeyResponse::new_screen(new_screen) + } else { + KeyResponse::render() + } + } + + fn handle_update_layout(&mut self) -> KeyResponse { + if let Some(new_screen) = self.update_layout_from_session() { + KeyResponse::new_screen(new_screen) + } else { + KeyResponse::render() + } + } + + fn update_layout_from_session(&mut self) -> Option<Screen> { + let Ok(focused_pane_info) = get_focused_pane_info() else { + eprintln!("Cannot retrieve focused tab info"); + return None; + }; + let focused_tab_index = focused_pane_info.0; + if self.save_current_tab_only { + match dump_session_layout_for_tab(focused_tab_index) { + Ok((session_layout, Some(session_layout_metadata))) => { + self.session_layout = session_layout; + self.current_layout_metadata = session_layout_metadata; + None + }, + Ok((_, None)) => Some(Screen::Error(super::ErrorScreen { + message: "Failed to retrieve session layout metadata".to_string(), + return_to_screen: Box::new(Screen::LayoutList(Default::default())), + })), + Err(error_msg) => Some(Screen::Error(super::ErrorScreen { + message: format!("Failed to dump session layout: {}", error_msg), + return_to_screen: Box::new(Default::default()), + })), + } + } else { + match dump_session_layout() { + Ok((session_layout, Some(session_layout_metadata))) => { + self.session_layout = session_layout; + self.current_layout_metadata = session_layout_metadata; + None + }, + Ok((_, None)) => Some(Screen::Error(super::ErrorScreen { + message: "Failed to retrieve session layout metadata".to_string(), + return_to_screen: Box::new(Screen::LayoutList(Default::default())), + })), + Err(error_msg) => Some(Screen::Error(super::ErrorScreen { + message: format!("Failed to dump session layout: {}", error_msg), + return_to_screen: Box::new(Default::default()), + })), + } + } + } + + fn cancel_save(&self) -> Screen { + Screen::LayoutList(LayoutListScreen::default()) + } + + fn toggle_tab_target(&mut self) { + self.save_current_tab_only = !self.save_current_tab_only; + } + + fn attempt_save_current_session(&self) -> KeyResponse { + let layout_name = self.determine_layout_name(); + + // Capture optimistic update BEFORE the API call + let optimistic = OptimisticUpdate::Add { + name: layout_name.clone(), + metadata: self.current_layout_metadata.clone(), + }; + + match self.save_session_layout(&layout_name) { + Ok(()) => KeyResponse::new_screen(Screen::LayoutList(LayoutListScreen::default())) + .with_optimistic(optimistic), + Err(error_msg) => KeyResponse::new_screen(self.create_error_screen(&error_msg)), + } + } + + fn create_error_screen(&self, message: &str) -> Screen { + Screen::Error(ErrorScreen { + message: message.to_string(), + return_to_screen: Box::new(Screen::NewLayoutFromSession(self.clone())), + }) + } + + fn determine_layout_name(&self) -> String { + let input_text = self.name_input.get_text(); + if input_text.is_empty() { + generate_random_name() + } else { + input_text.to_string() + } + } + + fn save_session_layout(&self, layout_name: &str) -> Result<(), String> { + save_layout(layout_name.to_owned(), self.session_layout.clone(), false) + .map_err(|err| format!("Failed to save layout '{}': {}", layout_name, err))?; + + eprintln!( + "Successfully saved current session as layout: {}", + layout_name + ); + Ok(()) + } + + pub fn render(&self, rows: usize, cols: usize) { + let display_layout = DisplayLayout::Valid(LayoutInfo::File( + "current".to_string(), + self.current_layout_metadata.clone(), + )); + + let layout_detail = LayoutDetail::new(&display_layout); + + let desired_ui_width = std::cmp::max( + self.help_text_full().0.chars().count(), + self.max_description_width(), + ); + + let actual_ui_width = std::cmp::min(desired_ui_width, cols); + + let available_cols_for_details = actual_ui_width.saturating_sub(2); // Account for indentation + let desired_details_height = + layout_detail.calculate_required_height(available_cols_for_details); + + let desired_ui_height = 12 + desired_details_height; + let base_y = rows.saturating_sub(desired_ui_height) / 2; + let base_x = cols.saturating_sub(actual_ui_width) / 2; + + let save_as_line_y = base_y + 5; + self.update_cursor_position(base_x, save_as_line_y, actual_ui_width); // NOTE: must be before any render + // happens + + self.render_title(base_x, base_y, actual_ui_width); + self.render_description(base_x, base_y + 2, actual_ui_width); + self.render_save_as_line(base_x, save_as_line_y, actual_ui_width); + self.render_tab_toggle(base_x, base_y + 7, actual_ui_width); + + let details_y = base_y + 9; + + let actual_details_height = if rows < desired_ui_height { + rows.saturating_sub(details_y + 2) + } else { + desired_details_height + }; + + layout_detail.render( + base_x + 2, + details_y, + actual_details_height, + available_cols_for_details, + ); + + let help_y = details_y + actual_details_height + 1; + self.render_help_text(base_x, help_y, actual_ui_width); + } + + fn render_title(&self, x: usize, y: usize, width: usize) { + let title = Text::new("Save Layout of Current Session").color_all(2); + print_text_with_coordinates(title, x, y, Some(width), None); + } + + fn description_text_full(&self) -> (&str, &str) { + ( + "This layout was created from the current session.", + "Save it to recreate the session later or share it with others.", + ) + } + fn description_text_short(&self) -> (&str, &str) { + ( + "Layout from current session.", + "Save it to recreate or share the later.", + ) + } + fn render_description(&self, x: usize, y: usize, width: usize) { + let (line1, line2) = if width >= self.max_description_width() { + self.description_text_full() + } else { + self.description_text_short() + }; + print_text_with_coordinates(Text::new(line1), x, y, None, None); + print_text_with_coordinates(Text::new(line2), x, y + 1, None, None); + } + fn max_description_width(&self) -> usize { + let (line1, line2) = self.description_text_full(); + std::cmp::max(line1.chars().count(), line2.chars().count()) + } + + fn save_as_line_text(&self, width: usize) -> (String, usize) { + // (text, cursor_position_in_line) + let input_text = self.name_input.get_text(); + let cursor_pos = self.name_input.get_cursor_position(); + + let display_name = if input_text.is_empty() && !self.editing_name { + "RANDOM" + } else { + input_text + }; + let mut text = format!("Save as: {} (<r> Rename)", display_name); + let mut cursor_position_in_line = 9 + cursor_pos; + if text.chars().count() > width { + let truncated_display_name = truncate_with_ellipsis_start( + display_name, + width.saturating_sub(22), // size of text without the display name + ); + text = format!("Save as: {} (<r> Rename)", truncated_display_name); + let truncated_len = truncated_display_name.chars().count(); + cursor_position_in_line = 9 + cursor_pos.min(truncated_len); + } + (text, cursor_position_in_line) + } + fn render_save_as_line(&self, x: usize, y: usize, width: usize) { + let (text, _) = self.save_as_line_text(width); + let colored = Text::new(&text).color_substring(3, "<r>"); + + print_text_with_coordinates(colored, x, y, None, None); + } + + fn render_tab_toggle(&self, x: usize, y: usize, width: usize) { + let text = if self.save_current_tab_only { + "<Tab> All Tabs | [Current Tab Only]" + } else { + "<Tab> [All Tabs] | Current Tab Only" + }; + + let short_text = if self.save_current_tab_only { + "<Tab> All | [Current Tab]" + } else { + "<Tab> [All] | Current Tab" + }; + let text = if text.chars().count() > width { + short_text + } else { + text + }; + let colored = Text::new(text) + .color_substring(3, "<Tab>") + .color_substring(0, "[All Tabs]") + .color_substring(0, "[All]") + .color_substring(0, "[Current Tab Only]") + .color_substring(0, "[Current Tab]"); + + print_text_with_coordinates(colored, x, y, None, None); + } + + fn help_text_full(&self) -> (&str, &[&str]) { + ( + "<Enter> - Save, <u> - Update Layout, <Esc> - Back", + &["<Enter>", "<u>", "<Esc>"], + ) + } + fn help_text_short(&self) -> (&str, &[&str]) { + ( + "<Enter> - Save, <u> - Update, <Esc> - Back", + &["<Enter>", "<u>", "<Esc>"], + ) + } + fn render_help_text(&self, x: usize, y: usize, width: usize) { + let (text, items_to_color) = if width < self.help_text_full().0.chars().count() { + self.help_text_short() + } else { + self.help_text_full() + }; + let mut text = Text::new(text); + for item in items_to_color { + text = text.color_substring(3, item) + } + print_text_with_coordinates(text, x, y, None, None); + } + + pub fn update_cursor_position(&self, base_x: usize, cursor_y: usize, max_width: usize) { + if self.editing_name { + let (_, cursor_position_in_line) = self.save_as_line_text(max_width); + show_cursor(Some((base_x + cursor_position_in_line, cursor_y))) + } else { + show_cursor(None) + } + } +} diff --git a/default-plugins/layout-manager/src/screens/rename_layout.rs b/default-plugins/layout-manager/src/screens/rename_layout.rs new file mode 100644 index 00000000..5ca48bb5 --- /dev/null +++ b/default-plugins/layout-manager/src/screens/rename_layout.rs @@ -0,0 +1,156 @@ +use super::{ErrorScreen, KeyResponse, LayoutListScreen, OptimisticUpdate, Screen}; +use crate::text_input::{InputAction, TextInput}; +use crate::ui::truncate_with_ellipsis_start; +use zellij_tile::prelude::*; + +#[derive(Clone)] +pub struct RenameLayoutScreen { + pub name_input: TextInput, + pub old_file_name: String, + pub selected_layout_index: usize, +} + +impl RenameLayoutScreen { + pub fn new(old_file_name: String, selected_layout_index: usize) -> Self { + Self { + name_input: TextInput::new(old_file_name.clone()), + old_file_name, + selected_layout_index, + } + } + + pub fn handle_key(&mut self, key: KeyWithModifier) -> KeyResponse { + let action = self.name_input.handle_key(key); + + match action { + InputAction::Continue => KeyResponse::render(), + InputAction::Submit => self.attempt_rename(), + InputAction::Cancel => KeyResponse::new_screen(self.cancel_rename()), + InputAction::Complete => KeyResponse::none(), + InputAction::NoAction => KeyResponse::none(), + } + } + + fn cancel_rename(&self) -> Screen { + Screen::LayoutList(LayoutListScreen::with_selected_index( + self.selected_layout_index, + )) + } + + fn attempt_rename(&self) -> KeyResponse { + let name_input_text = self.name_input.get_text(); + + if name_input_text.is_empty() { + show_cursor(None); + return KeyResponse::new_screen( + self.create_error_screen("Layout name cannot be empty"), + ); + } + + let optimistic = OptimisticUpdate::Rename { + old_name: self.old_file_name.clone(), + new_name: name_input_text.to_string(), + }; + + match rename_layout(&self.old_file_name, name_input_text) { + Ok(_) => KeyResponse::new_screen(Screen::LayoutList( + LayoutListScreen::with_selected_index(self.selected_layout_index), + )) + .with_optimistic(optimistic), + Err(error_msg) => KeyResponse::new_screen(self.create_error_screen(&error_msg)), + } + } + + fn create_error_screen(&self, message: &str) -> Screen { + show_cursor(None); + Screen::Error(ErrorScreen { + message: message.to_string(), + return_to_screen: Box::new(Screen::RenameLayout(self.clone())), + }) + } + + fn rename_line_text(&self, max_width: Option<usize>) -> (String, usize) { + // Returns (text, cursor_position_in_line) + let prompt = "Rename Layout: "; + let prompt_len = prompt.chars().count(); + + let input_text = self.name_input.get_text(); + let cursor_pos = self.name_input.get_cursor_position(); + + let mut text = format!("{}{}", prompt, input_text); + let mut cursor_position_in_line = prompt_len + cursor_pos; + + if let Some(max_width) = max_width { + if text.chars().count() > max_width { + let truncated_name = + truncate_with_ellipsis_start(input_text, max_width.saturating_sub(prompt_len)); + text = format!("{}{}", prompt, truncated_name); + let truncated_len = truncated_name.chars().count(); + cursor_position_in_line = prompt_len + cursor_pos.min(truncated_len); + } + } + + (text, cursor_position_in_line) + } + + fn render_rename_line(&self, x: usize, y: usize, width: usize) { + let (text, _) = self.rename_line_text(Some(width)); + let colored = Text::new(&text); + print_text_with_coordinates(colored, x, y, None, None); + } + + fn help_text(&self) -> (&str, &[&str]) { + ("<Enter> - Save, <Esc> - Cancel", &["<Enter>", "<Esc>"]) + } + + fn render_help_text(&self, x: usize, y: usize, _width: usize) { + let (text, items_to_color) = self.help_text(); + let mut text_obj = Text::new(text); + for item in items_to_color { + text_obj = text_obj.color_substring(3, item); + } + print_text_with_coordinates(text_obj, x, y, None, None); + } + + fn render_title(&self, x: usize, y: usize, width: usize) { + let title = Text::new("Rename Layout").color_all(2); + print_text_with_coordinates(title, x, y, Some(width), None); + } + + fn update_cursor_position(&self, base_x: usize, cursor_y: usize, max_width: usize) { + // Always show cursor since rename is always in editing mode + let (_, cursor_position_in_line) = self.rename_line_text(Some(max_width)); + show_cursor(Some((base_x + cursor_position_in_line, cursor_y))) + } + + pub fn render(&self, rows: usize, cols: usize) { + // Calculate desired width based on rename line and help text + let desired_ui_width = std::cmp::max( + self.help_text().0.chars().count(), + self.rename_line_text(None).0.chars().count(), + ); + + // Leave at least 4 columns margin (2 on each side) to prevent text from reaching screen edge + let max_allowed_width = cols.saturating_sub(4); + let actual_ui_width = std::cmp::min(desired_ui_width, max_allowed_width); + + // Calculate total height: title(1) + spacing(1) + rename_line(1) + spacing(1) + help(1) = 5 + let desired_ui_height = 5; + let actual_ui_height = std::cmp::min(desired_ui_height, rows); + + let base_y = rows.saturating_sub(actual_ui_height) / 2; + let base_x = cols.saturating_sub(actual_ui_width) / 2; + + // Update cursor BEFORE rendering (critical!) + let rename_line_y = base_y + 2; + self.update_cursor_position(base_x, rename_line_y, actual_ui_width); + + // Render components + self.render_title(base_x, base_y, actual_ui_width); + self.render_rename_line(base_x, rename_line_y, actual_ui_width); + + // Help text at bottom + let help_y = base_y + 4; + self.render_help_text(base_x, help_y, actual_ui_width); + } +} diff --git a/default-plugins/layout-manager/src/text_input.rs b/default-plugins/layout-manager/src/text_input.rs new file mode 100644 index 00000000..4e3a0b48 --- /dev/null +++ b/default-plugins/layout-manager/src/text_input.rs @@ -0,0 +1,1007 @@ +// This is a duplicate of the same file in the sequence (and possibly other) plugins +// Right now there's no good place to put them for shared logic except the zellij-tile-utils crate +// (which I'd rather eliminate), hence the duplication. +use zellij_tile::prelude::*; + +const MAX_UNDO_STACK_SIZE: usize = 100; + +/// Action returned by TextInput after handling a key event +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(test, derive(Eq))] +pub enum InputAction { + /// Continue editing + Continue, + /// User pressed Enter to submit + Submit, + /// User pressed Esc to cancel + Cancel, + /// User pressed Tab to request completion + Complete, + /// Key was not handled by the input + NoAction, +} + +/// A reusable text input component with cursor support and standard editing keybindings +#[derive(Debug, Clone)] +pub struct TextInput { + buffer: String, + cursor_position: usize, // Character position (0-based), NOT byte position + undo_stack: Vec<(String, usize)>, // (buffer, cursor) snapshots + redo_stack: Vec<(String, usize)>, + last_edit_was_insert: bool, // For coalescing consecutive inserts +} + +impl TextInput { + /// Create a new TextInput with the given initial text + /// Cursor is positioned at the end of the text + pub fn new(initial_text: String) -> Self { + let cursor_position = initial_text.chars().count(); + Self { + buffer: initial_text, + cursor_position, + undo_stack: Vec::new(), + redo_stack: Vec::new(), + last_edit_was_insert: false, + } + } + + /// Create an empty TextInput + pub fn empty() -> Self { + Self::new(String::new()) + } + + /// Get the current text + pub fn get_text(&self) -> &str { + &self.buffer + } + + /// Get the cursor position (in characters, not bytes) + pub fn get_cursor_position(&self) -> usize { + self.cursor_position + } + + /// Check if the input is empty + pub fn is_empty(&self) -> bool { + self.buffer.is_empty() + } + + /// Get a shorthand for cursor_position + #[allow(unused)] + pub fn cursor_position(&self) -> usize { + self.cursor_position + } + + /// Get mutable access to the underlying buffer for direct manipulation + #[allow(unused)] + pub fn get_text_mut(&mut self) -> &mut String { + &mut self.buffer + } + + /// Set the text and move cursor to the end + #[allow(unused)] + pub fn set_text(&mut self, text: String) { + self.break_coalescing(); + self.save_undo_state(); + self.cursor_position = text.chars().count(); + self.buffer = text; + } + + /// Set cursor position (clamped to text length) + #[allow(unused)] + pub fn set_cursor_position(&mut self, pos: usize) { + let text_len = self.buffer.chars().count(); + self.cursor_position = pos.min(text_len); + } + + /// Clear all text and reset cursor + pub fn clear(&mut self) { + self.break_coalescing(); + self.save_undo_state(); + self.buffer.clear(); + self.cursor_position = 0; + } + + /// Insert a character at the current cursor position + pub fn insert_char(&mut self, c: char) { + self.save_undo_state_unless_coalescing(); + // Convert cursor position (char index) to byte index + let byte_index = self.char_index_to_byte_index(self.cursor_position); + self.buffer.insert(byte_index, c); + self.cursor_position += 1; + } + + /// Delete the character before the cursor (backspace) + pub fn backspace(&mut self) { + if self.cursor_position > 0 { + self.break_coalescing(); + self.save_undo_state(); + self.cursor_position -= 1; + let byte_index = self.char_index_to_byte_index(self.cursor_position); + self.buffer.remove(byte_index); + } + } + + /// Delete the character at the cursor position (delete key) + pub fn delete(&mut self) { + let len = self.buffer.chars().count(); + if self.cursor_position < len { + self.break_coalescing(); + self.save_undo_state(); + let byte_index = self.char_index_to_byte_index(self.cursor_position); + self.buffer.remove(byte_index); + } + } + + /// Delete the word before the cursor (Ctrl/Alt + Backspace) + pub fn delete_word_backward(&mut self) { + if self.cursor_position == 0 { + return; + } + + self.break_coalescing(); + self.save_undo_state(); + + let old_position = self.cursor_position; + self.move_word_left(); + let new_position = self.cursor_position; + + // Delete from new position to old position + let start_byte = self.char_index_to_byte_index(new_position); + let end_byte = self.char_index_to_byte_index(old_position); + self.buffer.drain(start_byte..end_byte); + } + + /// Delete the word after the cursor (Ctrl/Alt + Delete) + pub fn delete_word_forward(&mut self) { + let chars: Vec<char> = self.buffer.chars().collect(); + let len = chars.len(); + + if self.cursor_position >= len { + return; + } + + self.break_coalescing(); + self.save_undo_state(); + + let start_position = self.cursor_position; + let mut end_position = start_position; + + // Skip the current word + while end_position < len && !chars[end_position].is_whitespace() { + end_position += 1; + } + + // Skip any whitespace after the word + while end_position < len && chars[end_position].is_whitespace() { + end_position += 1; + } + + // Delete from start to end position + let start_byte = self.char_index_to_byte_index(start_position); + let end_byte = self.char_index_to_byte_index(end_position); + self.buffer.drain(start_byte..end_byte); + } + + /// Move cursor one position to the left + pub fn move_left(&mut self) { + if self.cursor_position > 0 { + self.break_coalescing(); + self.cursor_position -= 1; + } + } + + /// Move cursor one position to the right + pub fn move_right(&mut self) { + let len = self.buffer.chars().count(); + if self.cursor_position < len { + self.break_coalescing(); + self.cursor_position += 1; + } + } + + /// Move cursor to the start of the text (Ctrl-A / Home) + pub fn move_to_start(&mut self) { + self.break_coalescing(); + self.cursor_position = 0; + } + + /// Move cursor to the end of the text (Ctrl-E / End) + pub fn move_to_end(&mut self) { + self.break_coalescing(); + self.cursor_position = self.buffer.chars().count(); + } + + /// Move cursor to the start of the previous word (Ctrl/Alt + Left) + pub fn move_word_left(&mut self) { + if self.cursor_position == 0 { + return; + } + + self.break_coalescing(); + + let chars: Vec<char> = self.buffer.chars().collect(); + let mut pos = self.cursor_position; + + // Skip any whitespace immediately to the left + while pos > 0 && chars[pos - 1].is_whitespace() { + pos -= 1; + } + + // Skip the word characters + while pos > 0 && !chars[pos - 1].is_whitespace() { + pos -= 1; + } + + self.cursor_position = pos; + } + + /// Move cursor to the start of the next word (Ctrl/Alt + Right) + pub fn move_word_right(&mut self) { + let chars: Vec<char> = self.buffer.chars().collect(); + let len = chars.len(); + + if self.cursor_position >= len { + return; + } + + self.break_coalescing(); + + let mut pos = self.cursor_position; + + // Skip the current word + while pos < len && !chars[pos].is_whitespace() { + pos += 1; + } + + // Skip any whitespace + while pos < len && chars[pos].is_whitespace() { + pos += 1; + } + + self.cursor_position = pos; + } + + /// Handle a key event and return the appropriate action + /// This is the main entry point for key handling + pub fn handle_key(&mut self, key: KeyWithModifier) -> InputAction { + // Check for Ctrl modifiers + if key.has_modifiers(&[KeyModifier::Ctrl]) { + match key.bare_key { + BareKey::Char('a') => { + self.move_to_start(); + return InputAction::Continue; + }, + BareKey::Char('e') => { + self.move_to_end(); + return InputAction::Continue; + }, + BareKey::Char('c') => { + // Ctrl-C clears the prompt + return InputAction::Cancel; + }, + BareKey::Char('z') => { + // Ctrl-Z: Undo + self.undo(); + return InputAction::Continue; + }, + BareKey::Char('y') => { + // Ctrl-Y: Redo + self.redo(); + return InputAction::Continue; + }, + BareKey::Left => { + self.move_word_left(); + return InputAction::Continue; + }, + BareKey::Right => { + self.move_word_right(); + return InputAction::Continue; + }, + BareKey::Backspace => { + self.delete_word_backward(); + return InputAction::Continue; + }, + BareKey::Delete => { + self.delete_word_forward(); + return InputAction::Continue; + }, + _ => {}, + } + } + + // Check for Ctrl+Shift modifiers (alternative redo: Ctrl+Shift+Z) + if key.has_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) { + match key.bare_key { + BareKey::Char('Z') => { + // Ctrl-Shift-Z: Redo (alternative) + self.redo(); + return InputAction::Continue; + }, + _ => {}, + } + } + + // Check for Alt modifiers + if key.has_modifiers(&[KeyModifier::Alt]) { + match key.bare_key { + BareKey::Left => { + self.move_word_left(); + return InputAction::Continue; + }, + BareKey::Right => { + self.move_word_right(); + return InputAction::Continue; + }, + BareKey::Backspace => { + self.delete_word_backward(); + return InputAction::Continue; + }, + BareKey::Delete => { + self.delete_word_forward(); + return InputAction::Continue; + }, + _ => {}, + } + } + + // Handle bare keys (no modifiers) + match key.bare_key { + BareKey::Enter => InputAction::Submit, + BareKey::Esc => InputAction::Cancel, + BareKey::Tab => InputAction::Complete, + BareKey::Backspace => { + self.backspace(); + InputAction::Continue + }, + BareKey::Delete => { + self.delete(); + InputAction::Continue + }, + BareKey::Left => { + self.move_left(); + InputAction::Continue + }, + BareKey::Right => { + self.move_right(); + InputAction::Continue + }, + BareKey::Home => { + self.move_to_start(); + InputAction::Continue + }, + BareKey::End => { + self.move_to_end(); + InputAction::Continue + }, + BareKey::Char(c) => { + self.insert_char(c); + InputAction::Continue + }, + _ => InputAction::NoAction, + } + } + + /// Helper: Convert character index to byte index + fn char_index_to_byte_index(&self, char_index: usize) -> usize { + self.buffer + .char_indices() + .nth(char_index) + .map(|(byte_idx, _)| byte_idx) + .unwrap_or(self.buffer.len()) + } + + /// Save current state to undo stack before making changes + fn save_undo_state(&mut self) { + if self.undo_stack.len() >= MAX_UNDO_STACK_SIZE { + self.undo_stack.remove(0); + } + self.undo_stack + .push((self.buffer.clone(), self.cursor_position)); + self.redo_stack.clear(); + } + + /// Save state only if not coalescing with previous insert + fn save_undo_state_unless_coalescing(&mut self) { + if !self.last_edit_was_insert { + self.save_undo_state(); + } + self.last_edit_was_insert = true; + } + + /// Mark that a non-insert edit occurred (breaks coalescing) + fn break_coalescing(&mut self) { + self.last_edit_was_insert = false; + } + + /// Undo last change + pub fn undo(&mut self) -> bool { + if let Some((buffer, cursor)) = self.undo_stack.pop() { + self.redo_stack + .push((self.buffer.clone(), self.cursor_position)); + self.buffer = buffer; + self.cursor_position = cursor; + self.break_coalescing(); + true + } else { + false + } + } + + /// Redo last undone change + pub fn redo(&mut self) -> bool { + if let Some((buffer, cursor)) = self.redo_stack.pop() { + self.undo_stack + .push((self.buffer.clone(), self.cursor_position)); + self.buffer = buffer; + self.cursor_position = cursor; + self.break_coalescing(); + true + } else { + false + } + } + + /// Check if undo is available + #[allow(unused)] + pub fn can_undo(&self) -> bool { + !self.undo_stack.is_empty() + } + + /// Check if redo is available + #[allow(unused)] + pub fn can_redo(&self) -> bool { + !self.redo_stack.is_empty() + } + + #[allow(unused)] + pub fn drain_text(&mut self) -> String { + self.cursor_position = 0; + self.buffer.drain(..).collect() + } +} + +// run with: +// cargo test --lib --target x86_64-unknown-linux-gnu +// +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_and_empty() { + let input = TextInput::new("hello".to_string()); + assert_eq!(input.get_text(), "hello"); + assert_eq!(input.get_cursor_position(), 5); + + let empty = TextInput::empty(); + assert_eq!(empty.get_text(), ""); + assert_eq!(empty.get_cursor_position(), 0); + } + + #[test] + fn test_insert_char() { + let mut input = TextInput::new("helo".to_string()); + input.cursor_position = 3; // Position after "hel" + input.insert_char('l'); + assert_eq!(input.get_text(), "hello"); + assert_eq!(input.get_cursor_position(), 4); + } + + #[test] + fn test_backspace() { + let mut input = TextInput::new("hello".to_string()); + input.backspace(); + assert_eq!(input.get_text(), "hell"); + assert_eq!(input.get_cursor_position(), 4); + + // Backspace at start does nothing + input.cursor_position = 0; + input.backspace(); + assert_eq!(input.get_text(), "hell"); + assert_eq!(input.get_cursor_position(), 0); + } + + #[test] + fn test_delete() { + let mut input = TextInput::new("hello".to_string()); + input.cursor_position = 0; + input.delete(); + assert_eq!(input.get_text(), "ello"); + assert_eq!(input.get_cursor_position(), 0); + + // Delete at end does nothing + input.move_to_end(); + input.delete(); + assert_eq!(input.get_text(), "ello"); + } + + #[test] + fn test_cursor_movement() { + let mut input = TextInput::new("hello".to_string()); + assert_eq!(input.get_cursor_position(), 5); + + input.move_left(); + assert_eq!(input.get_cursor_position(), 4); + + input.move_right(); + assert_eq!(input.get_cursor_position(), 5); + + input.move_to_start(); + assert_eq!(input.get_cursor_position(), 0); + + input.move_to_end(); + assert_eq!(input.get_cursor_position(), 5); + } + + #[test] + fn test_unicode_support() { + let mut input = TextInput::new("hello 🦀 world".to_string()); + assert_eq!(input.get_cursor_position(), 13); // 13 characters + + input.cursor_position = 6; // After "hello " + input.insert_char('🐱'); + assert_eq!(input.get_text(), "hello 🐱🦀 world"); + } + + #[test] + fn test_word_jump_right() { + let mut input = TextInput::new("hello world foo bar".to_string()); + input.cursor_position = 0; + + // Jump from start to "world" + input.move_word_right(); + assert_eq!(input.get_cursor_position(), 6); // After "hello " + + // Jump to "foo" + input.move_word_right(); + assert_eq!(input.get_cursor_position(), 12); // After "world " + + // Jump to "bar" + input.move_word_right(); + assert_eq!(input.get_cursor_position(), 16); // After "foo " + + // Jump to end + input.move_word_right(); + assert_eq!(input.get_cursor_position(), 19); // At end + } + + #[test] + fn test_word_jump_left() { + let mut input = TextInput::new("hello world foo bar".to_string()); + input.move_to_end(); + assert_eq!(input.get_cursor_position(), 19); + + // Jump back to "bar" + input.move_word_left(); + assert_eq!(input.get_cursor_position(), 16); // Start of "bar" + + // Jump back to "foo" + input.move_word_left(); + assert_eq!(input.get_cursor_position(), 12); // Start of "foo" + + // Jump back to "world" + input.move_word_left(); + assert_eq!(input.get_cursor_position(), 6); // Start of "world" + + // Jump back to "hello" + input.move_word_left(); + assert_eq!(input.get_cursor_position(), 0); // Start of "hello" + } + + #[test] + fn test_word_jump_with_multiple_spaces() { + let mut input = TextInput::new("hello world".to_string()); + input.cursor_position = 0; + + // Jump over multiple spaces + input.move_word_right(); + assert_eq!(input.get_cursor_position(), 8); // After "hello ", at start of "world" + + // Jump back should skip spaces + input.move_word_left(); + assert_eq!(input.get_cursor_position(), 0); // Back to start of "hello" + } + + #[test] + fn test_word_jump_boundaries() { + let mut input = TextInput::new("test".to_string()); + + // At start - word left does nothing + input.cursor_position = 0; + input.move_word_left(); + assert_eq!(input.get_cursor_position(), 0); + + // At end - word right does nothing + input.move_to_end(); + let end_pos = input.get_cursor_position(); + input.move_word_right(); + assert_eq!(input.get_cursor_position(), end_pos); + } + + #[test] + fn test_up_down_arrows() { + let mut input = TextInput::new("hello world".to_string()); + + // Start in the middle + input.cursor_position = 5; + assert_eq!(input.get_cursor_position(), 5); + + // Up arrow should go to start + input.move_to_start(); + assert_eq!(input.get_cursor_position(), 0); + + // Move back to middle + input.cursor_position = 5; + + // Down arrow should go to end + input.move_to_end(); + assert_eq!(input.get_cursor_position(), 11); + } + + #[test] + fn test_delete_word_backward() { + let mut input = TextInput::new("hello world foo".to_string()); + + // Delete "foo" from end + input.move_to_end(); + input.delete_word_backward(); + assert_eq!(input.get_text(), "hello world "); + assert_eq!(input.get_cursor_position(), 12); + + // Delete "world " + input.delete_word_backward(); + assert_eq!(input.get_text(), "hello "); + assert_eq!(input.get_cursor_position(), 6); + + // Delete "hello " + input.delete_word_backward(); + assert_eq!(input.get_text(), ""); + assert_eq!(input.get_cursor_position(), 0); + + // Delete on empty buffer does nothing + input.delete_word_backward(); + assert_eq!(input.get_text(), ""); + assert_eq!(input.get_cursor_position(), 0); + } + + #[test] + fn test_delete_word_backward_middle() { + let mut input = TextInput::new("hello world foo".to_string()); + + // Position in middle of "world" + input.cursor_position = 8; // After "hello wo" + input.delete_word_backward(); + assert_eq!(input.get_text(), "hello rld foo"); + assert_eq!(input.get_cursor_position(), 6); // After "hello " + } + + #[test] + fn test_delete_word_forward() { + let mut input = TextInput::new("hello world foo".to_string()); + + // Delete "hello " from start + input.cursor_position = 0; + input.delete_word_forward(); + assert_eq!(input.get_text(), "world foo"); + assert_eq!(input.get_cursor_position(), 0); + + // Delete "world " + input.delete_word_forward(); + assert_eq!(input.get_text(), "foo"); + assert_eq!(input.get_cursor_position(), 0); + + // Delete "foo" + input.delete_word_forward(); + assert_eq!(input.get_text(), ""); + assert_eq!(input.get_cursor_position(), 0); + + // Delete on empty buffer does nothing + input.delete_word_forward(); + assert_eq!(input.get_text(), ""); + assert_eq!(input.get_cursor_position(), 0); + } + + #[test] + fn test_delete_word_forward_middle() { + let mut input = TextInput::new("hello world foo".to_string()); + + // Position in middle of "world" + input.cursor_position = 8; // After "hello wo" + input.delete_word_forward(); + assert_eq!(input.get_text(), "hello wofoo"); + assert_eq!(input.get_cursor_position(), 8); // Same position, text deleted forward + } + + #[test] + fn test_delete_word_with_multiple_spaces() { + let mut input = TextInput::new("hello world".to_string()); + + // Delete forward includes trailing spaces + input.cursor_position = 0; + input.delete_word_forward(); + assert_eq!(input.get_text(), "world"); + assert_eq!(input.get_cursor_position(), 0); + } + + #[test] + fn test_undo_redo_basic() { + let mut input = TextInput::empty(); + + // Type "hello" + input.insert_char('h'); + input.insert_char('e'); + input.insert_char('l'); + input.insert_char('l'); + input.insert_char('o'); + assert_eq!(input.get_text(), "hello"); + + // Undo should remove all characters (coalesced into one undo entry) + assert!(input.can_undo()); + assert!(input.undo()); + assert_eq!(input.get_text(), ""); + assert_eq!(input.get_cursor_position(), 0); + + // Redo should restore "hello" + assert!(input.can_redo()); + assert!(input.redo()); + assert_eq!(input.get_text(), "hello"); + assert_eq!(input.get_cursor_position(), 5); + } + + #[test] + fn test_undo_coalescing_breaks_on_cursor_move() { + let mut input = TextInput::empty(); + + // Type "he" + input.insert_char('h'); + input.insert_char('e'); + + // Move cursor to start (breaks coalescing) + input.move_to_start(); + + // Type "llo" + input.insert_char('l'); + input.insert_char('l'); + input.insert_char('o'); + + assert_eq!(input.get_text(), "llohe"); + + // First undo removes "llo" (second coalesced group) + input.undo(); + assert_eq!(input.get_text(), "he"); + + // Second undo removes "he" (first coalesced group) + input.undo(); + assert_eq!(input.get_text(), ""); + } + + #[test] + fn test_undo_backspace() { + let mut input = TextInput::new("hello".to_string()); + + // Backspace once + input.backspace(); + assert_eq!(input.get_text(), "hell"); + + // Undo should restore "hello" + input.undo(); + assert_eq!(input.get_text(), "hello"); + assert_eq!(input.get_cursor_position(), 5); + } + + #[test] + fn test_undo_delete() { + let mut input = TextInput::new("hello".to_string()); + input.cursor_position = 0; + + // Delete first character + input.delete(); + assert_eq!(input.get_text(), "ello"); + + // Undo should restore "hello" + input.undo(); + assert_eq!(input.get_text(), "hello"); + assert_eq!(input.get_cursor_position(), 0); + } + + #[test] + fn test_undo_word_delete() { + let mut input = TextInput::new("hello world".to_string()); + + // Delete "world" backward + input.delete_word_backward(); + assert_eq!(input.get_text(), "hello "); + + // Undo should restore "hello world" + input.undo(); + assert_eq!(input.get_text(), "hello world"); + assert_eq!(input.get_cursor_position(), 11); + } + + #[test] + fn test_undo_clear() { + let mut input = TextInput::new("hello world".to_string()); + + // Clear the buffer + input.clear(); + assert_eq!(input.get_text(), ""); + + // Undo should restore the text + input.undo(); + assert_eq!(input.get_text(), "hello world"); + } + + #[test] + fn test_undo_set_text() { + let mut input = TextInput::new("hello".to_string()); + + // Replace with new text + input.set_text("goodbye".to_string()); + assert_eq!(input.get_text(), "goodbye"); + + // Undo should restore "hello" + input.undo(); + assert_eq!(input.get_text(), "hello"); + } + + #[test] + fn test_redo_clears_on_new_edit() { + let mut input = TextInput::empty(); + + // Type "hello" + input.insert_char('h'); + input.insert_char('e'); + input.insert_char('l'); + input.insert_char('l'); + input.insert_char('o'); + + // Undo + input.undo(); + assert_eq!(input.get_text(), ""); + assert!(input.can_redo()); + + // Make a new edit (should clear redo stack) + input.insert_char('x'); + assert!(!input.can_redo()); + } + + #[test] + fn test_multiple_undo_redo() { + let mut input = TextInput::empty(); + + // First edit: type "hello" + for c in "hello".chars() { + input.insert_char(c); + } + + // Break coalescing + input.move_left(); + + // Second edit: type "world" + for c in "world".chars() { + input.insert_char(c); + } + + assert_eq!(input.get_text(), "hellworldo"); + + // Undo "world" + input.undo(); + assert_eq!(input.get_text(), "hello"); + + // Undo "hello" + input.undo(); + assert_eq!(input.get_text(), ""); + + // Redo "hello" + input.redo(); + assert_eq!(input.get_text(), "hello"); + + // Redo "world" + input.redo(); + assert_eq!(input.get_text(), "hellworldo"); + } + + #[test] + fn test_undo_stack_limit() { + let mut input = TextInput::empty(); + + // Perform 102 separate edits (breaking coalescing each time) + for _i in 0..102 { + input.backspace(); // Break coalescing + input.insert_char('x'); + } + + // Should have at most 100 undo entries + let mut undo_count = 0; + while input.undo() { + undo_count += 1; + } + + // We should have 100 undo entries (the stack limit) + // Plus the final state change from the last coalescing break + assert!( + undo_count <= 100, + "Undo count should be at most 100, got {}", + undo_count + ); + } + + #[test] + fn test_undo_redo_empty_stack() { + let mut input = TextInput::empty(); + + // Undo on empty stack should return false + assert!(!input.can_undo()); + assert!(!input.undo()); + + // Redo on empty stack should return false + assert!(!input.can_redo()); + assert!(!input.redo()); + } + + #[test] + fn test_undo_restores_cursor_position() { + let mut input = TextInput::new("hello world".to_string()); + + // Move cursor to position 5 (before "world") + input.cursor_position = 5; + + // Insert a space + input.insert_char(' '); + assert_eq!(input.get_text(), "hello world"); + assert_eq!(input.get_cursor_position(), 6); + + // Undo should restore both text and cursor position + input.undo(); + assert_eq!(input.get_text(), "hello world"); + assert_eq!(input.get_cursor_position(), 5); + } + + #[test] + fn test_coalescing_consecutive_inserts() { + let mut input = TextInput::empty(); + + // Type several characters + input.insert_char('a'); + input.insert_char('b'); + input.insert_char('c'); + + assert_eq!(input.get_text(), "abc"); + + // Single undo should remove all three (they were coalesced) + input.undo(); + assert_eq!(input.get_text(), ""); + + // No more undo available + assert!(!input.can_undo()); + } + + #[test] + fn test_backspace_breaks_coalescing() { + let mut input = TextInput::empty(); + + // Type "ab" + input.insert_char('a'); + input.insert_char('b'); + + // Backspace + input.backspace(); + assert_eq!(input.get_text(), "a"); + + // Type "c" + input.insert_char('c'); + assert_eq!(input.get_text(), "ac"); + + // Undo should remove just "c" + input.undo(); + assert_eq!(input.get_text(), "a"); + + // Undo should remove backspace operation + input.undo(); + assert_eq!(input.get_text(), "ab"); + + // Undo should remove "ab" + input.undo(); + assert_eq!(input.get_text(), ""); + } +} diff --git a/default-plugins/layout-manager/src/ui/layout_detail_view.rs b/default-plugins/layout-manager/src/ui/layout_detail_view.rs new file mode 100644 index 00000000..2095d38f --- /dev/null +++ b/default-plugins/layout-manager/src/ui/layout_detail_view.rs @@ -0,0 +1,345 @@ +use super::text_utils::{get_layout_display_info, truncate_with_ellipsis, wrap_text_to_width}; +use crate::DisplayLayout; +use zellij_tile::prelude::*; + +pub struct LayoutDetail<'a> { + layout: &'a DisplayLayout, +} + +impl<'a> LayoutDetail<'a> { + pub fn new(layout: &'a DisplayLayout) -> Self { + Self { layout } + } + + pub fn render(&self, x: usize, y: usize, max_rows: usize, max_cols: usize) { + match self.layout { + DisplayLayout::Valid(_) => { + self.render_valid_layout(self.layout, x, y, max_rows, max_cols) + }, + DisplayLayout::Error { + name: _, + error: _, + error_message, + } => self.render_error(error_message, x, y, max_rows, max_cols), + } + } + + pub fn calculate_required_height(&self, max_cols: usize) -> usize { + match self.layout { + DisplayLayout::Valid(_) => { + let (_, metadata_opt) = get_layout_display_info(self.layout); + let Some(metadata) = metadata_opt else { + return 1; // No metadata available + }; + self.calculate_content_lines(metadata, max_cols) + }, + DisplayLayout::Error { .. } => { + 3 // Error message + hint line + }, + } + } + + fn calculate_content_lines(&self, metadata: &LayoutMetadata, max_cols: usize) -> usize { + if !self.should_show_tabs_section(metadata) { + // Panes only + let panes_lines = self.prepare_panes_content(metadata, max_cols); + panes_lines.len() + } else { + // Tabs and panes side-by-side + let (left_width, right_width, _) = self.calculate_column_layout(max_cols); + let tabs_lines = self.prepare_tabs_content(metadata, left_width); + let panes_lines = self.prepare_panes_content(metadata, right_width); + tabs_lines.len().max(panes_lines.len()) + } + } + + fn render_valid_layout( + &self, + layout: &DisplayLayout, + x: usize, + y: usize, + max_rows: usize, + max_cols: usize, + ) { + let (name, metadata_opt) = get_layout_display_info(layout); + let Some(metadata) = metadata_opt else { + if layout.is_builtin() { + self.render_built_in_indication(&name, x, y, max_rows, max_cols); + } else { + self.render_no_metadata(x, y); + } + return; + }; + + self.render_tabs_and_panes(metadata, x, y, max_rows, max_cols); + } + + fn render_no_metadata(&self, x: usize, y: usize) { + let msg = Text::new("No metadata available").color_all(1); + print_text_with_coordinates(msg, x, y + 1, None, None); + } + fn render_built_in_indication( + &self, + name: &str, + x: usize, + y: usize, + max_rows: usize, + max_cols: usize, + ) { + if max_rows == 0 { + return; + } + + let full_text = format!("{} is a built-in layout. Create your own layouts to automate or share workspace setup.", name); + let wrapped_lines = wrap_text_to_width(&full_text, max_cols); + + let mut current_y = y; + for line in wrapped_lines.iter().take(max_rows) { + let text = if line.contains(name) { + Text::new(line).color_substring(1, name) + } else { + Text::new(line) + }; + print_text_with_coordinates(text, x, current_y, None, None); + current_y += 1; + } + } + + fn render_error( + &self, + error_message: &str, + x: usize, + y: usize, + max_rows: usize, + max_cols: usize, + ) { + if max_rows == 0 { + return; + } + + let wrapped_lines = wrap_text_to_width(error_message, max_cols); + let available_rows = max_rows.saturating_sub(3); + + let mut current_y = y + 1; // + 1 (and saturating_sub(3) above) to be aligned witht he + // table on the left + for line in wrapped_lines.iter().take(available_rows) { + let text = Text::new(line).error_color_all(); + print_text_with_coordinates(text, x, current_y, None, None); + current_y += 1; + } + + let hint = Text::new("<m> - Show detailed error").color_substring(3, "<m>"); + print_text_with_coordinates(hint, x, current_y + 1, None, None); // 1 for gap + } + + fn render_tabs_and_panes( + &self, + metadata: &LayoutMetadata, + x: usize, + y: usize, + max_rows: usize, + max_cols: usize, + ) { + // let max_rows = max_rows.saturating_sub(1); + + if !self.should_show_tabs_section(metadata) { + self.render_panes_only(metadata, x, y, max_rows, max_cols); + return; + } + + let (left_width, right_width, padding) = self.calculate_column_layout(max_cols); + let right_x = x + left_width + padding; + + let tabs_lines = self.prepare_tabs_content(metadata, left_width); + let panes_lines = self.prepare_panes_content(metadata, right_width); + + let (tabs_truncated, panes_truncated) = + self.render_side_by_side_columns(&panes_lines, &tabs_lines, x, right_x, y, max_rows); + + if tabs_truncated { + self.render_truncation_ellipsis(x, y + max_rows); + } + if panes_truncated { + self.render_truncation_ellipsis(right_x, y + max_rows); + } + } + + fn render_side_by_side_columns( + &self, + left_lines: &[String], + right_lines: &[String], + left_x: usize, + right_x: usize, + start_y: usize, + max_rows: usize, + ) -> (bool, bool) { + let max_lines = left_lines.len().max(right_lines.len()); + let mut left_truncated = false; + let mut right_truncated = false; + + for line_idx in 0..=max_lines { + if line_idx >= max_rows { + if line_idx < left_lines.len() { + left_truncated = true; + } + if line_idx < right_lines.len() { + right_truncated = true; + } + break; + } + + let current_y = start_y + line_idx; + + if let Some(line) = left_lines.get(line_idx) { + self.render_colored_line(line, left_x, current_y, line_idx == 0, 2); + } + + if let Some(line) = right_lines.get(line_idx) { + self.render_colored_line(line, right_x, current_y, line_idx == 0, 2); + } + } + + (left_truncated, right_truncated) + } + + fn should_show_tabs_section(&self, metadata: &LayoutMetadata) -> bool { + metadata.tabs.len() > 1 + } + + fn prepare_tabs_content(&self, metadata: &LayoutMetadata, max_width: usize) -> Vec<String> { + let mut lines = Vec::new(); + + // Add title + let title = "Tabs:"; + lines.push(truncate_with_ellipsis(title, max_width)); + + // Add tabs + for (i, tab) in metadata.tabs.iter().enumerate() { + let tab_label = if let Some(name) = &tab.name { + name.clone() + } else { + format!("Tab {}", i + 1) + }; + + // Account for " - " prefix (4 characters) + let available_width = max_width.saturating_sub(4); + let truncated_label = truncate_with_ellipsis(&tab_label, available_width); + lines.push(format!(" - {}", truncated_label)); + } + + lines + } + + fn prepare_panes_content(&self, metadata: &LayoutMetadata, max_width: usize) -> Vec<String> { + let mut lines = Vec::new(); + + let (named_panes, terminal_count) = self.collect_pane_info(metadata); + + // Early return if nothing to show + if !self.has_panes_to_show(&named_panes, terminal_count) { + return lines; + } + + // Add title + let title = "Panes:"; + lines.push(truncate_with_ellipsis(title, max_width)); + + // Add named panes + for pane_name in &named_panes { + let available_width = max_width.saturating_sub(4); + let truncated_name = truncate_with_ellipsis(pane_name, available_width); + lines.push(format!(" - {}", truncated_name)); + } + + // Add terminal count if any + if terminal_count > 0 { + let text = if named_panes.is_empty() { + format!(" {} Terminals", terminal_count) + } else { + format!(" +{} Terminals", terminal_count) + }; + let truncated_text = truncate_with_ellipsis(&text, max_width); + lines.push(truncated_text); + } + + lines + } + + fn calculate_column_layout(&self, max_cols: usize) -> (usize, usize, usize) { + let left_width = (max_cols - 1) / 2; + let padding = 1; + let right_width = max_cols - left_width - padding; + (left_width, right_width, padding) + } + + fn render_colored_line( + &self, + line: &str, + x: usize, + y: usize, + is_title: bool, + title_color: usize, + ) { + let text = if is_title { + Text::new(line).color_all(title_color) + } else { + Text::new(line) + }; + print_text_with_coordinates(text, x, y, None, None); + } + + fn render_truncation_ellipsis(&self, x: usize, y: usize) { + let ellipsis = Text::new(" [...]").color_all(1); + print_text_with_coordinates(ellipsis, x, y, None, None); + } + + fn render_panes_only( + &self, + metadata: &LayoutMetadata, + x: usize, + y: usize, + max_rows: usize, + max_cols: usize, + ) { + let panes_lines = self.prepare_panes_content(metadata, max_cols); + + let mut was_truncated = false; + for (line_idx, pane_line) in panes_lines.iter().enumerate() { + if line_idx >= max_rows { + was_truncated = true; + break; + } + + let current_y = y + line_idx; + self.render_colored_line(pane_line, x, current_y, line_idx == 0, 2); + } + + if was_truncated { + self.render_truncation_ellipsis(x, y + max_rows); + } + } + + fn has_panes_to_show(&self, named_panes: &[String], terminal_count: usize) -> bool { + !named_panes.is_empty() || terminal_count > 0 + } + + fn collect_pane_info(&self, metadata: &LayoutMetadata) -> (Vec<String>, usize) { + let mut named_panes = Vec::new(); + let mut terminal_count = 0; + + for tab in &metadata.tabs { + for pane in &tab.panes { + // Skip builtin plugin panes + if pane.is_builtin_plugin { + continue; + } + match &pane.name { + Some(name) => named_panes.push(name.clone()), + None => terminal_count += 1, + } + } + } + + (named_panes, terminal_count) + } +} diff --git a/default-plugins/layout-manager/src/ui/layout_table.rs b/default-plugins/layout-manager/src/ui/layout_table.rs new file mode 100644 index 00000000..2f22f06e --- /dev/null +++ b/default-plugins/layout-manager/src/ui/layout_table.rs @@ -0,0 +1,452 @@ +use super::text_utils::{ + get_last_modified_string, get_layout_display_info, truncate_with_ellipsis, +}; +use crate::DisplayLayout; +use zellij_tile::prelude::*; + +fn create_table_row( + name: &str, + metadata: Option<&LayoutMetadata>, + is_error: bool, + is_builtin: bool, + max_name_width: Option<usize>, + matched_indices: Option<&Vec<usize>>, +) -> Vec<Text> { + let display_name = if let Some(max_width) = max_name_width { + truncate_with_ellipsis(name, max_width) + } else { + name.to_string() + }; + + let name_text = if let Some(indices) = matched_indices { + // Create text with highlighted matched characters + let mut text = Text::new(&display_name); + + // Apply base color to entire text + text = if is_error { + text.error_color_all() + } else { + text.color_all(1) + }; + + text = text.color_indices(3, indices.iter().cloned().collect()); + + text + } else { + // No matches, use original coloring + if is_error { + Text::new(&display_name).error_color_all() + } else { + Text::new(&display_name).color_all(1) + } + }; + + let last_modified_string = get_last_modified_string(metadata, is_builtin); + let last_modified = if is_builtin { + Text::new(&last_modified_string).color_all(0) + } else { + Text::new(&last_modified_string) + }; + + vec![name_text, last_modified] +} + +fn apply_selection(mut row: Vec<Text>) -> Vec<Text> { + for cell in &mut row { + *cell = std::mem::take(cell).selected(); + } + row +} + +pub struct LayoutsTable { + display_layouts: Vec<DisplayLayout>, + selected_layout_index: usize, + hidden_above: usize, + hidden_below: usize, + matched_indices: Vec<Option<Vec<usize>>>, +} + +impl LayoutsTable { + pub fn new( + display_layouts: Vec<DisplayLayout>, + selected_layout_index: usize, + hidden_above: usize, + hidden_below: usize, + ) -> Self { + Self { + display_layouts, + selected_layout_index, + hidden_above, + hidden_below, + matched_indices: Vec::new(), + } + } + + pub fn with_matched_indices(mut self, matched_indices: Vec<Option<Vec<usize>>>) -> Self { + self.matched_indices = matched_indices; + self + } + + pub fn render(&self, x: usize, y: usize, _max_rows: usize, max_cols: usize) { + let mut table = match self.overflow_indicator(self.hidden_above) { + Some(overflow_indicator) => { + Table::new().add_styled_row(vec![Text::new(" "), overflow_indicator]) + }, + None => Table::new().add_row(vec![" ", " "]), + }; + + // Calculate the actual width needed for the "last modified" column + let max_last_modified_width = self + .display_layouts + .iter() + .map(|layout| { + let (_, metadata_opt) = get_layout_display_info(layout); + get_last_modified_string(metadata_opt, layout.is_builtin()).len() + }) + .max() + .unwrap_or(1); + + let table_padding = 2; + let max_name_width = if max_cols > max_last_modified_width + table_padding { + Some(max_cols.saturating_sub(max_last_modified_width + table_padding)) + } else { + Some(max_cols.saturating_div(2)) + }; + + for (i, layout) in self.display_layouts.iter().enumerate() { + let (name, metadata_opt) = get_layout_display_info(layout); + let matched_indices = self.matched_indices.get(i).and_then(|opt| opt.as_ref()); + let mut row = create_table_row( + &name, + metadata_opt, + layout.is_error(), + layout.is_builtin(), + max_name_width, + matched_indices, + ); + + if i == self.selected_layout_index { + row = apply_selection(row); + } + + table = table.add_styled_row(row); + } + + if let Some(overflow_indicator) = self.overflow_indicator(self.hidden_below) { + table = table.add_styled_row(vec![Text::new(" "), overflow_indicator]); + } + + print_table_with_coordinates(table, x, y, Some(max_cols), None); + } + fn overflow_indicator(&self, count: usize) -> Option<Text> { + if count > 0 { + Some(Text::new(format!("[+{}]", count)).color_all(2)) + } else { + None + } + } +} + +fn color_control_text(text: &str, keys: &[&str]) -> Text { + let mut colored_text = Text::new(text); + for key in keys { + colored_text = colored_text.color_substring(3, key) + } + colored_text +} + +pub struct Controls { + retain_terminal_panes: bool, + retain_plugin_panes: bool, + apply_only_to_active_tab: bool, + show_more_options: bool, + typing_filter: bool, + filter_active: bool, +} + +impl Controls { + pub fn new( + retain_terminal_panes: bool, + retain_plugin_panes: bool, + apply_only_to_active_tab: bool, + show_more_options: bool, + typing_filter: bool, + filter_active: bool, + ) -> Self { + Self { + retain_terminal_panes, + retain_plugin_panes, + apply_only_to_active_tab, + show_more_options, + typing_filter, + filter_active, + } + } + + fn get_typing_filter_controls(&self, max_cols: usize) -> (&str, &[&str]) { + let long_text = "- <Enter> - accept filter"; + let short_text = "<Enter> - accept filter"; + let minimum_text = "<Enter> ..."; + let text = if max_cols >= long_text.chars().count() { + long_text + } else if max_cols >= short_text.chars().count() { + short_text + } else { + minimum_text + }; + (text, &["<Enter>"]) + } + + fn get_filter_active_controls(&self, max_cols: usize) -> (&str, &[&str]) { + let long_text = "- <Enter> Open, <↓↑> Nav, <e> Edit, <r> Rename, <Del> - Delete"; + let short_text = "<Enter>/<↓↑>/<e>/<r>/<Del> Open/Nav/Edit/Rename/Del"; + let minimum_text = "<Enter>/<↓↑>/<e>/<r>/<Del> ..."; + let text = if max_cols >= long_text.chars().count() { + long_text + } else if max_cols >= short_text.chars().count() { + short_text + } else { + minimum_text + }; + (text, &["<Enter>", "<↓↑>", "<e>", "<r>", "<Del>"]) + } + + fn get_default_controls(&self, max_cols: usize) -> (&str, &[&str]) { + let long_text = "- <Enter> Open, <↓↑> Nav, </> Filter, <e> Edit, <r> Rename, <Del> - Del"; + let short_text = "<Enter>/<↓↑>/</>/<e>/<r>/<Del> Open/Nav/Filter/Edit/Rename/Del"; + let minimum_text = "<Enter>/<↓↑>/</>/<e>/<r>/<Del> ..."; + let text = if max_cols >= long_text.chars().count() { + long_text + } else if max_cols >= short_text.chars().count() { + short_text + } else { + minimum_text + }; + (text, &["<Enter>", "<↓↑>", "</>", "<e>", "<r>", "<Del>"]) + } + + fn get_basic_controls_text_and_keys(&self, max_cols: usize) -> (&str, &[&str]) { + if self.filter_active { + self.get_filter_active_controls(max_cols) + } else { + self.get_default_controls(max_cols) + } + } + + fn get_override_text_and_keys(&self, max_cols: usize) -> (String, &[&str]) { + let toggle_word = if self.show_more_options { + "less" + } else { + "more" + }; + let long_text = format!( + "- <Tab> Override Session Layout, <?> {} options", + toggle_word + ); + let short_text = format!("<Tab> Override, <?> {} options", toggle_word); + let minimum_text = format!("<Tab>/<?> ..."); + let text = if max_cols >= long_text.chars().count() { + long_text + } else if max_cols >= short_text.chars().count() { + short_text + } else { + minimum_text + }; + (text, &["<Tab>", "<?>"]) + } + + fn get_new_layout_text_and_keys(&self, max_cols: usize) -> (&str, &[&str]) { + let long_text = "- New Layout: <n> from current session, <i> import"; + let short_text = "New: <n> current, <i> import"; + let minimum_text = "New: <n>/<N>/<i> ..."; + let text = if max_cols >= long_text.chars().count() { + long_text + } else if max_cols >= short_text.chars().count() { + short_text + } else { + minimum_text + }; + (text, &["<n>", "<i>"]) + } + + pub fn calculate_width(&self, max_cols: usize) -> usize { + let mut width = 0; + + // Line 1: Basic controls + let (basic_text, _) = self.get_default_controls(max_cols); + width = width.max(basic_text.chars().count()); + + // Line 2: Override and toggle + let (override_text, _) = self.get_override_text_and_keys(max_cols); + width = width.max(override_text.chars().count()); + + // Line 3 (or 5 if show_more_options): New layout controls + let (new_layout_text, _) = self.get_new_layout_text_and_keys(max_cols); + width = width.max(new_layout_text.chars().count()); + + // Lines 4-5: Advanced options (only if show_more_options) + if self.show_more_options { + let (retain_text, _) = self.get_retain_text_and_highlight(max_cols); + width = width.max(retain_text.chars().count()); + + let (target_text, _) = self.get_target_text_and_highlight(max_cols); + width = width.max(target_text.chars().count()); + } + + width + } + + pub fn render(&self, x: usize, y: usize, max_cols: usize) { + if self.typing_filter { + self.render_typing_filter_controls(x, y, max_cols); + } else { + self.render_basic_controls(x, y, max_cols); + self.render_override_and_toggle(x, y + 1, max_cols); + + let new_layout_y = if self.show_more_options { y + 4 } else { y + 2 }; + self.render_new_layout_controls(x, new_layout_y, max_cols); + + if self.show_more_options { + self.render_advanced_options(x, y + 2, max_cols); + } + } + } + + fn render_basic_controls(&self, x: usize, y: usize, max_width: usize) { + let (text, keys) = self.get_basic_controls_text_and_keys(max_width); + let controls_line = color_control_text(text, keys); + print_text_with_coordinates(controls_line, x, y, None, None); + } + + fn render_override_and_toggle(&self, x: usize, y: usize, max_cols: usize) { + let (text, keys) = self.get_override_text_and_keys(max_cols); + let override_line = color_control_text(&text, keys); + print_text_with_coordinates(override_line, x, y, None, None); + } + + fn render_new_layout_controls(&self, x: usize, y: usize, max_cols: usize) { + let (text, keys) = self.get_new_layout_text_and_keys(max_cols); + let new_layout_line = color_control_text(text, keys).color_substring(2, "New Layout:"); + print_text_with_coordinates(new_layout_line, x, y, None, None); + } + + fn render_typing_filter_controls(&self, x: usize, y: usize, max_cols: usize) { + let (text, keys) = self.get_typing_filter_controls(max_cols); + let controls_line = color_control_text(text, keys); + print_text_with_coordinates(controls_line, x, y, None, None); + } + + fn render_advanced_options(&self, x: usize, y: usize, max_cols: usize) { + self.render_retain_option(x, y, max_cols); + self.render_target_option(x, y + 1, max_cols); + } + + fn render_retain_option(&self, x: usize, y: usize, max_cols: usize) { + let (retain_text, retain_substring) = self.get_retain_text_and_highlight(max_cols); + let retain_line = + color_control_text(retain_text, &["<t>"]).color_substring(0, retain_substring); + print_text_with_coordinates(retain_line, x, y, None, None); + } + + fn get_retain_text_and_highlight(&self, max_cols: usize) -> (&str, &str) { + let long_text = " <t> Retain: Terminals | Plugins | Both | None "; + let short_text = " <t> Retain: Term | Pl | Both | None "; + if max_cols >= long_text.chars().count() { + match (self.retain_terminal_panes, self.retain_plugin_panes) { + (true, false) => ( + " <t> Retain: [Terminals] | Plugins | Both | None", + "[Terminals]", + ), + (false, true) => ( + " <t> Retain: Terminals | [Plugins] | Both | None", + "[Plugins]", + ), + (true, true) => ( + " <t> Retain: Terminals | Plugins | [Both] | None", + "[Both]", + ), + (false, false) => ( + " <t> Retain: Terminals | Plugins | Both | [None]", + "[None]", + ), + } + } else if max_cols >= short_text.chars().count() { + match (self.retain_terminal_panes, self.retain_plugin_panes) { + (true, false) => (" <t> Retain: [Term]| Pl | Both | None", "[Term]"), + (false, true) => (" <t> Retain: Term |[Pl]| Both | None", "[Pl]"), + (true, true) => (" <t> Retain: Term | Pl |[Both]| None", "[Both]"), + (false, false) => (" <t> Retain: Term | Pl | Both |[None]", "[None]"), + } + } else { + match (self.retain_terminal_panes, self.retain_plugin_panes) { + (true, false) => (" <t> R: [T] | P | B | N ...", "[T]"), + (false, true) => (" <t> R: T |[P]| B | N ...", "[P]"), + (true, true) => (" <t> R: T | P |[B]| N ...", "[B]"), + (false, false) => (" <t> R: T | P | B |[N]...", "[N]"), + } + } + } + + fn render_target_option(&self, x: usize, y: usize, max_cols: usize) { + let (target_text, target_substring) = self.get_target_text_and_highlight(max_cols); + let target_line = + color_control_text(target_text, &["<a>"]).color_substring(2, target_substring); + print_text_with_coordinates(target_line, x, y, None, None); + } + + fn get_target_text_and_highlight(&self, max_cols: usize) -> (&str, &str) { + if self.apply_only_to_active_tab { + let long_text = " <a> Target: All Tabs | [Current]"; + let short_text = " <a> Target: All |[Current]"; + if max_cols >= long_text.chars().count() { + (long_text, "[Current]") + } else { + (short_text, "[Current]") + } + } else { + let long_text = " <a> Target: [All Tabs] | Current "; + let short_text = " <a> Target: [All]| Current "; + if max_cols >= long_text.chars().count() { + (long_text, "[All Tabs]") + } else { + (short_text, "[All]") + } + } + } +} + +pub struct Title<'a> { + text: &'a str, +} + +impl<'a> Title<'a> { + pub fn new(text: &'a str) -> Self { + Self { text } + } + + pub fn render(&self, x: usize, y: usize) { + let title = Text::new(self.text).color_all(2); + print_text_with_coordinates(title, x, y, None, None); + } +} + +pub struct ErrorMessage<'a> { + message: &'a str, +} + +impl<'a> ErrorMessage<'a> { + pub fn new(message: &'a str) -> Self { + Self { message } + } + + pub fn render(&self, x: usize, y: usize) { + let title = Text::new("Error").error_color_all(); + print_text_with_coordinates(title, x, y, None, None); + + let message = Text::new(self.message).error_color_all(); + print_text_with_coordinates(message, x, y + 2, None, None); + + let help = Text::new("Press any key to continue"); + print_text_with_coordinates(help, x, y + 4, None, None); + } +} diff --git a/default-plugins/layout-manager/src/ui/mod.rs b/default-plugins/layout-manager/src/ui/mod.rs new file mode 100644 index 00000000..f5a98e00 --- /dev/null +++ b/default-plugins/layout-manager/src/ui/mod.rs @@ -0,0 +1,9 @@ +pub mod layout_detail_view; +pub mod layout_table; +pub mod multi_line_error_message; +pub mod text_utils; + +pub use layout_detail_view::LayoutDetail; +pub use layout_table::{Controls, ErrorMessage, LayoutsTable, Title}; +pub use multi_line_error_message::MultiLineErrorMessage; +pub use text_utils::*; diff --git a/default-plugins/layout-manager/src/ui/multi_line_error_message.rs b/default-plugins/layout-manager/src/ui/multi_line_error_message.rs new file mode 100644 index 00000000..223d7de3 --- /dev/null +++ b/default-plugins/layout-manager/src/ui/multi_line_error_message.rs @@ -0,0 +1,26 @@ +use zellij_tile::prelude::*; + +pub struct MultiLineErrorMessage { + message: Vec<String>, +} + +impl MultiLineErrorMessage { + pub fn new(message: Vec<String>) -> Self { + Self { message } + } + + pub fn render(&self, x: usize, y: usize, max_rows: usize) { + let title = Text::new("Error").error_color_all(); + print_text_with_coordinates(title, x, y, None, None); + + let mut current_y = y + 2; + for line in self.message.iter().take(max_rows) { + let text = Text::new(line).error_color_all(); + print_text_with_coordinates(text, x, current_y, None, None); + current_y += 1; + } + + let help = Text::new("Press any key to continue"); + print_text_with_coordinates(help, x, current_y + 2, None, None); + } +} diff --git a/default-plugins/layout-manager/src/ui/text_utils.rs b/default-plugins/layout-manager/src/ui/text_utils.rs new file mode 100644 index 00000000..3a4623d9 --- /dev/null +++ b/default-plugins/layout-manager/src/ui/text_utils.rs @@ -0,0 +1,195 @@ +use crate::DisplayLayout; +use unicode_width::UnicodeWidthStr; +use zellij_tile::prelude::*; + +pub fn truncate_with_ellipsis(text: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + if text.chars().count() <= max_width { + return text.to_string(); + } + if max_width <= 3 { + return text.chars().take(max_width).collect(); + } + // Reserve 3 characters for "..." + let truncate_at = max_width.saturating_sub(3); + format!("{}...", text.chars().take(truncate_at).collect::<String>()) +} + +pub fn truncate_with_ellipsis_start(text: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + if text.chars().count() <= max_width { + return text.to_string(); + } + if max_width <= 3 { + return text + .chars() + .rev() + .take(max_width) + .collect::<Vec<_>>() + .into_iter() + .rev() + .collect(); + } + let truncate_at = max_width.saturating_sub(3); + let suffix: String = text + .chars() + .rev() + .take(truncate_at) + .collect::<Vec<_>>() + .into_iter() + .rev() + .collect(); + format!("...{}", suffix) +} + +struct AnsiString<'a> { + content: &'a str, +} + +impl<'a> AnsiString<'a> { + fn new(content: &'a str) -> Self { + Self { content } + } + + fn stripped_content(&self) -> String { + strip_ansi_escapes::strip_str(self.content) + } + + fn display_width(&self) -> usize { + let stripped = self.stripped_content(); + UnicodeWidthStr::width(stripped.as_str()) + } + + fn fits_within(&self, max_width: usize) -> bool { + self.display_width() <= max_width + } + + fn truncate_to_width(&self, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + + if self.fits_within(max_width) { + return self.content.to_string(); + } + + let mut truncated = String::new(); + for ch in self.content.chars() { + let test_string = format!("{}{}", truncated, ch); + let test_ansi = AnsiString::new(&test_string); + + if test_ansi.display_width() > max_width { + break; + } + + truncated.push(ch); + } + + truncated + } + + fn with_reset_code(content: String) -> String { + format!("{}\u{1b}[0m", content) + } +} + +pub fn truncate_line_with_ansi(line: &str, max_width: usize) -> String { + let ansi_line = AnsiString::new(line); + let truncated = ansi_line.truncate_to_width(max_width); + AnsiString::with_reset_code(truncated) +} + +pub fn wrap_text_to_width(text: &str, max_width: usize) -> Vec<String> { + if max_width == 0 { + return Vec::new(); + } + if max_width < 3 { + return text.chars().map(|c| c.to_string()).collect(); + } + + let words: Vec<&str> = text.split_whitespace().collect(); + let mut lines = Vec::new(); + let mut current_line = String::new(); + + for word in words.iter() { + let word_to_add = if word.len() > max_width { + truncate_with_ellipsis(word, max_width) + } else { + word.to_string() + }; + + if current_line.is_empty() { + current_line = word_to_add; + } else { + let test_line = format!("{} {}", current_line, word_to_add); + if test_line.len() <= max_width { + current_line = test_line; + } else { + lines.push(current_line); + current_line = word_to_add; + } + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + + lines +} + +pub fn get_layout_display_info(layout: &DisplayLayout) -> (String, Option<&LayoutMetadata>) { + match layout { + DisplayLayout::Valid(info) => match info { + LayoutInfo::BuiltIn(name) => (name.clone(), None), + LayoutInfo::File(path, metadata) => { + let name = path.split('/').last().unwrap_or(path).to_string(); + (name, Some(metadata)) + }, + LayoutInfo::Url(url) => { + let name = url.split('/').last().unwrap_or(url).to_string(); + (name, None) + }, + LayoutInfo::Stringified(_) => ("raw".to_string(), None), + }, + DisplayLayout::Error { name, .. } => (name.clone(), None), + } +} + +fn format_elapsed(unix_epoch_str: &str) -> String { + let Ok(timestamp) = unix_epoch_str.parse::<i64>() else { + return " ".to_string(); + }; + + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let diff = now - timestamp; + + if diff < 60 { + "now".to_string() + } else if diff < 3600 { + format!("{}m ago", diff / 60) + } else if diff < 86400 { + format!("{}h ago", diff / 3600) + } else { + format!("{}d ago", diff / 86400) + } +} + +pub fn get_last_modified_string(metadata: Option<&LayoutMetadata>, is_builtin: bool) -> String { + if let Some(metadata) = metadata { + format_elapsed(&metadata.update_time) + } else if is_builtin { + "Built-in".to_string() + } else { + " ".to_string() + } +} |
