diff --git a/CHANGELOG.md b/CHANGELOG.md index a3b6e220..b131303e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] * feat: multiple select and bulk pane actions (https://github.com/zellij-org/zellij/pull/4169 and https://github.com/zellij-org/zellij/pull/4171 and https://github.com/zellij-org/zellij/pull/4221) +* feat: add an optional key tooltip to show the current keybindings for the compact bar (https://github.com/zellij-org/zellij/pull/4225) ## [0.42.2] - 2025-04-15 * refactor(terminal): track scroll_region as tuple rather than Option (https://github.com/zellij-org/zellij/pull/4082) diff --git a/default-plugins/compact-bar/src/action_types.rs b/default-plugins/compact-bar/src/action_types.rs new file mode 100644 index 00000000..7d1bd979 --- /dev/null +++ b/default-plugins/compact-bar/src/action_types.rs @@ -0,0 +1,129 @@ +use zellij_tile::prelude::actions::Action; +use zellij_tile::prelude::*; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ActionType { + MoveFocus, + MovePaneWithDirection, + MovePaneWithoutDirection, + ResizeIncrease, + ResizeDecrease, + ResizeAny, + Search, + NewPaneWithDirection, + NewPaneWithoutDirection, + BreakPaneLeftOrRight, + GoToAdjacentTab, + Scroll, + PageScroll, + HalfPageScroll, + SessionManager, + Configuration, + PluginManager, + About, + SwitchToMode(InputMode), + TogglePaneEmbedOrFloating, + ToggleFocusFullscreen, + ToggleFloatingPanes, + CloseFocus, + CloseTab, + ToggleActiveSyncTab, + ToggleTab, + BreakPane, + EditScrollback, + NewTab, + Detach, + Quit, + Other(String), // Fallback for unhandled actions +} + +impl ActionType { + pub fn description(&self) -> String { + match self { + ActionType::MoveFocus => "Move focus".to_string(), + ActionType::MovePaneWithDirection => "Move pane".to_string(), + ActionType::MovePaneWithoutDirection => "Move pane".to_string(), + ActionType::ResizeIncrease => "Increase size in direction".to_string(), + ActionType::ResizeDecrease => "Decrease size in direction".to_string(), + ActionType::ResizeAny => "Increase or decrease size".to_string(), + ActionType::Search => "Search".to_string(), + ActionType::NewPaneWithDirection => "Split right/down".to_string(), + ActionType::NewPaneWithoutDirection => "New pane".to_string(), + ActionType::BreakPaneLeftOrRight => "Break pane to adjacent tab".to_string(), + ActionType::GoToAdjacentTab => "Move tab focus".to_string(), + ActionType::Scroll => "Scroll".to_string(), + ActionType::PageScroll => "Scroll page".to_string(), + ActionType::HalfPageScroll => "Scroll half Page".to_string(), + ActionType::SessionManager => "Session manager".to_string(), + ActionType::PluginManager => "Plugin manager".to_string(), + ActionType::Configuration => "Configuration".to_string(), + ActionType::About => "About Zellij".to_string(), + ActionType::SwitchToMode(input_mode) if input_mode == &InputMode::RenamePane => { + "Rename pane".to_string() + }, + ActionType::SwitchToMode(input_mode) if input_mode == &InputMode::RenameTab => { + "Rename tab".to_string() + }, + ActionType::SwitchToMode(input_mode) if input_mode == &InputMode::EnterSearch => { + "Search".to_string() + }, + ActionType::SwitchToMode(input_mode) if input_mode == &InputMode::Locked => { + "Lock".to_string() + }, + ActionType::SwitchToMode(input_mode) if input_mode == &InputMode::Normal => { + "Unlock".to_string() + }, + ActionType::SwitchToMode(input_mode) => format!("{:?}", input_mode), + ActionType::TogglePaneEmbedOrFloating => "Float or embed".to_string(), + ActionType::ToggleFocusFullscreen => "Toggle fullscreen".to_string(), + ActionType::ToggleFloatingPanes => "Show/hide floating panes".to_string(), + ActionType::CloseFocus => "Close pane".to_string(), + ActionType::CloseTab => "Close tab".to_string(), + ActionType::ToggleActiveSyncTab => "Sync panes in tab".to_string(), + ActionType::ToggleTab => "Circle tab focus".to_string(), + ActionType::BreakPane => "Break pane to new tab".to_string(), + ActionType::EditScrollback => "Open pane scrollback in editor".to_string(), + ActionType::NewTab => "New tab".to_string(), + ActionType::Detach => "Detach".to_string(), + ActionType::Quit => "Quit".to_string(), + ActionType::Other(_) => "Other action".to_string(), + } + } + + pub fn from_action(action: &Action) -> Self { + match action { + Action::MoveFocus(_) => ActionType::MoveFocus, + Action::MovePane(Some(_)) => ActionType::MovePaneWithDirection, + Action::MovePane(None) => ActionType::MovePaneWithoutDirection, + Action::Resize(Resize::Increase, Some(_)) => ActionType::ResizeIncrease, + Action::Resize(Resize::Decrease, Some(_)) => ActionType::ResizeDecrease, + Action::Resize(_, None) => ActionType::ResizeAny, + Action::Search(_) => ActionType::Search, + Action::NewPane(Some(_), _, _) => ActionType::NewPaneWithDirection, + Action::NewPane(None, _, _) => ActionType::NewPaneWithoutDirection, + Action::BreakPaneLeft | Action::BreakPaneRight => ActionType::BreakPaneLeftOrRight, + Action::GoToPreviousTab | Action::GoToNextTab => ActionType::GoToAdjacentTab, + Action::ScrollUp | Action::ScrollDown => ActionType::Scroll, + Action::PageScrollUp | Action::PageScrollDown => ActionType::PageScroll, + Action::HalfPageScrollUp | Action::HalfPageScrollDown => ActionType::HalfPageScroll, + Action::SwitchToMode(input_mode) => ActionType::SwitchToMode(*input_mode), + Action::TogglePaneEmbedOrFloating => ActionType::TogglePaneEmbedOrFloating, + Action::ToggleFocusFullscreen => ActionType::ToggleFocusFullscreen, + Action::ToggleFloatingPanes => ActionType::ToggleFloatingPanes, + Action::CloseFocus => ActionType::CloseFocus, + Action::CloseTab => ActionType::CloseTab, + Action::ToggleActiveSyncTab => ActionType::ToggleActiveSyncTab, + Action::ToggleTab => ActionType::ToggleTab, + Action::BreakPane => ActionType::BreakPane, + Action::EditScrollback => ActionType::EditScrollback, + Action::Detach => ActionType::Detach, + Action::Quit => ActionType::Quit, + action if action.launches_plugin("session-manager") => ActionType::SessionManager, + action if action.launches_plugin("configuration") => ActionType::Configuration, + action if action.launches_plugin("plugin-manager") => ActionType::PluginManager, + action if action.launches_plugin("zellij:about") => ActionType::About, + action if matches!(action, Action::NewTab(..)) => ActionType::NewTab, + _ => ActionType::Other(format!("{:?}", action)), + } + } +} diff --git a/default-plugins/compact-bar/src/clipboard_utils.rs b/default-plugins/compact-bar/src/clipboard_utils.rs new file mode 100644 index 00000000..0ce07936 --- /dev/null +++ b/default-plugins/compact-bar/src/clipboard_utils.rs @@ -0,0 +1,27 @@ +use crate::LinePart; +use zellij_tile::prelude::*; + +pub fn text_copied_hint(copy_destination: CopyDestination) -> LinePart { + let hint = match copy_destination { + CopyDestination::Command => "Text piped to external command", + #[cfg(not(target_os = "macos"))] + CopyDestination::Primary => "Text copied to system primary selection", + #[cfg(target_os = "macos")] // primary selection does not exist on macos + CopyDestination::Primary => "Text copied to system clipboard", + CopyDestination::System => "Text copied to system clipboard", + }; + LinePart { + part: serialize_text(&Text::new(&hint).color_range(2, ..).opaque()), + len: hint.len(), + tab_index: None, + } +} + +pub fn system_clipboard_error() -> LinePart { + let hint = " Error using the system clipboard."; + LinePart { + part: serialize_text(&Text::new(&hint).color_range(2, ..).opaque()), + len: hint.len(), + tab_index: None, + } +} diff --git a/default-plugins/compact-bar/src/keybind_utils.rs b/default-plugins/compact-bar/src/keybind_utils.rs new file mode 100644 index 00000000..c871d7c5 --- /dev/null +++ b/default-plugins/compact-bar/src/keybind_utils.rs @@ -0,0 +1,499 @@ +use crate::action_types::ActionType; +use std::collections::HashSet; +use zellij_tile::prelude::actions::Action; +use zellij_tile::prelude::*; + +pub struct KeybindProcessor; + +impl KeybindProcessor { + /// Find predetermined actions based on predicates while maintaining order + pub fn find_predetermined_actions( + mode_info: &ModeInfo, + mode: InputMode, + predicates: Vec, + ) -> Vec<(String, String)> + where + F: Fn(&Action) -> bool, + { + let mut result = Vec::new(); + let keybinds = mode_info.get_keybinds_for_mode(mode); + let mut processed_action_types = HashSet::new(); + + // Iterate through predicates in order to maintain the desired sequence + for predicate in predicates { + // Find the first matching action for this predicate + let mut found_match = false; + for (_key, actions) in &keybinds { + if let Some(first_action) = actions.first() { + if predicate(first_action) { + let action_type = ActionType::from_action(first_action); + + // Skip if we've already processed this action type + if processed_action_types.contains(&action_type) { + found_match = true; + break; + } + + let mut matching_keys = Vec::new(); + + // Find all keys that match this action type (including different directions) + for (inner_key, inner_actions) in &keybinds { + if let Some(inner_first_action) = inner_actions.first() { + if ActionType::from_action(inner_first_action) == action_type { + matching_keys.push(format!("{}", inner_key)); + } + } + } + + if !matching_keys.is_empty() { + let description = action_type.description(); + let should_add_brackets_to_keys = mode != InputMode::Normal; + + // Check if this is switching to normal mode + // let is_switching_to_locked = matches!(first_action, Action::SwitchToMode(InputMode::Normal)); + let is_switching_to_locked = + matches!(first_action, Action::SwitchToMode(InputMode::Locked)); + + let grouped_keys = Self::group_key_sets( + &matching_keys, + should_add_brackets_to_keys, + is_switching_to_locked, + ); + result.push((grouped_keys, description)); + processed_action_types.insert(action_type); + } + + found_match = true; + break; + } + } + } + + // If we found a match for this predicate, we've processed it + if found_match { + continue; + } + } + + result + } + + /// Group keys into sets and separate different key types with '|' + fn group_key_sets( + keys: &[String], + should_add_brackets_to_keys: bool, + is_switching_to_locked: bool, + ) -> String { + if keys.is_empty() { + return String::new(); + } + + // Filter out Esc and Enter keys when switching to normal mode, but only if other keys exist + let filtered_keys: Vec = if is_switching_to_locked { + let non_esc_enter_keys: Vec = keys + .iter() + .filter(|k| k.as_str() != "ESC" && k.as_str() != "ENTER") + .cloned() + .collect(); + + if non_esc_enter_keys.is_empty() { + // If no other keys exist, keep the original keys + keys.to_vec() + } else { + // Use filtered keys (without Esc/Enter) + non_esc_enter_keys + } + } else { + keys.to_vec() + }; + + if filtered_keys.len() == 1 { + return if should_add_brackets_to_keys { + format!("<{}>", filtered_keys[0]) + } else { + filtered_keys[0].clone() + }; + } + + // Group keys by type + let mut arrow_keys = Vec::new(); + let mut hjkl_lower = Vec::new(); + let mut hjkl_upper = Vec::new(); + let mut square_bracket_keys = Vec::new(); + let mut plus_minus_keys = Vec::new(); + let mut pgup_pgdown = Vec::new(); + let mut other_keys = Vec::new(); + + for key in &filtered_keys { + match key.as_str() { + "Left" | "←" => arrow_keys.push("←"), + "Down" | "↓" => arrow_keys.push("↓"), + "Up" | "↑" => arrow_keys.push("↑"), + "Right" | "→" => arrow_keys.push("→"), + "h" => hjkl_lower.push("h"), + "j" => hjkl_lower.push("j"), + "k" => hjkl_lower.push("k"), + "l" => hjkl_lower.push("l"), + "H" => hjkl_upper.push("H"), + "J" => hjkl_upper.push("J"), + "K" => hjkl_upper.push("K"), + "L" => hjkl_upper.push("L"), + "[" => square_bracket_keys.push("["), + "]" => square_bracket_keys.push("]"), + "+" => plus_minus_keys.push("+"), + "-" => plus_minus_keys.push("-"), + "=" => plus_minus_keys.push("="), + "PgUp" => pgup_pgdown.push("PgUp"), + "PgDn" => pgup_pgdown.push("PgDn"), + _ => { + if should_add_brackets_to_keys { + other_keys.push(format!("<{}>", key)); + } else { + other_keys.push(key.clone()); + } + }, + } + } + + let mut groups = Vec::new(); + + // Add hjkl group if present (prioritize hjkl over arrows) + if !hjkl_lower.is_empty() { + Self::sort_hjkl(&mut hjkl_lower); + groups.push(Self::format_key_group( + &hjkl_lower, + should_add_brackets_to_keys, + false, + )); + } + + // Add HJKL group if present + if !hjkl_upper.is_empty() { + Self::sort_hjkl_upper(&mut hjkl_upper); + groups.push(Self::format_key_group( + &hjkl_upper, + should_add_brackets_to_keys, + false, + )); + } + + // Add arrow keys group if present + if !arrow_keys.is_empty() { + Self::sort_arrows(&mut arrow_keys); + groups.push(Self::format_key_group( + &arrow_keys, + should_add_brackets_to_keys, + false, + )); + } + + if !square_bracket_keys.is_empty() { + Self::sort_square_brackets(&mut square_bracket_keys); + groups.push(Self::format_key_group( + &square_bracket_keys, + should_add_brackets_to_keys, + false, + )); + } + + if !plus_minus_keys.is_empty() { + Self::sort_plus_minus(&mut plus_minus_keys); + groups.push(Self::format_key_group( + &plus_minus_keys, + should_add_brackets_to_keys, + false, + )); + } + + if !pgup_pgdown.is_empty() { + Self::sort_pgup_pgdown(&mut pgup_pgdown); + groups.push(Self::format_key_group( + &pgup_pgdown, + should_add_brackets_to_keys, + true, + )); + } + + // Add other keys with / separator + if !other_keys.is_empty() { + groups.push(other_keys.join("/")); + } + + groups.join("/") + } + + fn sort_hjkl(keys: &mut Vec<&str>) { + keys.sort_by(|a, b| { + let order = ["h", "j", "k", "l"]; + let pos_a = order.iter().position(|&x| &x == a).unwrap_or(usize::MAX); + let pos_b = order.iter().position(|&x| &x == b).unwrap_or(usize::MAX); + pos_a.cmp(&pos_b) + }); + } + + fn sort_hjkl_upper(keys: &mut Vec<&str>) { + keys.sort_by(|a, b| { + let order = ["H", "J", "K", "L"]; + let pos_a = order.iter().position(|&x| &x == a).unwrap_or(usize::MAX); + let pos_b = order.iter().position(|&x| &x == b).unwrap_or(usize::MAX); + pos_a.cmp(&pos_b) + }); + } + + fn sort_arrows(keys: &mut Vec<&str>) { + keys.sort(); + keys.dedup(); + keys.sort_by(|a, b| { + let order = ["←", "↓", "↑", "→"]; + let pos_a = order.iter().position(|&x| &x == a).unwrap_or(usize::MAX); + let pos_b = order.iter().position(|&x| &x == b).unwrap_or(usize::MAX); + pos_a.cmp(&pos_b) + }); + } + + fn sort_square_brackets(keys: &mut Vec<&str>) { + keys.sort_by(|a, b| { + let order = ["[", "]"]; + let pos_a = order.iter().position(|&x| &x == a).unwrap_or(usize::MAX); + let pos_b = order.iter().position(|&x| &x == b).unwrap_or(usize::MAX); + pos_a.cmp(&pos_b) + }); + } + + fn sort_plus_minus(keys: &mut Vec<&str>) { + keys.sort_by(|a, b| { + let order = ["+", "-"]; + let pos_a = order.iter().position(|&x| &x == a).unwrap_or(usize::MAX); + let pos_b = order.iter().position(|&x| &x == b).unwrap_or(usize::MAX); + pos_a.cmp(&pos_b) + }); + // Remove "=" if both "+" and "=" are present + if keys.contains(&"+") && keys.contains(&"=") { + keys.retain(|k| k != &"="); + } + } + + fn sort_pgup_pgdown(keys: &mut Vec<&str>) { + keys.sort_by(|a, b| { + let order = ["PgUp", "PgDn"]; + let pos_a = order.iter().position(|&x| &x == a).unwrap_or(usize::MAX); + let pos_b = order.iter().position(|&x| &x == b).unwrap_or(usize::MAX); + pos_a.cmp(&pos_b) + }); + } + + fn format_key_group( + keys: &[&str], + should_add_brackets: bool, + use_pipe_separator: bool, + ) -> String { + let separator = if use_pipe_separator { "|" } else { "" }; + let joined = keys.join(separator); + + if should_add_brackets { + format!("<{}>", joined) + } else { + joined + } + } + + /// Get predetermined actions for a specific mode + pub fn get_predetermined_actions( + mode_info: &ModeInfo, + mode: InputMode, + ) -> Vec<(String, String)> { + match mode { + InputMode::Locked => { + let ordered_predicates = vec![|action: &Action| { + matches!(action, Action::SwitchToMode(InputMode::Normal)) + }]; + Self::find_predetermined_actions(mode_info, mode, ordered_predicates) + }, + InputMode::Normal => { + let ordered_predicates = vec![ + |action: &Action| matches!(action, Action::SwitchToMode(InputMode::Locked)), + |action: &Action| matches!(action, Action::SwitchToMode(InputMode::Pane)), + |action: &Action| matches!(action, Action::SwitchToMode(InputMode::Tab)), + |action: &Action| matches!(action, Action::SwitchToMode(InputMode::Resize)), + |action: &Action| matches!(action, Action::SwitchToMode(InputMode::Move)), + |action: &Action| matches!(action, Action::SwitchToMode(InputMode::Scroll)), + |action: &Action| matches!(action, Action::SwitchToMode(InputMode::Session)), + |action: &Action| matches!(action, Action::Quit), + ]; + Self::find_predetermined_actions(mode_info, mode, ordered_predicates) + }, + InputMode::Pane => { + let ordered_predicates = vec![ + |action: &Action| matches!(action, Action::NewPane(None, None, false)), + |action: &Action| matches!(action, Action::MoveFocus(Direction::Left)), + |action: &Action| matches!(action, Action::MoveFocus(Direction::Down)), + |action: &Action| matches!(action, Action::MoveFocus(Direction::Up)), + |action: &Action| matches!(action, Action::MoveFocus(Direction::Right)), + |action: &Action| matches!(action, Action::CloseFocus), + |action: &Action| matches!(action, Action::SwitchToMode(InputMode::RenamePane)), + |action: &Action| matches!(action, Action::ToggleFocusFullscreen), + |action: &Action| matches!(action, Action::ToggleFloatingPanes), + |action: &Action| matches!(action, Action::TogglePaneEmbedOrFloating), + |action: &Action| { + matches!(action, Action::NewPane(Some(Direction::Right), None, false)) + }, + |action: &Action| { + matches!(action, Action::NewPane(Some(Direction::Down), None, false)) + }, + ]; + Self::find_predetermined_actions(mode_info, mode, ordered_predicates) + }, + InputMode::Tab => { + let ordered_predicates = vec![ + |action: &Action| matches!(action, Action::GoToPreviousTab), + |action: &Action| matches!(action, Action::GoToNextTab), + |action: &Action| { + matches!(action, Action::NewTab(None, _, None, None, None, true)) + }, + |action: &Action| matches!(action, Action::CloseTab), + |action: &Action| matches!(action, Action::SwitchToMode(InputMode::RenameTab)), + |action: &Action| matches!(action, Action::TabNameInput(_)), + |action: &Action| matches!(action, Action::ToggleActiveSyncTab), + |action: &Action| matches!(action, Action::BreakPane), + |action: &Action| matches!(action, Action::BreakPaneLeft), + |action: &Action| matches!(action, Action::BreakPaneRight), + |action: &Action| matches!(action, Action::ToggleTab), + ]; + Self::find_predetermined_actions(mode_info, mode, ordered_predicates) + }, + InputMode::Resize => { + let ordered_predicates = vec![ + |action: &Action| matches!(action, Action::Resize(Resize::Increase, None)), + |action: &Action| matches!(action, Action::Resize(Resize::Decrease, None)), + |action: &Action| { + matches!( + action, + Action::Resize(Resize::Increase, Some(Direction::Left)) + ) + }, + |action: &Action| { + matches!( + action, + Action::Resize(Resize::Increase, Some(Direction::Down)) + ) + }, + |action: &Action| { + matches!( + action, + Action::Resize(Resize::Increase, Some(Direction::Up)) + ) + }, + |action: &Action| { + matches!( + action, + Action::Resize(Resize::Increase, Some(Direction::Right)) + ) + }, + |action: &Action| { + matches!( + action, + Action::Resize(Resize::Decrease, Some(Direction::Left)) + ) + }, + |action: &Action| { + matches!( + action, + Action::Resize(Resize::Decrease, Some(Direction::Down)) + ) + }, + |action: &Action| { + matches!( + action, + Action::Resize(Resize::Decrease, Some(Direction::Up)) + ) + }, + |action: &Action| { + matches!( + action, + Action::Resize(Resize::Decrease, Some(Direction::Right)) + ) + }, + ]; + Self::find_predetermined_actions(mode_info, mode, ordered_predicates) + }, + InputMode::Move => { + let ordered_predicates = vec![ + |action: &Action| matches!(action, Action::MovePane(Some(Direction::Left))), + |action: &Action| matches!(action, Action::MovePane(Some(Direction::Down))), + |action: &Action| matches!(action, Action::MovePane(Some(Direction::Up))), + |action: &Action| matches!(action, Action::MovePane(Some(Direction::Right))), + ]; + Self::find_predetermined_actions(mode_info, mode, ordered_predicates) + }, + InputMode::Scroll => { + let ordered_predicates = vec![ + |action: &Action| matches!(action, Action::ScrollDown), + |action: &Action| matches!(action, Action::ScrollUp), + |action: &Action| matches!(action, Action::HalfPageScrollDown), + |action: &Action| matches!(action, Action::HalfPageScrollUp), + |action: &Action| matches!(action, Action::PageScrollDown), + |action: &Action| matches!(action, Action::PageScrollUp), + |action: &Action| { + matches!(action, Action::SwitchToMode(InputMode::EnterSearch)) + }, + |action: &Action| matches!(action, Action::EditScrollback), + ]; + Self::find_predetermined_actions(mode_info, mode, ordered_predicates) + }, + InputMode::Search => { + let ordered_predicates = vec![ + |action: &Action| { + matches!(action, Action::SwitchToMode(InputMode::EnterSearch)) + }, + |action: &Action| matches!(action, Action::SearchInput(_)), + |action: &Action| matches!(action, Action::ScrollDown), + |action: &Action| matches!(action, Action::ScrollUp), + |action: &Action| matches!(action, Action::PageScrollDown), + |action: &Action| matches!(action, Action::PageScrollUp), + |action: &Action| matches!(action, Action::HalfPageScrollDown), + |action: &Action| matches!(action, Action::HalfPageScrollUp), + |action: &Action| { + matches!(action, Action::Search(actions::SearchDirection::Down)) + }, + |action: &Action| { + matches!(action, Action::Search(actions::SearchDirection::Up)) + }, + |action: &Action| { + matches!( + action, + Action::SearchToggleOption(actions::SearchOption::CaseSensitivity) + ) + }, + |action: &Action| { + matches!( + action, + Action::SearchToggleOption(actions::SearchOption::Wrap) + ) + }, + |action: &Action| { + matches!( + action, + Action::SearchToggleOption(actions::SearchOption::WholeWord) + ) + }, + ]; + Self::find_predetermined_actions(mode_info, mode, ordered_predicates) + }, + InputMode::Session => { + let ordered_predicates = vec![ + |action: &Action| matches!(action, Action::Detach), + |action: &Action| action.launches_plugin("session-manager"), + |action: &Action| action.launches_plugin("plugin-manager"), + |action: &Action| action.launches_plugin("configuration"), + |action: &Action| action.launches_plugin("zellij:about"), + ]; + Self::find_predetermined_actions(mode_info, mode, ordered_predicates) + }, + InputMode::EnterSearch + | InputMode::RenameTab + | InputMode::RenamePane + | InputMode::Prompt + | InputMode::Tmux => Vec::new(), + } + } +} diff --git a/default-plugins/compact-bar/src/line.rs b/default-plugins/compact-bar/src/line.rs index 5d50d527..451cbeb7 100644 --- a/default-plugins/compact-bar/src/line.rs +++ b/default-plugins/compact-bar/src/line.rs @@ -1,235 +1,611 @@ use ansi_term::ANSIStrings; use unicode_width::UnicodeWidthStr; -use crate::{LinePart, ARROW_SEPARATOR}; +use crate::{LinePart, TabRenderData, ARROW_SEPARATOR}; use zellij_tile::prelude::*; use zellij_tile_utils::style; -fn get_current_title_len(current_title: &[LinePart]) -> usize { - current_title.iter().map(|p| p.len).sum() +pub fn tab_line( + mode_info: &ModeInfo, + tab_data: TabRenderData, + cols: usize, + toggle_tooltip_key: Option, + tooltip_is_active: bool, +) -> Vec { + let config = TabLineConfig { + session_name: mode_info.session_name.to_owned(), + hide_session_name: mode_info.style.hide_session_name, + mode: mode_info.mode, + active_swap_layout_name: tab_data.active_swap_layout_name, + is_swap_layout_dirty: tab_data.is_swap_layout_dirty, + toggle_tooltip_key, + tooltip_is_active, + }; + + let builder = TabLineBuilder::new(config, mode_info.style.colors, mode_info.capabilities, cols); + builder.build(tab_data.tabs, tab_data.active_tab_index) } -// move elements from before_active and after_active into tabs_to_render while they fit in cols -// adds collapsed_tabs to the left and right if there's left over tabs that don't fit -fn populate_tabs_in_tab_line( - tabs_before_active: &mut Vec, - tabs_after_active: &mut Vec, - tabs_to_render: &mut Vec, +#[derive(Debug, Clone)] +pub struct TabLineConfig { + pub session_name: Option, + pub hide_session_name: bool, + pub mode: InputMode, + pub active_swap_layout_name: Option, + pub is_swap_layout_dirty: bool, + pub toggle_tooltip_key: Option, + pub tooltip_is_active: bool, +} + +fn calculate_total_length(parts: &[LinePart]) -> usize { + parts.iter().map(|p| p.len).sum() +} + +struct TabLinePopulator { cols: usize, palette: Styling, capabilities: PluginCapabilities, -) { - let mut middle_size = get_current_title_len(tabs_to_render); +} - let mut total_left = 0; - let mut total_right = 0; - loop { - let left_count = tabs_before_active.len(); - let right_count = tabs_after_active.len(); - - // left_more_tab_index is the tab to the left of the leftmost visible tab - let left_more_tab_index = left_count.saturating_sub(1); - let collapsed_left = left_more_message( - left_count, +impl TabLinePopulator { + fn new(cols: usize, palette: Styling, capabilities: PluginCapabilities) -> Self { + Self { + cols, palette, - tab_separator(capabilities), - left_more_tab_index, - ); - // right_more_tab_index is the tab to the right of the rightmost visible tab - let right_more_tab_index = left_count + tabs_to_render.len(); - let collapsed_right = right_more_message( - right_count, - palette, - tab_separator(capabilities), - right_more_tab_index, - ); - - let total_size = collapsed_left.len + middle_size + collapsed_right.len; - - if total_size > cols { - // break and dont add collapsed tabs to tabs_to_render, they will not fit - break; + capabilities, } + } - let left = if let Some(tab) = tabs_before_active.last() { - tab.len - } else { - usize::MAX - }; + fn populate_tabs( + &self, + tabs_before_active: &mut Vec, + tabs_after_active: &mut Vec, + tabs_to_render: &mut Vec, + ) { + let mut middle_size = calculate_total_length(tabs_to_render); + let mut total_left = 0; + let mut total_right = 0; - let right = if let Some(tab) = tabs_after_active.first() { - tab.len - } else { - usize::MAX - }; + loop { + let left_count = tabs_before_active.len(); + let right_count = tabs_after_active.len(); - // total size is shortened if the next tab to be added is the last one, as that will remove the collapsed tab + let collapsed_indicators = + self.create_collapsed_indicators(left_count, right_count, tabs_to_render.len()); + + let total_size = + collapsed_indicators.left.len + middle_size + collapsed_indicators.right.len; + + if total_size > self.cols { + break; + } + + let tab_sizes = TabSizes { + left: tabs_before_active.last().map_or(usize::MAX, |tab| tab.len), + right: tabs_after_active.get(0).map_or(usize::MAX, |tab| tab.len), + }; + + let fit_analysis = self.analyze_tab_fit( + &tab_sizes, + total_size, + left_count, + right_count, + &collapsed_indicators, + ); + + match self.decide_next_action(&fit_analysis, total_left, total_right) { + TabAction::AddLeft => { + if let Some(tab) = tabs_before_active.pop() { + middle_size += tab.len; + total_left += tab.len; + tabs_to_render.insert(0, tab); + } + }, + TabAction::AddRight => { + if !tabs_after_active.is_empty() { + let tab = tabs_after_active.remove(0); + middle_size += tab.len; + total_right += tab.len; + tabs_to_render.push(tab); + } + }, + TabAction::Finish => { + tabs_to_render.insert(0, collapsed_indicators.left); + tabs_to_render.push(collapsed_indicators.right); + break; + }, + } + } + } + + fn create_collapsed_indicators( + &self, + left_count: usize, + right_count: usize, + rendered_count: usize, + ) -> CollapsedIndicators { + let left_more_tab_index = left_count.saturating_sub(1); + let right_more_tab_index = left_count + rendered_count; + + CollapsedIndicators { + left: self.create_left_indicator(left_count, left_more_tab_index), + right: self.create_right_indicator(right_count, right_more_tab_index), + } + } + + fn analyze_tab_fit( + &self, + tab_sizes: &TabSizes, + total_size: usize, + left_count: usize, + right_count: usize, + collapsed_indicators: &CollapsedIndicators, + ) -> TabFitAnalysis { let size_by_adding_left = - left.saturating_add(total_size) + tab_sizes + .left + .saturating_add(total_size) .saturating_sub(if left_count == 1 { - collapsed_left.len + collapsed_indicators.left.len } else { 0 }); + let size_by_adding_right = - right + tab_sizes + .right .saturating_add(total_size) .saturating_sub(if right_count == 1 { - collapsed_right.len + collapsed_indicators.right.len } else { 0 }); - let left_fits = size_by_adding_left <= cols; - let right_fits = size_by_adding_right <= cols; - // active tab is kept in the middle by adding to the side that - // has less width, or if the tab on the other side doesn't fit - if (total_left <= total_right || !right_fits) && left_fits { - // add left tab - let tab = tabs_before_active.pop().unwrap(); - middle_size += tab.len; - total_left += tab.len; - tabs_to_render.insert(0, tab); - } else if right_fits { - // add right tab - let tab = tabs_after_active.remove(0); - middle_size += tab.len; - total_right += tab.len; - tabs_to_render.push(tab); + TabFitAnalysis { + left_fits: size_by_adding_left <= self.cols, + right_fits: size_by_adding_right <= self.cols, + } + } + + fn decide_next_action( + &self, + fit_analysis: &TabFitAnalysis, + total_left: usize, + total_right: usize, + ) -> TabAction { + if (total_left <= total_right || !fit_analysis.right_fits) && fit_analysis.left_fits { + TabAction::AddLeft + } else if fit_analysis.right_fits { + TabAction::AddRight } else { - // there's either no space to add more tabs or no more tabs to add, so we're done - tabs_to_render.insert(0, collapsed_left); - tabs_to_render.push(collapsed_right); - break; + TabAction::Finish + } + } + + fn create_left_indicator(&self, tab_count: usize, tab_index: usize) -> LinePart { + if tab_count == 0 { + return LinePart::default(); + } + + let more_text = self.format_count_text(tab_count, "← +{}", " ← +many "); + self.create_styled_indicator(more_text, tab_index) + } + + fn create_right_indicator(&self, tab_count: usize, tab_index: usize) -> LinePart { + if tab_count == 0 { + return LinePart::default(); + } + + let more_text = self.format_count_text(tab_count, "+{} →", " +many → "); + self.create_styled_indicator(more_text, tab_index) + } + + fn format_count_text(&self, count: usize, format_str: &str, fallback: &str) -> String { + if count < 10000 { + format!(" {} ", format_str.replace("{}", &count.to_string())) + } else { + fallback.to_string() + } + } + + fn create_styled_indicator(&self, text: String, tab_index: usize) -> LinePart { + let separator = tab_separator(self.capabilities); + let text_len = text.width() + 2 * separator.width(); + + let colors = IndicatorColors { + text: self.palette.ribbon_unselected.base, + separator: self.palette.text_unselected.background, + background: self.palette.text_selected.emphasis_0, + }; + + let styled_parts = [ + style!(colors.separator, colors.background).paint(separator), + style!(colors.text, colors.background).bold().paint(text), + style!(colors.background, colors.separator).paint(separator), + ]; + + LinePart { + part: ANSIStrings(&styled_parts).to_string(), + len: text_len, + tab_index: Some(tab_index), } } } -fn left_more_message( - tab_count_to_the_left: usize, - palette: Styling, - separator: &str, - tab_index: usize, -) -> LinePart { - if tab_count_to_the_left == 0 { - return LinePart::default(); - } - let more_text = if tab_count_to_the_left < 10000 { - format!(" ← +{} ", tab_count_to_the_left) - } else { - " ← +many ".to_string() - }; - // 238 - // chars length plus separator length on both sides - let more_text_len = more_text.width() + 2 * separator.width(); - let (text_color, sep_color) = ( - palette.ribbon_unselected.base, - palette.text_unselected.background, - ); - let plus_ribbon_bg = palette.text_selected.emphasis_0; - let left_separator = style!(sep_color, plus_ribbon_bg).paint(separator); - let more_styled_text = style!(text_color, plus_ribbon_bg).bold().paint(more_text); - let right_separator = style!(plus_ribbon_bg, sep_color).paint(separator); - let more_styled_text = - ANSIStrings(&[left_separator, more_styled_text, right_separator]).to_string(); - LinePart { - part: more_styled_text, - len: more_text_len, - tab_index: Some(tab_index), - } +#[derive(Debug)] +struct CollapsedIndicators { + left: LinePart, + right: LinePart, } -fn right_more_message( - tab_count_to_the_right: usize, - palette: Styling, - separator: &str, - tab_index: usize, -) -> LinePart { - if tab_count_to_the_right == 0 { - return LinePart::default(); - }; - let more_text = if tab_count_to_the_right < 10000 { - format!(" +{} → ", tab_count_to_the_right) - } else { - " +many → ".to_string() - }; - // chars length plus separator length on both sides - let more_text_len = more_text.width() + 2 * separator.width(); - - let (text_color, sep_color) = ( - palette.ribbon_unselected.base, - palette.text_unselected.background, - ); - let plus_ribbon_bg = palette.text_selected.emphasis_0; - let left_separator = style!(sep_color, plus_ribbon_bg).paint(separator); - let more_styled_text = style!(text_color, plus_ribbon_bg).bold().paint(more_text); - let right_separator = style!(plus_ribbon_bg, sep_color).paint(separator); - let more_styled_text = - ANSIStrings(&[left_separator, more_styled_text, right_separator]).to_string(); - LinePart { - part: more_styled_text, - len: more_text_len, - tab_index: Some(tab_index), - } +#[derive(Debug)] +struct TabSizes { + left: usize, + right: usize, } -fn tab_line_prefix( - session_name: Option<&str>, - mode: InputMode, +#[derive(Debug)] +struct TabFitAnalysis { + left_fits: bool, + right_fits: bool, +} + +#[derive(Debug)] +struct IndicatorColors { + text: PaletteColor, + separator: PaletteColor, + background: PaletteColor, +} + +#[derive(Debug)] +enum TabAction { + AddLeft, + AddRight, + Finish, +} + +struct TabLinePrefixBuilder { palette: Styling, cols: usize, -) -> Vec { - let prefix_text = " Zellij ".to_string(); +} - let prefix_text_len = prefix_text.chars().count(); - let text_color = palette.text_unselected.base; - let bg_color = palette.text_unselected.background; - let locked_mode_color = palette.text_unselected.emphasis_3; - let normal_mode_color = palette.text_unselected.emphasis_2; - let other_modes_color = palette.text_unselected.emphasis_0; +impl TabLinePrefixBuilder { + fn new(palette: Styling, cols: usize) -> Self { + Self { palette, cols } + } - let prefix_styled_text = style!(text_color, bg_color).bold().paint(prefix_text); - let mut parts = vec![LinePart { - part: prefix_styled_text.to_string(), - len: prefix_text_len, - tab_index: None, - }]; - if let Some(name) = session_name { + fn build(&self, session_name: Option<&str>, mode: InputMode) -> Vec { + let mut parts = vec![self.create_zellij_part()]; + let mut used_len = parts.get(0).map_or(0, |p| p.len); + + if let Some(name) = session_name { + if let Some(name_part) = self.create_session_name_part(name, used_len) { + used_len += name_part.len; + parts.push(name_part); + } + } + + if let Some(mode_part) = self.create_mode_part(mode, used_len) { + parts.push(mode_part); + } + + parts + } + + fn create_zellij_part(&self) -> LinePart { + let prefix_text = " Zellij "; + let colors = self.get_text_colors(); + + LinePart { + part: style!(colors.text, colors.background) + .bold() + .paint(prefix_text) + .to_string(), + len: prefix_text.chars().count(), + tab_index: None, + } + } + + fn create_session_name_part(&self, name: &str, used_len: usize) -> Option { let name_part = format!("({})", name); let name_part_len = name_part.width(); - let name_part_styled_text = style!(text_color, bg_color).bold().paint(name_part); - if cols.saturating_sub(prefix_text_len) >= name_part_len { - parts.push(LinePart { - part: name_part_styled_text.to_string(), + + if self.cols.saturating_sub(used_len) >= name_part_len { + let colors = self.get_text_colors(); + Some(LinePart { + part: style!(colors.text, colors.background) + .bold() + .paint(name_part) + .to_string(), len: name_part_len, tab_index: None, }) + } else { + None } } - let mode_part = format!("{:?}", mode).to_uppercase(); - let mode_part_padded = format!(" {} ", mode_part); - let mode_part_len = mode_part_padded.width(); - let mode_part_styled_text = if mode == InputMode::Locked { - style!(locked_mode_color, bg_color) - .bold() - .paint(mode_part_padded) - } else if mode == InputMode::Normal { - style!(normal_mode_color, bg_color) - .bold() - .paint(mode_part_padded) - } else { - style!(other_modes_color, bg_color) - .bold() - .paint(mode_part_padded) - }; - if cols.saturating_sub(prefix_text_len) >= mode_part_len { - parts.push(LinePart { - part: format!("{}", mode_part_styled_text), - len: mode_part_len, - tab_index: None, - }) + + fn create_mode_part(&self, mode: InputMode, used_len: usize) -> Option { + let mode_text = format!(" {} ", format!("{:?}", mode).to_uppercase()); + let mode_len = mode_text.width(); + + if self.cols.saturating_sub(used_len) >= mode_len { + let colors = self.get_text_colors(); + let style = match mode { + InputMode::Locked => { + style!(self.palette.text_unselected.emphasis_3, colors.background) + }, + InputMode::Normal => { + style!(self.palette.text_unselected.emphasis_2, colors.background) + }, + _ => style!(self.palette.text_unselected.emphasis_0, colors.background), + }; + + Some(LinePart { + part: style.bold().paint(mode_text).to_string(), + len: mode_len, + tab_index: None, + }) + } else { + None + } + } + + fn get_text_colors(&self) -> IndicatorColors { + IndicatorColors { + text: self.palette.text_unselected.base, + background: self.palette.text_unselected.background, + separator: self.palette.text_unselected.background, + } + } +} + +struct RightSideElementsBuilder { + palette: Styling, + capabilities: PluginCapabilities, +} + +impl RightSideElementsBuilder { + fn new(palette: Styling, capabilities: PluginCapabilities) -> Self { + Self { + palette, + capabilities, + } + } + + fn build(&self, config: &TabLineConfig, available_space: usize) -> Vec { + let mut elements = Vec::new(); + + if let Some(ref tooltip_key) = config.toggle_tooltip_key { + elements.push(self.create_tooltip_indicator(tooltip_key, config.tooltip_is_active)); + } + + if let Some(swap_status) = self.create_swap_layout_status(config, available_space) { + elements.push(swap_status); + } + + elements + } + + fn create_tooltip_indicator(&self, toggle_key: &str, is_active: bool) -> LinePart { + let key_text = toggle_key; + let key = Text::new(key_text).color_all(3); + let ribbon_text = "Tooltip"; + let mut ribbon = Text::new(ribbon_text); + + if is_active { + ribbon = ribbon.selected(); + } + + LinePart { + part: format!("{} {}", serialize_text(&key), serialize_ribbon(&ribbon)), + len: key_text.chars().count() + ribbon_text.chars().count() + 6, + tab_index: None, + } + } + + fn create_swap_layout_status( + &self, + config: &TabLineConfig, + max_len: usize, + ) -> Option { + let swap_layout_name = config.active_swap_layout_name.as_ref()?; + + let mut layout_name = format!(" {} ", swap_layout_name); + layout_name.make_ascii_uppercase(); + let layout_name_len = layout_name.len() + 3; + + let colors = SwapLayoutColors { + bg: self.palette.text_unselected.background, + fg: self.palette.ribbon_unselected.background, + green: self.palette.ribbon_selected.background, + }; + + let separator = tab_separator(self.capabilities); + let styled_parts = self.create_swap_layout_styled_parts( + &layout_name, + config.mode, + config.is_swap_layout_dirty, + &colors, + separator, + ); + + let indicator = format!("{}{}{}", styled_parts.0, styled_parts.1, styled_parts.2); + let (part, full_len) = (indicator.clone(), layout_name_len); + let short_len = layout_name_len + 1; + + if full_len <= max_len { + Some(LinePart { + part, + len: full_len, + tab_index: None, + }) + } else if short_len <= max_len && config.mode != InputMode::Locked { + Some(LinePart { + part: indicator, + len: short_len, + tab_index: None, + }) + } else { + None + } + } + + fn create_swap_layout_styled_parts( + &self, + layout_name: &str, + mode: InputMode, + is_dirty: bool, + colors: &SwapLayoutColors, + separator: &str, + ) -> (String, String, String) { + match mode { + InputMode::Locked => ( + style!(colors.bg, colors.fg).paint(separator).to_string(), + style!(colors.bg, colors.fg) + .italic() + .paint(layout_name) + .to_string(), + style!(colors.fg, colors.bg).paint(separator).to_string(), + ), + _ if is_dirty => ( + style!(colors.bg, colors.fg).paint(separator).to_string(), + style!(colors.bg, colors.fg) + .bold() + .paint(layout_name) + .to_string(), + style!(colors.fg, colors.bg).paint(separator).to_string(), + ), + _ => ( + style!(colors.bg, colors.green).paint(separator).to_string(), + style!(colors.bg, colors.green) + .bold() + .paint(layout_name) + .to_string(), + style!(colors.green, colors.bg).paint(separator).to_string(), + ), + } + } +} + +#[derive(Debug)] +struct SwapLayoutColors { + bg: PaletteColor, + fg: PaletteColor, + green: PaletteColor, +} + +pub struct TabLineBuilder { + config: TabLineConfig, + palette: Styling, + capabilities: PluginCapabilities, + cols: usize, +} + +impl TabLineBuilder { + pub fn new( + config: TabLineConfig, + palette: Styling, + capabilities: PluginCapabilities, + cols: usize, + ) -> Self { + Self { + config, + palette, + capabilities, + cols, + } + } + + pub fn build(self, all_tabs: Vec, active_tab_index: usize) -> Vec { + let (tabs_before_active, active_tab, tabs_after_active) = + self.split_tabs(all_tabs, active_tab_index); + + let prefix_builder = TabLinePrefixBuilder::new(self.palette, self.cols); + let session_name = if self.config.hide_session_name { + None + } else { + self.config.session_name.as_deref() + }; + + let mut prefix = prefix_builder.build(session_name, self.config.mode); + let prefix_len = calculate_total_length(&prefix); + + if prefix_len + active_tab.len > self.cols { + return prefix; + } + + let mut tabs_to_render = vec![active_tab]; + let populator = TabLinePopulator::new( + self.cols.saturating_sub(prefix_len), + self.palette, + self.capabilities, + ); + + let mut tabs_before = tabs_before_active; + let mut tabs_after = tabs_after_active; + populator.populate_tabs(&mut tabs_before, &mut tabs_after, &mut tabs_to_render); + + prefix.append(&mut tabs_to_render); + + self.add_right_side_elements(&mut prefix); + prefix + } + + fn split_tabs( + &self, + mut all_tabs: Vec, + active_tab_index: usize, + ) -> (Vec, LinePart, Vec) { + let mut tabs_after_active = all_tabs.split_off(active_tab_index); + let mut tabs_before_active = all_tabs; + + let active_tab = if !tabs_after_active.is_empty() { + tabs_after_active.remove(0) + } else { + tabs_before_active.pop().unwrap_or_default() + }; + + (tabs_before_active, active_tab, tabs_after_active) + } + + fn add_right_side_elements(&self, prefix: &mut Vec) { + let current_len = calculate_total_length(prefix); + + if current_len < self.cols { + let right_builder = RightSideElementsBuilder::new(self.palette, self.capabilities); + let available_space = self.cols.saturating_sub(current_len); + let mut right_elements = right_builder.build(&self.config, available_space); + + let right_len = calculate_total_length(&right_elements); + + if current_len + right_len <= self.cols { + let remaining_space = self + .cols + .saturating_sub(current_len) + .saturating_sub(right_len); + + if remaining_space > 0 { + prefix.push(self.create_spacer(remaining_space)); + } + + prefix.append(&mut right_elements); + } + } + } + + fn create_spacer(&self, space: usize) -> LinePart { + let bg = self.palette.text_unselected.background; + let buffer = (0..space) + .map(|_| style!(bg, bg).paint(" ").to_string()) + .collect::(); + + LinePart { + part: buffer, + len: space, + tab_index: None, + } } - parts } pub fn tab_separator(capabilities: PluginCapabilities) -> &'static str { @@ -239,137 +615,3 @@ pub fn tab_separator(capabilities: PluginCapabilities) -> &'static str { "" } } - -pub fn tab_line( - session_name: Option<&str>, - mut all_tabs: Vec, - active_tab_index: usize, - cols: usize, - palette: Styling, - capabilities: PluginCapabilities, - hide_session_name: bool, - mode: InputMode, - active_swap_layout_name: &Option, - is_swap_layout_dirty: bool, -) -> Vec { - let mut tabs_after_active = all_tabs.split_off(active_tab_index); - let mut tabs_before_active = all_tabs; - let active_tab = if !tabs_after_active.is_empty() { - tabs_after_active.remove(0) - } else { - tabs_before_active.pop().unwrap() - }; - let mut prefix = match hide_session_name { - true => tab_line_prefix(None, mode, palette, cols), - false => tab_line_prefix(session_name, mode, palette, cols), - }; - let prefix_len = get_current_title_len(&prefix); - - // if active tab alone won't fit in cols, don't draw any tabs - if prefix_len + active_tab.len > cols { - return prefix; - } - - let mut tabs_to_render = vec![active_tab]; - - populate_tabs_in_tab_line( - &mut tabs_before_active, - &mut tabs_after_active, - &mut tabs_to_render, - cols.saturating_sub(prefix_len), - palette, - capabilities, - ); - prefix.append(&mut tabs_to_render); - - let current_title_len = get_current_title_len(&prefix); - if current_title_len < cols { - let mut remaining_space = cols - current_title_len; - let remaining_bg = palette.text_unselected.background; - if let Some(swap_layout_status) = swap_layout_status( - remaining_space, - active_swap_layout_name, - is_swap_layout_dirty, - mode, - &palette, - tab_separator(capabilities), - ) { - remaining_space -= swap_layout_status.len; - let mut buffer = String::new(); - for _ in 0..remaining_space { - buffer.push_str(&style!(remaining_bg, remaining_bg).paint(" ").to_string()); - } - prefix.push(LinePart { - part: buffer, - len: remaining_space, - tab_index: None, - }); - prefix.push(swap_layout_status); - } - } - - prefix -} - -fn swap_layout_status( - max_len: usize, - swap_layout_name: &Option, - is_swap_layout_damaged: bool, - input_mode: InputMode, - palette: &Styling, - separator: &str, -) -> Option { - match swap_layout_name { - Some(swap_layout_name) => { - let mut swap_layout_name = format!(" {} ", swap_layout_name); - swap_layout_name.make_ascii_uppercase(); - let swap_layout_name_len = swap_layout_name.len() + 3; - let bg = palette.text_unselected.background; - let fg = palette.ribbon_unselected.background; - let green = palette.ribbon_selected.background; - - let (prefix_separator, swap_layout_name, suffix_separator) = - if input_mode == InputMode::Locked { - ( - style!(bg, fg).paint(separator), - style!(bg, fg).italic().paint(&swap_layout_name), - style!(fg, bg).paint(separator), - ) - } else if is_swap_layout_damaged { - ( - style!(bg, fg).paint(separator), - style!(bg, fg).bold().paint(&swap_layout_name), - style!(fg, bg).paint(separator), - ) - } else { - ( - style!(bg, green).paint(separator), - style!(bg, green).bold().paint(&swap_layout_name), - style!(green, bg).paint(separator), - ) - }; - let swap_layout_indicator = format!( - "{}{}{}", - prefix_separator, swap_layout_name, suffix_separator - ); - let (part, full_len) = (format!("{}", swap_layout_indicator), swap_layout_name_len); - let short_len = swap_layout_name_len + 1; // 1 is the space between - if full_len <= max_len { - Some(LinePart { - part, - len: full_len, - tab_index: None, - }) - } else if short_len <= max_len && input_mode != InputMode::Locked { - Some(LinePart { - part: swap_layout_indicator, - len: short_len, - tab_index: None, - }) - } else { - None - } - }, - None => None, - } -} diff --git a/default-plugins/compact-bar/src/main.rs b/default-plugins/compact-bar/src/main.rs index 91924211..9dde0e31 100644 --- a/default-plugins/compact-bar/src/main.rs +++ b/default-plugins/compact-bar/src/main.rs @@ -1,5 +1,9 @@ +mod action_types; +mod clipboard_utils; +mod keybind_utils; mod line; mod tab; +mod tooltip; use std::cmp::{max, min}; use std::collections::BTreeMap; @@ -8,8 +12,18 @@ use std::convert::TryInto; use tab::get_tab_to_focus; use zellij_tile::prelude::*; +use crate::clipboard_utils::{system_clipboard_error, text_copied_hint}; use crate::line::tab_line; use crate::tab::tab_style; +use crate::tooltip::TooltipRenderer; + +static ARROW_SEPARATOR: &str = ""; + +const CONFIG_IS_TOOLTIP: &str = "is_tooltip"; +const CONFIG_TOGGLE_TOOLTIP_KEY: &str = "tooltip"; +const MSG_TOGGLE_TOOLTIP: &str = "toggle_tooltip"; +const MSG_TOGGLE_PERSISTED_TOOLTIP: &str = "toggle_persisted_tooltip"; +const MSG_LAUNCH_TOOLTIP: &str = "launch_tooltip_if_not_launched"; #[derive(Debug, Default)] pub struct LinePart { @@ -20,207 +34,517 @@ pub struct LinePart { #[derive(Default)] struct State { + // Tab state tabs: Vec, active_tab_idx: usize, + + // Display state mode_info: ModeInfo, tab_line: Vec, + display_area_rows: usize, + display_area_cols: usize, + + // Clipboard state text_copy_destination: Option, display_system_clipboard_failure: bool, + + // Plugin configuration + config: BTreeMap, + own_plugin_id: Option, + toggle_tooltip_key: Option, + + // Tooltip state + is_tooltip: bool, + tooltip_is_active: bool, + persist: bool, + is_first_run: bool, } -static ARROW_SEPARATOR: &str = ""; +struct TabRenderData { + tabs: Vec, + active_tab_index: usize, + active_swap_layout_name: Option, + is_swap_layout_dirty: bool, +} register_plugin!(State); impl ZellijPlugin for State { - fn load(&mut self, _configuration: BTreeMap) { - set_selectable(false); - subscribe(&[ - EventType::TabUpdate, - EventType::ModeUpdate, - EventType::Mouse, - EventType::CopyToClipboard, - EventType::InputReceived, - EventType::SystemClipboardFailure, - EventType::PaneUpdate, - ]); + fn load(&mut self, configuration: BTreeMap) { + self.initialize_configuration(configuration); + self.setup_subscriptions(); + self.configure_keybinds(); + self.own_plugin_id = Some(get_plugin_ids().plugin_id); } fn update(&mut self, event: Event) -> bool { - let mut should_render = false; + self.is_first_run = false; + match event { - Event::ModeUpdate(mode_info) => { - if self.mode_info != mode_info { - should_render = true; - } - self.mode_info = mode_info - }, - Event::TabUpdate(tabs) => { - if let Some(active_tab_index) = tabs.iter().position(|t| t.active) { - // tabs are indexed starting from 1 so we need to add 1 - let active_tab_idx = active_tab_index + 1; - if self.active_tab_idx != active_tab_idx || self.tabs != tabs { - should_render = true; - } - self.active_tab_idx = active_tab_idx; - self.tabs = tabs; - } else { - eprintln!("Could not find active tab."); - } - }, - Event::Mouse(me) => match me { - Mouse::LeftClick(_, col) => { - let tab_to_focus = get_tab_to_focus(&self.tab_line, self.active_tab_idx, col); - if let Some(idx) = tab_to_focus { - switch_tab_to(idx.try_into().unwrap()); - } - }, - Mouse::ScrollUp(_) => { - switch_tab_to(min(self.active_tab_idx + 1, self.tabs.len()) as u32); - }, - Mouse::ScrollDown(_) => { - switch_tab_to(max(self.active_tab_idx.saturating_sub(1), 1) as u32); - }, - _ => {}, + Event::ModeUpdate(mode_info) => self.handle_mode_update(mode_info), + Event::TabUpdate(tabs) => self.handle_tab_update(tabs), + Event::PaneUpdate(pane_manifest) => self.handle_pane_update(pane_manifest), + Event::Mouse(mouse_event) => { + self.handle_mouse_event(mouse_event); + false }, Event::CopyToClipboard(copy_destination) => { - match self.text_copy_destination { - Some(text_copy_destination) => { - if text_copy_destination != copy_destination { - should_render = true; - } - }, - None => { - should_render = true; - }, - } - self.text_copy_destination = Some(copy_destination); + self.handle_clipboard_copy(copy_destination) }, - Event::SystemClipboardFailure => { - should_render = true; - self.display_system_clipboard_failure = true; - }, - Event::InputReceived => { - if self.text_copy_destination.is_some() - || self.display_system_clipboard_failure == true - { - should_render = true; - } - self.text_copy_destination = None; - self.display_system_clipboard_failure = false; - }, - _ => { - eprintln!("Got unrecognized event: {:?}", event); - }, - }; - should_render + Event::SystemClipboardFailure => self.handle_clipboard_failure(), + Event::InputReceived => self.handle_input_received(), + _ => false, + } } - fn render(&mut self, _rows: usize, cols: usize) { - if let Some(copy_destination) = self.text_copy_destination { - let hint = text_copied_hint(copy_destination).part; + fn pipe(&mut self, message: PipeMessage) -> bool { + if self.is_tooltip && message.is_private { + self.handle_tooltip_pipe(message); + } else if message.name == MSG_TOGGLE_TOOLTIP + && message.is_private + && self.toggle_tooltip_key.is_some() + { + self.toggle_persisted_tooltip(self.mode_info.mode); + } + false + } - let background = self.mode_info.style.colors.text_unselected.background; - match background { - PaletteColor::Rgb((r, g, b)) => { - print!("{}\u{1b}[48;2;{};{};{}m\u{1b}[0K", hint, r, g, b); - }, - PaletteColor::EightBit(color) => { - print!("{}\u{1b}[48;5;{}m\u{1b}[0K", hint, color); - }, - } - } else if self.display_system_clipboard_failure { - let hint = system_clipboard_error().part; - let background = self.mode_info.style.colors.text_unselected.background; - match background { - PaletteColor::Rgb((r, g, b)) => { - print!("{}\u{1b}[48;2;{};{};{}m\u{1b}[0K", hint, r, g, b); - }, - PaletteColor::EightBit(color) => { - print!("{}\u{1b}[48;5;{}m\u{1b}[0K", hint, color); - }, - } + fn render(&mut self, rows: usize, cols: usize) { + if self.is_tooltip { + self.render_tooltip(rows, cols); } else { - if self.tabs.is_empty() { - return; - } - let mut all_tabs: Vec = vec![]; - let mut active_tab_index = 0; - let mut active_swap_layout_name = None; - let mut is_swap_layout_dirty = false; - let mut is_alternate_tab = false; - for t in &mut self.tabs { - let mut tabname = t.name.clone(); - if t.active && self.mode_info.mode == InputMode::RenameTab { - if tabname.is_empty() { - tabname = String::from("Enter name..."); - } - active_tab_index = t.position; - } else if t.active { - active_tab_index = t.position; - is_swap_layout_dirty = t.is_swap_layout_dirty; - active_swap_layout_name = t.active_swap_layout_name.clone(); - } - let tab = tab_style( - tabname, - t, - is_alternate_tab, - self.mode_info.style.colors, - self.mode_info.capabilities, - ); - is_alternate_tab = !is_alternate_tab; - all_tabs.push(tab); - } - self.tab_line = tab_line( - self.mode_info.session_name.as_deref(), - all_tabs, - active_tab_index, - cols.saturating_sub(1), - self.mode_info.style.colors, - self.mode_info.capabilities, - self.mode_info.style.hide_session_name, - self.mode_info.mode, - &active_swap_layout_name, - is_swap_layout_dirty, - ); - let output = self - .tab_line - .iter() - .fold(String::new(), |output, part| output + &part.part); - let background = self.mode_info.style.colors.text_unselected.background; - match background { - PaletteColor::Rgb((r, g, b)) => { - print!("{}\u{1b}[48;2;{};{};{}m\u{1b}[0K", output, r, g, b); - }, - PaletteColor::EightBit(color) => { - print!("{}\u{1b}[48;5;{}m\u{1b}[0K", output, color); - }, - } + self.render_tab_line(cols); } } } -pub fn text_copied_hint(copy_destination: CopyDestination) -> LinePart { - let hint = match copy_destination { - CopyDestination::Command => "Text piped to external command", - #[cfg(not(target_os = "macos"))] - CopyDestination::Primary => "Text copied to system primary selection", - #[cfg(target_os = "macos")] // primary selection does not exist on macos - CopyDestination::Primary => "Text copied to system clipboard", - CopyDestination::System => "Text copied to system clipboard", - }; - LinePart { - part: serialize_text(&Text::new(&hint).color_range(2, ..).opaque()), - len: hint.len(), - tab_index: None, +impl State { + fn initialize_configuration(&mut self, configuration: BTreeMap) { + self.config = configuration.clone(); + self.is_tooltip = self.parse_bool_config(CONFIG_IS_TOOLTIP, false); + + if !self.is_tooltip { + if let Some(tooltip_toggle_key) = configuration.get(CONFIG_TOGGLE_TOOLTIP_KEY) { + self.toggle_tooltip_key = Some(tooltip_toggle_key.clone()); + } + } + + if self.is_tooltip { + self.is_first_run = true; + } + } + + fn setup_subscriptions(&self) { + set_selectable(false); + + let events = if self.is_tooltip { + vec![EventType::ModeUpdate, EventType::TabUpdate] + } else { + vec![ + EventType::TabUpdate, + EventType::PaneUpdate, + EventType::ModeUpdate, + EventType::Mouse, + EventType::CopyToClipboard, + EventType::InputReceived, + EventType::SystemClipboardFailure, + ] + }; + + subscribe(&events); + } + + fn configure_keybinds(&self) { + if !self.is_tooltip && self.toggle_tooltip_key.is_some() { + if let Some(toggle_key) = &self.toggle_tooltip_key { + reconfigure(bind_toggle_key_config(toggle_key), false); + } + } + } + + fn parse_bool_config(&self, key: &str, default: bool) -> bool { + self.config + .get(key) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) + } + + // Event handlers + fn handle_mode_update(&mut self, mode_info: ModeInfo) -> bool { + let should_render = self.mode_info != mode_info; + let old_mode = self.mode_info.mode; + let new_mode = mode_info.mode; + let base_mode = mode_info.base_mode.unwrap_or(InputMode::Normal); + + self.mode_info = mode_info; + + if self.is_tooltip { + self.handle_tooltip_mode_update(old_mode, new_mode, base_mode); + } else { + self.handle_main_mode_update(new_mode, base_mode); + } + + should_render + } + + fn handle_main_mode_update(&self, new_mode: InputMode, base_mode: InputMode) { + if self.toggle_tooltip_key.is_some() + && new_mode != base_mode + && !self.is_restricted_mode(new_mode) + { + self.launch_tooltip_if_not_launched(new_mode); + } + } + + fn handle_tooltip_mode_update( + &mut self, + old_mode: InputMode, + new_mode: InputMode, + base_mode: InputMode, + ) { + if !self.persist && (new_mode == base_mode || self.is_restricted_mode(new_mode)) { + close_self(); + } else if new_mode != old_mode || self.persist { + self.update_tooltip_for_mode_change(new_mode); + } + } + + fn handle_tab_update(&mut self, tabs: Vec) -> bool { + self.update_display_area(&tabs); + + if let Some(active_tab_index) = tabs.iter().position(|t| t.active) { + let active_tab_idx = active_tab_index + 1; // Convert to 1-based indexing + let should_render = self.active_tab_idx != active_tab_idx || self.tabs != tabs; + + if self.is_tooltip && self.active_tab_idx != active_tab_idx { + self.move_tooltip_to_new_tab(active_tab_idx); + } + + self.active_tab_idx = active_tab_idx; + self.tabs = tabs; + should_render + } else { + eprintln!("Could not find active tab."); + false + } + } + + fn handle_pane_update(&mut self, pane_manifest: PaneManifest) -> bool { + if self.toggle_tooltip_key.is_some() { + let previous_tooltip_state = self.tooltip_is_active; + self.tooltip_is_active = self.detect_tooltip_presence(&pane_manifest); + previous_tooltip_state != self.tooltip_is_active + } else { + false + } + } + + fn handle_mouse_event(&mut self, mouse_event: Mouse) { + if self.is_tooltip { + return; + } + + match mouse_event { + Mouse::LeftClick(_, col) => self.handle_tab_click(col), + Mouse::ScrollUp(_) => self.scroll_tab_up(), + Mouse::ScrollDown(_) => self.scroll_tab_down(), + _ => {}, + } + } + + fn handle_clipboard_copy(&mut self, copy_destination: CopyDestination) -> bool { + if self.is_tooltip { + return false; + } + + let should_render = match self.text_copy_destination { + Some(current) => current != copy_destination, + None => true, + }; + + self.text_copy_destination = Some(copy_destination); + should_render + } + + fn handle_clipboard_failure(&mut self) -> bool { + if self.is_tooltip { + return false; + } + + self.display_system_clipboard_failure = true; + true + } + + fn handle_input_received(&mut self) -> bool { + if self.is_tooltip { + return false; + } + + let should_render = + self.text_copy_destination.is_some() || self.display_system_clipboard_failure; + self.clear_clipboard_state(); + should_render + } + + fn handle_tooltip_pipe(&mut self, message: PipeMessage) { + if message.name == MSG_TOGGLE_PERSISTED_TOOLTIP { + if self.is_first_run { + self.persist = true; + } else { + #[cfg(target_family = "wasm")] + close_self(); + } + } + } + + // Helper methods + fn update_display_area(&mut self, tabs: &[TabInfo]) { + for tab in tabs { + if tab.active { + self.display_area_rows = tab.display_area_rows; + self.display_area_cols = tab.display_area_columns; + break; + } + } + } + + fn detect_tooltip_presence(&self, pane_manifest: &PaneManifest) -> bool { + for (_tab_index, panes) in &pane_manifest.panes { + for pane in panes { + if pane.plugin_url == Some("zellij:compact-bar".to_owned()) + && pane.pane_x != pane.pane_content_x + { + return true; + } + } + } + false + } + + fn handle_tab_click(&self, col: usize) { + if let Some(tab_idx) = get_tab_to_focus(&self.tab_line, self.active_tab_idx, col) { + switch_tab_to(tab_idx.try_into().unwrap()); + } + } + + fn scroll_tab_up(&self) { + let next_tab = min(self.active_tab_idx + 1, self.tabs.len()); + switch_tab_to(next_tab as u32); + } + + fn scroll_tab_down(&self) { + let prev_tab = max(self.active_tab_idx.saturating_sub(1), 1); + switch_tab_to(prev_tab as u32); + } + + fn clear_clipboard_state(&mut self) { + self.text_copy_destination = None; + self.display_system_clipboard_failure = false; + } + + fn is_restricted_mode(&self, mode: InputMode) -> bool { + matches!( + mode, + InputMode::Locked + | InputMode::EnterSearch + | InputMode::RenameTab + | InputMode::RenamePane + | InputMode::Prompt + | InputMode::Tmux + ) + } + + // Tooltip operations + fn toggle_persisted_tooltip(&self, new_mode: InputMode) { + let message = self + .create_tooltip_message(MSG_TOGGLE_PERSISTED_TOOLTIP, new_mode) + .with_args(self.create_persist_args()); + + #[cfg(target_family = "wasm")] + pipe_message_to_plugin(message); + } + + fn launch_tooltip_if_not_launched(&self, new_mode: InputMode) { + let message = self.create_tooltip_message(MSG_LAUNCH_TOOLTIP, new_mode); + pipe_message_to_plugin(message); + } + + fn create_tooltip_message(&self, name: &str, mode: InputMode) -> MessageToPlugin { + let mut tooltip_config = self.config.clone(); + tooltip_config.insert(CONFIG_IS_TOOLTIP.to_string(), "true".to_string()); + + MessageToPlugin::new(name) + .with_plugin_url("zellij:OWN_URL") + .with_plugin_config(tooltip_config) + .with_floating_pane_coordinates(self.calculate_tooltip_coordinates()) + .new_plugin_instance_should_have_pane_title(format!("{:?}", mode)) + } + + fn create_persist_args(&self) -> BTreeMap { + let mut args = BTreeMap::new(); + args.insert("persist".to_string(), String::new()); + args + } + + fn update_tooltip_for_mode_change(&self, new_mode: InputMode) { + if let Some(plugin_id) = self.own_plugin_id { + let coordinates = self.calculate_tooltip_coordinates(); + change_floating_panes_coordinates(vec![(PaneId::Plugin(plugin_id), coordinates)]); + rename_plugin_pane(plugin_id, format!("{:?}", new_mode)); + } + } + + fn move_tooltip_to_new_tab(&self, new_tab_index: usize) { + if let Some(plugin_id) = self.own_plugin_id { + break_panes_to_tab_with_index( + &[PaneId::Plugin(plugin_id)], + new_tab_index.saturating_sub(1), // Convert to 0-based indexing + false, + ); + } + } + + fn calculate_tooltip_coordinates(&self) -> FloatingPaneCoordinates { + let tooltip_renderer = TooltipRenderer::new(&self.mode_info); + let (tooltip_rows, tooltip_cols) = + tooltip_renderer.calculate_dimensions(self.mode_info.mode); + + let width = tooltip_cols + 4; // 2 for borders, 2 for padding + let height = tooltip_rows + 2; // 2 for borders + let x_position = 2; + let y_position = self.display_area_rows.saturating_sub(height + 2); + + FloatingPaneCoordinates::new( + Some(x_position.to_string()), + Some(y_position.to_string()), + Some(width.to_string()), + Some(height.to_string()), + Some(true), + ) + .unwrap_or_default() + } + + // Rendering + fn render_tooltip(&self, rows: usize, cols: usize) { + let tooltip_renderer = TooltipRenderer::new(&self.mode_info); + tooltip_renderer.render(rows, cols); + } + + fn render_tab_line(&mut self, cols: usize) { + if let Some(copy_destination) = self.text_copy_destination { + self.render_clipboard_hint(copy_destination); + } else if self.display_system_clipboard_failure { + self.render_clipboard_error(); + } else { + self.render_tabs(cols); + } + } + + fn render_clipboard_hint(&self, copy_destination: CopyDestination) { + let hint = text_copied_hint(copy_destination).part; + self.render_background_with_text(&hint); + } + + fn render_clipboard_error(&self) { + let hint = system_clipboard_error().part; + self.render_background_with_text(&hint); + } + + fn render_background_with_text(&self, text: &str) { + let background = self.mode_info.style.colors.text_unselected.background; + match background { + PaletteColor::Rgb((r, g, b)) => { + print!("{}\u{1b}[48;2;{};{};{}m\u{1b}[0K", text, r, g, b); + }, + PaletteColor::EightBit(color) => { + print!("{}\u{1b}[48;5;{}m\u{1b}[0K", text, color); + }, + } + } + + fn render_tabs(&mut self, cols: usize) { + if self.tabs.is_empty() { + return; + } + + let tab_data = self.prepare_tab_data(); + self.tab_line = tab_line( + &self.mode_info, + tab_data, + cols, + self.toggle_tooltip_key.clone(), + self.tooltip_is_active, + ); + + let output = self + .tab_line + .iter() + .fold(String::new(), |acc, part| acc + &part.part); + + self.render_background_with_text(&output); + } + + fn prepare_tab_data(&self) -> TabRenderData { + let mut all_tabs = Vec::new(); + let mut active_tab_index = 0; + let mut active_swap_layout_name = None; + let mut is_swap_layout_dirty = false; + let mut is_alternate_tab = false; + + for tab in &self.tabs { + let tab_name = self.get_tab_display_name(tab); + + if tab.active { + active_tab_index = tab.position; + if self.mode_info.mode != InputMode::RenameTab { + is_swap_layout_dirty = tab.is_swap_layout_dirty; + active_swap_layout_name = tab.active_swap_layout_name.clone(); + } + } + + let styled_tab = tab_style( + tab_name, + tab, + is_alternate_tab, + self.mode_info.style.colors, + self.mode_info.capabilities, + ); + + is_alternate_tab = !is_alternate_tab; + all_tabs.push(styled_tab); + } + + TabRenderData { + tabs: all_tabs, + active_tab_index, + active_swap_layout_name, + is_swap_layout_dirty, + } + } + + fn get_tab_display_name(&self, tab: &TabInfo) -> String { + let mut tab_name = tab.name.clone(); + if tab.active && self.mode_info.mode == InputMode::RenameTab && tab_name.is_empty() { + tab_name = "Enter name...".to_string(); + } + tab_name } } -pub fn system_clipboard_error() -> LinePart { - let hint = " Error using the system clipboard."; - LinePart { - part: serialize_text(&Text::new(&hint).color_range(2, ..).opaque()), - len: hint.len(), - tab_index: None, - } +fn bind_toggle_key_config(toggle_key: &str) -> String { + format!( + r#" + keybinds {{ + shared {{ + bind "{}" {{ + MessagePlugin "compact-bar" {{ + name "toggle_tooltip" + toggle_tooltip_key "{}" + }} + }} + }} + }} + "#, + toggle_key, toggle_key + ) } diff --git a/default-plugins/compact-bar/src/tooltip.rs b/default-plugins/compact-bar/src/tooltip.rs new file mode 100644 index 00000000..b068b3ed --- /dev/null +++ b/default-plugins/compact-bar/src/tooltip.rs @@ -0,0 +1,136 @@ +use crate::keybind_utils::KeybindProcessor; +use zellij_tile::prelude::*; + +pub struct TooltipRenderer<'a> { + mode_info: &'a ModeInfo, +} + +impl<'a> TooltipRenderer<'a> { + pub fn new(mode_info: &'a ModeInfo) -> Self { + Self { mode_info } + } + + pub fn render(&self, rows: usize, cols: usize) { + let current_mode = self.mode_info.mode; + + if current_mode == InputMode::Normal { + let (text_components, tooltip_rows, tooltip_columns) = + self.normal_mode_tooltip(current_mode); + let base_x = cols.saturating_sub(tooltip_columns) / 2; + let base_y = rows.saturating_sub(tooltip_rows) / 2; + + for (text, ribbon, x, y) in text_components { + let text_width = text.content().chars().count(); + let ribbon_content_width = ribbon.content().chars().count(); + let ribbon_total_width = ribbon_content_width + 4; + let total_element_width = text_width + ribbon_total_width + 1; + + // Check if this element would exceed the available columns and render an ellipses + // if it does + if base_x + x + total_element_width > cols { + let remaining_space = cols.saturating_sub(base_x + x); + let ellipsis = Text::new("..."); + print_text_with_coordinates( + ellipsis, + base_x + x, + base_y + y, + Some(remaining_space), + None, + ); + break; + } + + print_text_with_coordinates(text, base_x + x, base_y + y, None, None); + print_ribbon_with_coordinates( + ribbon, + base_x + x + text_width + 1, + base_y + y, + None, + None, + ); + } + } else { + let (table, tooltip_rows, tooltip_columns) = self.other_mode_tooltip(current_mode); + let base_x = cols.saturating_sub(tooltip_columns) / 2; + let base_y = rows.saturating_sub(tooltip_rows) / 2; + print_table_with_coordinates(table, base_x, base_y, None, None); + } + } + + pub fn calculate_dimensions(&self, current_mode: InputMode) -> (usize, usize) { + match current_mode { + InputMode::Normal => { + let (_, tooltip_rows, tooltip_cols) = self.normal_mode_tooltip(current_mode); + (tooltip_rows, tooltip_cols) + }, + _ => { + let (_, tooltip_rows, tooltip_cols) = self.other_mode_tooltip(current_mode); + (tooltip_rows + 1, tooltip_cols) // + 1 for the invisible table title + }, + } + } + + fn normal_mode_tooltip( + &self, + current_mode: InputMode, + ) -> (Vec<(Text, Text, usize, usize)>, usize, usize) { + let actions = KeybindProcessor::get_predetermined_actions(self.mode_info, current_mode); + let y = 0; + let mut running_x = 0; + let mut components = Vec::new(); + let mut max_columns = 0; + + for (key, description) in actions { + let text = Text::new(&key).color_all(3); + let ribbon = Text::new(&description); + + let line_length = key.chars().count() + 1 + description.chars().count(); + + components.push((text, ribbon, running_x, y)); + running_x += line_length + 5; + max_columns = max_columns.max(running_x); + } + + let total_rows = 1; + (components, total_rows, max_columns) + } + + fn other_mode_tooltip(&self, current_mode: InputMode) -> (Table, usize, usize) { + let actions = KeybindProcessor::get_predetermined_actions(self.mode_info, current_mode); + let actions_vec: Vec<_> = actions.into_iter().collect(); + + let mut table = Table::new().add_row(vec![" ".to_owned(); 2]); + let mut row_count = 1; // Start with header row + + if actions_vec.is_empty() { + let tooltip_text = match self.mode_info.mode { + InputMode::EnterSearch => "Entering search term...".to_owned(), + InputMode::RenameTab => "Renaming tab...".to_owned(), + InputMode::RenamePane => "Renaming pane...".to_owned(), + _ => { + format!("{:?}", self.mode_info.mode) + }, + }; + let total_width = tooltip_text.chars().count(); + table = table.add_styled_row(vec![Text::new(tooltip_text).color_all(0)]); + row_count += 1; + (table, row_count, total_width) + } else { + let mut key_width = 0; + let mut action_width = 0; + for (key, description) in actions_vec.into_iter() { + let description_formatted = format!("- {}", description); + key_width = key_width.max(key.chars().count()); + action_width = action_width.max(description_formatted.chars().count()); + table = table.add_styled_row(vec![ + Text::new(&key).color_all(3), + Text::new(description_formatted), + ]); + row_count += 1; + } + + let total_width = key_width + action_width + 1; // +1 for separator + (table, row_count, total_width) + } + } +} diff --git a/zellij-server/src/plugins/mod.rs b/zellij-server/src/plugins/mod.rs index ec13df38..ab853614 100644 --- a/zellij-server/src/plugins/mod.rs +++ b/zellij-server/src/plugins/mod.rs @@ -675,6 +675,7 @@ pub(crate) fn plugin_thread_main( } => { let should_float = floating.unwrap_or(true); let mut pipe_messages = vec![]; + let floating_pane_coordinates = None; // TODO: do we want to allow this? match plugin { Some(plugin_url) => { // send to specific plugin(s) @@ -695,6 +696,7 @@ pub(crate) fn plugin_thread_main( &bus, &mut wasm_bridge, &plugin_aliases, + floating_pane_coordinates, ); }, None => { @@ -727,6 +729,7 @@ pub(crate) fn plugin_thread_main( } => { let should_float = floating.unwrap_or(true); let mut pipe_messages = vec![]; + let floating_pane_coordinates = None; // TODO: do we want to allow this? if let Some((plugin_id, client_id)) = plugin_and_client_id { let is_private = true; pipe_messages.push(( @@ -755,6 +758,7 @@ pub(crate) fn plugin_thread_main( &bus, &mut wasm_bridge, &plugin_aliases, + floating_pane_coordinates, ); }, None => { @@ -798,6 +802,7 @@ pub(crate) fn plugin_thread_main( .new_plugin_args .as_ref() .and_then(|n| n.pane_id_to_replace); + let floating_pane_coordinates = message.floating_pane_coordinates; match (message.plugin_url, message.destination_plugin_id) { (Some(plugin_url), None) => { // send to specific plugin(s) @@ -818,6 +823,7 @@ pub(crate) fn plugin_thread_main( &bus, &mut wasm_bridge, &plugin_aliases, + floating_pane_coordinates, ); }, (None, Some(destination_plugin_id)) => { @@ -997,6 +1003,7 @@ fn pipe_to_specific_plugins( bus: &Bus, wasm_bridge: &mut WasmBridge, plugin_aliases: &PluginAliases, + floating_pane_coordinates: Option, ) { let is_private = true; let size = Size::default(); @@ -1018,6 +1025,7 @@ fn pipe_to_specific_plugins( pane_title.clone(), pane_id_to_replace.clone(), cli_client_id, + floating_pane_coordinates, ); for (plugin_id, client_id) in all_plugin_ids { pipe_messages.push(( diff --git a/zellij-server/src/plugins/wasm_bridge.rs b/zellij-server/src/plugins/wasm_bridge.rs index 00b30aa1..4562f5a5 100644 --- a/zellij-server/src/plugins/wasm_bridge.rs +++ b/zellij-server/src/plugins/wasm_bridge.rs @@ -21,7 +21,9 @@ use std::{ }; use wasmtime::{Engine, Module}; use zellij_utils::consts::{ZELLIJ_CACHE_DIR, ZELLIJ_TMP_DIR}; -use zellij_utils::data::{InputMode, PermissionStatus, PermissionType, PipeMessage, PipeSource}; +use zellij_utils::data::{ + FloatingPaneCoordinates, InputMode, PermissionStatus, PermissionType, PipeMessage, PipeSource, +}; use zellij_utils::downloader::Downloader; use zellij_utils::input::keybinds::Keybinds; use zellij_utils::input::permission::PermissionCache; @@ -1409,6 +1411,7 @@ impl WasmBridge { pane_title: Option, pane_id_to_replace: Option, cli_client_id: Option, + floating_pane_coordinates: Option, ) -> Vec<(PluginId, Option)> { let run_plugin = run_plugin_or_alias.get_run_plugin(); match run_plugin { @@ -1435,6 +1438,8 @@ impl WasmBridge { ) { Ok((plugin_id, client_id)) => { let start_suppressed = false; + let should_focus = Some(false); // we should not focus plugins that + // were started from another plugin drop(self.senders.send_to_screen(ScreenInstruction::AddPlugin( Some(should_float), should_be_open_in_place, @@ -1445,8 +1450,8 @@ impl WasmBridge { pane_id_to_replace, cwd, start_suppressed, - None, - None, + floating_pane_coordinates, + should_focus, Some(client_id), ))); vec![(plugin_id, Some(client_id))] diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs index 64c8836e..6a8ea313 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -24,8 +24,8 @@ use zellij_utils::{ envs::set_session_name, input::command::TerminalAction, input::layout::{ - FloatingPaneLayout, Layout, Run, RunPluginOrAlias, SwapFloatingLayout, SwapTiledLayout, - TiledPaneLayout, + FloatingPaneLayout, Layout, Run, RunPluginOrAlias, SplitSize, SwapFloatingLayout, + SwapTiledLayout, TiledPaneLayout, }, position::Position, }; @@ -2407,8 +2407,9 @@ impl Screen { } // here we pass None instead of the client_id we have because we do not need to // necessarily trigger a relayout for this tab + let pane_was_floating = tab.pane_id_is_floating(&pane_id); if let Some(pane) = tab.extract_pane(pane_id, true).take() { - extracted_panes.push(pane); + extracted_panes.push((pane_was_floating, pane)); break; } } @@ -2422,11 +2423,27 @@ impl Screen { return Ok(()); } if let Some(new_active_tab) = self.get_indexed_tab_mut(tab_index) { - for pane in extracted_panes { + for (pane_was_floating, pane) in extracted_panes { let pane_id = pane.pid(); - // here we pass None instead of the ClientId, because we do not want this pane to be - // necessarily focused - new_active_tab.add_tiled_pane(pane, pane_id, None)?; + if pane_was_floating { + let floating_pane_coordinates = FloatingPaneCoordinates { + x: Some(SplitSize::Fixed(pane.x())), + y: Some(SplitSize::Fixed(pane.y())), + width: Some(SplitSize::Fixed(pane.cols())), + height: Some(SplitSize::Fixed(pane.rows())), + pinned: Some(pane.current_geom().is_pinned), + }; + new_active_tab.add_floating_pane( + pane, + pane_id, + Some(floating_pane_coordinates), + false, + )?; + } else { + // here we pass None instead of the ClientId, because we do not want this pane to be + // necessarily focused + new_active_tab.add_tiled_pane(pane, pane_id, None)?; + } } } else { log::error!("Could not find tab with index: {:?}", tab_index); diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index 12376817..23b4b97f 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -4538,6 +4538,7 @@ impl Tab { pane.set_active_at(Instant::now()); pane.set_geom(new_pane_geom); pane.set_content_offset(Offset::frame(1)); // floating panes always have a frame + pane.render_full_viewport(); // to make sure the frame is re-rendered resize_pty!(pane, self.os_api, self.senders, self.character_cell_size) .with_context(err_context)?; self.floating_panes.add_pane(pane_id, pane); diff --git a/zellij-server/src/ui/pane_boundaries_frame.rs b/zellij-server/src/ui/pane_boundaries_frame.rs index 2b0d02eb..337c6a83 100644 --- a/zellij-server/src/ui/pane_boundaries_frame.rs +++ b/zellij-server/src/ui/pane_boundaries_frame.rs @@ -169,7 +169,8 @@ impl PaneFrame { ) -> Option<(Vec, usize)> { // string and length because of color let has_scroll = self.scroll_position.0 > 0 || self.scroll_position.1 > 0; - if has_scroll { + if has_scroll && self.is_selectable { + // TODO: don't show SCROLL at all for plugins let pin_indication = if self.is_floating && self.is_selectable { self.render_pinned_indication(max_length) } else { diff --git a/zellij-tile/src/shim.rs b/zellij-tile/src/shim.rs index d67768f1..3dda6d1f 100644 --- a/zellij-tile/src/shim.rs +++ b/zellij-tile/src/shim.rs @@ -1183,7 +1183,7 @@ pub fn break_panes_to_new_tab( unsafe { host_run_plugin_command() }; } -/// Create a new tab that includes the specified pane ids +/// Move the pane ids to the tab with the specified index pub fn break_panes_to_tab_with_index( pane_ids: &[PaneId], tab_index: usize, diff --git a/zellij-tile/src/ui_components/text.rs b/zellij-tile/src/ui_components/text.rs index 787125b5..68e8c0a1 100644 --- a/zellij-tile/src/ui_components/text.rs +++ b/zellij-tile/src/ui_components/text.rs @@ -60,7 +60,7 @@ impl Text { while let Some(pos) = self.text[start..].find(substr) { let abs_pos = start + pos; - self = self.color_range(index_level, abs_pos..abs_pos + substr.len()); + self = self.color_range(index_level, abs_pos..abs_pos + substr.chars().count()); start = abs_pos + substr.len(); } @@ -92,6 +92,9 @@ impl Text { self } + pub fn content(&self) -> &str { + &self.text + } fn pad_indices(&mut self, index_level: usize) { if self.indices.get(index_level).is_none() { for _ in self.indices.len()..=index_level { diff --git a/zellij-utils/assets/plugins/about.wasm b/zellij-utils/assets/plugins/about.wasm index 07ac7021..86891995 100755 Binary files a/zellij-utils/assets/plugins/about.wasm and b/zellij-utils/assets/plugins/about.wasm differ diff --git a/zellij-utils/assets/plugins/compact-bar.wasm b/zellij-utils/assets/plugins/compact-bar.wasm index 3918af37..8d6e7105 100755 Binary files a/zellij-utils/assets/plugins/compact-bar.wasm and b/zellij-utils/assets/plugins/compact-bar.wasm differ diff --git a/zellij-utils/assets/plugins/configuration.wasm b/zellij-utils/assets/plugins/configuration.wasm index a2aa03fe..bcf8110a 100755 Binary files a/zellij-utils/assets/plugins/configuration.wasm and b/zellij-utils/assets/plugins/configuration.wasm differ diff --git a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm index be0bd88a..a3a0171c 100755 Binary files a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm and b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm differ diff --git a/zellij-utils/assets/plugins/multiple-select.wasm b/zellij-utils/assets/plugins/multiple-select.wasm index 584569c2..011a4ecc 100755 Binary files a/zellij-utils/assets/plugins/multiple-select.wasm and b/zellij-utils/assets/plugins/multiple-select.wasm differ diff --git a/zellij-utils/assets/plugins/plugin-manager.wasm b/zellij-utils/assets/plugins/plugin-manager.wasm index 3817aa9e..a440cc97 100755 Binary files a/zellij-utils/assets/plugins/plugin-manager.wasm and b/zellij-utils/assets/plugins/plugin-manager.wasm differ diff --git a/zellij-utils/assets/plugins/session-manager.wasm b/zellij-utils/assets/plugins/session-manager.wasm index 17cd792c..503fc235 100755 Binary files a/zellij-utils/assets/plugins/session-manager.wasm and b/zellij-utils/assets/plugins/session-manager.wasm differ diff --git a/zellij-utils/assets/plugins/status-bar.wasm b/zellij-utils/assets/plugins/status-bar.wasm index fefe06ba..061528d7 100755 Binary files a/zellij-utils/assets/plugins/status-bar.wasm and b/zellij-utils/assets/plugins/status-bar.wasm differ diff --git a/zellij-utils/assets/plugins/strider.wasm b/zellij-utils/assets/plugins/strider.wasm index ae11f8e0..8a5cb6b6 100755 Binary files a/zellij-utils/assets/plugins/strider.wasm and b/zellij-utils/assets/plugins/strider.wasm differ diff --git a/zellij-utils/assets/plugins/tab-bar.wasm b/zellij-utils/assets/plugins/tab-bar.wasm index 5f1e2c9f..8a5820cc 100755 Binary files a/zellij-utils/assets/plugins/tab-bar.wasm and b/zellij-utils/assets/plugins/tab-bar.wasm differ diff --git a/zellij-utils/assets/prost/api.plugin_command.rs b/zellij-utils/assets/prost/api.plugin_command.rs index 5bdccac9..b6b3d96d 100644 --- a/zellij-utils/assets/prost/api.plugin_command.rs +++ b/zellij-utils/assets/prost/api.plugin_command.rs @@ -590,6 +590,8 @@ pub struct MessageToPluginPayload { pub new_plugin_args: ::core::option::Option, #[prost(uint32, optional, tag="7")] pub destination_plugin_id: ::core::option::Option, + #[prost(message, optional, tag="8")] + pub floating_pane_coordinates: ::core::option::Option, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs index 2cceeab8..6b9a49d7 100644 --- a/zellij-utils/src/data.rs +++ b/zellij-utils/src/data.rs @@ -2,6 +2,7 @@ use crate::input::actions::Action; use crate::input::config::ConversionError; use crate::input::keybinds::Keybinds; use crate::input::layout::{RunPlugin, SplitSize}; +use crate::pane_size::PaneGeom; use crate::shared::colors as default_colors; use clap::ArgEnum; use serde::{Deserialize, Serialize}; @@ -1883,6 +1884,7 @@ pub struct MessageToPlugin { /// these will only be used in case we need to launch a new plugin to send this message to, /// since none are running pub new_plugin_args: Option, + pub floating_pane_coordinates: Option, } #[derive(Debug, Default, Clone)] @@ -1946,6 +1948,13 @@ impl MessageToPlugin { self.message_args = args; self } + pub fn with_floating_pane_coordinates( + mut self, + floating_pane_coordinates: FloatingPaneCoordinates, + ) -> Self { + self.floating_pane_coordinates = Some(floating_pane_coordinates); + self + } pub fn new_plugin_instance_should_float(mut self, should_float: bool) -> Self { let new_plugin_args = self.new_plugin_args.get_or_insert_with(Default::default); new_plugin_args.should_float = Some(should_float); @@ -2144,6 +2153,18 @@ impl FloatingPaneCoordinates { } } +impl From for FloatingPaneCoordinates { + fn from(pane_geom: PaneGeom) -> Self { + FloatingPaneCoordinates { + x: Some(SplitSize::Fixed(pane_geom.x)), + y: Some(SplitSize::Fixed(pane_geom.y)), + width: Some(SplitSize::Fixed(pane_geom.cols.as_usize())), + height: Some(SplitSize::Fixed(pane_geom.rows.as_usize())), + pinned: Some(pane_geom.is_pinned), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct OriginatingPlugin { pub plugin_id: u32, diff --git a/zellij-utils/src/plugin_api/plugin_command.proto b/zellij-utils/src/plugin_api/plugin_command.proto index 621b5de6..b8b6bad1 100644 --- a/zellij-utils/src/plugin_api/plugin_command.proto +++ b/zellij-utils/src/plugin_api/plugin_command.proto @@ -491,6 +491,7 @@ message MessageToPluginPayload { repeated ContextItem message_args = 5; optional NewPluginArgs new_plugin_args = 6; optional uint32 destination_plugin_id = 7; + optional FloatingPaneCoordinates floating_pane_coordinates = 8; } message NewPluginArgs { diff --git a/zellij-utils/src/plugin_api/plugin_command.rs b/zellij-utils/src/plugin_api/plugin_command.rs index 08a9545e..30a6a03d 100644 --- a/zellij-utils/src/plugin_api/plugin_command.rs +++ b/zellij-utils/src/plugin_api/plugin_command.rs @@ -935,6 +935,7 @@ impl TryFrom for PluginCommand { message_args, new_plugin_args, destination_plugin_id, + floating_pane_coordinates, })) => { let plugin_config: BTreeMap = plugin_config .into_iter() @@ -962,6 +963,8 @@ impl TryFrom for PluginCommand { }) }), destination_plugin_id, + floating_pane_coordinates: floating_pane_coordinates + .and_then(|f| f.try_into().ok()), })) }, _ => Err("Mismatched payload for MessageToPlugin"), @@ -2151,6 +2154,9 @@ impl TryFrom for ProtobufPluginCommand { } }), destination_plugin_id: message_to_plugin.destination_plugin_id, + floating_pane_coordinates: message_to_plugin + .floating_pane_coordinates + .and_then(|f| f.try_into().ok()), })), }) },