feat(ui): add optional tooltip with key hints to the compact bar (#4225)

* initial implementation

* some refactoring

* refactor: separate some concerns and tidy up

* fix: move tooltip to focused tab as needed

* some ux adjustments

* some refactoring

* only show tooltip if we have configured a shortcut key

* add plugin artifacts

* fix tests

* truncate tooltip if it exceeds width

* change config name

* remove comment

* docs(changelog): add PR
This commit is contained in:
Aram Drevekenin 2025-06-10 17:23:46 +02:00 committed by GitHub
parent a9f8bbcd19
commit 7ef7cd5ecd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1925 additions and 502 deletions

View file

@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
## [Unreleased] ## [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: 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 ## [0.42.2] - 2025-04-15
* refactor(terminal): track scroll_region as tuple rather than Option (https://github.com/zellij-org/zellij/pull/4082) * refactor(terminal): track scroll_region as tuple rather than Option (https://github.com/zellij-org/zellij/pull/4082)

View file

@ -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)),
}
}
}

View file

@ -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,
}
}

View file

@ -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<F>(
mode_info: &ModeInfo,
mode: InputMode,
predicates: Vec<F>,
) -> 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<String> = if is_switching_to_locked {
let non_esc_enter_keys: Vec<String> = 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(),
}
}
}

View file

@ -1,235 +1,611 @@
use ansi_term::ANSIStrings; use ansi_term::ANSIStrings;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::{LinePart, ARROW_SEPARATOR}; use crate::{LinePart, TabRenderData, ARROW_SEPARATOR};
use zellij_tile::prelude::*; use zellij_tile::prelude::*;
use zellij_tile_utils::style; use zellij_tile_utils::style;
fn get_current_title_len(current_title: &[LinePart]) -> usize { pub fn tab_line(
current_title.iter().map(|p| p.len).sum() mode_info: &ModeInfo,
tab_data: TabRenderData,
cols: usize,
toggle_tooltip_key: Option<String>,
tooltip_is_active: bool,
) -> Vec<LinePart> {
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 #[derive(Debug, Clone)]
// adds collapsed_tabs to the left and right if there's left over tabs that don't fit pub struct TabLineConfig {
fn populate_tabs_in_tab_line( pub session_name: Option<String>,
tabs_before_active: &mut Vec<LinePart>, pub hide_session_name: bool,
tabs_after_active: &mut Vec<LinePart>, pub mode: InputMode,
tabs_to_render: &mut Vec<LinePart>, pub active_swap_layout_name: Option<String>,
pub is_swap_layout_dirty: bool,
pub toggle_tooltip_key: Option<String>,
pub tooltip_is_active: bool,
}
fn calculate_total_length(parts: &[LinePart]) -> usize {
parts.iter().map(|p| p.len).sum()
}
struct TabLinePopulator {
cols: usize, cols: usize,
palette: Styling, palette: Styling,
capabilities: PluginCapabilities, capabilities: PluginCapabilities,
) { }
let mut middle_size = get_current_title_len(tabs_to_render);
impl TabLinePopulator {
fn new(cols: usize, palette: Styling, capabilities: PluginCapabilities) -> Self {
Self {
cols,
palette,
capabilities,
}
}
fn populate_tabs(
&self,
tabs_before_active: &mut Vec<LinePart>,
tabs_after_active: &mut Vec<LinePart>,
tabs_to_render: &mut Vec<LinePart>,
) {
let mut middle_size = calculate_total_length(tabs_to_render);
let mut total_left = 0; let mut total_left = 0;
let mut total_right = 0; let mut total_right = 0;
loop { loop {
let left_count = tabs_before_active.len(); let left_count = tabs_before_active.len();
let right_count = tabs_after_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 collapsed_indicators =
let left_more_tab_index = left_count.saturating_sub(1); self.create_collapsed_indicators(left_count, right_count, tabs_to_render.len());
let collapsed_left = left_more_message(
left_count,
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; let total_size =
collapsed_indicators.left.len + middle_size + collapsed_indicators.right.len;
if total_size > cols { if total_size > self.cols {
// break and dont add collapsed tabs to tabs_to_render, they will not fit
break; break;
} }
let left = if let Some(tab) = tabs_before_active.last() { let tab_sizes = TabSizes {
tab.len left: tabs_before_active.last().map_or(usize::MAX, |tab| tab.len),
} else { right: tabs_after_active.get(0).map_or(usize::MAX, |tab| tab.len),
usize::MAX
}; };
let right = if let Some(tab) = tabs_after_active.first() { let fit_analysis = self.analyze_tab_fit(
tab.len &tab_sizes,
} else { total_size,
usize::MAX left_count,
}; right_count,
&collapsed_indicators,
);
// total size is shortened if the next tab to be added is the last one, as that will remove the collapsed tab match self.decide_next_action(&fit_analysis, total_left, total_right) {
let size_by_adding_left = TabAction::AddLeft => {
left.saturating_add(total_size) if let Some(tab) = tabs_before_active.pop() {
.saturating_sub(if left_count == 1 {
collapsed_left.len
} else {
0
});
let size_by_adding_right =
right
.saturating_add(total_size)
.saturating_sub(if right_count == 1 {
collapsed_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; middle_size += tab.len;
total_left += tab.len; total_left += tab.len;
tabs_to_render.insert(0, tab); tabs_to_render.insert(0, tab);
} else if right_fits { }
// add right tab },
TabAction::AddRight => {
if !tabs_after_active.is_empty() {
let tab = tabs_after_active.remove(0); let tab = tabs_after_active.remove(0);
middle_size += tab.len; middle_size += tab.len;
total_right += tab.len; total_right += tab.len;
tabs_to_render.push(tab); tabs_to_render.push(tab);
} 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); TabAction::Finish => {
tabs_to_render.push(collapsed_right); tabs_to_render.insert(0, collapsed_indicators.left);
tabs_to_render.push(collapsed_indicators.right);
break; break;
},
}
} }
} }
}
fn left_more_message( fn create_collapsed_indicators(
tab_count_to_the_left: usize, &self,
palette: Styling, left_count: usize,
separator: &str, right_count: usize,
tab_index: usize, rendered_count: usize,
) -> LinePart { ) -> CollapsedIndicators {
if tab_count_to_the_left == 0 { 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 =
tab_sizes
.left
.saturating_add(total_size)
.saturating_sub(if left_count == 1 {
collapsed_indicators.left.len
} else {
0
});
let size_by_adding_right =
tab_sizes
.right
.saturating_add(total_size)
.saturating_sub(if right_count == 1 {
collapsed_indicators.right.len
} else {
0
});
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 {
TabAction::Finish
}
}
fn create_left_indicator(&self, tab_count: usize, tab_index: usize) -> LinePart {
if tab_count == 0 {
return LinePart::default(); 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),
}
}
fn right_more_message( let more_text = self.format_count_text(tab_count, "← +{}", " ← +many ");
tab_count_to_the_right: usize, self.create_styled_indicator(more_text, tab_index)
palette: Styling, }
separator: &str,
tab_index: usize, fn create_right_indicator(&self, tab_count: usize, tab_index: usize) -> LinePart {
) -> LinePart { if tab_count == 0 {
if tab_count_to_the_right == 0 {
return LinePart::default(); return LinePart::default();
}; }
let more_text = if tab_count_to_the_right < 10000 {
format!(" +{}", tab_count_to_the_right) let more_text = self.format_count_text(tab_count, "+{} →", " +many → ");
} else { self.create_styled_indicator(more_text, tab_index)
" +many → ".to_string() }
};
// chars length plus separator length on both sides fn format_count_text(&self, count: usize, format_str: &str, fallback: &str) -> String {
let more_text_len = more_text.width() + 2 * separator.width(); 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),
];
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 { LinePart {
part: more_styled_text, part: ANSIStrings(&styled_parts).to_string(),
len: more_text_len, len: text_len,
tab_index: Some(tab_index), tab_index: Some(tab_index),
} }
}
} }
fn tab_line_prefix( #[derive(Debug)]
session_name: Option<&str>, struct CollapsedIndicators {
mode: InputMode, left: LinePart,
right: LinePart,
}
#[derive(Debug)]
struct TabSizes {
left: usize,
right: usize,
}
#[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, palette: Styling,
cols: usize, cols: usize,
) -> Vec<LinePart> { }
let prefix_text = " Zellij ".to_string();
let prefix_text_len = prefix_text.chars().count(); impl TabLinePrefixBuilder {
let text_color = palette.text_unselected.base; fn new(palette: Styling, cols: usize) -> Self {
let bg_color = palette.text_unselected.background; Self { palette, cols }
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; fn build(&self, session_name: Option<&str>, mode: InputMode) -> Vec<LinePart> {
let mut parts = vec![self.create_zellij_part()];
let mut used_len = parts.get(0).map_or(0, |p| p.len);
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 { 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<LinePart> {
let name_part = format!("({})", name); let name_part = format!("({})", name);
let name_part_len = name_part.width(); 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 { if self.cols.saturating_sub(used_len) >= name_part_len {
parts.push(LinePart { let colors = self.get_text_colors();
part: name_part_styled_text.to_string(), Some(LinePart {
part: style!(colors.text, colors.background)
.bold()
.paint(name_part)
.to_string(),
len: name_part_len, len: name_part_len,
tab_index: None, tab_index: 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 { } else {
style!(other_modes_color, bg_color) None
.bold() }
.paint(mode_part_padded) }
fn create_mode_part(&self, mode: InputMode, used_len: usize) -> Option<LinePart> {
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),
}; };
if cols.saturating_sub(prefix_text_len) >= mode_part_len {
parts.push(LinePart { Some(LinePart {
part: format!("{}", mode_part_styled_text), part: style.bold().paint(mode_text).to_string(),
len: mode_part_len, len: mode_len,
tab_index: None, 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<LinePart> {
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<LinePart> {
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<LinePart>, active_tab_index: usize) -> Vec<LinePart> {
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<LinePart>,
active_tab_index: usize,
) -> (Vec<LinePart>, LinePart, Vec<LinePart>) {
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<LinePart>) {
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::<String>();
LinePart {
part: buffer,
len: space,
tab_index: None,
}
} }
parts
} }
pub fn tab_separator(capabilities: PluginCapabilities) -> &'static str { 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<LinePart>,
active_tab_index: usize,
cols: usize,
palette: Styling,
capabilities: PluginCapabilities,
hide_session_name: bool,
mode: InputMode,
active_swap_layout_name: &Option<String>,
is_swap_layout_dirty: bool,
) -> Vec<LinePart> {
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<String>,
is_swap_layout_damaged: bool,
input_mode: InputMode,
palette: &Styling,
separator: &str,
) -> Option<LinePart> {
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,
}
}

View file

@ -1,5 +1,9 @@
mod action_types;
mod clipboard_utils;
mod keybind_utils;
mod line; mod line;
mod tab; mod tab;
mod tooltip;
use std::cmp::{max, min}; use std::cmp::{max, min};
use std::collections::BTreeMap; use std::collections::BTreeMap;
@ -8,8 +12,18 @@ use std::convert::TryInto;
use tab::get_tab_to_focus; use tab::get_tab_to_focus;
use zellij_tile::prelude::*; use zellij_tile::prelude::*;
use crate::clipboard_utils::{system_clipboard_error, text_copied_hint};
use crate::line::tab_line; use crate::line::tab_line;
use crate::tab::tab_style; 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)] #[derive(Debug, Default)]
pub struct LinePart { pub struct LinePart {
@ -20,207 +34,517 @@ pub struct LinePart {
#[derive(Default)] #[derive(Default)]
struct State { struct State {
// Tab state
tabs: Vec<TabInfo>, tabs: Vec<TabInfo>,
active_tab_idx: usize, active_tab_idx: usize,
// Display state
mode_info: ModeInfo, mode_info: ModeInfo,
tab_line: Vec<LinePart>, tab_line: Vec<LinePart>,
display_area_rows: usize,
display_area_cols: usize,
// Clipboard state
text_copy_destination: Option<CopyDestination>, text_copy_destination: Option<CopyDestination>,
display_system_clipboard_failure: bool, display_system_clipboard_failure: bool,
// Plugin configuration
config: BTreeMap<String, String>,
own_plugin_id: Option<u32>,
toggle_tooltip_key: Option<String>,
// Tooltip state
is_tooltip: bool,
tooltip_is_active: bool,
persist: bool,
is_first_run: bool,
} }
static ARROW_SEPARATOR: &str = ""; struct TabRenderData {
tabs: Vec<LinePart>,
active_tab_index: usize,
active_swap_layout_name: Option<String>,
is_swap_layout_dirty: bool,
}
register_plugin!(State); register_plugin!(State);
impl ZellijPlugin for State { impl ZellijPlugin for State {
fn load(&mut self, _configuration: BTreeMap<String, String>) { fn load(&mut self, configuration: BTreeMap<String, String>) {
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 {
self.is_first_run = false;
match event {
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) => {
self.handle_clipboard_copy(copy_destination)
},
Event::SystemClipboardFailure => self.handle_clipboard_failure(),
Event::InputReceived => self.handle_input_received(),
_ => false,
}
}
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
}
fn render(&mut self, rows: usize, cols: usize) {
if self.is_tooltip {
self.render_tooltip(rows, cols);
} else {
self.render_tab_line(cols);
}
}
}
impl State {
fn initialize_configuration(&mut self, configuration: BTreeMap<String, String>) {
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); set_selectable(false);
subscribe(&[
let events = if self.is_tooltip {
vec![EventType::ModeUpdate, EventType::TabUpdate]
} else {
vec![
EventType::TabUpdate, EventType::TabUpdate,
EventType::PaneUpdate,
EventType::ModeUpdate, EventType::ModeUpdate,
EventType::Mouse, EventType::Mouse,
EventType::CopyToClipboard, EventType::CopyToClipboard,
EventType::InputReceived, EventType::InputReceived,
EventType::SystemClipboardFailure, EventType::SystemClipboardFailure,
EventType::PaneUpdate, ]
]); };
subscribe(&events);
} }
fn update(&mut self, event: Event) -> bool { fn configure_keybinds(&self) {
let mut should_render = false; if !self.is_tooltip && self.toggle_tooltip_key.is_some() {
match event { if let Some(toggle_key) = &self.toggle_tooltip_key {
Event::ModeUpdate(mode_info) => { reconfigure(bind_toggle_key_config(toggle_key), false);
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;
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 { } else {
eprintln!("Could not find active tab."); self.handle_main_mode_update(new_mode, base_mode);
} }
},
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::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);
},
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 should_render
} }
fn render(&mut self, _rows: usize, cols: usize) { fn handle_main_mode_update(&self, new_mode: InputMode, base_mode: InputMode) {
if let Some(copy_destination) = self.text_copy_destination { if self.toggle_tooltip_key.is_some()
let hint = text_copied_hint(copy_destination).part; && new_mode != base_mode
&& !self.is_restricted_mode(new_mode)
{
self.launch_tooltip_if_not_launched(new_mode);
}
}
let background = self.mode_info.style.colors.text_unselected.background; fn handle_tooltip_mode_update(
match background { &mut self,
PaletteColor::Rgb((r, g, b)) => { old_mode: InputMode,
print!("{}\u{1b}[48;2;{};{};{}m\u{1b}[0K", hint, r, g, b); new_mode: InputMode,
}, base_mode: InputMode,
PaletteColor::EightBit(color) => { ) {
print!("{}\u{1b}[48;5;{}m\u{1b}[0K", hint, color); 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);
} }
} 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 handle_tab_update(&mut self, tabs: Vec<TabInfo>) -> 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 { } 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<String, String> {
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() { if self.tabs.is_empty() {
return; return;
} }
let mut all_tabs: Vec<LinePart> = vec![];
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_tab_index = 0;
let mut active_swap_layout_name = None; let mut active_swap_layout_name = None;
let mut is_swap_layout_dirty = false; let mut is_swap_layout_dirty = false;
let mut is_alternate_tab = false; let mut is_alternate_tab = false;
for t in &mut self.tabs {
let mut tabname = t.name.clone(); for tab in &self.tabs {
if t.active && self.mode_info.mode == InputMode::RenameTab { let tab_name = self.get_tab_display_name(tab);
if tabname.is_empty() {
tabname = String::from("Enter name..."); 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();
} }
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, let styled_tab = tab_style(
t, tab_name,
tab,
is_alternate_tab, is_alternate_tab,
self.mode_info.style.colors, self.mode_info.style.colors,
self.mode_info.capabilities, self.mode_info.capabilities,
); );
is_alternate_tab = !is_alternate_tab; is_alternate_tab = !is_alternate_tab;
all_tabs.push(tab); all_tabs.push(styled_tab);
} }
self.tab_line = tab_line(
self.mode_info.session_name.as_deref(), TabRenderData {
all_tabs, tabs: all_tabs,
active_tab_index, active_tab_index,
cols.saturating_sub(1), active_swap_layout_name,
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, 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);
},
} }
} }
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 text_copied_hint(copy_destination: CopyDestination) -> LinePart { fn bind_toggle_key_config(toggle_key: &str) -> String {
let hint = match copy_destination { format!(
CopyDestination::Command => "Text piped to external command", r#"
#[cfg(not(target_os = "macos"))] keybinds {{
CopyDestination::Primary => "Text copied to system primary selection", shared {{
#[cfg(target_os = "macos")] // primary selection does not exist on macos bind "{}" {{
CopyDestination::Primary => "Text copied to system clipboard", MessagePlugin "compact-bar" {{
CopyDestination::System => "Text copied to system clipboard", name "toggle_tooltip"
}; toggle_tooltip_key "{}"
LinePart { }}
part: serialize_text(&Text::new(&hint).color_range(2, ..).opaque()), }}
len: hint.len(), }}
tab_index: None, }}
} "#,
} toggle_key, toggle_key
)
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,
}
} }

View file

@ -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)
}
}
}

View file

@ -675,6 +675,7 @@ pub(crate) fn plugin_thread_main(
} => { } => {
let should_float = floating.unwrap_or(true); let should_float = floating.unwrap_or(true);
let mut pipe_messages = vec![]; let mut pipe_messages = vec![];
let floating_pane_coordinates = None; // TODO: do we want to allow this?
match plugin { match plugin {
Some(plugin_url) => { Some(plugin_url) => {
// send to specific plugin(s) // send to specific plugin(s)
@ -695,6 +696,7 @@ pub(crate) fn plugin_thread_main(
&bus, &bus,
&mut wasm_bridge, &mut wasm_bridge,
&plugin_aliases, &plugin_aliases,
floating_pane_coordinates,
); );
}, },
None => { None => {
@ -727,6 +729,7 @@ pub(crate) fn plugin_thread_main(
} => { } => {
let should_float = floating.unwrap_or(true); let should_float = floating.unwrap_or(true);
let mut pipe_messages = vec![]; 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 { if let Some((plugin_id, client_id)) = plugin_and_client_id {
let is_private = true; let is_private = true;
pipe_messages.push(( pipe_messages.push((
@ -755,6 +758,7 @@ pub(crate) fn plugin_thread_main(
&bus, &bus,
&mut wasm_bridge, &mut wasm_bridge,
&plugin_aliases, &plugin_aliases,
floating_pane_coordinates,
); );
}, },
None => { None => {
@ -798,6 +802,7 @@ pub(crate) fn plugin_thread_main(
.new_plugin_args .new_plugin_args
.as_ref() .as_ref()
.and_then(|n| n.pane_id_to_replace); .and_then(|n| n.pane_id_to_replace);
let floating_pane_coordinates = message.floating_pane_coordinates;
match (message.plugin_url, message.destination_plugin_id) { match (message.plugin_url, message.destination_plugin_id) {
(Some(plugin_url), None) => { (Some(plugin_url), None) => {
// send to specific plugin(s) // send to specific plugin(s)
@ -818,6 +823,7 @@ pub(crate) fn plugin_thread_main(
&bus, &bus,
&mut wasm_bridge, &mut wasm_bridge,
&plugin_aliases, &plugin_aliases,
floating_pane_coordinates,
); );
}, },
(None, Some(destination_plugin_id)) => { (None, Some(destination_plugin_id)) => {
@ -997,6 +1003,7 @@ fn pipe_to_specific_plugins(
bus: &Bus<PluginInstruction>, bus: &Bus<PluginInstruction>,
wasm_bridge: &mut WasmBridge, wasm_bridge: &mut WasmBridge,
plugin_aliases: &PluginAliases, plugin_aliases: &PluginAliases,
floating_pane_coordinates: Option<FloatingPaneCoordinates>,
) { ) {
let is_private = true; let is_private = true;
let size = Size::default(); let size = Size::default();
@ -1018,6 +1025,7 @@ fn pipe_to_specific_plugins(
pane_title.clone(), pane_title.clone(),
pane_id_to_replace.clone(), pane_id_to_replace.clone(),
cli_client_id, cli_client_id,
floating_pane_coordinates,
); );
for (plugin_id, client_id) in all_plugin_ids { for (plugin_id, client_id) in all_plugin_ids {
pipe_messages.push(( pipe_messages.push((

View file

@ -21,7 +21,9 @@ use std::{
}; };
use wasmtime::{Engine, Module}; use wasmtime::{Engine, Module};
use zellij_utils::consts::{ZELLIJ_CACHE_DIR, ZELLIJ_TMP_DIR}; 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::downloader::Downloader;
use zellij_utils::input::keybinds::Keybinds; use zellij_utils::input::keybinds::Keybinds;
use zellij_utils::input::permission::PermissionCache; use zellij_utils::input::permission::PermissionCache;
@ -1409,6 +1411,7 @@ impl WasmBridge {
pane_title: Option<String>, pane_title: Option<String>,
pane_id_to_replace: Option<PaneId>, pane_id_to_replace: Option<PaneId>,
cli_client_id: Option<ClientId>, cli_client_id: Option<ClientId>,
floating_pane_coordinates: Option<FloatingPaneCoordinates>,
) -> Vec<(PluginId, Option<ClientId>)> { ) -> Vec<(PluginId, Option<ClientId>)> {
let run_plugin = run_plugin_or_alias.get_run_plugin(); let run_plugin = run_plugin_or_alias.get_run_plugin();
match run_plugin { match run_plugin {
@ -1435,6 +1438,8 @@ impl WasmBridge {
) { ) {
Ok((plugin_id, client_id)) => { Ok((plugin_id, client_id)) => {
let start_suppressed = false; 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( drop(self.senders.send_to_screen(ScreenInstruction::AddPlugin(
Some(should_float), Some(should_float),
should_be_open_in_place, should_be_open_in_place,
@ -1445,8 +1450,8 @@ impl WasmBridge {
pane_id_to_replace, pane_id_to_replace,
cwd, cwd,
start_suppressed, start_suppressed,
None, floating_pane_coordinates,
None, should_focus,
Some(client_id), Some(client_id),
))); )));
vec![(plugin_id, Some(client_id))] vec![(plugin_id, Some(client_id))]

View file

@ -24,8 +24,8 @@ use zellij_utils::{
envs::set_session_name, envs::set_session_name,
input::command::TerminalAction, input::command::TerminalAction,
input::layout::{ input::layout::{
FloatingPaneLayout, Layout, Run, RunPluginOrAlias, SwapFloatingLayout, SwapTiledLayout, FloatingPaneLayout, Layout, Run, RunPluginOrAlias, SplitSize, SwapFloatingLayout,
TiledPaneLayout, SwapTiledLayout, TiledPaneLayout,
}, },
position::Position, 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 // here we pass None instead of the client_id we have because we do not need to
// necessarily trigger a relayout for this tab // 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() { if let Some(pane) = tab.extract_pane(pane_id, true).take() {
extracted_panes.push(pane); extracted_panes.push((pane_was_floating, pane));
break; break;
} }
} }
@ -2422,12 +2423,28 @@ impl Screen {
return Ok(()); return Ok(());
} }
if let Some(new_active_tab) = self.get_indexed_tab_mut(tab_index) { 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(); let pane_id = pane.pid();
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 // here we pass None instead of the ClientId, because we do not want this pane to be
// necessarily focused // necessarily focused
new_active_tab.add_tiled_pane(pane, pane_id, None)?; new_active_tab.add_tiled_pane(pane, pane_id, None)?;
} }
}
} else { } else {
log::error!("Could not find tab with index: {:?}", tab_index); log::error!("Could not find tab with index: {:?}", tab_index);
} }

View file

@ -4538,6 +4538,7 @@ impl Tab {
pane.set_active_at(Instant::now()); pane.set_active_at(Instant::now());
pane.set_geom(new_pane_geom); pane.set_geom(new_pane_geom);
pane.set_content_offset(Offset::frame(1)); // floating panes always have a frame 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) resize_pty!(pane, self.os_api, self.senders, self.character_cell_size)
.with_context(err_context)?; .with_context(err_context)?;
self.floating_panes.add_pane(pane_id, pane); self.floating_panes.add_pane(pane_id, pane);

View file

@ -169,7 +169,8 @@ impl PaneFrame {
) -> Option<(Vec<TerminalCharacter>, usize)> { ) -> Option<(Vec<TerminalCharacter>, usize)> {
// string and length because of color // string and length because of color
let has_scroll = self.scroll_position.0 > 0 || self.scroll_position.1 > 0; 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 { let pin_indication = if self.is_floating && self.is_selectable {
self.render_pinned_indication(max_length) self.render_pinned_indication(max_length)
} else { } else {

View file

@ -1183,7 +1183,7 @@ pub fn break_panes_to_new_tab(
unsafe { host_run_plugin_command() }; 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( pub fn break_panes_to_tab_with_index(
pane_ids: &[PaneId], pane_ids: &[PaneId],
tab_index: usize, tab_index: usize,

View file

@ -60,7 +60,7 @@ impl Text {
while let Some(pos) = self.text[start..].find(substr) { while let Some(pos) = self.text[start..].find(substr) {
let abs_pos = start + pos; 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(); start = abs_pos + substr.len();
} }
@ -92,6 +92,9 @@ impl Text {
self self
} }
pub fn content(&self) -> &str {
&self.text
}
fn pad_indices(&mut self, index_level: usize) { fn pad_indices(&mut self, index_level: usize) {
if self.indices.get(index_level).is_none() { if self.indices.get(index_level).is_none() {
for _ in self.indices.len()..=index_level { for _ in self.indices.len()..=index_level {

View file

@ -590,6 +590,8 @@ pub struct MessageToPluginPayload {
pub new_plugin_args: ::core::option::Option<NewPluginArgs>, pub new_plugin_args: ::core::option::Option<NewPluginArgs>,
#[prost(uint32, optional, tag="7")] #[prost(uint32, optional, tag="7")]
pub destination_plugin_id: ::core::option::Option<u32>, pub destination_plugin_id: ::core::option::Option<u32>,
#[prost(message, optional, tag="8")]
pub floating_pane_coordinates: ::core::option::Option<FloatingPaneCoordinates>,
} }
#[allow(clippy::derive_partial_eq_without_eq)] #[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)] #[derive(Clone, PartialEq, ::prost::Message)]

View file

@ -2,6 +2,7 @@ use crate::input::actions::Action;
use crate::input::config::ConversionError; use crate::input::config::ConversionError;
use crate::input::keybinds::Keybinds; use crate::input::keybinds::Keybinds;
use crate::input::layout::{RunPlugin, SplitSize}; use crate::input::layout::{RunPlugin, SplitSize};
use crate::pane_size::PaneGeom;
use crate::shared::colors as default_colors; use crate::shared::colors as default_colors;
use clap::ArgEnum; use clap::ArgEnum;
use serde::{Deserialize, Serialize}; 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, /// these will only be used in case we need to launch a new plugin to send this message to,
/// since none are running /// since none are running
pub new_plugin_args: Option<NewPluginArgs>, pub new_plugin_args: Option<NewPluginArgs>,
pub floating_pane_coordinates: Option<FloatingPaneCoordinates>,
} }
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
@ -1946,6 +1948,13 @@ impl MessageToPlugin {
self.message_args = args; self.message_args = args;
self 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 { 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); let new_plugin_args = self.new_plugin_args.get_or_insert_with(Default::default);
new_plugin_args.should_float = Some(should_float); new_plugin_args.should_float = Some(should_float);
@ -2144,6 +2153,18 @@ impl FloatingPaneCoordinates {
} }
} }
impl From<PaneGeom> 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OriginatingPlugin { pub struct OriginatingPlugin {
pub plugin_id: u32, pub plugin_id: u32,

View file

@ -491,6 +491,7 @@ message MessageToPluginPayload {
repeated ContextItem message_args = 5; repeated ContextItem message_args = 5;
optional NewPluginArgs new_plugin_args = 6; optional NewPluginArgs new_plugin_args = 6;
optional uint32 destination_plugin_id = 7; optional uint32 destination_plugin_id = 7;
optional FloatingPaneCoordinates floating_pane_coordinates = 8;
} }
message NewPluginArgs { message NewPluginArgs {

View file

@ -935,6 +935,7 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand {
message_args, message_args,
new_plugin_args, new_plugin_args,
destination_plugin_id, destination_plugin_id,
floating_pane_coordinates,
})) => { })) => {
let plugin_config: BTreeMap<String, String> = plugin_config let plugin_config: BTreeMap<String, String> = plugin_config
.into_iter() .into_iter()
@ -962,6 +963,8 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand {
}) })
}), }),
destination_plugin_id, destination_plugin_id,
floating_pane_coordinates: floating_pane_coordinates
.and_then(|f| f.try_into().ok()),
})) }))
}, },
_ => Err("Mismatched payload for MessageToPlugin"), _ => Err("Mismatched payload for MessageToPlugin"),
@ -2151,6 +2154,9 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand {
} }
}), }),
destination_plugin_id: message_to_plugin.destination_plugin_id, 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()),
})), })),
}) })
}, },