aboutsummaryrefslogtreecommitdiffstats
path: root/default-plugins/layout-manager/src/ui
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-01-13 11:27:07 +0100
committerGitHub <noreply@github.com>2026-01-13 11:27:07 +0100
commit4a27ceea0d99b036ca607e05714cef15689d7c26 (patch)
tree22c0148718a82082bdb196c5e867697f514cc541 /default-plugins/layout-manager/src/ui
parentdocs(CONTRIBUTING): Describe rust toolchain versioning policy (#4585) (diff)
downloadsl0ppij-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')
-rw-r--r--default-plugins/layout-manager/src/ui/layout_detail_view.rs345
-rw-r--r--default-plugins/layout-manager/src/ui/layout_table.rs452
-rw-r--r--default-plugins/layout-manager/src/ui/mod.rs9
-rw-r--r--default-plugins/layout-manager/src/ui/multi_line_error_message.rs26
-rw-r--r--default-plugins/layout-manager/src/ui/text_utils.rs195
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()
+ }
+}