aboutsummaryrefslogtreecommitdiffstats
path: root/default-plugins/layout-manager/src
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
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')
-rw-r--r--default-plugins/layout-manager/src/errors.rs155
-rw-r--r--default-plugins/layout-manager/src/main.rs295
-rw-r--r--default-plugins/layout-manager/src/screens/import_layout.rs331
-rw-r--r--default-plugins/layout-manager/src/screens/layout_list/mod.rs689
-rw-r--r--default-plugins/layout-manager/src/screens/layout_list/search.rs187
-rw-r--r--default-plugins/layout-manager/src/screens/mod.rs79
-rw-r--r--default-plugins/layout-manager/src/screens/new_layout_from_session.rs352
-rw-r--r--default-plugins/layout-manager/src/screens/rename_layout.rs156
-rw-r--r--default-plugins/layout-manager/src/text_input.rs1007
-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
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()
+ }
+}