diff options
| author | Aram Drevekenin <aram@poor.dev> | 2025-04-29 20:52:17 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-04-29 20:52:17 +0200 |
| commit | 27c8986939a1642f3e4afda73e9717b7bd328824 (patch) | |
| tree | e80ee57aeede2578a5ce9fa6d0ab4a350ed9d838 /default-plugins | |
| parent | chore: update development version (diff) | |
| download | sl0ppij-27c8986939a1642f3e4afda73e9717b7bd328824.tar.gz sl0ppij-27c8986939a1642f3e4afda73e9717b7bd328824.tar.xz sl0ppij-27c8986939a1642f3e4afda73e9717b7bd328824.zip | |
feat: multiple Select and Bulk Pane Actions (#4169)
* initial implementation with break panes to new tab
* break pane group left/right
* group embed/eject panes
* stack pane group on resize
* close pane group
* style(fmt): rustfmt
* fix tests
* group drag and ungroup with the mouse
* fix mouse hover for multiple clients
* fix for multiple clients
* multiple select plugin initial
* use real data in plugin
* adjust functionality
* fix some ux issues
* reflect group mouse group selections in plugin
* group/ungroup panes in Zellij
* highlight frames when marked by the plugin
* refactor: render function in plugin
* some ui responsiveness
* some more responsiveness and adjust hover text
* break out functionality
* stack functionality
* break panes left/right and close multiple panes
* fix(tab): only relayout the relevant layout when non-focused pane is closed
* status bar UI
* embed and float panes
* work
* fix some ui/ux issues
* refactor: move stuff around
* some responsiveness and fix search result browsing bug
* change plugin pane title
* differentiate group from focused pane
* add keyboard shortcut
* add ui to compact bar
* make boundary colors appear properly without pane frames
* get plugins to also display their frame color
* make hover shortcuts appear on command panes
* fix: do not render search string component if it's empty
* BeforeClose Event and unhighlight panes on exit
* some UI/UX fixes
* some fixes to the catppuccin-latte theme
* remove ungroup shortcut
* make some ui components opaque
* fix more opaque elements
* fix some issues with stacking pane order
* keyboard shortcuts for grouping
* config to opt out of advanced mouse actions
* make selected + focused frame color distinct
* group marking mode
* refactor: multiple-select plugin
* adjust stacking group behavior
* adjust flashing periods
* render common modifier in group controls
* add to compact bar
* adjust key hint wording
* add key to presets and default config
* some cleanups
* some refactoring
* fix tests
* fix plugin system tests
* tests: group/ungroup/hover
* test: BeforeClose plugin event
* new plugin assets
* style(fmt): rustfmt
* remove warnings
* tests: give plugin more time to load
Diffstat (limited to 'default-plugins')
| -rw-r--r-- | default-plugins/compact-bar/src/line.rs | 300 | ||||
| -rw-r--r-- | default-plugins/compact-bar/src/main.rs | 28 | ||||
| -rw-r--r-- | default-plugins/configuration/src/presets.rs | 32 | ||||
| -rw-r--r-- | default-plugins/fixture-plugin-for-tests/src/main.rs | 5 | ||||
| -rw-r--r-- | default-plugins/multiple-select/.cargo/config.toml | 2 | ||||
| -rw-r--r-- | default-plugins/multiple-select/.gitignore | 1 | ||||
| -rw-r--r-- | default-plugins/multiple-select/Cargo.toml | 10 | ||||
| l--------- | default-plugins/multiple-select/LICENSE.md | 1 | ||||
| -rw-r--r-- | default-plugins/multiple-select/src/main.rs | 212 | ||||
| -rw-r--r-- | default-plugins/multiple-select/src/state.rs | 634 | ||||
| -rw-r--r-- | default-plugins/multiple-select/src/ui.rs | 873 | ||||
| -rw-r--r-- | default-plugins/status-bar/src/main.rs | 27 | ||||
| -rw-r--r-- | default-plugins/status-bar/src/one_line_ui.rs | 254 |
13 files changed, 2366 insertions, 13 deletions
diff --git a/default-plugins/compact-bar/src/line.rs b/default-plugins/compact-bar/src/line.rs index 5d50d527..43431446 100644 --- a/default-plugins/compact-bar/src/line.rs +++ b/default-plugins/compact-bar/src/line.rs @@ -2,6 +2,7 @@ use ansi_term::ANSIStrings; use unicode_width::UnicodeWidthStr; use crate::{LinePart, ARROW_SEPARATOR}; +use zellij_tile::prelude::actions::Action; use zellij_tile::prelude::*; use zellij_tile_utils::style; @@ -251,6 +252,8 @@ pub fn tab_line( mode: InputMode, active_swap_layout_name: &Option<String>, is_swap_layout_dirty: bool, + mode_info: &ModeInfo, + grouped_pane_count: Option<usize>, ) -> Vec<LinePart> { let mut tabs_after_active = all_tabs.split_off(active_tab_index); let mut tabs_before_active = all_tabs; @@ -286,15 +289,21 @@ pub fn tab_line( 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 right_side_component = match grouped_pane_count { + Some(grouped_pane_count) => { + render_group_controls(mode_info, grouped_pane_count, remaining_space) + }, + None => swap_layout_status( + remaining_space, + active_swap_layout_name, + is_swap_layout_dirty, + mode, + &palette, + tab_separator(capabilities), + ), + }; + if let Some(right_side_component) = right_side_component { + remaining_space -= right_side_component.len; let mut buffer = String::new(); for _ in 0..remaining_space { buffer.push_str(&style!(remaining_bg, remaining_bg).paint(" ").to_string()); @@ -304,7 +313,7 @@ pub fn tab_line( len: remaining_space, tab_index: None, }); - prefix.push(swap_layout_status); + prefix.push(right_side_component); } } @@ -373,3 +382,274 @@ fn swap_layout_status( None => None, } } + +fn render_group_controls( + help: &ModeInfo, + grouped_pane_count: usize, + max_len: usize, +) -> Option<LinePart> { + let currently_marking_group = help.currently_marking_pane_group.unwrap_or(false); + let keymap = help.get_mode_keybinds(); + let (common_modifiers, multiple_select_key, pane_group_toggle_key, group_mark_toggle_key) = { + let multiple_select_key = multiple_select_key(&keymap); + let pane_group_toggle_key = single_action_key(&keymap, &[Action::TogglePaneInGroup]); + let group_mark_toggle_key = single_action_key(&keymap, &[Action::ToggleGroupMarking]); + let common_modifiers = get_common_modifiers( + vec![ + multiple_select_key.iter().next(), + pane_group_toggle_key.iter().next(), + group_mark_toggle_key.iter().next(), + ] + .into_iter() + .filter_map(|k| k) + .collect(), + ); + let multiple_select_key: Vec<KeyWithModifier> = multiple_select_key + .iter() + .map(|k| k.strip_common_modifiers(&common_modifiers)) + .collect(); + let pane_group_toggle_key: Vec<KeyWithModifier> = pane_group_toggle_key + .iter() + .map(|k| k.strip_common_modifiers(&common_modifiers)) + .collect(); + let group_mark_toggle_key: Vec<KeyWithModifier> = group_mark_toggle_key + .iter() + .map(|k| k.strip_common_modifiers(&common_modifiers)) + .collect(); + ( + common_modifiers, + multiple_select_key, + pane_group_toggle_key, + group_mark_toggle_key, + ) + }; + let multiple_select_key = multiple_select_key + .iter() + .next() + .map(|key| format!("{}", key)) + .unwrap_or("UNBOUND".to_owned()); + let pane_group_toggle_key = pane_group_toggle_key + .iter() + .next() + .map(|key| format!("{}", key)) + .unwrap_or("UNBOUND".to_owned()); + let group_mark_toggle_key = group_mark_toggle_key + .iter() + .next() + .map(|key| format!("{}", key)) + .unwrap_or("UNBOUND".to_owned()); + let background = help.style.colors.text_unselected.background; + let foreground = help.style.colors.text_unselected.base; + let superkey_prefix_style = style!(foreground, background).bold(); + let common_modifier_text = if common_modifiers.is_empty() { + "".to_owned() + } else { + format!( + "{} + ", + common_modifiers + .iter() + .map(|c| c.to_string()) + .collect::<Vec<_>>() + .join("-") + ) + }; + + // full + let full_selected_panes_text = if common_modifier_text.is_empty() { + format!("{} SELECTED PANES", grouped_pane_count) + } else { + format!("{} SELECTED PANES |", grouped_pane_count) + }; + let full_group_actions_text = format!("<{}> Group Actions", &multiple_select_key); + let full_toggle_group_text = format!("<{}> Toggle Group", &pane_group_toggle_key); + let full_group_mark_toggle_text = format!("<{}> Follow Focus", &group_mark_toggle_key); + let ribbon_paddings_len = 12; + let full_controls_line_len = full_selected_panes_text.chars().count() + + 1 + + common_modifier_text.chars().count() + + full_group_actions_text.chars().count() + + full_toggle_group_text.chars().count() + + full_group_mark_toggle_text.chars().count() + + ribbon_paddings_len + + 1; // 1 for the end padding + + // medium + let medium_selected_panes_text = if common_modifier_text.is_empty() { + format!("{} SELECTED", grouped_pane_count) + } else { + format!("{} SELECTED |", grouped_pane_count) + }; + let medium_group_actions_text = format!("<{}> Actions", &multiple_select_key); + let medium_toggle_group_text = format!("<{}> Toggle", &pane_group_toggle_key); + let medium_group_mark_toggle_text = format!("<{}> Follow", &group_mark_toggle_key); + let ribbon_paddings_len = 12; + let medium_controls_line_len = medium_selected_panes_text.chars().count() + + 1 + + common_modifier_text.chars().count() + + medium_group_actions_text.chars().count() + + medium_toggle_group_text.chars().count() + + medium_group_mark_toggle_text.chars().count() + + ribbon_paddings_len + + 1; // 1 for the end padding + + // short + let short_selected_panes_text = if common_modifier_text.is_empty() { + format!("{} SELECTED", grouped_pane_count) + } else { + format!("{} SELECTED |", grouped_pane_count) + }; + let short_group_actions_text = format!("<{}>", &multiple_select_key); + let short_toggle_group_text = format!("<{}>", &pane_group_toggle_key); + let short_group_mark_toggle_text = format!("<{}>", &group_mark_toggle_key); + let color_emphasis_range_end = if common_modifier_text.is_empty() { + 0 + } else { + 2 + }; + let ribbon_paddings_len = 12; + let short_controls_line_len = short_selected_panes_text.chars().count() + + 1 + + common_modifier_text.chars().count() + + short_group_actions_text.chars().count() + + short_toggle_group_text.chars().count() + + short_group_mark_toggle_text.chars().count() + + ribbon_paddings_len + + 1; // 1 for the end padding + + let ( + selected_panes_text, + group_actions_text, + toggle_group_text, + group_mark_toggle_text, + controls_line_len, + ) = if max_len >= full_controls_line_len { + ( + full_selected_panes_text, + full_group_actions_text, + full_toggle_group_text, + full_group_mark_toggle_text, + full_controls_line_len, + ) + } else if max_len >= medium_controls_line_len { + ( + medium_selected_panes_text, + medium_group_actions_text, + medium_toggle_group_text, + medium_group_mark_toggle_text, + medium_controls_line_len, + ) + } else if max_len >= short_controls_line_len { + ( + short_selected_panes_text, + short_group_actions_text, + short_toggle_group_text, + short_group_mark_toggle_text, + short_controls_line_len, + ) + } else { + return None; + }; + let selected_panes = serialize_text( + &Text::new(&selected_panes_text) + .color_range( + 3, + ..selected_panes_text + .chars() + .count() + .saturating_sub(color_emphasis_range_end), + ) + .opaque(), + ); + let group_actions_ribbon = serialize_ribbon( + &Text::new(&group_actions_text).color_range(0, 1..=multiple_select_key.chars().count()), + ); + let toggle_group_ribbon = serialize_ribbon( + &Text::new(&toggle_group_text).color_range(0, 1..=pane_group_toggle_key.chars().count()), + ); + let mut group_mark_toggle_ribbon = Text::new(&group_mark_toggle_text) + .color_range(0, 1..=group_mark_toggle_key.chars().count()); + if currently_marking_group { + group_mark_toggle_ribbon = group_mark_toggle_ribbon.selected(); + } + let group_mark_toggle_ribbon = serialize_ribbon(&group_mark_toggle_ribbon); + let controls_line = if common_modifiers.is_empty() { + format!( + "{} {}{}{}", + selected_panes, group_actions_ribbon, toggle_group_ribbon, group_mark_toggle_ribbon + ) + } else { + let common_modifier = + serialize_text(&Text::new(&common_modifier_text).color_range(0, ..).opaque()); + format!( + "{} {}{}{}{}", + selected_panes, + common_modifier, + group_actions_ribbon, + toggle_group_ribbon, + group_mark_toggle_ribbon + ) + }; + let remaining_space = max_len.saturating_sub(controls_line_len); + let mut padding = String::new(); + let mut padding_len = 0; + for _ in 0..remaining_space { + padding.push_str(&ANSIStrings(&[superkey_prefix_style.paint(" ")]).to_string()); + padding_len += 1; + } + Some(LinePart { + part: format!("{}{}", padding, controls_line), + len: controls_line_len + padding_len, + tab_index: None, + }) +} + +fn multiple_select_key(keymap: &[(KeyWithModifier, Vec<Action>)]) -> Vec<KeyWithModifier> { + let mut matching = keymap.iter().find_map(|(key, acvec)| { + let has_match = acvec + .iter() + .find(|a| a.launches_plugin("zellij:multiple-select")) // TODO: make this an alias + .is_some(); + if has_match { + Some(key.clone()) + } else { + None + } + }); + if let Some(matching) = matching.take() { + vec![matching] + } else { + vec![] + } +} + +fn single_action_key( + keymap: &[(KeyWithModifier, Vec<Action>)], + action: &[Action], +) -> Vec<KeyWithModifier> { + let mut matching = keymap.iter().find_map(|(key, acvec)| { + if acvec.iter().next() == action.iter().next() { + Some(key.clone()) + } else { + None + } + }); + if let Some(matching) = matching.take() { + vec![matching] + } else { + vec![] + } +} + +fn get_common_modifiers(mut keyvec: Vec<&KeyWithModifier>) -> Vec<KeyModifier> { + if keyvec.is_empty() { + return vec![]; + } + let mut common_modifiers = keyvec.pop().unwrap().key_modifiers.clone(); + for key in keyvec { + common_modifiers = common_modifiers + .intersection(&key.key_modifiers) + .cloned() + .collect(); + } + common_modifiers.into_iter().collect() +} diff --git a/default-plugins/compact-bar/src/main.rs b/default-plugins/compact-bar/src/main.rs index 3775b12f..d4a43ab8 100644 --- a/default-plugins/compact-bar/src/main.rs +++ b/default-plugins/compact-bar/src/main.rs @@ -26,6 +26,8 @@ struct State { tab_line: Vec<LinePart>, text_copy_destination: Option<CopyDestination>, display_system_clipboard_failure: bool, + own_client_id: Option<ClientId>, + grouped_panes_count: Option<usize>, } static ARROW_SEPARATOR: &str = ""; @@ -42,7 +44,9 @@ impl ZellijPlugin for State { EventType::CopyToClipboard, EventType::InputReceived, EventType::SystemClipboardFailure, + EventType::PaneUpdate, ]); + self.own_client_id = Some(get_plugin_ids().client_id); } fn update(&mut self, event: Event) -> bool { @@ -108,6 +112,28 @@ impl ZellijPlugin for State { self.text_copy_destination = None; self.display_system_clipboard_failure = false; }, + Event::PaneUpdate(pane_manifest) => { + if let Some(own_client_id) = self.own_client_id { + let mut grouped_panes_count = 0; + for (_tab_index, pane_infos) in pane_manifest.panes { + for pane_info in pane_infos { + let is_in_pane_group = + pane_info.index_in_pane_group.get(&own_client_id).is_some(); + if is_in_pane_group { + grouped_panes_count += 1; + } + } + } + if Some(grouped_panes_count) != self.grouped_panes_count { + if grouped_panes_count == 0 { + self.grouped_panes_count = None; + } else { + self.grouped_panes_count = Some(grouped_panes_count); + } + should_render = true; + } + } + }, _ => { eprintln!("Got unrecognized event: {:?}", event); }, @@ -181,6 +207,8 @@ impl ZellijPlugin for State { self.mode_info.mode, &active_swap_layout_name, is_swap_layout_dirty, + &self.mode_info, + self.grouped_panes_count, ); let output = self .tab_line diff --git a/default-plugins/configuration/src/presets.rs b/default-plugins/configuration/src/presets.rs index 41d7a79e..54713635 100644 --- a/default-plugins/configuration/src/presets.rs +++ b/default-plugins/configuration/src/presets.rs @@ -168,6 +168,14 @@ keybinds clear-defaults=true {{ bind "{secondary_modifier} -" {{ Resize "Decrease"; }} bind "{secondary_modifier} [" {{ PreviousSwapLayout; }} bind "{secondary_modifier} ]" {{ NextSwapLayout; }} + bind "{secondary_modifier} m" {{ + LaunchOrFocusPlugin "zellij:multiple-select" {{ + floating true + move_to_focused_tab true + }} + }} + bind "{secondary_modifier} p" {{ TogglePaneInGroup; }} + bind "{secondary_modifier} Shift p" {{ ToggleGroupMarking; }} }} shared_except "locked" "renametab" "renamepane" {{ bind "Enter" {{ SwitchToMode "Locked"; }} @@ -392,6 +400,14 @@ keybinds clear-defaults=true {{ bind "{secondary_modifier} -" {{ Resize "Decrease"; }} bind "{secondary_modifier} [" {{ PreviousSwapLayout; }} bind "{secondary_modifier} ]" {{ NextSwapLayout; }} + bind "{secondary_modifier} m" {{ + LaunchOrFocusPlugin "zellij:multiple-select" {{ + floating true + move_to_focused_tab true + }} + }} + bind "{secondary_modifier} p" {{ TogglePaneInGroup; }} + bind "{secondary_modifier} Shift p" {{ ToggleGroupMarking; }} }} shared_except "normal" "locked" {{ bind "Enter" "Esc" {{ SwitchToMode "Normal"; }} @@ -595,6 +611,14 @@ keybinds clear-defaults=true {{ bind "{secondary_modifier} -" {{ Resize "Decrease"; }} bind "{secondary_modifier} [" {{ PreviousSwapLayout; }} bind "{secondary_modifier} ]" {{ NextSwapLayout; }} + bind "{secondary_modifier} m" {{ + LaunchOrFocusPlugin "zellij:multiple-select" {{ + floating true + move_to_focused_tab true + }} + }} + bind "{secondary_modifier} p" {{ TogglePaneInGroup; }} + bind "{secondary_modifier} Shift p" {{ ToggleGroupMarking; }} }} shared_except "normal" "locked" {{ bind "Enter" "Esc" {{ SwitchToMode "Normal"; }} @@ -1158,6 +1182,14 @@ keybinds clear-defaults=true {{ bind "{secondary_modifier} -" {{ Resize "Decrease"; }} bind "{secondary_modifier} [" {{ PreviousSwapLayout; }} bind "{secondary_modifier} ]" {{ NextSwapLayout; }} + bind "{secondary_modifier} m" {{ + LaunchOrFocusPlugin "zellij:multiple-select" {{ + floating true + move_to_focused_tab true + }} + }} + bind "{secondary_modifier} p" {{ TogglePaneInGroup; }} + bind "{secondary_modifier} Shift p" {{ ToggleGroupMarking; }} }} shared_except "normal" "locked" {{ bind "Enter" "Esc" {{ SwitchToMode "Normal"; }} diff --git a/default-plugins/fixture-plugin-for-tests/src/main.rs b/default-plugins/fixture-plugin-for-tests/src/main.rs index 24f0f649..7f66fa4e 100644 --- a/default-plugins/fixture-plugin-for-tests/src/main.rs +++ b/default-plugins/fixture-plugin-for-tests/src/main.rs @@ -67,6 +67,7 @@ impl ZellijPlugin for State { EventType::FileSystemCreate, EventType::FileSystemUpdate, EventType::FileSystemDelete, + EventType::BeforeClose, ]); watch_filesystem(); } @@ -535,6 +536,10 @@ impl ZellijPlugin for State { self.received_payload = Some(payload.clone()); } }, + Event::BeforeClose => { + // this is just to assert something to make sure this event was triggered + highlight_and_unhighlight_panes(vec![PaneId::Terminal(1)], vec![PaneId::Plugin(1)]); + }, Event::SystemClipboardFailure => { // this is just to trigger the worker message post_message_to(PluginMessage { diff --git a/default-plugins/multiple-select/.cargo/config.toml b/default-plugins/multiple-select/.cargo/config.toml new file mode 100644 index 00000000..6b509f5b --- /dev/null +++ b/default-plugins/multiple-select/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-wasip1" diff --git a/default-plugins/multiple-select/.gitignore b/default-plugins/multiple-select/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/default-plugins/multiple-select/.gitignore @@ -0,0 +1 @@ +/target diff --git a/default-plugins/multiple-select/Cargo.toml b/default-plugins/multiple-select/Cargo.toml new file mode 100644 index 00000000..c5580ad4 --- /dev/null +++ b/default-plugins/multiple-select/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "multiple-select" +version = "0.1.0" +authors = ["Aram Drevekenin <aram@poor.dev>"] +edition = "2021" +license = "MIT" + +[dependencies] +zellij-tile = { path = "../../zellij-tile" } +fuzzy-matcher = "0.3.7" diff --git a/default-plugins/multiple-select/LICENSE.md b/default-plugins/multiple-select/LICENSE.md new file mode 120000 index 00000000..f0608a63 --- /dev/null +++ b/default-plugins/multiple-select/LICENSE.md @@ -0,0 +1 @@ +../../LICENSE.md
\ No newline at end of file diff --git a/default-plugins/multiple-select/src/main.rs b/default-plugins/multiple-select/src/main.rs new file mode 100644 index 00000000..773fd6fb --- /dev/null +++ b/default-plugins/multiple-select/src/main.rs @@ -0,0 +1,212 @@ +pub mod state; +pub mod ui; + +use state::{MarkedIndex, VisibilityAndFocus}; +use std::collections::BTreeMap; +use ui::PaneItem; +use zellij_tile::prelude::*; + +#[derive(Debug, Default)] +pub struct App { + own_plugin_id: Option<u32>, + own_client_id: Option<ClientId>, + own_tab_index: Option<usize>, + total_tabs_in_session: Option<usize>, + search_string: String, + previous_search_string: String, // used eg. for the new tab title when breaking panes + left_side_panes: Vec<PaneItem>, + right_side_panes: Vec<PaneItem>, + search_results: Option<Vec<PaneItem>>, + visibility_and_focus: VisibilityAndFocus, + marked_index: Option<MarkedIndex>, +} + +register_plugin!(App); + +impl ZellijPlugin for App { + fn load(&mut self, _configuration: BTreeMap<String, String>) { + subscribe(&[ + EventType::Key, + EventType::Mouse, + EventType::ModeUpdate, + EventType::RunCommandResult, + EventType::TabUpdate, + EventType::PaneUpdate, + EventType::FailedToWriteConfigToDisk, + EventType::ConfigWasWrittenToDisk, + EventType::BeforeClose, + ]); + let plugin_ids = get_plugin_ids(); + self.own_plugin_id = Some(plugin_ids.plugin_id); + self.own_client_id = Some(plugin_ids.client_id); + rename_plugin_pane(plugin_ids.plugin_id, "Multiple Select"); + } + fn update(&mut self, event: Event) -> bool { + let mut should_render = false; + match event { + Event::PaneUpdate(pane_manifest) => { + self.react_to_zellij_state_update(pane_manifest); + should_render = true; + }, + Event::Key(key) => { + match key.bare_key { + BareKey::Tab if key.has_no_modifiers() => { + self.visibility_and_focus.toggle_focus(); + self.marked_index = None; + self.update_highlighted_panes(); + should_render = true; + }, + BareKey::Char(character) + if key.has_no_modifiers() + && self.visibility_and_focus.left_side_is_focused() + && self.marked_index.is_none() => + { + self.search_string.push(character); + self.update_search_results(); + should_render = true; + }, + BareKey::Backspace + if key.has_no_modifiers() + && self.visibility_and_focus.left_side_is_focused() + && self.marked_index.is_none() => + { + self.search_string.pop(); + self.update_search_results(); + should_render = true; + }, + BareKey::Enter if key.has_no_modifiers() => { + if self.visibility_and_focus.left_side_is_focused() { + if let Some(marked_index) = self.marked_index.take() { + let keep_left_side_focused = false; + self.group_panes(marked_index, keep_left_side_focused); + } else { + match self.search_results.take() { + Some(search_results) => { + self.group_search_results(search_results) + }, + None => self.group_all_panes(), + } + self.handle_left_side_emptied(); + } + } + should_render = true; + }, + BareKey::Right + if key.has_no_modifiers() + && self.visibility_and_focus.left_side_is_focused() => + { + if let Some(marked_index) = self.marked_index.take() { + let keep_left_side_focused = true; + self.group_panes(marked_index, keep_left_side_focused); + should_render = true; + } + }, + BareKey::Left + if key.has_no_modifiers() + && self.visibility_and_focus.right_side_is_focused() => + { + if self.visibility_and_focus.right_side_is_focused() { + if let Some(marked_index) = self.marked_index.take() { + self.ungroup_panes(marked_index); + should_render = true; + } + } + }, + BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + if self.visibility_and_focus.right_side_is_focused() { + // this means we're in the selection panes part and we want to clear + // them + self.ungroup_all_panes(); + } else if self.visibility_and_focus.left_side_is_focused() { + if self.marked_index.is_some() { + self.marked_index = None; + self.update_highlighted_panes(); + } else { + self.ungroup_all_panes_and_close_self(); + } + } + should_render = true; + }, + BareKey::Down if key.has_no_modifiers() => { + self.move_marked_index_down(); + should_render = true; + }, + BareKey::Up if key.has_no_modifiers() => { + self.move_marked_index_up(); + should_render = true; + }, + BareKey::Char(' ') if key.has_no_modifiers() && self.marked_index.is_some() => { + self.mark_entry(); + should_render = true; + }, + BareKey::Char('b') + if key.has_no_modifiers() + && self.visibility_and_focus.right_side_is_focused() => + { + self.break_grouped_panes_to_new_tab(); + }, + BareKey::Char('s') + if key.has_no_modifiers() + && self.visibility_and_focus.right_side_is_focused() => + { + self.stack_grouped_panes(); + }, + BareKey::Char('f') + if key.has_no_modifiers() + && self.visibility_and_focus.right_side_is_focused() => + { + self.float_grouped_panes(); + }, + BareKey::Char('e') + if key.has_no_modifiers() + && self.visibility_and_focus.right_side_is_focused() => + { + self.embed_grouped_panes(); + }, + BareKey::Char('r') + if key.has_no_modifiers() + && self.visibility_and_focus.right_side_is_focused() => + { + self.break_grouped_panes_right(); + }, + BareKey::Char('l') + if key.has_no_modifiers() + && self.visibility_and_focus.right_side_is_focused() => + { + self.break_grouped_panes_left(); + }, + BareKey::Char('c') + if key.has_no_modifiers() + && self.visibility_and_focus.right_side_is_focused() => + { + self.close_grouped_panes(); + }, + _ => {}, + } + }, + Event::BeforeClose => { + self.unhighlight_all_panes(); + }, + _ => {}, + } + should_render + } + fn render(&mut self, rows: usize, cols: usize) { + self.render_close_shortcut(cols); + self.render_tab_shortcut(cols, rows); + match self.visibility_and_focus { + VisibilityAndFocus::OnlyLeftSideVisible => self.render_left_side(rows, cols, true), + VisibilityAndFocus::OnlyRightSideVisible => self.render_right_side(rows, cols, true), + VisibilityAndFocus::BothSidesVisibleLeftSideFocused => { + self.render_left_side(rows, cols, true); + self.render_right_side(rows, cols, false); + }, + VisibilityAndFocus::BothSidesVisibleRightSideFocused => { + self.render_left_side(rows, cols, false); + self.render_right_side(rows, cols, true); + }, + } + self.render_focus_boundary(rows, cols); + self.render_help_line(rows, cols); + } +} diff --git a/default-plugins/multiple-select/src/state.rs b/default-plugins/multiple-select/src/state.rs new file mode 100644 index 00000000..3bf863f8 --- /dev/null +++ b/default-plugins/multiple-select/src/state.rs @@ -0,0 +1,634 @@ +use crate::{ui::PaneItem, App}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; + +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; + +use zellij_tile::prelude::*; + +#[derive(Debug, Default)] +pub struct MarkedIndex { + pub main_index: usize, + pub additional_indices: HashSet<usize>, +} + +impl MarkedIndex { + pub fn new(main_index: usize) -> Self { + MarkedIndex { + main_index, + additional_indices: HashSet::new(), + } + } +} + +impl MarkedIndex { + pub fn toggle_additional_mark(&mut self) { + if self.additional_indices.contains(&self.main_index) { + self.additional_indices.retain(|a| a != &self.main_index); + } else { + self.additional_indices.insert(self.main_index); + } + } +} + +#[derive(Debug)] +pub enum VisibilityAndFocus { + OnlyLeftSideVisible, + OnlyRightSideVisible, + BothSidesVisibleLeftSideFocused, + BothSidesVisibleRightSideFocused, +} + +impl Default for VisibilityAndFocus { + fn default() -> Self { + VisibilityAndFocus::OnlyLeftSideVisible + } +} + +impl VisibilityAndFocus { + pub fn only_left_side_is_focused(&self) -> bool { + match self { + VisibilityAndFocus::OnlyLeftSideVisible => true, + _ => false, + } + } + pub fn left_side_is_focused(&self) -> bool { + match self { + VisibilityAndFocus::OnlyLeftSideVisible + | VisibilityAndFocus::BothSidesVisibleLeftSideFocused => true, + _ => false, + } + } + pub fn right_side_is_focused(&self) -> bool { + match self { + VisibilityAndFocus::OnlyRightSideVisible + | VisibilityAndFocus::BothSidesVisibleRightSideFocused => true, + _ => false, + } + } + pub fn hide_left_side(&mut self) { + *self = VisibilityAndFocus::OnlyRightSideVisible + } + pub fn hide_right_side(&mut self) { + *self = VisibilityAndFocus::OnlyLeftSideVisible + } + pub fn focus_right_side(&mut self) { + *self = VisibilityAndFocus::BothSidesVisibleRightSideFocused + } + pub fn toggle_focus(&mut self) { + match self { + VisibilityAndFocus::BothSidesVisibleLeftSideFocused => { + *self = VisibilityAndFocus::BothSidesVisibleRightSideFocused + }, + VisibilityAndFocus::BothSidesVisibleRightSideFocused => { + *self = VisibilityAndFocus::BothSidesVisibleLeftSideFocused + }, + VisibilityAndFocus::OnlyLeftSideVisible => { + *self = VisibilityAndFocus::BothSidesVisibleRightSideFocused + }, + VisibilityAndFocus::OnlyRightSideVisible => { + *self = VisibilityAndFocus::BothSidesVisibleLeftSideFocused + }, + } + } + pub fn show_both_sides(&mut self) { + match self { + VisibilityAndFocus::OnlyLeftSideVisible => { + *self = VisibilityAndFocus::BothSidesVisibleLeftSideFocused + }, + VisibilityAndFocus::OnlyRightSideVisible => { + *self = VisibilityAndFocus::BothSidesVisibleRightSideFocused + }, + VisibilityAndFocus::BothSidesVisibleLeftSideFocused + | VisibilityAndFocus::BothSidesVisibleRightSideFocused => { + // no-op + }, + } + } +} + +impl App { + pub fn react_to_zellij_state_update(&mut self, pane_manifest: PaneManifest) { + let is_first_update = self.right_side_panes.is_empty() && self.left_side_panes.is_empty(); + let panes_on_the_left_before = self.left_side_panes.len(); + let panes_on_the_right_before = self.right_side_panes.len(); + self.update_tab_info(&pane_manifest); + self.update_panes(pane_manifest); + if is_first_update && !self.right_side_panes.is_empty() { + // in this case, the plugin was started with an existing group + // most likely, the user wants to perform operations just on this group, so we + // only show the group, giving the option to add more panes + self.visibility_and_focus.hide_left_side(); + } + let pane_count_changed = (panes_on_the_left_before != self.left_side_panes.len()) + || (panes_on_the_right_before != self.right_side_panes.len()); + if !is_first_update && pane_count_changed { + let has_panes_on_the_right = !self.right_side_panes.is_empty(); + let has_panes_on_the_left = !self.left_side_panes.is_empty(); + if has_panes_on_the_right && has_panes_on_the_left { + self.visibility_and_focus.show_both_sides(); + } else if has_panes_on_the_right { + self.visibility_and_focus.hide_left_side(); + } else if has_panes_on_the_left { + self.visibility_and_focus.hide_right_side(); + } + } + } + pub fn update_panes(&mut self, pane_manifest: PaneManifest) { + let mut all_panes = BTreeMap::new(); + for (_tab_index, pane_infos) in pane_manifest.panes { + for pane_info in pane_infos { + if pane_info.is_selectable { + if pane_info.is_plugin { + all_panes.insert(PaneId::Plugin(pane_info.id), pane_info); + } else { + all_panes.insert(PaneId::Terminal(pane_info.id), pane_info); + } + } + } + } + self.left_side_panes + .retain(|p| all_panes.contains_key(&p.id)); + self.right_side_panes + .retain(|p| all_panes.contains_key(&p.id)); + let mut new_selected_panes: BTreeMap<usize, PaneItem> = BTreeMap::new(); // usize -> index_in_pane_group + for (pane_id, pane) in all_panes.into_iter() { + let is_known = self + .left_side_panes + .iter() + .find(|p| p.id == pane_id) + .is_some() + || self + .right_side_panes + .iter() + .find(|p| p.id == pane_id) + .is_some(); + let index_in_pane_group = self + .own_client_id + .and_then(|own_client_id| pane.index_in_pane_group.get(&own_client_id)); + let is_grouped_for_own_client_id = index_in_pane_group.is_some(); + if !is_known { + if is_grouped_for_own_client_id { + if let Some(index_in_pane_group) = index_in_pane_group { + // we do this rather than adding them directly to right_side_panes so that + // we can make sure they're in the same order as the group is so that + // things like stacking order will do the right thing + new_selected_panes.insert( + *index_in_pane_group, + PaneItem { + text: pane.title, + id: pane_id, + color_indices: vec![], + }, + ); + } + } else { + self.left_side_panes.push(PaneItem { + text: pane.title, + id: pane_id, + color_indices: vec![], + }); + } + } else { + if is_grouped_for_own_client_id { + if let Some(position) = + self.left_side_panes.iter().position(|p| p.id == pane_id) + { + // pane was added to a pane group outside the plugin (eg. with mouse selection) + let mut pane = self.left_side_panes.remove(position); + pane.clear(); + self.right_side_panes.push(pane); + } + } else { + if let Some(position) = + self.right_side_panes.iter().position(|p| p.id == pane_id) + { + // pane was removed from a pane group outside the plugin (eg. with mouse selection) + let mut pane = self.right_side_panes.remove(position); + pane.clear(); + self.left_side_panes.push(pane); + } + } + } + } + for (_index_in_pane_group, pane_item) in new_selected_panes.into_iter() { + self.right_side_panes.push(pane_item); + } + } + pub fn update_tab_info(&mut self, pane_manifest: &PaneManifest) { + for (tab_index, pane_infos) in &pane_manifest.panes { + for pane_info in pane_infos { + if pane_info.is_plugin && Some(pane_info.id) == self.own_plugin_id { + self.own_tab_index = Some(*tab_index); + } + } + } + self.total_tabs_in_session = Some(pane_manifest.panes.keys().count()); + } + pub fn update_search_results(&mut self) { + let mut matches = vec![]; + let matcher = SkimMatcherV2::default().use_cache(true); + for pane_item in &self.left_side_panes { + if let Some((score, indices)) = + matcher.fuzzy_indices(&pane_item.text, &self.search_string) + { + let mut pane_item = pane_item.clone(); + pane_item.color_indices = indices; + matches.push((score, pane_item)); + } + } + matches.sort_by(|(a_score, _a), (b_score, _b)| b_score.cmp(&a_score)); + if self.search_string.is_empty() { + self.search_results = None; + } else { + self.search_results = Some( + matches + .into_iter() + .map(|(_s, pane_item)| pane_item) + .collect(), + ); + } + } + pub fn group_panes_in_zellij(&mut self, pane_ids: Vec<PaneId>) { + group_and_ungroup_panes(pane_ids, vec![]); + } + pub fn ungroup_panes_in_zellij(&mut self, pane_ids: Vec<PaneId>) { + group_and_ungroup_panes(vec![], pane_ids); + } + pub fn update_highlighted_panes(&self) { + let mut pane_ids_to_highlight = vec![]; + let mut pane_ids_to_unhighlight = vec![]; + if let Some(marked_index) = &self.marked_index { + if self.visibility_and_focus.left_side_is_focused() { + if let Some(main_index_pane_id) = self + .search_results + .as_ref() + .and_then(|s| s.get(marked_index.main_index)) + .or_else(|| self.left_side_panes.get(marked_index.main_index)) + .map(|p| p.id) + { + pane_ids_to_highlight.push(main_index_pane_id); + } + for index in &marked_index.additional_indices { + if let Some(pane_id) = self + .search_results + .as_ref() + .and_then(|s| s.get(*index)) + .or_else(|| self.left_side_panes.get(*index)) + .map(|p| p.id) + { + pane_ids_to_highlight.push(pane_id); + } + } + } else { + if let Some(main_index_pane_id) = self + .right_side_panes + .get(marked_index.main_index) + .map(|p| p.id) + { + pane_ids_to_highlight.push(main_index_pane_id); + } + for index in &marked_index.additional_indices { + if let Some(pane_id) = self.right_side_panes.get(*index).map(|p| p.id) { + pane_ids_to_highlight.push(pane_id); + } + } + } + } + for pane in &self.left_side_panes { + if !pane_ids_to_highlight.contains(&pane.id) { + pane_ids_to_unhighlight.push(pane.id); + } + } + for pane in &self.right_side_panes { + if !pane_ids_to_highlight.contains(&pane.id) { + pane_ids_to_unhighlight.push(pane.id); + } + } + highlight_and_unhighlight_panes(pane_ids_to_highlight, pane_ids_to_unhighlight); + } + pub fn unhighlight_all_panes(&mut self) { + let mut pane_ids_to_unhighlight = HashSet::new(); + for pane_item in &self.left_side_panes { + pane_ids_to_unhighlight.insert(pane_item.id); + } + for pane_item in &self.right_side_panes { + pane_ids_to_unhighlight.insert(pane_item.id); + } + highlight_and_unhighlight_panes(vec![], pane_ids_to_unhighlight.into_iter().collect()); + } + pub fn ungroup_all_panes(&mut self) { + let mut unselected_panes = vec![]; + for pane_item in self.right_side_panes.iter_mut() { + pane_item.clear(); + unselected_panes.push(pane_item.id); + } + self.left_side_panes.append(&mut self.right_side_panes); + self.ungroup_panes_in_zellij(unselected_panes); + self.visibility_and_focus.hide_right_side(); + self.marked_index = None; + } + pub fn ungroup_all_panes_and_close_self(&mut self) { + let mut pane_ids_to_ungroup = HashSet::new(); + for pane_item in &self.left_side_panes { + pane_ids_to_ungroup.insert(pane_item.id); + } + for pane_item in &self.right_side_panes { + pane_ids_to_ungroup.insert(pane_item.id); + } + group_and_ungroup_panes(vec![], pane_ids_to_ungroup.into_iter().collect()); + close_self(); + } + pub fn group_panes(&mut self, mut marked_index: MarkedIndex, keep_left_side_focused: bool) { + let mut all_selected_indices: BTreeSet<usize> = + marked_index.additional_indices.drain().collect(); + all_selected_indices.insert(marked_index.main_index); + + // reverse so that the indices will remain consistent while + // removing + let mut selected_panes = vec![]; + for index in all_selected_indices.iter().rev() { + let index = self + .search_results + .as_mut() + .and_then(|search_results| { + if search_results.len() > *index { + Some(search_results.remove(*index)) + } else { + None + } + }) + .and_then(|selected_search_result| { + self.left_side_panes + .iter() + .position(|p| p.id == selected_search_result.id) + }) + .unwrap_or(*index); + if self.left_side_panes.len() > index { + let selected_pane = self.left_side_panes.remove(index); + selected_panes.push(selected_pane); + } + } + let pane_ids_to_make_selected: Vec<PaneId> = selected_panes.iter().map(|p| p.id).collect(); + self.right_side_panes + .append(&mut selected_panes.into_iter().rev().collect()); + + let displayed_list_len = match self.search_results.as_ref() { + Some(search_results) => search_results.len(), + None => self.left_side_panes.len(), + }; + + if displayed_list_len == 0 { + self.handle_left_side_emptied(); + } else if keep_left_side_focused { + if marked_index.main_index > displayed_list_len.saturating_sub(1) { + self.marked_index = Some(MarkedIndex::new(displayed_list_len.saturating_sub(1))); + } else { + self.marked_index = Some(marked_index); + } + self.visibility_and_focus.show_both_sides(); + } else { + self.visibility_and_focus.focus_right_side(); + } + + self.group_panes_in_zellij(pane_ids_to_make_selected); + self.update_highlighted_panes(); + } + pub fn ungroup_panes(&mut self, mut marked_index: MarkedIndex) { + let mut all_selected_indices: BTreeSet<usize> = + marked_index.additional_indices.drain().collect(); + all_selected_indices.insert(marked_index.main_index); + + // reverse so that the indices will remain consistent while + // removing + let mut selected_panes = vec![]; + for index in all_selected_indices.iter().rev() { + if self.right_side_panes.len() > *index { + let mut selected_pane = self.right_side_panes.remove(*index); + selected_pane.clear(); + selected_panes.push(selected_pane); + } + } + self.ungroup_panes_in_zellij(selected_panes.iter().map(|p| p.id).collect()); + self.left_side_panes + .append(&mut selected_panes.into_iter().rev().collect()); + + if self.right_side_panes.is_empty() { + self.marked_index = None; + self.visibility_and_focus.hide_right_side(); + } else if marked_index.main_index > self.right_side_panes.len().saturating_sub(1) { + self.marked_index = Some(MarkedIndex::new( + self.right_side_panes.len().saturating_sub(1), + )); + self.visibility_and_focus.show_both_sides(); + } else { + self.marked_index = Some(marked_index); + self.visibility_and_focus.show_both_sides(); + } + self.update_highlighted_panes(); + } + pub fn group_search_results(&mut self, search_results: Vec<PaneItem>) { + let mut pane_ids_to_make_selected = vec![]; + for search_result in search_results { + let pane_id = search_result.id; + pane_ids_to_make_selected.push(pane_id); + self.left_side_panes.retain(|p| p.id != pane_id); + self.right_side_panes.push(search_result); + } + self.group_panes_in_zellij(pane_ids_to_make_selected); + } + pub fn group_all_panes(&mut self) { + let pane_ids_to_make_selected: Vec<PaneId> = + self.left_side_panes.iter().map(|p| p.id).collect(); + self.right_side_panes.append(&mut self.left_side_panes); + self.group_panes_in_zellij(pane_ids_to_make_selected); + } + pub fn handle_left_side_emptied(&mut self) { + self.visibility_and_focus.hide_left_side(); + self.previous_search_string = self.search_string.drain(..).collect(); + self.marked_index = None; + self.search_results = None; + self.update_highlighted_panes(); + } + pub fn move_marked_index_down(&mut self) { + match self.marked_index.as_mut() { + Some(marked_index) => { + let is_searching = self.search_results.is_some(); + let search_result_count = + self.search_results.as_ref().map(|s| s.len()).unwrap_or(0); + if self.visibility_and_focus.left_side_is_focused() + && is_searching + && marked_index.main_index == search_result_count.saturating_sub(1) + { + marked_index.main_index = 0; + } else if self.visibility_and_focus.left_side_is_focused() + && !is_searching + && marked_index.main_index == self.left_side_panes.len().saturating_sub(1) + { + marked_index.main_index = 0; + } else if self.visibility_and_focus.right_side_is_focused() + && marked_index.main_index == self.right_side_panes.len().saturating_sub(1) + { + marked_index.main_index = 0; + } else { + marked_index.main_index += 1 + } + }, + None => { + if self.visibility_and_focus.left_side_is_focused() { + let is_searching = self.search_results.is_some(); + let has_search_results = self + .search_results + .as_ref() + .map(|s| !s.is_empty()) + .unwrap_or(false); + if is_searching && has_search_results { + self.marked_index = Some(MarkedIndex::new(0)); + } else if !is_searching && !self.left_side_panes.is_empty() { + self.marked_index = Some(MarkedIndex::new(0)); + } + } else if self.visibility_and_focus.right_side_is_focused() + && !self.right_side_panes.is_empty() + { + self.marked_index = Some(MarkedIndex::new(0)); + } + }, + } + self.update_highlighted_panes(); + } + pub fn move_marked_index_up(&mut self) { + match self.marked_index.as_mut() { + Some(marked_index) => { + if self.visibility_and_focus.left_side_is_focused() && marked_index.main_index == 0 + { + if let Some(search_result_count) = self.search_results.as_ref().map(|s| s.len()) + { + marked_index.main_index = search_result_count.saturating_sub(1); + } else { + marked_index.main_index = self.left_side_panes.len().saturating_sub(1); + } + } else if self.visibility_and_focus.right_side_is_focused() + && marked_index.main_index == 0 + { + marked_index.main_index = self.right_side_panes.len().saturating_sub(1); + } else { + marked_index.main_index = marked_index.main_index.saturating_sub(1); + } + }, + None => { + if self.visibility_and_focus.left_side_is_focused() { + let is_searching = self.search_results.is_some(); + let has_search_results = self + .search_results + .as_ref() + .map(|s| !s.is_empty()) + .unwrap_or(false); + if is_searching && has_search_results { + let search_results_count = + self.search_results.as_ref().map(|s| s.len()).unwrap_or(0); + self.marked_index = + Some(MarkedIndex::new(search_results_count.saturating_sub(1))); + } else if !is_searching && !self.left_side_panes.is_empty() { + self.marked_index = Some(MarkedIndex::new( + self.left_side_panes.len().saturating_sub(1), + )); + } + } else if self.visibility_and_focus.right_side_is_focused() + && !self.right_side_panes.is_empty() + { + self.marked_index = Some(MarkedIndex::new( + self.right_side_panes.len().saturating_sub(1), + )); + } + }, + } + self.update_highlighted_panes(); + } + pub fn mark_entry(&mut self) { + if let Some(marked_index) = self.marked_index.as_mut() { + marked_index.toggle_additional_mark(); + self.update_highlighted_panes(); + } + } + pub fn break_grouped_panes_to_new_tab(&mut self) { + let pane_ids_to_break_to_new_tab: Vec<PaneId> = + self.right_side_panes.drain(..).map(|p| p.id).collect(); + let title_for_new_tab = if !self.previous_search_string.is_empty() { + Some(self.previous_search_string.clone()) + } else { + None + }; + break_panes_to_new_tab(&pane_ids_to_break_to_new_tab, title_for_new_tab, true); + self.ungroup_panes_in_zellij(pane_ids_to_break_to_new_tab); + close_self(); + } + pub fn stack_grouped_panes(&mut self) { + let pane_ids_to_stack: Vec<PaneId> = + self.right_side_panes.drain(..).map(|p| p.id).collect(); + stack_panes(pane_ids_to_stack.clone()); + self.ungroup_panes_in_zellij(pane_ids_to_stack); + close_self(); + } + pub fn float_grouped_panes(&mut self) { + let pane_ids_to_float: Vec<PaneId> = + self.right_side_panes.drain(..).map(|p| p.id).collect(); + float_multiple_panes(pane_ids_to_float.clone()); + self.ungroup_panes_in_zellij(pane_ids_to_float); + close_self(); + } + pub fn embed_grouped_panes(&mut self) { + let pane_ids_to_embed: Vec<PaneId> = + self.right_side_panes.drain(..).map(|p| p.id).collect(); + embed_multiple_panes(pane_ids_to_embed.clone()); + self.ungroup_panes_in_zellij(pane_ids_to_embed); + close_self(); + } + pub fn break_grouped_panes_right(&mut self) { + if let Some(own_tab_index) = self.own_tab_index { + if Some(own_tab_index + 1) < self.total_tabs_in_session { + let pane_ids_to_break_right: Vec<PaneId> = + self.right_side_panes.drain(..).map(|p| p.id).collect(); + break_panes_to_tab_with_index(&pane_ids_to_break_right, own_tab_index + 1, true); + } else { + let pane_ids_to_break_to_new_tab: Vec<PaneId> = + self.right_side_panes.drain(..).map(|p| p.id).collect(); + let title_for_new_tab = if !self.previous_search_string.is_empty() { + Some(self.previous_search_string.clone()) + } else { + None + }; + break_panes_to_new_tab(&pane_ids_to_break_to_new_tab, title_for_new_tab, true); + } + close_self(); + } + } + pub fn break_grouped_panes_left(&mut self) { + if let Some(own_tab_index) = self.own_tab_index { + if own_tab_index > 0 { + let pane_ids_to_break_left: Vec<PaneId> = + self.right_side_panes.drain(..).map(|p| p.id).collect(); + break_panes_to_tab_with_index( + &pane_ids_to_break_left, + own_tab_index.saturating_sub(1), + true, + ); + } else { + let pane_ids_to_break_to_new_tab: Vec<PaneId> = + self.right_side_panes.drain(..).map(|p| p.id).collect(); + let title_for_new_tab = if !self.previous_search_string.is_empty() { + Some(self.previous_search_string.clone()) + } else { + None + }; + break_panes_to_new_tab(&pane_ids_to_break_to_new_tab, title_for_new_tab, true); + } + close_self(); + } + } + pub fn close_grouped_panes(&mut self) { + let pane_ids_to_close: Vec<PaneId> = + self.right_side_panes.drain(..).map(|p| p.id).collect(); + close_multiple_panes(pane_ids_to_close); + close_self(); + } +} diff --git a/default-plugins/multiple-select/src/ui.rs b/default-plugins/multiple-select/src/ui.rs new file mode 100644 index 00000000..0c74b0fe --- /dev/null +++ b/default-plugins/multiple-select/src/ui.rs @@ -0,0 +1,873 @@ +use crate::{App, VisibilityAndFocus}; +use zellij_tile::prelude::*; + +const TOP_LEFT_CORNER_CHARACTER: &'static str = "┌"; +const TOP_RIGHT_CORNER_CHARACTER: &'static str = "┐"; +const BOTTOM_LEFT_CORNER_CHARACTER: &'static str = "└"; +const BOTTOM_RIGHT_CORNER_CHARACTER: &'static str = "┘"; +const BOUNDARY_CHARACTER: &'static str = "│"; +const HORIZONTAL_BOUNDARY_CHARACTER: &'static str = "─"; + +#[derive(Debug, Clone)] +pub struct PaneItem { + pub text: String, + pub id: PaneId, + pub color_indices: Vec<usize>, +} + +impl PaneItem { + pub fn clear(&mut self) { + self.color_indices.clear(); + } + pub fn render(&self, max_width_for_item: usize) -> NestedListItem { + let pane_item_text_len = self.text.chars().count(); + if pane_item_text_len <= max_width_for_item { + NestedListItem::new(&self.text) + .color_range(0, ..) + .color_indices(3, self.color_indices.iter().copied().collect()) + } else { + let length_of_each_half = max_width_for_item.saturating_sub(3) / 2; + let first_half: String = self.text.chars().take(length_of_each_half).collect(); + let second_half: String = self + .text + .chars() + .rev() + .take(length_of_each_half) + .collect::<Vec<_>>() + .iter() + .rev() + .collect(); + let second_half_start_index = pane_item_text_len.saturating_sub(length_of_each_half); + let adjusted_indices: Vec<usize> = self + .color_indices + .iter() + .filter_map(|i| { + if i < &length_of_each_half { + Some(*i) + } else if i >= &second_half_start_index { + Some(i.saturating_sub(second_half_start_index) + length_of_each_half + 3) + //3 for the bulletin + } else { + None + } + }) + .collect(); + NestedListItem::new(format!("{}...{}", first_half, second_half)) + .color_range(0, ..) + .color_indices(3, adjusted_indices) + } + } +} + +// rendering code +impl App { + pub fn render_close_shortcut(&self, cols: usize) { + let should_render_close_shortcut = + self.visibility_and_focus.left_side_is_focused() && self.marked_index.is_none(); + if should_render_close_shortcut { + let x_coordinates_right_padding = + if self.visibility_and_focus.only_left_side_is_focused() { + 5 + } else { + 1 + }; + let ctrl_c_shortcut_text = "<Ctrl c> - Close"; + let ctrl_c_shortcut = Text::new(ctrl_c_shortcut_text).color_range(3, ..=7); + print_text_with_coordinates( + ctrl_c_shortcut, + cols.saturating_sub(ctrl_c_shortcut_text.chars().count()) + .saturating_sub(x_coordinates_right_padding), + 0, + None, + None, + ); + } + } + pub fn render_tab_shortcut(&self, cols: usize, rows: usize) { + match self.visibility_and_focus { + VisibilityAndFocus::BothSidesVisibleRightSideFocused => { + let side_width = self.calculate_side_width(cols); + let tab_shortcut = Text::new("<TAB> - select more panes").color_range(3, ..=4); + print_text_with_coordinates( + tab_shortcut, + side_width + 6, + rows.saturating_sub(2), + None, + None, + ); + }, + VisibilityAndFocus::BothSidesVisibleLeftSideFocused => { + let side_width = self.calculate_side_width(cols); + let tab_shortcut_text = "<TAB> - browse selected panes"; + let tab_shortcut = Text::new(tab_shortcut_text).color_range(3, ..=4); + print_text_with_coordinates( + tab_shortcut, + side_width.saturating_sub(tab_shortcut_text.chars().count() + 1), + rows.saturating_sub(2), + None, + None, + ); + }, + VisibilityAndFocus::OnlyRightSideVisible => { + let tab_shortcut = Text::new("<TAB> - select more panes").color_range(3, ..=4); + print_text_with_coordinates(tab_shortcut, 4, rows.saturating_sub(2), None, None); + }, + VisibilityAndFocus::OnlyLeftSideVisible => { + // not visible + }, + }; + } + pub fn render_left_side(&self, rows: usize, cols: usize, is_focused: bool) { + let title_y = 0; + let left_side_base_x = 1; + let list_y = 2; + let side_width = self.calculate_side_width(cols); + let max_left_list_height = rows.saturating_sub(8); + let ( + extra_pane_count_on_top_left, + extra_pane_count_on_bottom_left, + extra_selected_item_count_on_top_left, + extra_selected_item_count_on_bottom_left, + left_side_panes, + ) = self.left_side_panes_list(side_width, max_left_list_height); + let (filter_prompt_text, filter_prompt) = self.filter_panes_prompt(); + let filter = self.filter(side_width.saturating_sub(filter_prompt_text.chars().count() + 1)); + let ( + _enter_select_panes_text, + enter_select_panes, + space_shortcut_text, + space_shortcut, + _escape_shortcut_text, + escape_shortcut, + ) = self.left_side_controls(side_width); + print_text_with_coordinates(filter_prompt, left_side_base_x, title_y, None, None); + if is_focused { + print_text_with_coordinates( + filter, + left_side_base_x + filter_prompt_text.chars().count(), + title_y, + None, + None, + ); + } + print_nested_list_with_coordinates( + left_side_panes.clone(), + left_side_base_x, + list_y, + Some(side_width), + None, + ); + if is_focused { + if let Some(marked_index) = self.marked_index.as_ref().map(|i| i.main_index) { + print_text_with_coordinates( + Text::new(">").color_range(3, ..).selected(), + left_side_base_x + 1, + (list_y + marked_index).saturating_sub(extra_pane_count_on_top_left), + None, + None, + ); + } + } + if extra_pane_count_on_top_left > 0 { + self.print_extra_pane_count( + extra_pane_count_on_top_left, + extra_selected_item_count_on_top_left, + list_y.saturating_sub(1), + left_side_base_x, + side_width, + ); + } + if extra_pane_count_on_bottom_left > 0 { + self.print_extra_pane_count( + extra_pane_count_on_bottom_left, + extra_selected_item_count_on_bottom_left, + list_y + left_side_panes.len(), + left_side_base_x, + side_width, + ); + } + if is_focused && !left_side_panes.is_empty() { + let controls_x = 1; + print_text_with_coordinates( + enter_select_panes, + controls_x, + list_y + left_side_panes.len() + 1, + None, + None, + ); + if self.marked_index.is_some() { + print_text_with_coordinates( + space_shortcut.clone(), + controls_x, + list_y + left_side_panes.len() + 2, + None, + None, + ); + print_text_with_coordinates( + escape_shortcut.clone(), + controls_x + space_shortcut_text.chars().count() + 1, + list_y + left_side_panes.len() + 2, + None, + None, + ); + } + } + } + pub fn render_right_side(&self, rows: usize, cols: usize, is_focused: bool) { + let side_width = self.calculate_side_width(cols); + let right_side_base_x = match self.visibility_and_focus { + VisibilityAndFocus::OnlyLeftSideVisible | VisibilityAndFocus::OnlyRightSideVisible => 1, + VisibilityAndFocus::BothSidesVisibleLeftSideFocused + | VisibilityAndFocus::BothSidesVisibleRightSideFocused => side_width + 4, + }; + let title_y = 0; + let list_y: usize = 2; + let max_right_list_height = rows.saturating_sub(11); + let selected_prompt = self.selected_panes_title(); + let ( + extra_pane_count_on_top_right, + extra_pane_count_on_bottom_right, + extra_selected_item_count_on_top_right, + extra_selected_item_count_on_bottom_right, + right_side_panes, + ) = self.right_side_panes_list(side_width, max_right_list_height); + let right_side_pane_count = right_side_panes.len(); + let ( + right_side_controls_1, + right_side_controls_2, + right_side_controls_3, + right_side_controls_4, + ) = self.right_side_controls(side_width); + if extra_pane_count_on_top_right > 0 { + self.print_extra_pane_count( + extra_pane_count_on_top_right, + extra_selected_item_count_on_top_right, + list_y.saturating_sub(1), + right_side_base_x, + side_width, + ); + } + if extra_pane_count_on_bottom_right > 0 { + self.print_extra_pane_count( + extra_pane_count_on_bottom_right, + extra_selected_item_count_on_bottom_right, + list_y + right_side_panes.len(), + right_side_base_x, + side_width, + ); + } + print_text_with_coordinates(selected_prompt, right_side_base_x + 3, title_y, None, None); + print_nested_list_with_coordinates( + right_side_panes, + right_side_base_x, + list_y, + Some(side_width), + None, + ); + if is_focused { + if let Some(marked_index) = self.marked_index.as_ref().map(|i| i.main_index) { + print_text_with_coordinates( + Text::new(">").color_range(3, ..).selected(), + right_side_base_x + 1, + (list_y + marked_index).saturating_sub(extra_pane_count_on_top_right), + None, + None, + ); + } + } + if is_focused && !self.right_side_panes.is_empty() { + print_text_with_coordinates( + right_side_controls_1, + right_side_base_x + 1, + list_y + right_side_pane_count + 1, + None, + None, + ); + print_text_with_coordinates( + right_side_controls_2, + right_side_base_x + 1, + list_y + right_side_pane_count + 3, + None, + None, + ); + print_text_with_coordinates( + right_side_controls_3, + right_side_base_x + 1, + list_y + right_side_pane_count + 4, + None, + None, + ); + print_text_with_coordinates( + right_side_controls_4, + right_side_base_x + 1, + list_y + right_side_pane_count + 5, + None, + None, + ); + } + } + pub fn render_help_line(&self, rows: usize, cols: usize) { + let help_line_text = match self.visibility_and_focus { + VisibilityAndFocus::OnlyLeftSideVisible => { + let full_help_line = "Help: Select one or more panes to group for bulk operations"; + let short_help_line = "Help: Select panes to group"; + if cols >= full_help_line.chars().count() { + full_help_line + } else { + short_help_line + } + }, + VisibilityAndFocus::OnlyRightSideVisible => { + let full_help_line = "Help: Perform bulk operations on all selected panes"; + let short_help_line = "Help: Perform bulk operations"; + if cols >= full_help_line.chars().count() { + full_help_line + } else { + short_help_line + } + }, + _ => { + let full_help_line = + "Help: Select panes on the left, then perform operations on the right."; + let short_help_line = "Help: Group panes for bulk operations"; + if cols >= full_help_line.chars().count() { + full_help_line + } else { + short_help_line + } + }, + }; + let help_line = Text::new(help_line_text); + print_text_with_coordinates(help_line, 0, rows, None, None); + } + pub fn render_focus_boundary(&self, rows: usize, cols: usize) { + let side_width = self.calculate_side_width(cols); + let x = match self.visibility_and_focus { + VisibilityAndFocus::OnlyRightSideVisible => 0, + VisibilityAndFocus::BothSidesVisibleLeftSideFocused + | VisibilityAndFocus::BothSidesVisibleRightSideFocused + | VisibilityAndFocus::OnlyLeftSideVisible => side_width + 2, + }; + let y = 0; + let height = rows.saturating_sub(2); + for i in y..=height { + if i == y && self.visibility_and_focus.left_side_is_focused() { + print_text_with_coordinates( + Text::new(TOP_RIGHT_CORNER_CHARACTER), + x, + i, + None, + None, + ); + print_text_with_coordinates( + Text::new(HORIZONTAL_BOUNDARY_CHARACTER), + x.saturating_sub(1), + i, + None, + None, + ); + print_text_with_coordinates( + Text::new(HORIZONTAL_BOUNDARY_CHARACTER), + x.saturating_sub(2), + i, + None, + None, + ); + } else if i == y && !self.visibility_and_focus.left_side_is_focused() { + print_text_with_coordinates(Text::new(TOP_LEFT_CORNER_CHARACTER), x, i, None, None); + print_text_with_coordinates( + Text::new(HORIZONTAL_BOUNDARY_CHARACTER), + x + 1, + i, + None, + None, + ); + print_text_with_coordinates( + Text::new(HORIZONTAL_BOUNDARY_CHARACTER), + x + 2, + i, + None, + None, + ); + } else if i == height && self.visibility_and_focus.left_side_is_focused() { + print_text_with_coordinates( + Text::new(BOTTOM_RIGHT_CORNER_CHARACTER), + x, + i, + None, + None, + ); + print_text_with_coordinates( + Text::new(HORIZONTAL_BOUNDARY_CHARACTER), + x.saturating_sub(1), + i, + None, + None, + ); + print_text_with_coordinates( + Text::new(HORIZONTAL_BOUNDARY_CHARACTER), + x.saturating_sub(2), + i, + None, + None, + ); + } else if i == height && !self.visibility_and_focus.left_side_is_focused() { + print_text_with_coordinates( + Text::new(BOTTOM_LEFT_CORNER_CHARACTER), + x, + i, + None, + None, + ); + print_text_with_coordinates( + Text::new(HORIZONTAL_BOUNDARY_CHARACTER), + x + 1, + i, + None, + None, + ); + print_text_with_coordinates( + Text::new(HORIZONTAL_BOUNDARY_CHARACTER), + x + 2, + i, + None, + None, + ); + } else { + print_text_with_coordinates(Text::new(BOUNDARY_CHARACTER), x, i, None, None); + } + } + } + pub fn calculate_side_width(&self, cols: usize) -> usize { + match self.visibility_and_focus { + VisibilityAndFocus::OnlyLeftSideVisible | VisibilityAndFocus::OnlyRightSideVisible => { + cols.saturating_sub(4) + }, + VisibilityAndFocus::BothSidesVisibleLeftSideFocused + | VisibilityAndFocus::BothSidesVisibleRightSideFocused => (cols / 2).saturating_sub(3), + } + } +} + +// ui components +impl App { + fn filter_panes_prompt(&self) -> (&'static str, Text) { + let search_prompt_text = if self.search_string.is_empty() { + "ALL PANES " + } else { + "FILTER: " + }; + let search_prompt = if self.visibility_and_focus.left_side_is_focused() { + Text::new(&search_prompt_text).color_range(2, ..) + } else { + Text::new(&search_prompt_text) + }; + (search_prompt_text, search_prompt) + } + fn filter(&self, max_width: usize) -> Text { + let search_string_text = if self.marked_index.is_none() && self.search_string.is_empty() { + let full = "[Type filter term...]"; + let short = "[...]"; + if max_width >= full.chars().count() { + full.to_owned() + } else { + short.to_owned() + } + } else if self.marked_index.is_none() && !self.search_string.is_empty() { + if max_width >= self.search_string.chars().count() + 1 { + format!("{}_", self.search_string) + } else { + let truncated: String = self + .search_string + .chars() + .rev() + .take(max_width.saturating_sub(4)) + .collect::<Vec<_>>() + .iter() + .rev() + .collect(); + format!("...{}_", truncated) + } + } else if self.marked_index.is_some() && !self.search_string.is_empty() { + if max_width >= self.search_string.chars().count() { + format!("{}", self.search_string) + } else { + let truncated: String = self + .search_string + .chars() + .rev() + .take(max_width.saturating_sub(4)) + .collect::<Vec<_>>() + .iter() + .rev() + .collect(); + format!("...{}", truncated) + } + } else { + format!("") + }; + Text::new(&search_string_text).color_range(3, ..) + } + fn left_side_panes_list( + &self, + max_width: usize, + max_list_height: usize, + ) -> (usize, usize, usize, usize, Vec<NestedListItem>) { + // returns: extra_pane_count_on_top, extra_pane_count_on_bottom, + // extra_selected_item_count_on_top, extra_selected_item_count_on_bottom, list + let mut left_side_panes = vec![]; + let pane_items_on_the_left = self + .search_results + .as_ref() + .unwrap_or_else(|| &self.left_side_panes); + let max_width_for_item = max_width.saturating_sub(3); // 3 for the list bulletin + let item_count = pane_items_on_the_left.iter().count(); + let first_item_index = if self.visibility_and_focus.left_side_is_focused() { + self.marked_index + .as_ref() + .map(|s| s.main_index.saturating_sub(max_list_height / 2)) + .unwrap_or(0) + } else { + 0 + }; + let last_item_index = std::cmp::min( + (max_list_height + first_item_index).saturating_sub(1), + item_count.saturating_sub(1), + ); + for (i, pane_item) in pane_items_on_the_left + .iter() + .enumerate() + .skip(first_item_index) + { + if i > last_item_index { + break; + } + let mut item = pane_item.render(max_width_for_item); + if Some(i) == self.marked_index.as_ref().map(|s| s.main_index) + && self.visibility_and_focus.left_side_is_focused() + { + item = item.selected(); + if self + .marked_index + .as_ref() + .map(|s| s.additional_indices.contains(&i)) + .unwrap_or(false) + { + item = item.selected().color_range(1, ..); + } + } else if self + .marked_index + .as_ref() + .map(|s| s.additional_indices.contains(&i)) + .unwrap_or(false) + && self.visibility_and_focus.left_side_is_focused() + { + item = item.selected(); + } + left_side_panes.push(item); + } + let extra_panes_on_top = first_item_index; + let extra_panes_on_bottom = item_count.saturating_sub(last_item_index + 1); + let extra_selected_item_count_on_top = if self.visibility_and_focus.left_side_is_focused() { + self.marked_index + .as_ref() + .map(|s| { + s.additional_indices + .iter() + .filter(|a| a < &&first_item_index) + .count() + }) + .unwrap_or(0) + } else { + 0 + }; + let extra_selected_item_count_on_bottom = + if self.visibility_and_focus.left_side_is_focused() { + self.marked_index + .as_ref() + .map(|s| { + s.additional_indices + .iter() + .filter(|a| a > &&last_item_index) + .count() + }) + .unwrap_or(0) + } else { + 0 + }; + ( + extra_panes_on_top, + extra_panes_on_bottom, + extra_selected_item_count_on_top, + extra_selected_item_count_on_bottom, + left_side_panes, + ) + } + fn left_side_controls( + &self, + max_width: usize, + ) -> (&'static str, Text, &'static str, Text, &'static str, Text) { + // returns three components and their text + let (enter_select_panes_text, enter_select_panes) = if self.marked_index.is_some() { + let enter_select_panes_text_full = "<ENTER> - select, <↓↑→> - navigate"; + let enter_select_panes_text_short = "<ENTER> / <↓↑→>..."; + if max_width >= enter_select_panes_text_full.chars().count() { + let enter_select_panes_full = Text::new(enter_select_panes_text_full) + .color_range(3, ..=6) + .color_range(3, 18..=22); + (enter_select_panes_text_full, enter_select_panes_full) + } else { + let enter_select_panes_short = Text::new(enter_select_panes_text_short) + .color_range(3, ..=6) + .color_range(3, 10..=14); + (enter_select_panes_text_short, enter_select_panes_short) + } + } else { + let enter_select_panes_text_full = "<ENTER> - select all, <↓↑> - navigate"; + let enter_select_panes_text_short = "<ENTER> / <↓↑>..."; + if max_width >= enter_select_panes_text_full.chars().count() { + let enter_select_panes_full = Text::new(enter_select_panes_text_full) + .color_range(3, ..=6) + .color_range(3, 21..=25); + (enter_select_panes_text_full, enter_select_panes_full) + } else { + let enter_select_panes_short = Text::new(enter_select_panes_text_short) + .color_range(3, ..=6) + .color_range(3, 10..=13); + (enter_select_panes_text_short, enter_select_panes_short) + } + }; + let space_shortcut_text_full = "<SPACE> - mark many,"; + let space_shortcut_text_short = "<SPACE> /"; + if self.marked_index.is_some() { + let escape_shortcut_text_full = "<Ctrl c> - remove marks"; + let escape_shortcut_text_short = "<Ctrl c>..."; + let (escape_shortcut, space_shortcut, escape_shortcut_text, space_shortcut_text) = + if max_width + >= space_shortcut_text_full.chars().count() + + escape_shortcut_text_full.chars().count() + { + ( + Text::new(escape_shortcut_text_full).color_range(3, ..=7), + Text::new(space_shortcut_text_full).color_range(3, ..=6), + escape_shortcut_text_full, + space_shortcut_text_full, + ) + } else { + ( + Text::new(escape_shortcut_text_short).color_range(3, ..=7), + Text::new(space_shortcut_text_short).color_range(3, ..=6), + escape_shortcut_text_short, + space_shortcut_text_short, + ) + }; + ( + enter_select_panes_text, + enter_select_panes, + space_shortcut_text, + space_shortcut, + escape_shortcut_text, + escape_shortcut, + ) + } else { + let escape_shortcut_text = if self.right_side_panes.is_empty() { + "<Ctrl c> - Close" + } else { + "" + }; + let escape_shortcut = Text::new(escape_shortcut_text).color_range(3, ..=7); + let space_shortcut = Text::new(space_shortcut_text_full).color_range(3, ..=6); + ( + enter_select_panes_text, + enter_select_panes, + space_shortcut_text_full, + space_shortcut, + escape_shortcut_text, + escape_shortcut, + ) + } + } + fn selected_panes_title(&self) -> Text { + let selected_prompt_text = "SELECTED PANES: "; + let selected_prompt = if self.visibility_and_focus.left_side_is_focused() { + Text::new(selected_prompt_text) + } else { + Text::new(selected_prompt_text).color_range(2, ..) + }; + selected_prompt + } + fn right_side_panes_list( + &self, + max_width: usize, + max_list_height: usize, + ) -> (usize, usize, usize, usize, Vec<NestedListItem>) { + // returns: extra_pane_count_on_top, extra_pane_count_on_bottom, + // extra_selected_item_count_on_top, extra_selected_item_count_on_bottom, list + let mut right_side_panes = vec![]; + let item_count = self.right_side_panes.iter().count(); + let first_item_index = if self.visibility_and_focus.left_side_is_focused() { + 0 + } else { + self.marked_index + .as_ref() + .map(|s| s.main_index.saturating_sub(max_list_height / 2)) + .unwrap_or(0) + }; + let last_item_index = std::cmp::min( + (max_list_height + first_item_index).saturating_sub(1), + item_count.saturating_sub(1), + ); + + let max_width_for_item = max_width.saturating_sub(3); // 3 for the list bulletin + for (i, pane_item) in self + .right_side_panes + .iter() + .enumerate() + .skip(first_item_index) + { + if i > last_item_index { + break; + } + let mut item = pane_item.render(max_width_for_item); + if &Some(i) == &self.marked_index.as_ref().map(|s| s.main_index) + && self.visibility_and_focus.right_side_is_focused() + { + item = item.selected(); + if self + .marked_index + .as_ref() + .map(|s| s.additional_indices.contains(&i)) + .unwrap_or(false) + { + item = item.selected().color_range(1, ..); + } + } else if self + .marked_index + .as_ref() + .map(|s| s.additional_indices.contains(&i)) + .unwrap_or(false) + && self.visibility_and_focus.right_side_is_focused() + { + item = item.selected(); + } + right_side_panes.push(item); + } + + let extra_panes_on_top = first_item_index; + let extra_panes_on_bottom = self + .right_side_panes + .iter() + .len() + .saturating_sub(last_item_index + 1); + let extra_selected_item_count_on_top = if self.visibility_and_focus.left_side_is_focused() { + 0 + } else { + self.marked_index + .as_ref() + .map(|s| { + s.additional_indices + .iter() + .filter(|a| a < &&first_item_index) + .count() + }) + .unwrap_or(0) + }; + let extra_selected_item_count_on_bottom = + if self.visibility_and_focus.left_side_is_focused() { + 0 + } else { + self.marked_index + .as_ref() + .map(|s| { + s.additional_indices + .iter() + .filter(|a| a > &&last_item_index) + .count() + }) + .unwrap_or(0) + }; + ( + extra_panes_on_top, + extra_panes_on_bottom, + extra_selected_item_count_on_top, + extra_selected_item_count_on_bottom, + right_side_panes, + ) + } + fn right_side_controls(&self, cols: usize) -> (Text, Text, Text, Text) { + let right_side_controls_text_1_full = "<←↓↑> - navigate, <Ctrl c> - clear"; + let right_side_controls_text_1_short = "<←↓↑>/<Ctrl c>..."; + let right_side_controls_1 = if cols >= right_side_controls_text_1_full.chars().count() { + Text::new(right_side_controls_text_1_full) + .color_range(3, ..=4) + .color_range(3, 18..=25) + } else { + Text::new(right_side_controls_text_1_short) + .color_range(3, ..=4) + .color_range(3, 6..=13) + }; + let right_side_controls_text_2_full = "<b> - break out, <s> - stack, <c> - close"; + let right_side_controls_text_2_short = "<b>/<s>/<c>..."; + let right_side_controls_2 = if cols >= right_side_controls_text_2_full.chars().count() { + Text::new(right_side_controls_text_2_full) + .color_range(3, ..=2) + .color_range(3, 17..=19) + .color_range(3, 30..=32) + } else { + Text::new(right_side_controls_text_2_short) + .color_range(3, ..=2) + .color_range(3, 4..=6) + .color_range(3, 8..=10) + }; + let right_side_controls_text_3_full = "<r> - break right, <l> - break left"; + let right_side_controls_text_3_short = "<r>/<l>..."; + let right_side_controls_3 = if cols >= right_side_controls_text_3_full.chars().count() { + Text::new(right_side_controls_text_3_full) + .color_range(3, ..=2) + .color_range(3, 19..=21) + } else { + Text::new(right_side_controls_text_3_short) + .color_range(3, ..=2) + .color_range(3, 4..=6) + }; + let right_side_controls_text_4_full = "<e> - embed, <f> - float"; + let right_side_controls_text_4_short = "<e>/<f>..."; + let right_side_controls_4 = if cols >= right_side_controls_text_4_full.chars().count() { + Text::new(right_side_controls_text_4_full) + .color_range(3, ..=2) + .color_range(3, 13..=15) + } else { + Text::new(right_side_controls_text_4_short) + .color_range(3, ..=2) + .color_range(3, 4..=6) + }; + ( + right_side_controls_1, + right_side_controls_2, + right_side_controls_3, + right_side_controls_4, + ) + } + fn print_extra_pane_count( + &self, + count: usize, + selected_count: usize, + y: usize, + list_x: usize, + list_width: usize, + ) { + let extra_count_text = if selected_count > 0 { + format!("[+{} ({} selected)]", count, selected_count) + } else { + format!("[+{}]", count) + }; + let extra_count = Text::new(&extra_count_text).color_range(1, ..); + print_text_with_coordinates( + extra_count, + (list_x + list_width).saturating_sub(extra_count_text.chars().count()), + y, + None, + None, + ); + } +} diff --git a/default-plugins/status-bar/src/main.rs b/default-plugins/status-bar/src/main.rs index 65b73819..dd5d54c5 100644 --- a/default-plugins/status-bar/src/main.rs +++ b/default-plugins/status-bar/src/main.rs @@ -39,6 +39,8 @@ struct State { display_system_clipboard_failure: bool, classic_ui: bool, base_mode_is_locked: bool, + own_client_id: Option<ClientId>, + grouped_panes_count: Option<usize>, } register_plugin!(State); @@ -197,10 +199,12 @@ impl ZellijPlugin for State { .get("classic") .map(|c| c == "true") .unwrap_or(false); + self.own_client_id = Some(get_plugin_ids().client_id); set_selectable(false); subscribe(&[ EventType::ModeUpdate, EventType::TabUpdate, + EventType::PaneUpdate, EventType::CopyToClipboard, EventType::InputReceived, EventType::SystemClipboardFailure, @@ -223,6 +227,28 @@ impl ZellijPlugin for State { } self.tabs = tabs; }, + Event::PaneUpdate(pane_manifest) => { + if let Some(own_client_id) = self.own_client_id { + let mut grouped_panes_count = 0; + for (_tab_index, pane_infos) in pane_manifest.panes { + for pane_info in pane_infos { + let is_in_pane_group = + pane_info.index_in_pane_group.get(&own_client_id).is_some(); + if is_in_pane_group { + grouped_panes_count += 1; + } + } + } + if Some(grouped_panes_count) != self.grouped_panes_count { + if grouped_panes_count == 0 { + self.grouped_panes_count = None; + } else { + self.grouped_panes_count = Some(grouped_panes_count); + } + should_render = true; + } + } + }, Event::CopyToClipboard(copy_destination) => { match self.text_copy_destination { Some(text_copy_destination) => { @@ -280,6 +306,7 @@ impl ZellijPlugin for State { self.base_mode_is_locked, self.text_copy_destination, self.display_system_clipboard_failure, + self.grouped_panes_count, ), fill_bg, ); diff --git a/default-plugins/status-bar/src/one_line_ui.rs b/default-plugins/status-bar/src/one_line_ui.rs index 83217425..f6b88491 100644 --- a/default-plugins/status-bar/src/one_line_ui.rs +++ b/default-plugins/status-bar/src/one_line_ui.rs @@ -6,7 +6,7 @@ use ansi_term::{ use std::collections::HashMap; use zellij_tile::prelude::actions::Action; use zellij_tile::prelude::*; -use zellij_tile_utils::palette_match; +use zellij_tile_utils::{palette_match, style}; use crate::first_line::{to_char, KeyAction, KeyMode, KeyShortcut}; use crate::second_line::{system_clipboard_error, text_copied_hint}; @@ -22,6 +22,7 @@ pub fn one_line_ui( base_mode_is_locked: bool, text_copied_to_clipboard_destination: Option<CopyDestination>, clipboard_failure: bool, + grouped_pane_count: Option<usize>, ) -> LinePart { if let Some(text_copied_to_clipboard_destination) = text_copied_to_clipboard_destination { return text_copied_hint(text_copied_to_clipboard_destination); @@ -35,11 +36,18 @@ pub fn one_line_ui( *max_len = max_len.saturating_sub(line_part.len); }; + let currently_marking_pane_group = help.currently_marking_pane_group.unwrap_or(false); render_mode_key_indicators(help, max_len, separator, base_mode_is_locked) .map(|mode_key_indicators| append(&mode_key_indicators, &mut max_len)) .and_then(|_| match help.mode { - InputMode::Normal | InputMode::Locked => render_secondary_info(help, tab_info, max_len) - .map(|secondary_info| append(&secondary_info, &mut max_len)), + InputMode::Normal | InputMode::Locked => match grouped_pane_count { + Some(grouped_pane_count) => { + render_group_controls(help, grouped_pane_count, max_len) + }, + None if currently_marking_pane_group => render_group_controls(help, 0, max_len), + None => render_secondary_info(help, tab_info, max_len), + } + .map(|secondary_info| append(&secondary_info, &mut max_len)), _ => add_keygroup_separator(help, max_len) .map(|key_group_separator| append(&key_group_separator, &mut max_len)) .and_then(|_| keybinds(help, max_len)) @@ -656,6 +664,225 @@ fn render_common_modifiers( line_part_to_render.len += prefix_text.chars().count() + separator.chars().count(); } +fn render_group_controls( + help: &ModeInfo, + grouped_pane_count: usize, + max_len: usize, +) -> Option<LinePart> { + let currently_marking_group = help.currently_marking_pane_group.unwrap_or(false); + let keymap = help.get_mode_keybinds(); + let (common_modifiers, multiple_select_key, pane_group_toggle_key, group_mark_toggle_key) = { + let multiple_select_key = multiple_select_key(&keymap); + let pane_group_toggle_key = single_action_key(&keymap, &[Action::TogglePaneInGroup]); + let group_mark_toggle_key = single_action_key(&keymap, &[Action::ToggleGroupMarking]); + let common_modifiers = get_common_modifiers( + vec![ + multiple_select_key.iter().next(), + pane_group_toggle_key.iter().next(), + group_mark_toggle_key.iter().next(), + ] + .into_iter() + .filter_map(|k| k) + .collect(), + ); + let multiple_select_key: Vec<KeyWithModifier> = multiple_select_key + .iter() + .map(|k| k.strip_common_modifiers(&common_modifiers)) + .collect(); + let pane_group_toggle_key: Vec<KeyWithModifier> = pane_group_toggle_key + .iter() + .map(|k| k.strip_common_modifiers(&common_modifiers)) + .collect(); + let group_mark_toggle_key: Vec<KeyWithModifier> = group_mark_toggle_key + .iter() + .map(|k| k.strip_common_modifiers(&common_modifiers)) + .collect(); + ( + common_modifiers, + multiple_select_key, + pane_group_toggle_key, + group_mark_toggle_key, + ) + }; + let multiple_select_key = multiple_select_key + .iter() + .next() + .map(|key| format!("{}", key)) + .unwrap_or("UNBOUND".to_owned()); + let pane_group_toggle_key = pane_group_toggle_key + .iter() + .next() + .map(|key| format!("{}", key)) + .unwrap_or("UNBOUND".to_owned()); + let group_mark_toggle_key = group_mark_toggle_key + .iter() + .next() + .map(|key| format!("{}", key)) + .unwrap_or("UNBOUND".to_owned()); + let background = help.style.colors.text_unselected.background; + let foreground = help.style.colors.text_unselected.base; + let superkey_prefix_style = style!(foreground, background).bold(); + let common_modifier_text = if common_modifiers.is_empty() { + "".to_owned() + } else { + format!( + "{} + ", + common_modifiers + .iter() + .map(|c| c.to_string()) + .collect::<Vec<_>>() + .join("-") + ) + }; + + // full + let full_selected_panes_text = if common_modifier_text.is_empty() { + format!("{} SELECTED PANES", grouped_pane_count) + } else { + format!("{} SELECTED PANES |", grouped_pane_count) + }; + let full_group_actions_text = format!("<{}> Group Actions", &multiple_select_key); + let full_toggle_group_text = format!("<{}> Toggle Group", &pane_group_toggle_key); + let full_group_mark_toggle_text = format!("<{}> Follow Focus", &group_mark_toggle_key); + let ribbon_paddings_len = 12; + let full_controls_line_len = full_selected_panes_text.chars().count() + + 1 + + common_modifier_text.chars().count() + + full_group_actions_text.chars().count() + + full_toggle_group_text.chars().count() + + full_group_mark_toggle_text.chars().count() + + ribbon_paddings_len + + 1; // 1 for the end padding + + // medium + let medium_selected_panes_text = if common_modifier_text.is_empty() { + format!("{} SELECTED", grouped_pane_count) + } else { + format!("{} SELECTED |", grouped_pane_count) + }; + let medium_group_actions_text = format!("<{}> Actions", &multiple_select_key); + let medium_toggle_group_text = format!("<{}> Toggle", &pane_group_toggle_key); + let medium_group_mark_toggle_text = format!("<{}> Follow", &group_mark_toggle_key); + let ribbon_paddings_len = 12; + let medium_controls_line_len = medium_selected_panes_text.chars().count() + + 1 + + common_modifier_text.chars().count() + + medium_group_actions_text.chars().count() + + medium_toggle_group_text.chars().count() + + medium_group_mark_toggle_text.chars().count() + + ribbon_paddings_len + + 1; // 1 for the end padding + + // short + let short_selected_panes_text = if common_modifier_text.is_empty() { + format!("{} SELECTED", grouped_pane_count) + } else { + format!("{} SELECTED |", grouped_pane_count) + }; + let short_group_actions_text = format!("<{}>", &multiple_select_key); + let short_toggle_group_text = format!("<{}>", &pane_group_toggle_key); + let short_group_mark_toggle_text = format!("<{}>", &group_mark_toggle_key); + let color_emphasis_range_end = if common_modifier_text.is_empty() { + 0 + } else { + 2 + }; + let ribbon_paddings_len = 12; + let short_controls_line_len = short_selected_panes_text.chars().count() + + 1 + + common_modifier_text.chars().count() + + short_group_actions_text.chars().count() + + short_toggle_group_text.chars().count() + + short_group_mark_toggle_text.chars().count() + + ribbon_paddings_len + + 1; // 1 for the end padding + + let ( + selected_panes_text, + group_actions_text, + toggle_group_text, + group_mark_toggle_text, + controls_line_len, + ) = if max_len >= full_controls_line_len { + ( + full_selected_panes_text, + full_group_actions_text, + full_toggle_group_text, + full_group_mark_toggle_text, + full_controls_line_len, + ) + } else if max_len >= medium_controls_line_len { + ( + medium_selected_panes_text, + medium_group_actions_text, + medium_toggle_group_text, + medium_group_mark_toggle_text, + medium_controls_line_len, + ) + } else if max_len >= short_controls_line_len { + ( + short_selected_panes_text, + short_group_actions_text, + short_toggle_group_text, + short_group_mark_toggle_text, + short_controls_line_len, + ) + } else { + return None; + }; + let selected_panes = serialize_text( + &Text::new(&selected_panes_text) + .color_range( + 3, + ..selected_panes_text + .chars() + .count() + .saturating_sub(color_emphasis_range_end), + ) + .opaque(), + ); + let group_actions_ribbon = serialize_ribbon( + &Text::new(&group_actions_text).color_range(0, 1..=multiple_select_key.chars().count()), + ); + let toggle_group_ribbon = serialize_ribbon( + &Text::new(&toggle_group_text).color_range(0, 1..=pane_group_toggle_key.chars().count()), + ); + let mut group_mark_toggle_ribbon = Text::new(&group_mark_toggle_text) + .color_range(0, 1..=group_mark_toggle_key.chars().count()); + if currently_marking_group { + group_mark_toggle_ribbon = group_mark_toggle_ribbon.selected(); + } + let group_mark_toggle_ribbon = serialize_ribbon(&group_mark_toggle_ribbon); + let controls_line = if common_modifiers.is_empty() { + format!( + "{} {}{}{}", + selected_panes, group_actions_ribbon, toggle_group_ribbon, group_mark_toggle_ribbon + ) + } else { + let common_modifier = + serialize_text(&Text::new(&common_modifier_text).color_range(0, ..).opaque()); + format!( + "{} {}{}{}{}", + selected_panes, + common_modifier, + group_actions_ribbon, + toggle_group_ribbon, + group_mark_toggle_ribbon + ) + }; + let remaining_space = max_len.saturating_sub(controls_line_len); + let mut padding = String::new(); + let mut padding_len = 0; + for _ in 0..remaining_space { + padding.push_str(&ANSIStrings(&[superkey_prefix_style.paint(" ")]).to_string()); + padding_len += 1; + } + Some(LinePart { + part: format!("{}{}", padding, controls_line), + len: controls_line_len + padding_len, + }) +} + fn render_secondary_info( help: &ModeInfo, tab_info: Option<&TabInfo>, @@ -1168,6 +1395,7 @@ fn add_keygroup_separator(help: &ModeInfo, max_len: usize) -> Option<LinePart> { bits.push( Style::new() .fg(separator_color) + .on(bg_color) .bold() .paint(format!(" {} ", mode_help_text)), ); @@ -1330,6 +1558,7 @@ fn get_keys_and_hints(mi: &ModeInfo) -> Vec<(String, String, Vec<KeyWithModifier (s("Switch Location"), s("Move"), action_key_group(&km, &[ &[Action::MovePane(Some(Dir::Left))], &[Action::MovePane(Some(Dir::Down))], &[Action::MovePane(Some(Dir::Up))], &[Action::MovePane(Some(Dir::Right))]])), + (s("When done"), s("Back"), to_basemode_key), ]} else if mi.mode == IM::Scroll { vec![ (s("Enter search term"), s("Search"), action_key(&km, &[A::SwitchToMode(IM::EnterSearch), A::SearchInput(vec![0])])), @@ -1516,6 +1745,25 @@ fn configuration_key(keymap: &[(KeyWithModifier, Vec<Action>)]) -> Vec<KeyWithMo } } +fn multiple_select_key(keymap: &[(KeyWithModifier, Vec<Action>)]) -> Vec<KeyWithModifier> { + let mut matching = keymap.iter().find_map(|(key, acvec)| { + let has_match = acvec + .iter() + .find(|a| a.launches_plugin("zellij:multiple-select")) // TODO: make this an alias + .is_some(); + if has_match { + Some(key.clone()) + } else { + None + } + }); + if let Some(matching) = matching.take() { + vec![matching] + } else { + vec![] + } +} + fn style_key_with_modifier(keyvec: &[KeyWithModifier], color_index: Option<usize>) -> LinePart { if keyvec.is_empty() { return LinePart::default(); |
