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/ui | |
| 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/ui')
5 files changed, 1027 insertions, 0 deletions
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() + } +} |
