use std::collections::BTreeMap; use std::time::Instant; use zellij_tile::prelude::actions::Action; use zellij_tile::prelude::*; #[derive(Debug, Default)] pub struct App { own_plugin_id: Option, own_client_id: Option, own_tab_index: Option, total_tabs_in_session: Option, grouped_panes: Vec, grouped_panes_count: usize, all_client_grouped_panes: BTreeMap>, mode_info: ModeInfo, closing: bool, highlighted_at: Option, baseline_ui_width: usize, current_rows: usize, current_cols: usize, display_area_rows: usize, display_area_cols: usize, alternate_coordinates: bool, } register_plugin!(App); impl ZellijPlugin for App { fn load(&mut self, _configuration: BTreeMap) { subscribe(&[ EventType::Key, EventType::InterceptedKeyPress, EventType::ModeUpdate, EventType::PaneUpdate, EventType::TabUpdate, EventType::Timer, ]); let plugin_ids = get_plugin_ids(); self.own_plugin_id = Some(plugin_ids.plugin_id); self.own_client_id = Some(plugin_ids.client_id); intercept_key_presses(); set_selectable(false); } fn update(&mut self, event: Event) -> bool { if self.closing { return false; } intercept_key_presses(); // we do this here so that all clients (even those connected after // load) will have their keys intercepted match event { Event::ModeUpdate(mode_info) => self.handle_mode_update(mode_info), Event::PaneUpdate(pane_manifest) => self.handle_pane_update(pane_manifest), Event::TabUpdate(tab_infos) => self.handle_tab_update(tab_infos), Event::InterceptedKeyPress(key) => self.handle_key_press(key), Event::Timer(_) => self.handle_timer(), _ => false, } } fn render(&mut self, rows: usize, cols: usize) { self.update_current_size(rows, cols); if self.grouped_panes_count == 0 { self.render_no_panes_message(rows, cols); } else { let ui_width = self.calculate_ui_width(); self.update_baseline_ui_width(ui_width); let base_x = cols.saturating_sub(self.baseline_ui_width) / 2; let base_y = rows.saturating_sub(8) / 2; self.render_header(base_x, base_y); self.render_shortcuts(base_x, base_y + 2); self.render_controls(base_x, base_y + 7); } } } impl App { fn update_current_size(&mut self, new_rows: usize, new_cols: usize) { let size_changed = new_rows != self.current_rows || new_cols != self.current_cols; self.current_rows = new_rows; self.current_cols = new_cols; if size_changed { self.baseline_ui_width = 0; } } fn update_baseline_ui_width(&mut self, current_ui_width: usize) { if current_ui_width > self.baseline_ui_width { self.baseline_ui_width = current_ui_width; } } fn calculate_ui_width(&self) -> usize { let controls_width = group_controls_length(&self.mode_info); let header_width = Self::header_text().0.len(); let shortcuts_max_width = self.shortcuts_max_width(); std::cmp::max( controls_width, std::cmp::max(header_width, shortcuts_max_width), ) } fn render_no_panes_message(&self, rows: usize, cols: usize) { let message = "PANES SELECTED FOR OTHER CLIENT"; let message_component = Text::new(message).color_all(2); let base_x = cols.saturating_sub(message.len()) / 2; let base_y = rows / 2; print_text_with_coordinates(message_component, base_x, base_y, None, None); let esc_message = " - close"; let esc_message_component = Text::new(esc_message).color_substring(3, ""); let esc_base_x = cols.saturating_sub(esc_message.len()) / 2; let esc_base_y = base_y + 2; print_text_with_coordinates(esc_message_component, esc_base_x, esc_base_y, None, None); } fn header_text() -> (&'static str, Text) { let header_text = " - cancel, - move"; let header_text_component = Text::new(header_text) .color_substring(3, "") .color_substring(3, ""); (header_text, header_text_component) } fn shortcuts_max_width(&self) -> usize { std::cmp::max( std::cmp::max( self.group_actions_text().0.len(), Self::shortcuts_line1_text().0.len(), ), std::cmp::max( Self::shortcuts_line2_text().0.len(), Self::shortcuts_line3_text().0.len(), ), ) } fn group_actions_text(&self) -> (&'static str, Text) { let count_text = if self.grouped_panes_count == 1 { format!("GROUP ACTIONS ({} SELECTED PANE)", self.grouped_panes_count) } else { format!( "GROUP ACTIONS ({} SELECTED PANES)", self.grouped_panes_count ) }; let component = Text::new(&count_text).color_all(2); (Box::leak(count_text.into_boxed_str()), component) } fn shortcuts_line1_text() -> (&'static str, Text) { let text = " - break out, - stack, - close"; let component = Text::new(text) .color_substring(3, "") .color_substring(3, "") .color_substring(3, ""); (text, component) } fn shortcuts_line2_text() -> (&'static str, Text) { let text = " - break left, - break right"; let component = Text::new(text) .color_substring(3, "") .color_substring(3, ""); (text, component) } fn shortcuts_line3_text() -> (&'static str, Text) { let text = " - embed, - float"; let component = Text::new(text) .color_substring(3, "") .color_substring(3, ""); (text, component) } fn handle_mode_update(&mut self, mode_info: ModeInfo) -> bool { if self.mode_info != mode_info { self.mode_info = mode_info; let ui_width = self.calculate_ui_width(); self.update_baseline_ui_width(ui_width); true } else { false } } fn handle_pane_update(&mut self, pane_manifest: PaneManifest) -> bool { let Some(own_client_id) = self.own_client_id else { return false; }; self.update_all_client_grouped_panes(&pane_manifest); self.update_own_grouped_panes(&pane_manifest, own_client_id); self.update_tab_info(&pane_manifest); self.total_tabs_in_session = Some(pane_manifest.panes.keys().count()); true } fn handle_tab_update(&mut self, tab_infos: Vec) -> bool { for tab in tab_infos { if tab.active { self.display_area_rows = tab.display_area_rows; self.display_area_cols = tab.display_area_columns; break; } } false } fn update_all_client_grouped_panes(&mut self, pane_manifest: &PaneManifest) { self.all_client_grouped_panes.clear(); for (_tab_index, pane_infos) in &pane_manifest.panes { for pane_info in pane_infos { for (client_id, _index_in_pane_group) in &pane_info.index_in_pane_group { let pane_id = if pane_info.is_plugin { PaneId::Plugin(pane_info.id) } else { PaneId::Terminal(pane_info.id) }; self.all_client_grouped_panes .entry(*client_id) .or_insert_with(Vec::new) .push(pane_id); } } } } fn update_own_grouped_panes(&mut self, pane_manifest: &PaneManifest, own_client_id: ClientId) { self.grouped_panes.clear(); let mut count = 0; let mut panes_with_index = Vec::new(); for (_tab_index, pane_infos) in &pane_manifest.panes { for pane_info in pane_infos { if let Some(index_in_pane_group) = pane_info.index_in_pane_group.get(&own_client_id) { let pane_id = if pane_info.is_plugin { PaneId::Plugin(pane_info.id) } else { PaneId::Terminal(pane_info.id) }; panes_with_index.push((*index_in_pane_group, pane_id)); count += 1; } } } panes_with_index.sort_by_key(|(index, _)| *index); for (_, pane_id) in panes_with_index { self.grouped_panes.push(pane_id); } if self.all_clients_have_empty_groups() { self.close_self(); } let previous_count = self.grouped_panes_count; self.grouped_panes_count = count; if let Some(own_plugin_id) = self.own_plugin_id { if previous_count != count { rename_plugin_pane(own_plugin_id, "Multiple Pane Select".to_string()); } if previous_count != 0 && count != 0 && previous_count != count { if self.doherty_threshold_elapsed_since_highlight() { self.highlighted_at = Some(Instant::now()); highlight_and_unhighlight_panes(vec![PaneId::Plugin(own_plugin_id)], vec![]); set_timeout(0.4); } } } } fn all_clients_have_empty_groups(&self) -> bool { self.all_client_grouped_panes .values() .all(|panes| panes.is_empty()) } fn doherty_threshold_elapsed_since_highlight(&self) -> bool { self.highlighted_at .map(|h| h.elapsed() >= std::time::Duration::from_millis(400)) .unwrap_or(true) } 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); return; } } } } fn handle_key_press(&mut self, key: KeyWithModifier) -> bool { if !key.has_no_modifiers() { return false; } match key.bare_key { BareKey::Char('b') => self.break_grouped_panes_to_new_tab(), BareKey::Char('s') => self.stack_grouped_panes(), BareKey::Char('f') => self.float_grouped_panes(), BareKey::Char('e') => self.embed_grouped_panes(), BareKey::Char('r') => self.break_grouped_panes_right(), BareKey::Char('l') => self.break_grouped_panes_left(), BareKey::Char('c') => self.close_grouped_panes(), BareKey::Tab => self.next_coordinates(), BareKey::Esc => { self.ungroup_panes_in_zellij(); self.close_self(); }, _ => return false, } false } fn handle_timer(&mut self) -> bool { if let Some(own_plugin_id) = self.own_plugin_id { if self.doherty_threshold_elapsed_since_highlight() { highlight_and_unhighlight_panes(vec![], vec![PaneId::Plugin(own_plugin_id)]); } } false } fn render_header(&self, base_x: usize, base_y: usize) { let header_text = Self::header_text(); print_text_with_coordinates(header_text.1, base_x, base_y, None, None); } fn render_shortcuts(&self, base_x: usize, base_y: usize) { let mut running_y = base_y; print_text_with_coordinates(self.group_actions_text().1, base_x, running_y, None, None); running_y += 1; print_text_with_coordinates( Self::shortcuts_line1_text().1, base_x, running_y, None, None, ); running_y += 1; print_text_with_coordinates( Self::shortcuts_line2_text().1, base_x, running_y, None, None, ); running_y += 1; print_text_with_coordinates( Self::shortcuts_line3_text().1, base_x, running_y, None, None, ); } fn render_controls(&self, base_x: usize, base_y: usize) { render_group_controls(&self.mode_info, base_x, base_y); } fn execute_action_and_close(&mut self, action: F) where F: FnOnce(&[PaneId]), { let pane_ids = self.grouped_panes.clone(); action(&pane_ids); self.close_self(); } pub fn break_grouped_panes_to_new_tab(&mut self) { self.execute_action_and_close(|pane_ids| { break_panes_to_new_tab(pane_ids, None, true); }); self.ungroup_panes_in_zellij(); } pub fn stack_grouped_panes(&mut self) { self.execute_action_and_close(|pane_ids| { stack_panes(pane_ids.to_vec()); }); self.ungroup_panes_in_zellij(); } pub fn float_grouped_panes(&mut self) { self.execute_action_and_close(|pane_ids| { float_multiple_panes(pane_ids.to_vec()); }); self.ungroup_panes_in_zellij(); } pub fn embed_grouped_panes(&mut self) { self.execute_action_and_close(|pane_ids| { embed_multiple_panes(pane_ids.to_vec()); }); self.ungroup_panes_in_zellij(); } pub fn break_grouped_panes_right(&mut self) { let Some(own_tab_index) = self.own_tab_index else { return; }; let pane_ids = self.grouped_panes.clone(); if Some(own_tab_index + 1) < self.total_tabs_in_session { break_panes_to_tab_with_index(&pane_ids, own_tab_index + 1, true); } else { break_panes_to_new_tab(&pane_ids, None, true); } self.close_self(); } pub fn break_grouped_panes_left(&mut self) { let Some(own_tab_index) = self.own_tab_index else { return; }; let pane_ids = self.grouped_panes.clone(); if own_tab_index > 0 { break_panes_to_tab_with_index(&pane_ids, own_tab_index.saturating_sub(1), true); } else { break_panes_to_new_tab(&pane_ids, None, true); } self.close_self(); } pub fn close_grouped_panes(&mut self) { self.execute_action_and_close(|pane_ids| { close_multiple_panes(pane_ids.to_vec()); }); } pub fn ungroup_panes_in_zellij(&mut self) { let all_grouped_panes: Vec = self .all_client_grouped_panes .values() .flat_map(|panes| panes.iter().cloned()) .collect(); let for_all_clients = true; group_and_ungroup_panes(vec![], all_grouped_panes, for_all_clients); } pub fn close_self(&mut self) { self.closing = true; close_self(); } pub fn next_coordinates(&mut self) { let width_30_percent = (self.display_area_cols as f64 * 0.3) as usize; let height_30_percent = (self.display_area_rows as f64 * 0.3) as usize; let width = std::cmp::max(width_30_percent, 48); let height = std::cmp::max(height_30_percent, 10); let y_position = self.display_area_rows.saturating_sub(height + 2); if let Some(own_plugin_id) = self.own_plugin_id { if self.alternate_coordinates { let x_position = 2; let Some(next_coordinates) = FloatingPaneCoordinates::new( Some(format!("{}", x_position)), Some(format!("{}", y_position)), Some(format!("{}", width)), Some(format!("{}", height)), Some(true), Some(false), ) else { return; }; change_floating_panes_coordinates(vec![( PaneId::Plugin(own_plugin_id), next_coordinates, )]); self.alternate_coordinates = false; } else { let x_position = self .display_area_cols .saturating_sub(width) .saturating_sub(2); let Some(next_coordinates) = FloatingPaneCoordinates::new( Some(format!("{}", x_position)), Some(format!("{}", y_position)), Some(format!("{}", width)), Some(format!("{}", height)), Some(true), Some(false), ) else { return; }; change_floating_panes_coordinates(vec![( PaneId::Plugin(own_plugin_id), next_coordinates, )]); self.alternate_coordinates = true; } } } } fn render_group_controls(mode_info: &ModeInfo, base_x: usize, base_y: usize) { let keymap = mode_info.get_mode_keybinds(); let (common_modifiers, pane_group_key, group_mark_key) = extract_key_bindings(&keymap); let pane_group_bound = pane_group_key != "UNBOUND"; let group_mark_bound = group_mark_key != "UNBOUND"; if !pane_group_bound && !group_mark_bound { return; } render_common_modifiers(&common_modifiers, base_x, base_y); let mut next_x = base_x + render_common_modifiers(&common_modifiers, base_x, base_y); if pane_group_bound { next_x = render_toggle_group_ribbon(&pane_group_key, next_x, base_y); } if group_mark_bound { render_follow_focus_ribbon(&group_mark_key, next_x, base_y, mode_info); } } fn group_controls_length(mode_info: &ModeInfo) -> usize { let keymap = mode_info.get_mode_keybinds(); let (common_modifiers, pane_group_key, group_mark_key) = extract_key_bindings(&keymap); let pane_group_bound = pane_group_key != "UNBOUND"; let group_mark_bound = group_mark_key != "UNBOUND"; let mut length = 0; if !common_modifiers.is_empty() { let modifiers_text = format!( "{} + ", common_modifiers .iter() .map(|m| m.to_string()) .collect::>() .join(" ") ); length += modifiers_text.chars().count(); } if pane_group_bound { let toggle_text = format!("<{}> Toggle", pane_group_key); length += toggle_text.chars().count() + 4; } if group_mark_bound { let follow_text = format!("<{}> Follow Focus", group_mark_key); length += follow_text.chars().count() + 4; } length } fn extract_key_bindings( keymap: &[(KeyWithModifier, Vec)], ) -> (Vec, String, String) { let pane_group_keys = get_key_for_action(keymap, &[Action::TogglePaneInGroup]); let group_mark_keys = get_key_for_action(keymap, &[Action::ToggleGroupMarking]); let key_refs: Vec<&KeyWithModifier> = [pane_group_keys.first(), group_mark_keys.first()] .into_iter() .flatten() .collect(); let common_modifiers = get_common_modifiers(key_refs); let pane_group_key = format_key_without_modifiers(&pane_group_keys, &common_modifiers); let group_mark_key = format_key_without_modifiers(&group_mark_keys, &common_modifiers); (common_modifiers, pane_group_key, group_mark_key) } fn format_key_without_modifiers( keys: &[KeyWithModifier], common_modifiers: &[KeyModifier], ) -> String { keys.first() .map(|key| format!("{}", key.strip_common_modifiers(&common_modifiers.to_vec()))) .unwrap_or_else(|| "UNBOUND".to_string()) } fn render_common_modifiers( common_modifiers: &[KeyModifier], base_x: usize, base_y: usize, ) -> usize { if !common_modifiers.is_empty() { let modifiers_text = format!( "{} + ", common_modifiers .iter() .map(|m| m.to_string()) .collect::>() .join(" ") ); print_text_with_coordinates( Text::new(&modifiers_text).color_all(0), base_x, base_y, None, None, ); modifiers_text.chars().count() } else { 0 } } fn get_key_for_action( keymap: &[(KeyWithModifier, Vec)], target_action: &[Action], ) -> Vec { keymap .iter() .find_map(|(key, actions)| { if actions.first() == target_action.first() { Some(key.clone()) } else { None } }) .map(|key| vec![key]) .unwrap_or_default() } fn get_common_modifiers(keys: Vec<&KeyWithModifier>) -> Vec { if keys.is_empty() { return vec![]; } let mut common = keys[0].key_modifiers.clone(); for key in keys.iter().skip(1) { common = common.intersection(&key.key_modifiers).cloned().collect(); } common.into_iter().collect() } fn render_follow_focus_ribbon( group_mark_key: &str, x_position: usize, base_y: usize, mode_info: &ModeInfo, ) { let follow_text = format!("<{}> Follow Focus", group_mark_key); let key_highlight = format!("{}", group_mark_key); let mut ribbon = Text::new(&follow_text).color_substring(0, &key_highlight); if mode_info.currently_marking_pane_group.unwrap_or(false) { ribbon = ribbon.selected(); } print_ribbon_with_coordinates(ribbon, x_position, base_y, None, None); } fn render_toggle_group_ribbon(pane_group_key: &str, base_x: usize, base_y: usize) -> usize { let toggle_text = format!("<{}> Toggle", pane_group_key); let key_highlight = format!("{}", pane_group_key); print_ribbon_with_coordinates( Text::new(&toggle_text).color_substring(0, &key_highlight), base_x, base_y, None, None, ); base_x + toggle_text.len() + 4 }