diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-07-04 16:27:08 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-04 16:27:08 +0200 |
| commit | 82c4a24d701ecf9a48aa01bcc5c0bb3882747fe7 (patch) | |
| tree | 906e2f207dc1f5bf01112d7889663a2ce0a4cf2f /zellij-server | |
| parent | Reduce compilation time (#5321) (diff) | |
| download | sl0ppij-82c4a24d701ecf9a48aa01bcc5c0bb3882747fe7.tar.gz sl0ppij-82c4a24d701ecf9a48aa01bcc5c0bb3882747fe7.tar.xz sl0ppij-82c4a24d701ecf9a48aa01bcc5c0bb3882747fe7.zip | |
Stacked lists (#5331)
* initial implementation
* improve ui
* fix: stacked resize interaction
* title list boundaries fix
* fix BEL
* mouse parity
* float/embed + bulk-op features
* fix in-place bug
* improve ui
* config live reload
* improve ui
* fix issues, add exit status
* fix for serialization
* fix tests
* integration tests
* comments
* refactor: duplicate title logic
* refactor: consolidate logic
* fix: flaky tests
* add a stacked swap layout
* rustfmt
* fix e2e
* add pr
Diffstat (limited to 'zellij-server')
| -rw-r--r-- | zellij-server/src/lib.rs | 1 | ||||
| -rw-r--r-- | zellij-server/src/panes/plugin_pane.rs | 8 | ||||
| -rw-r--r-- | zellij-server/src/panes/terminal_pane.rs | 29 | ||||
| -rw-r--r-- | zellij-server/src/panes/tiled_panes/mod.rs | 60 | ||||
| -rw-r--r-- | zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__override_layout_plugin_command.snap | 108 | ||||
| -rw-r--r-- | zellij-server/src/screen.rs | 48 | ||||
| -rw-r--r-- | zellij-server/src/tab/mod.rs | 879 | ||||
| -rw-r--r-- | zellij-server/src/tab/mouse_handler.rs | 13 | ||||
| -rw-r--r-- | zellij-server/src/tab/unit/layout_applier_tests.rs | 8 | ||||
| -rw-r--r-- | zellij-server/src/tab/unit/tab_integration_tests.rs | 9 | ||||
| -rw-r--r-- | zellij-server/src/tab/unit/tab_tests.rs | 3 | ||||
| -rw-r--r-- | zellij-server/src/ui/pane_boundaries_frame.rs | 178 | ||||
| -rw-r--r-- | zellij-server/src/ui/pane_contents_and_ui.rs | 46 | ||||
| -rw-r--r-- | zellij-server/src/unit/screen_tests.rs | 6 |
14 files changed, 1317 insertions, 79 deletions
diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs index 62f8d651..783080dc 100644 --- a/zellij-server/src/lib.rs +++ b/zellij-server/src/lib.rs @@ -427,6 +427,7 @@ impl SessionMetaData { rounded_corners: new_config.ui.pane_frames.rounded_corners, hide_session_name: new_config.ui.pane_frames.hide_session_name, stacked_resize: new_config.options.stacked_resize.unwrap_or(true), + stacked_pane_list: new_config.options.stacked_pane_list.unwrap_or(true), default_editor: new_config.options.scrollback_editor.clone(), advanced_mouse_actions: new_config .options diff --git a/zellij-server/src/panes/plugin_pane.rs b/zellij-server/src/panes/plugin_pane.rs index 6f2d5980..8eec16fc 100644 --- a/zellij-server/src/panes/plugin_pane.rs +++ b/zellij-server/src/panes/plugin_pane.rs @@ -428,10 +428,14 @@ impl Pane for PluginPane { if self.borderless { return Ok(None); } - let frame_geom = self.current_geom(); + let frame_geom = frame_params + .frame_geom_override + .unwrap_or_else(|| self.current_geom()); let grid = get_or_create_grid!(self, client_id); let err_context = || format!("failed to render frame for client {client_id}"); - let pane_title = if let Some(text_color_override) = self + let pane_title = if frame_params.blank_title { + String::new() + } else if let Some(text_color_override) = self .pane_frame_color_override .as_ref() .and_then(|(_color, text)| text.as_ref()) diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs index 84c2e970..429d025b 100644 --- a/zellij-server/src/panes/terminal_pane.rs +++ b/zellij-server/src/panes/terminal_pane.rs @@ -414,27 +414,24 @@ impl Pane for TerminalPane { modifier_text.push(']'); } format!("SEARCHING: {}{}", self.search_term, modifier_text) - } else if self.pane_name.is_empty() { - self.grid - .title - .clone() - .unwrap_or_else(|| self.pane_title.clone()) } else { - self.pane_name.clone() + self.current_title() }; - let pane_title = if let Some(text_color_override) = self + let pane_title = if frame_params.blank_title { + String::new() + } else if let Some(text_color_override) = self .pane_frame_color_override .as_ref() .and_then(|(_color, text)| text.as_ref()) { text_color_override.into() - } else if self.has_bell_notification { - format!("{} [!]", normal_title) } else { - normal_title + self.title_with_bell_indicator(normal_title) }; - let frame_geom = self.current_geom(); + let frame_geom = frame_params + .frame_geom_override + .unwrap_or_else(|| self.current_geom()); let is_pinned = frame_geom.is_pinned; let mut frame = PaneFrame::new( frame_geom.into(), @@ -897,6 +894,9 @@ impl Pane for TerminalPane { self.pane_name.to_owned() } } + fn stack_list_entry_label(&self) -> String { + self.title_with_bell_indicator(self.current_title()) + } fn custom_title(&self) -> Option<String> { if self.pane_name.is_empty() { None @@ -1079,6 +1079,13 @@ impl Pane for TerminalPane { } impl TerminalPane { + fn title_with_bell_indicator(&self, title: String) -> String { + if self.has_bell_notification { + format!("{} [!]", title) + } else { + title + } + } #[allow(clippy::too_many_arguments)] pub fn new( pid: u32, diff --git a/zellij-server/src/panes/tiled_panes/mod.rs b/zellij-server/src/panes/tiled_panes/mod.rs index e3f66c37..0c17aa01 100644 --- a/zellij-server/src/panes/tiled_panes/mod.rs +++ b/zellij-server/src/panes/tiled_panes/mod.rs @@ -63,6 +63,8 @@ pub struct TiledPanes { mode_info: Rc<RefCell<HashMap<ClientId, ModeInfo>>>, character_cell_size: Rc<RefCell<Option<SizeInPixels>>>, stacked_resize: Rc<RefCell<bool>>, + stacked_pane_list: Rc<RefCell<bool>>, + reserved_top_rows: Rc<RefCell<HashMap<PaneId, usize>>>, default_mode_info: ModeInfo, style: Style, session_is_mirrored: bool, @@ -87,6 +89,8 @@ impl TiledPanes { mode_info: Rc<RefCell<HashMap<ClientId, ModeInfo>>>, character_cell_size: Rc<RefCell<Option<SizeInPixels>>>, stacked_resize: Rc<RefCell<bool>>, + stacked_pane_list: Rc<RefCell<bool>>, + reserved_top_rows: Rc<RefCell<HashMap<PaneId, usize>>>, session_is_mirrored: bool, pane_frame_style: PaneFrameStyle, default_mode_info: ModeInfo, @@ -103,6 +107,8 @@ impl TiledPanes { mode_info, character_cell_size, stacked_resize, + stacked_pane_list, + reserved_top_rows, default_mode_info, style, session_is_mirrored, @@ -570,13 +576,40 @@ impl TiledPanes { .positions_and_sizes_of_all_stacks() .unwrap_or_else(|| Default::default()) }; + let reserved_top_rows = if self.fullscreen_is_active.is_some() { + HashMap::new() + } else { + self.reserved_top_rows.borrow().clone() + }; for pane in self.panes.values_mut() { if !pane.borderless() { pane.set_frame(draws_full_frames); } + let reserved_rows = reserved_top_rows.get(&pane.pid()).copied().unwrap_or(0); + #[allow(clippy::if_same_then_else)] - if draws_full_frames && !pane.borderless() { + if reserved_rows > 0 { + if draws_full_frames && !pane.borderless() { + pane.set_content_offset(Offset { + top: 1 + reserved_rows, + bottom: 1, + left: 1, + right: 1, + }); + } else { + let position_and_size = pane.current_geom(); + let (pane_columns_offset, pane_rows_offset) = + pane_content_offset(&position_and_size, &viewport); + let visible_member_title_rows = if draws_full_frames { 1 } else { 0 }; + pane.set_content_offset(Offset { + top: visible_member_title_rows + reserved_rows, + bottom: pane_rows_offset, + left: 0, + right: pane_columns_offset, + }); + } + } else if draws_full_frames && !pane.borderless() { pane.set_content_offset(Offset::frame(1)); } else if draws_full_frames && pane.borderless() { pane.set_content_offset(Offset::default()); @@ -1124,6 +1157,19 @@ impl TiledPanes { && help_text_visible.get(client_id).copied().unwrap_or(false) }) && selectable_pane_count > 1 && self.fullscreen_is_active.is_none(); + let reserved_rows_for_pane = if self.fullscreen_is_active.is_some() { + 0 + } else { + { self.reserved_top_rows.borrow().get(&pane.pid()).copied() }.unwrap_or(0) + }; + let visible_member_frame_override = if reserved_rows_for_pane > 0 { + let mut geom = pane.current_geom(); + geom.y += reserved_rows_for_pane; + geom.rows.decrease_inner(reserved_rows_for_pane); + Some(geom) + } else { + None + }; let mut pane_contents_and_ui = PaneContentsAndUi::new( pane, output, @@ -1137,8 +1183,10 @@ impl TiledPanes { &mouse_hover_pane_id, current_pane_group.clone(), show_help_text, - omit_pane_title, + omit_pane_title && reserved_rows_for_pane == 0, ); + pane_contents_and_ui.set_frame_geom_override(visible_member_frame_override); + pane_contents_and_ui.set_blank_title(reserved_rows_for_pane > 0); for client_id in &connected_clients { let client_mode = self .mode_info @@ -1166,7 +1214,9 @@ impl TiledPanes { pane_is_selectable, ) .with_context(err_context)?; - } else if self.pane_frame_style.draws_titles() || pane_is_stacked { + } else if (self.pane_frame_style.draws_titles() || pane_is_stacked) + && reserved_rows_for_pane == 0 + { pane_contents_and_ui .render_pane_frame( *client_id, @@ -1241,6 +1291,10 @@ impl TiledPanes { pub fn get_panes(&self) -> impl Iterator<Item = (&PaneId, &Box<dyn Pane>)> { self.panes.iter() } + pub fn position_and_size_of_stack(&mut self, pane_id: &PaneId) -> Option<PaneGeom> { + StackedPanes::new_from_btreemap(&mut self.panes, &self.panes_to_hide) + .position_and_size_of_stack(pane_id) + } pub fn set_geom_for_pane_with_run( &mut self, run: Option<Run>, diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__override_layout_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__override_layout_plugin_command.snap index f4d7fae5..2ef7c7a3 100644 --- a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__override_layout_plugin_command.snap +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__override_layout_plugin_command.snap @@ -1375,6 +1375,112 @@ Some( ( { MinPanes( + 3, + ): TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: None, + focus: None, + external_children_index: Some( + 0, + ), + children_are_stacked: true, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + default_fg: None, + default_bg: None, + }, + ], + split_size: None, + run: None, + borderless: None, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + default_fg: None, + default_bg: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: Some( + Fixed( + 1, + ), + ), + run: Some( + Plugin( + Alias( + PluginAlias { + name: "compact-bar", + configuration: Some( + PluginUserConfiguration( + {}, + ), + ), + initial_cwd: None, + run_plugin: None, + }, + ), + ), + ), + borderless: Some( + true, + ), + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + default_fg: None, + default_bg: None, + }, + ], + split_size: None, + run: None, + borderless: None, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + default_fg: None, + default_bg: None, + }, + }, + Some( + "stacked", + ), + ), + ( + { + MinPanes( 4, ): TiledPaneLayout { children_split_direction: Horizontal, @@ -1512,7 +1618,7 @@ Some( }, }, Some( - "stacked", + "half-stacked", ), ), ], diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs index 97831ba7..34c9ced5 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -744,6 +744,7 @@ pub enum ScreenInstruction { rounded_corners: bool, hide_session_name: bool, stacked_resize: bool, + stacked_pane_list: bool, default_editor: Option<PathBuf>, advanced_mouse_actions: bool, mouse_hover_effects: bool, @@ -1411,6 +1412,7 @@ pub(crate) struct Screen { pixel_dimensions: PixelDimensions, character_cell_size: Rc<RefCell<Option<SizeInPixels>>>, stacked_resize: Rc<RefCell<bool>>, + stacked_pane_list: Rc<RefCell<bool>>, sixel_image_store: Rc<RefCell<SixelImageStore>>, terminal_emulator_colors: Rc<RefCell<Palette>>, terminal_emulator_color_codes: Rc<RefCell<HashMap<usize, String>>>, @@ -1549,6 +1551,7 @@ impl Screen { layout_dir: Option<PathBuf>, explicitly_disable_kitty_keyboard_protocol: bool, stacked_resize: bool, + stacked_pane_list: bool, default_editor: Option<PathBuf>, web_clients_allowed: bool, web_sharing: WebSharing, @@ -1573,6 +1576,7 @@ impl Screen { pixel_dimensions: Default::default(), character_cell_size: Rc::new(RefCell::new(None)), stacked_resize: Rc::new(RefCell::new(stacked_resize)), + stacked_pane_list: Rc::new(RefCell::new(stacked_pane_list)), sixel_image_store: Rc::new(RefCell::new(SixelImageStore::default())), style: client_attributes.style, connected_clients: Rc::new(RefCell::new(HashMap::new())), @@ -2022,6 +2026,9 @@ impl Screen { .with_context(err_context)?; } destination_tab.set_force_render(); + if destination_tab.has_stack_lists() { + destination_tab.set_should_clear_display_before_rendering(); + } destination_tab.visible(true).with_context(err_context)?; } Ok(()) @@ -3305,6 +3312,7 @@ impl Screen { self.size, self.character_cell_size.clone(), self.stacked_resize.clone(), + self.stacked_pane_list.clone(), self.sixel_image_store.clone(), self.bus .os_input @@ -4604,12 +4612,13 @@ impl Screen { ) -> Result<()> { let err_context = || "failed break pane out of tab".to_string(); let active_tab = self.get_active_tab_mut(client_id)?; + let active_pane_id = active_tab + .get_active_pane_id(client_id) + .with_context(err_context)?; if active_tab.get_selectable_tiled_panes_count() > 1 || active_tab.get_visible_selectable_floating_panes_count() > 0 + || active_tab.pane_is_stack_list_member(&active_pane_id) { - let active_pane_id = active_tab - .get_active_pane_id(client_id) - .with_context(err_context)?; let active_pane = active_tab .extract_pane(active_pane_id, false) .with_context(err_context)?; @@ -4650,9 +4659,6 @@ impl Screen { None, ))?; } else { - let active_pane_id = active_tab - .get_active_pane_id(client_id) - .with_context(err_context)?; self.bus .senders .send_to_background_jobs(BackgroundJob::DisplayPaneError( @@ -5027,6 +5033,7 @@ impl Screen { rounded_corners: bool, hide_session_name: bool, stacked_resize: bool, + stacked_pane_list: bool, default_editor: Option<PathBuf>, advanced_mouse_actions: bool, mouse_hover_effects: bool, @@ -5065,6 +5072,9 @@ impl Screen { { *self.stacked_resize.borrow_mut() = stacked_resize; } + { + *self.stacked_pane_list.borrow_mut() = stacked_pane_list; + } if let Some(copy_to_clipboard) = copy_to_clipboard { self.copy_options.clipboard = copy_to_clipboard; } @@ -5081,6 +5091,7 @@ impl Screen { tab.update_mouse_hover_effects(mouse_hover_effects); tab.update_focus_follows_mouse(focus_follows_mouse); tab.update_mouse_click_through(mouse_click_through); + tab.sync_stacked_pane_list_mode(); } // Clear hover state when disabled @@ -5527,6 +5538,7 @@ impl Screen { ); } + let stack_list_geoms = tab.stack_list_serialization_geoms(); let tiled_panes: Vec<PaneLayoutMetadata> = tab .get_tiled_panes() .map(|(pane_id, p)| { @@ -5535,14 +5547,21 @@ impl Screen { // is currently only the case the scrollback editing panes, and // when dumping the layout we want the "real" pane and not the // editor pane - match suppressed_panes.remove(pane_id) { + let geom_override = stack_list_geoms.get(pane_id).copied(); + let (pane_id, p) = match suppressed_panes.remove(pane_id) { Some((is_scrollback_editor, suppressed_pane)) if *is_scrollback_editor => { (suppressed_pane.pid(), suppressed_pane) }, _ => (*pane_id, p), - } + }; + ( + pane_id, + p, + geom_override.unwrap_or_else(|| p.position_and_size()), + ) }) - .map(|(pane_id, p)| { + .chain(tab.hidden_stack_list_members_for_serialization()) + .map(|(pane_id, p, geom)| { let focused_clients: Vec<ClientId> = active_pane_ids .iter() .filter_map(|(c_id, p_id)| { @@ -5552,7 +5571,7 @@ impl Screen { let (default_fg, default_bg) = p.get_pane_default_colors(); PaneLayoutMetadata::new( pane_id, - p.position_and_size(), + geom, p.borderless(), p.invoked_with().clone(), p.custom_title(), @@ -6076,6 +6095,11 @@ fn find_already_running_panes( let running_tiled_instructions: Vec<Option<Run>> = active_tab .get_tiled_panes() .map(|(_, pane)| pane.invoked_with().clone()) + .chain( + active_tab + .suppressed_stack_list_members() + .map(|(_, pane)| pane.invoked_with().clone()), + ) .collect(); let mut tiled_to_ignore = Vec::new(); @@ -6191,6 +6215,7 @@ pub(crate) fn screen_thread_main( .unwrap_or(false); // by default, we try to support this if the terminal supports it and // the program running inside a pane requests it let stacked_resize = config_options.stacked_resize.unwrap_or(true); + let stacked_pane_list = config_options.stacked_pane_list.unwrap_or(true); let web_clients_allowed = config_options .web_sharing .map(|s| s.web_clients_allowed()) @@ -6234,6 +6259,7 @@ pub(crate) fn screen_thread_main( layout_dir, explicitly_disable_kitty_keyboard_protocol, stacked_resize, + stacked_pane_list, default_editor, web_clients_allowed, web_sharing, @@ -9379,6 +9405,7 @@ pub(crate) fn screen_thread_main( rounded_corners, hide_session_name, stacked_resize, + stacked_pane_list, default_editor, advanced_mouse_actions, mouse_hover_effects, @@ -9403,6 +9430,7 @@ pub(crate) fn screen_thread_main( rounded_corners, hide_session_name, stacked_resize, + stacked_pane_list, default_editor, advanced_mouse_actions, mouse_hover_effects, diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index a2601966..18f3e0a7 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -13,6 +13,7 @@ pub use mouse_handler::{MouseEffect, MouseHandler, PaneEdge, PaneResizeState}; use std::env::temp_dir; use std::net::IpAddr; use std::path::PathBuf; +use unicode_width::UnicodeWidthStr; use uuid::Uuid; use zellij_utils::data::PaneContents; use zellij_utils::data::{ @@ -33,7 +34,10 @@ use crate::screen::{CopyOptions, ScreenInstruction}; use crate::ui::hint_text::{ held_hint_variants, hover_hint_variants, resize_hint_variants, HintExitStatus, }; -use crate::ui::{loading_indication::LoadingIndication, pane_boundaries_frame::FrameParams}; +use crate::ui::{ + loading_indication::LoadingIndication, pane_boundaries_frame::FrameParams, + pane_contents_and_ui::PaneContentsAndUi, +}; use layout_applier::LayoutApplier; use swap_layouts::SwapLayouts; @@ -70,7 +74,7 @@ use zellij_utils::{ options::PaneFrameStyle, parse_keys, }, - pane_size::{Offset, PaneGeom, Size, SizeInPixels, Viewport}, + pane_size::{Dimension, Offset, PaneGeom, Size, SizeInPixels, Viewport}, }; #[macro_export] @@ -151,6 +155,26 @@ const MAX_PENDING_VTE_EVENTS: usize = 7000; type HoldForCommand = Option<RunCommand>; pub type SuppressedPanes = HashMap<PaneId, (bool, Box<dyn Pane>)>; // bool => is scrollback editor +pub type StackListId = usize; + +#[derive(Debug, Clone)] +pub struct StackList { + pub members: Vec<PaneId>, + pub visible: PaneId, +} + +impl StackList { + pub fn rank_of(&self, pane_id: &PaneId) -> Option<usize> { + self.members.iter().position(|p| p == pane_id) + } +} + +enum StackListRemoval { + NotRemoved, + DissolvedInPlace, + Removed(Option<Box<dyn Pane>>), +} + enum BufferedTabInstruction { SetPaneSelectable(PaneId, bool), HandlePtyBytes(u32, VteBytes), @@ -168,6 +192,13 @@ pub(crate) struct Tab { tiled_panes: TiledPanes, floating_panes: FloatingPanes, suppressed_panes: SuppressedPanes, + stacked_pane_list: Rc<RefCell<bool>>, + reserved_top_rows: Rc<RefCell<HashMap<PaneId, usize>>>, + stack_lists: HashMap<StackListId, StackList>, + stack_list_of_member: HashMap<PaneId, StackListId>, + stack_list_parked_pairs: HashMap<PaneId, PaneId>, + next_stack_list_id: StackListId, + stack_list_session_is_mirrored: bool, max_panes: Option<usize>, viewport: Rc<RefCell<Viewport>>, // includes all non-UI panes display_area: Rc<RefCell<Size>>, // includes all panes (including eg. the status bar and tab bar in the default layout) @@ -628,6 +659,9 @@ pub trait Pane { fn start_loading_indication(&mut self, _loading_indication: LoadingIndication) {} // only relevant for plugins fn progress_animation_offset(&mut self) {} // only relevant for plugins fn current_title(&self) -> String; + fn stack_list_entry_label(&self) -> String { + self.current_title() + } fn custom_title(&self) -> Option<String>; fn has_explicit_title(&self) -> bool { false @@ -745,6 +779,7 @@ impl Tab { display_area: Size, character_cell_size: Rc<RefCell<Option<SizeInPixels>>>, stacked_resize: Rc<RefCell<bool>>, + stacked_pane_list: Rc<RefCell<bool>>, sixel_image_store: Rc<RefCell<SixelImageStore>>, os_api: Box<dyn ServerOsApi>, senders: ThreadSenders, @@ -800,6 +835,8 @@ impl Tab { let display_area = Rc::new(RefCell::new(display_area)); let connected_clients = Rc::new(RefCell::new(connected_clients)); let mode_info = Rc::new(RefCell::new(HashMap::new())); + let reserved_top_rows: Rc<RefCell<HashMap<PaneId, usize>>> = + Rc::new(RefCell::new(HashMap::new())); let tiled_panes = TiledPanes::new( display_area.clone(), @@ -809,6 +846,8 @@ impl Tab { mode_info.clone(), character_cell_size.clone(), stacked_resize.clone(), + stacked_pane_list.clone(), + reserved_top_rows.clone(), session_is_mirrored, pane_frame_style, default_mode_info.clone(), @@ -842,6 +881,13 @@ impl Tab { tiled_panes, floating_panes, suppressed_panes: HashMap::new(), + stacked_pane_list, + reserved_top_rows, + stack_lists: HashMap::new(), + stack_list_of_member: HashMap::new(), + stack_list_parked_pairs: HashMap::new(), + next_stack_list_id: 0, + stack_list_session_is_mirrored: session_is_mirrored, name: name.clone(), prev_name: name, default_name, @@ -908,6 +954,645 @@ impl Tab { } } + pub fn stacked_pane_list_is_active(&self) -> bool { + *self.stacked_pane_list.borrow() + } + pub fn pane_is_stack_list_member(&self, pane_id: &PaneId) -> bool { + self.stack_list_of_member.contains_key(pane_id) + } + pub fn has_stack_lists(&self) -> bool { + !self.stack_lists.is_empty() + } + fn stack_list_id_of_member(&self, pane_id: &PaneId) -> Option<StackListId> { + self.stack_list_of_member.get(pane_id).copied() + } + pub fn pane_is_hidden_stack_list_member(&self, pane_id: &PaneId) -> bool { + self.stack_list_id_of_member(pane_id) + .and_then(|stack_list_id| self.stack_lists.get(&stack_list_id)) + .map(|list| list.visible != *pane_id) + .unwrap_or(false) + } + pub fn focus_hidden_stack_list_member(&mut self, pane_id: PaneId, client_id: ClientId) -> bool { + self.swap_in_hidden_stack_list_member(pane_id, Some(client_id)) + } + fn swap_in_hidden_stack_list_member( + &mut self, + pane_id: PaneId, + client_id: Option<ClientId>, + ) -> bool { + if !self.pane_is_hidden_stack_list_member(&pane_id) { + return false; + } + match self.stack_list_id_of_member(&pane_id) { + Some(stack_list_id) => { + self.select_stack_list_member(stack_list_id, pane_id, client_id); + true + }, + None => false, + } + } + fn stack_list_member_at_point(&self, pane_id: PaneId, point: &Position) -> PaneId { + let Some(stack_list_id) = self.stack_list_id_of_member(&pane_id) else { + return pane_id; + }; + let Some(list) = self.stack_lists.get(&stack_list_id) else { + return pane_id; + }; + let Some(pane) = self.tiled_panes.get_pane(pane_id) else { + return pane_id; + }; + let geom = pane.current_geom(); + if point.line() < geom.y as isize { + return pane_id; + } + let rank = (point.line() - geom.y as isize) as usize; + list.members.get(rank).copied().unwrap_or(pane_id) + } + pub fn suppressed_stack_list_members(&self) -> impl Iterator<Item = (&PaneId, &Box<dyn Pane>)> { + self.suppressed_panes + .iter() + .filter(|(pane_id, _)| self.stack_list_of_member.contains_key(pane_id)) + .map(|(pane_id, (_, pane))| (pane_id, pane)) + } + pub fn stack_list_serialization_geoms(&self) -> HashMap<PaneId, PaneGeom> { + let mut synthetic_geoms = HashMap::new(); + if self.stack_lists.is_empty() { + return synthetic_geoms; + } + let mut next_synthetic_stack_id = self + .tiled_panes + .get_panes() + .filter_map(|(_, pane)| pane.position_and_size().stacked) + .max() + .map(|highest_stack_id| highest_stack_id + 1) + .unwrap_or(0); + for list in self.stack_lists.values() { + let Some(visible_pane) = self.tiled_panes.get_pane(list.visible) else { + continue; + }; + let full_rect = visible_pane.position_and_size(); + let collapsed_member_count = list.members.len().saturating_sub(1); + let mut running_y = full_rect.y; + for member in &list.members { + let mut member_geom = full_rect; + member_geom.stacked = Some(next_synthetic_stack_id); + member_geom.y = running_y; + if *member == list.visible { + member_geom.rows.set_inner( + full_rect + .rows + .as_usize() + .saturating_sub(collapsed_member_count), + ); + } else { + member_geom.rows = Dimension::fixed(1); + member_geom.logical_position = self + .suppressed_panes + .get(member) + .and_then(|(_, pane)| pane.position_and_size().logical_position); + } + running_y += member_geom.rows.as_usize(); + synthetic_geoms.insert(*member, member_geom); + } + next_synthetic_stack_id += 1; + } + synthetic_geoms + } + pub fn hidden_stack_list_members_for_serialization( + &self, + ) -> Vec<(PaneId, &Box<dyn Pane>, PaneGeom)> { + let synthetic_geoms = self.stack_list_serialization_geoms(); + let mut hidden_members = vec![]; + for list in self.stack_lists.values() { + for member in &list.members { + if *member == list.visible { + continue; + } + let Some(member_geom) = synthetic_geoms.get(member) else { + continue; + }; + let resolved_pane = self + .stack_list_parked_pairs + .get(member) + .and_then(|parked_pid| self.suppressed_panes.get(parked_pid)) + .filter(|(is_scrollback_editor, _)| *is_scrollback_editor) + .or_else(|| self.suppressed_panes.get(member)); + if let Some((_, pane)) = resolved_pane { + hidden_members.push((pane.pid(), pane, *member_geom)); + } + } + } + hidden_members + } + fn pane_parked_by(&self, id: &PaneId) -> Option<PaneId> { + self.suppressed_panes + .get(id) + .map(|(_, parked)| parked.pid()) + .filter(|parked_pid| parked_pid != id) + } + fn adopt_parked_pair(&mut self, member: PaneId) { + if let Some(parked_pid) = self.stack_list_parked_pairs.remove(&member) { + if let Some(parked_entry) = self.suppressed_panes.remove(&parked_pid) { + self.suppressed_panes.insert(member, parked_entry); + } + } + } + fn refresh_or_dissolve_stack_list(&mut self, stack_list_id: StackListId) { + let (member_count, visible) = match self.stack_lists.get(&stack_list_id) { + Some(list) => (list.members.len(), list.visible), + None => return, + }; + if member_count <= 1 { + self.dissolve_stack_list(stack_list_id); + } else { + self.reserved_top_rows + .borrow_mut() + .insert(visible, member_count); + self.tiled_panes.reapply_pane_frames(); + self.resize_stack_list_hidden_members(stack_list_id); + } + } + fn select_stack_list_member( + &mut self, + stack_list_id: StackListId, + target_member: PaneId, + client_id: Option<ClientId>, + ) { + let current_visible = match self.stack_lists.get(&stack_list_id) { + Some(list) => list.visible, + None => return, + }; + if current_visible == target_member { + return; + } + let pane_parked_by_current_visible = self.pane_parked_by(¤t_visible); + let target_box = match self.suppressed_panes.remove(&target_member) { + Some((_is_scrollback_editor, pane)) => pane, + None => { + log::error!( + "stack-list member {:?} not found in suppressed panes", + target_member + ); + return; + }, + }; + let removed_visible = self.tiled_panes.replace_pane(current_visible, target_box); + if let Some(removed_visible) = removed_visible { + self.insert_suppressed_pane(current_visible, (false, removed_visible)); + if let Some(parked_pid) = pane_parked_by_current_visible { + self.stack_list_parked_pairs + .insert(current_visible, parked_pid); + } + } + self.adopt_parked_pair(target_member); + if let Some(list) = self.stack_lists.get_mut(&stack_list_id) { + list.visible = target_member; + } + self.reserved_top_rows.borrow_mut().remove(¤t_visible); + if let Some(client_id) = client_id { + self.tiled_panes.focus_pane(target_member, client_id); + } + self.refresh_or_dissolve_stack_list(stack_list_id); + self.set_force_render(); + self.set_should_clear_display_before_rendering(); + } + fn move_focus_within_stack_list(&mut self, client_id: ClientId, down: bool) -> bool { + let active_pane_id = match self.tiled_panes.get_active_pane_id(client_id) { + Some(id) => id, + None => return false, + }; + let stack_list_id = match self.stack_list_id_of_member(&active_pane_id) { + Some(id) => id, + None => return false, + }; + let target_member = { + let list = match self.stack_lists.get(&stack_list_id) { + Some(l) => l, + None => return false, + }; + if list.visible != active_pane_id { + return false; + } + let rank = match list.rank_of(&active_pane_id) { + Some(r) => r, + None => return false, + }; + let target_rank = if down { + rank + 1 + } else if rank == 0 { + return false; + } else { + rank - 1 + }; + match list.members.get(target_rank) { + Some(member) => *member, + None => return false, + } + }; + self.select_stack_list_member(stack_list_id, target_member, Some(client_id)); + true + } + fn close_stack_list_member(&mut self, id: PaneId, exit_status: Option<i32>) -> bool { + match self.remove_stack_list_member(id) { + StackListRemoval::Removed(removed) => { + if let (Some(exit_status), Some(mut removed)) = (exit_status, removed) { + removed.update_exit_status(exit_status); + } + true + }, + StackListRemoval::DissolvedInPlace | StackListRemoval::NotRemoved => false, + } + } + fn remove_stack_list_member(&mut self, id: PaneId) -> StackListRemoval { + let stack_list_id = match self.stack_list_id_of_member(&id) { + Some(sid) => sid, + None => return StackListRemoval::NotRemoved, + }; + let is_visible = self + .stack_lists + .get(&stack_list_id) + .map(|l| l.visible == id) + .unwrap_or(false); + let removed = if is_visible { + let (members, rank) = { + let list = match self.stack_lists.get(&stack_list_id) { + Some(l) => l, + None => return StackListRemoval::NotRemoved, + }; + (list.members.clone(), list.rank_of(&id).unwrap_or(0)) + }; + let promote_target = members + .iter() + .skip(rank + 1) + .next() + .copied() + .or_else(|| members[..rank].iter().last().copied()); + let promote_target = match promote_target { + Some(target) => target, + None => { + self.dissolve_stack_list(stack_list_id); + self.reserved_top_rows.borrow_mut().remove(&id); + return StackListRemoval::DissolvedInPlace; + }, + }; + let target_box = match self.suppressed_panes.remove(&promote_target) { + Some((_is_scrollback_editor, pane)) => pane, + None => { + log::error!("stack-list promote target not found in suppressed panes"); + return StackListRemoval::NotRemoved; + }, + }; + let removed_visible = self.tiled_panes.replace_pane(id, target_box); + self.adopt_parked_pair(promote_target); + self.stack_list_of_member.remove(&id); + self.reserved_top_rows.borrow_mut().remove(&id); + if let Some(list) = self.stack_lists.get_mut(&stack_list_id) { + list.members.retain(|m| *m != id); + list.visible = promote_target; + } + self.refresh_or_dissolve_stack_list(stack_list_id); + removed_visible + } else { + let removed = self.suppressed_panes.remove(&id).map(|(_, p)| p); + let substitute_member = self + .stack_list_parked_pairs + .remove(&id) + .filter(|parked_pid| self.suppressed_panes.contains_key(parked_pid)); + self.stack_list_of_member.remove(&id); + self.reserved_top_rows.borrow_mut().remove(&id); + if let Some(substitute_member) = substitute_member { + self.stack_list_of_member + .insert(substitute_member, stack_list_id); + if let Some(list) = self.stack_lists.get_mut(&stack_list_id) { + for member in list.members.iter_mut() { + if *member == id { + *member = substitute_member; + } + } + } + } else if let Some(list) = self.stack_lists.get_mut(&stack_list_id) { + list.members.retain(|m| *m != id); + } + self.refresh_or_dissolve_stack_list(stack_list_id); + removed + }; + self.set_force_render(); + self.set_should_clear_display_before_rendering(); + StackListRemoval::Removed(removed) + } + fn track_stack_list_member_id_swap(&mut self, old_id: PaneId, new_id: PaneId) { + let stack_list_id = match self.stack_list_id_of_member(&old_id) { + Some(sid) => sid, + None => return, + }; + self.stack_list_of_member.remove(&old_id); + self.stack_list_of_member.insert(new_id, stack_list_id); + let mut swapped_visible_member = false; + if let Some(list) = self.stack_lists.get_mut(&stack_list_id) { + for member in list.members.iter_mut() { + if *member == old_id { + *member = new_id; + } + } + if list.visible == old_id { + list.visible = new_id; + swapped_visible_member = true; + } + } + { + let mut reserved = self.reserved_top_rows.borrow_mut(); + if let Some(reserved_rows) = reserved.remove(&old_id) { + reserved.insert(new_id, reserved_rows); + } + } + if swapped_visible_member { + self.refresh_or_dissolve_stack_list(stack_list_id); + self.set_force_render(); + self.set_should_clear_display_before_rendering(); + } + } + fn dissolve_stack_list(&mut self, stack_list_id: StackListId) { + if let Some(list) = self.stack_lists.get(&stack_list_id) { + let members = list.members.clone(); + self.drop_stack_list_ledger(stack_list_id, &members); + self.tiled_panes.reapply_pane_frames(); + } + } + fn drop_stack_list_ledger(&mut self, stack_list_id: StackListId, members: &[PaneId]) { + self.stack_lists.remove(&stack_list_id); + let mut reserved = self.reserved_top_rows.borrow_mut(); + for member in members { + self.stack_list_of_member.remove(member); + self.stack_list_parked_pairs.remove(member); + reserved.remove(member); + } + } + fn degroupify(&mut self, stack_list_id: StackListId) { + let (members, visible) = match self.stack_lists.get(&stack_list_id) { + Some(list) => (list.members.clone(), list.visible), + None => return, + }; + let n = members.len(); + if n == 0 || !self.tiled_panes.panes_contain(&visible) { + self.drop_stack_list_ledger(stack_list_id, &members); + return; + } + let geoms = self.tiled_panes.stack_panes(visible, n); + if geoms.len() != n { + log::error!("degroupify: could not compute classic stack geoms"); + return; + } + for (i, member) in members.iter().enumerate() { + let mut geom = geoms[i]; + if *member == visible { + geom.logical_position = self + .tiled_panes + .get_pane(*member) + .and_then(|p| p.position_and_size().logical_position); + self.tiled_panes.set_geom_for_pane_with_id(member, geom); + } else if let Some((_is_scrollback_editor, mut pane)) = + self.suppressed_panes.remove(member) + { + geom.logical_position = pane.position_and_size().logical_position; + pane.set_geom(geom); + self.tiled_panes.add_pane_with_existing_geom(*member, pane); + self.adopt_parked_pair(*member); + } + } + self.drop_stack_list_ledger(stack_list_id, &members); + self.tiled_panes.expand_pane_in_stack(visible); + self.tiled_panes.reapply_pane_frames(); + self.set_force_render(); + self.set_should_clear_display_before_rendering(); + } + // the classic layout solver cannot see suppressed stack-list members; dissolve + // lists into in-grid stacks first, groupify_all re-forms them on mode entry + fn degroupify_all(&mut self) { + let ids: Vec<StackListId> = self.stack_lists.keys().copied().collect(); + for id in ids { + self.degroupify(id); + } + } + fn dissolve_stack_lists_for_classic_mutation(&mut self) { + if self.stacked_pane_list_is_active() { + self.degroupify_all(); + } + } + pub fn sync_stacked_pane_list_mode(&mut self) { + if self.stacked_pane_list_is_active() { + self.groupify_all(); + } else if self.has_stack_lists() { + self.degroupify_all(); + } + } + // the inverse of degroupify_all: adopts classic in-grid stacks into stack + // lists, suppressing all but the visible member of each + fn groupify_all(&mut self) { + if self.tiled_panes.fullscreen_is_active() { + return; + } + let mut stacks: HashMap<usize, Vec<(usize, PaneId, bool)>> = HashMap::new(); + for (id, pane) in self.tiled_panes.get_panes() { + let geom = pane.position_and_size(); + if let Some(stack_id) = geom.stacked { + let is_expanded = !geom.rows.is_fixed(); + stacks + .entry(stack_id) + .or_default() + .push((geom.y, *id, is_expanded)); + } + } + let mut converted_any = false; + for (_stack_id, mut members) in stacks { + if members.len() < 2 { + continue; + } + members.sort_by_key(|(y, _, _)| *y); + let representative = members[0].1; + let rect = match self.tiled_panes.position_and_size_of_stack(&representative) { + Some(r) => r, + None => continue, + }; + let visible = members + .iter() + .find(|(_, _, expanded)| *expanded) + .map(|(_, id, _)| *id) + .unwrap_or_else(|| members.last().map(|(_, id, _)| *id).unwrap()); + let ordered_ids: Vec<PaneId> = members.iter().map(|(_, id, _)| *id).collect(); + let logical_position = self + .tiled_panes + .get_pane(visible) + .and_then(|p| p.position_and_size().logical_position); + for (_, id, _) in &members { + if *id == visible { + continue; + } + if let Some(pane) = self.tiled_panes.extract_pane(*id) { + let pane_parked_by_member = self.pane_parked_by(id); + self.insert_suppressed_pane(*id, (false, pane)); + if let Some(parked_pid) = pane_parked_by_member { + self.stack_list_parked_pairs.insert(*id, parked_pid); + } + } + } + if let Some(vpane) = self.tiled_panes.get_pane_mut(visible) { + let mut g = rect; + g.stacked = None; + g.logical_position = logical_position; + vpane.set_geom(g); + } + let stack_list_id = self.next_stack_list_id; + self.next_stack_list_id += 1; + for id in &ordered_ids { + self.stack_list_of_member.insert(*id, stack_list_id); + } + self.stack_lists.insert( + stack_list_id, + StackList { + members: ordered_ids, + visible, + }, + ); + self.refresh_or_dissolve_stack_list(stack_list_id); + converted_any = true; + } + if converted_any { + self.set_force_render(); + self.set_should_clear_display_before_rendering(); + } + } + fn resize_all_stack_list_hidden_members(&mut self) { + let ids: Vec<StackListId> = self.stack_lists.keys().copied().collect(); + for id in ids { + self.resize_stack_list_hidden_members(id); + } + } + fn resize_stack_list_hidden_members(&mut self, stack_list_id: StackListId) { + let (visible_geom, visible_offset, hidden_members) = { + let list = match self.stack_lists.get(&stack_list_id) { + Some(l) => l, + None => return, + }; + let (geom, offset) = match self.tiled_panes.get_pane(list.visible) { + Some(p) => (p.position_and_size(), p.get_content_offset()), + None => return, + }; + let hidden: Vec<PaneId> = list + .members + .iter() + .copied() + .filter(|m| *m != list.visible) + .collect(); + (geom, offset, hidden) + }; + for member in hidden_members { + if let Some((_is_scrollback_editor, pane)) = self.suppressed_panes.get_mut(&member) { + pane.set_geom(visible_geom); + pane.set_content_offset(visible_offset); + let _ = resize_pty!(pane, self.os_api, self.senders, self.character_cell_size); + } + } + } + fn render_stack_list_headers( + &mut self, + output: &mut Output, + client_id_override: Option<ClientId>, + ) -> Result<()> { + if self.stack_lists.is_empty() || self.tiled_panes.fullscreen_is_active() { + return Ok(()); + } + let mut connected_clients: HashSet<ClientId> = + { self.connected_clients.borrow().iter().copied().collect() }; + if let Some(override_id) = client_id_override { + connected_clients.insert(override_id); + } + if connected_clients.is_empty() { + return Ok(()); + } + let multiple_users_exist_in_session = { self.connected_clients_in_app.borrow().len() > 1 }; + let should_draw_pane_frames = self.pane_frame_style.draws_full_frames(); + let session_is_mirrored = self.stack_list_session_is_mirrored; + let current_pane_group: HashMap<ClientId, Vec<PaneId>> = + { self.current_pane_group.borrow().clone_inner() }; + let active_panes: HashMap<ClientId, PaneId> = connected_clients + .iter() + .filter_map(|c| self.tiled_panes.get_active_pane_id(*c).map(|p| (*c, p))) + .collect(); + let client_modes: Vec<(ClientId, InputMode)> = { + let mode_info = self.mode_info.borrow(); + connected_clients + .iter() + .map(|c| { + let mode = mode_info.get(c).unwrap_or(&self.default_mode_info).mode; + (*c, mode) + }) + .collect() + }; + let lists: Vec<StackList> = self.stack_lists.values().cloned().collect(); + for list in lists { + let rect = match self.tiled_panes.get_pane(list.visible) { + Some(p) => p.position_and_size(), + None => continue, + }; + let widest_member_title = list + .members + .iter() + .filter_map(|member| { + if *member == list.visible { + self.tiled_panes.get_pane(*member).map(|p| &**p) + } else { + self.suppressed_panes.get(member).map(|(_, p)| &**p) + } + }) + .map(|pane| pane.stack_list_entry_label().width()) + .max() + .unwrap_or(0); + for (rank, member) in list.members.iter().enumerate() { + let mut header_geom = rect; + header_geom.y = rect.y + rank; + header_geom.rows = Dimension::fixed(1); + header_geom.stacked = None; + let pane_box = if *member == list.visible { + self.tiled_panes.get_pane_mut(*member) + } else { + self.suppressed_panes.get_mut(member).map(|(_, p)| p) + }; + let pane_box = match pane_box { + Some(p) => p, + None => continue, + }; + let mut pane_contents_and_ui = PaneContentsAndUi::new( + pane_box, + output, + self.style, + &active_panes, + multiple_users_exist_in_session, + None, + false, + false, + should_draw_pane_frames, + &self.mouse_hover_pane_id, + current_pane_group.clone(), + false, + false, + ); + pane_contents_and_ui.set_frame_geom_override(Some(header_geom)); + pane_contents_and_ui + .set_stack_list_entry(Some(widest_member_title), *member == list.visible); + for (client_id, client_mode) in &client_modes { + pane_contents_and_ui.render_pane_frame( + *client_id, + *client_mode, + session_is_mirrored, + false, + true, + )?; + } + } + } + Ok(()) + } + pub fn apply_layout( &mut self, layout: TiledPaneLayout, @@ -1078,7 +1763,7 @@ impl Tab { } else { let selectable_tiled_panes = self.tiled_panes.get_panes().filter(|(_, p)| p.selectable()); - if selectable_tiled_panes.count() > 1 { + if selectable_tiled_panes.count() + self.suppressed_stack_list_members().count() > 1 { self.swap_layouts.tiled_layout_info() } else { // no layout for single pane @@ -1127,6 +1812,7 @@ impl Tab { if self.tiled_panes.fullscreen_is_active() { self.tiled_panes.unset_fullscreen(); } + self.dissolve_stack_lists_for_classic_mutation(); if let Some(layout_candidate) = self .swap_layouts .swap_tiled_panes(&self.tiled_panes, search_backwards) @@ -1522,7 +2208,9 @@ impl Tab { } } } else if let Some(focused_pane_id) = self.tiled_panes.focused_pane_id(client_id) { - if self.get_selectable_tiled_panes().count() <= 1 { + if self.get_selectable_tiled_panes().count() <= 1 + && !self.pane_is_stack_list_member(&focused_pane_id) + { // don't close the only pane on screen... return Ok(()); } @@ -1564,7 +2252,9 @@ impl Tab { self.add_tiled_pane(floating_pane_to_embed, pane_id, false, client_id)?; } } else if self.tiled_panes.panes_contain(&pane_id) { - if self.get_selectable_tiled_panes().count() <= 1 { + if self.get_selectable_tiled_panes().count() <= 1 + && !self.pane_is_stack_list_member(&pane_id) + { log::error!("Cannot float the last tiled pane..."); // don't close the only pane on screen... return Ok(()); @@ -1572,6 +2262,10 @@ impl Tab { if let Some(embedded_pane_to_float) = self.extract_pane(pane_id, true) { self.add_floating_pane(embedded_pane_to_float, pane_id, None, true, client_id)?; } + } else if self.pane_is_hidden_stack_list_member(&pane_id) { + if let Some(hidden_member_to_float) = self.extract_pane(pane_id, true) { + self.add_floating_pane(hidden_member_to_float, pane_id, None, true, client_id)?; + } } Ok(()) } @@ -2236,6 +2930,10 @@ impl Tab { }; match replaced_pane { Some(replaced_pane) => { + self.track_stack_list_member_id_swap( + replaced_pane.pid(), + PaneId::Terminal(pid), + ); self.insert_scrollback_editor_replaced_pane(replaced_pane, pid); self.get_active_pane(client_id) .with_context(|| format!("no active pane found for client {client_id}")) @@ -2304,6 +3002,10 @@ impl Tab { self.character_cell_size ) .non_fatal(); + self.track_stack_list_member_id_swap( + pane_id_to_replace, + PaneId::Terminal(pid), + ); self.insert_scrollback_editor_replaced_pane(replaced_pane, pid); }, None => { @@ -2367,6 +3069,12 @@ impl Tab { self.tiled_panes .replace_pane(old_pane_id, Box::new(new_pane)) }; + if replaced_pane.is_some() { + self.track_stack_list_member_id_swap( + old_pane_id, + PaneId::Terminal(new_pane_id), + ); + } if close_replaced_pane { if let Some(pid) = replaced_pane.as_ref().map(|p| p.pid()) { self.senders @@ -2437,6 +3145,9 @@ impl Tab { self.tiled_panes .replace_pane(old_pane_id, Box::new(new_pane)) }; + if replaced_pane.is_some() { + self.track_stack_list_member_id_swap(old_pane_id, PaneId::Plugin(plugin_pid)); + } if close_replaced_pane { drop(replaced_pane); } else { @@ -2473,6 +3184,7 @@ impl Tab { pane_to_replace_with: Box<dyn Pane>, completion_tx: Option<NotificationEnd>, ) { + let replacement_pane_id = pane_to_replace_with.pid(); let mut replaced_pane = if self.floating_panes.panes_contain(&pane_id_to_replace) { self.floating_panes .replace_pane(pane_id_to_replace, pane_to_replace_with) @@ -2481,6 +3193,9 @@ impl Tab { self.tiled_panes .replace_pane(pane_id_to_replace, pane_to_replace_with) }; + if replaced_pane.is_some() { + self.track_stack_list_member_id_swap(pane_id_to_replace, replacement_pane_id); + } if let Some(replaced_pane) = replaced_pane.take() { let pane_id = replaced_pane.pid(); let _ = self @@ -2537,6 +3252,7 @@ impl Tab { if self.tiled_panes.fullscreen_is_active() { self.toggle_active_pane_fullscreen(client_id); } + self.dissolve_stack_lists_for_classic_mutation(); if self.tiled_panes.can_split_pane_horizontally(client_id) { if let PaneId::Terminal(term_pid) = pid { let next_terminal_position = self.get_next_terminal_position(); @@ -2604,6 +3320,7 @@ impl Tab { if self.tiled_panes.fullscreen_is_active() { self.toggle_active_pane_fullscreen(client_id); } + self.dissolve_stack_lists_for_classic_mutation(); if self.tiled_panes.can_split_pane_vertically(client_id) { if let PaneId::Terminal(term_pid) = pid { let next_terminal_position = self.get_next_terminal_position(); @@ -2732,6 +3449,7 @@ impl Tab { .tiled_panes .get_panes() .chain(self.floating_panes.get_panes()) + .chain(self.suppressed_stack_list_members()) .filter(|(_, pane)| pane.has_bell()) .map(|(pane_id, _)| *pane_id) .collect(); @@ -2781,6 +3499,7 @@ impl Tab { .tiled_panes .get_panes() .chain(self.floating_panes.get_panes()) + .chain(self.suppressed_stack_list_members()) .filter(|(_, pane)| pane.has_bell()) .map(|(pane_id, _)| *pane_id) .collect(); @@ -3073,7 +3792,13 @@ impl Tab { // returns true if a UI update should be triggered (eg. when closing a command pane with // ctrl-c) let mut should_trigger_ui_change = false; - let pane_ids = self.get_static_and_floating_pane_ids(); + let mut pane_ids = self.get_static_and_floating_pane_ids(); + pane_ids.extend( + self.stack_list_of_member + .keys() + .filter(|member| self.pane_is_hidden_stack_list_member(member)) + .copied(), + ); for pane_id in pane_ids { let ui_change_triggered = self .write_to_pane_id( @@ -3393,9 +4118,14 @@ impl Tab { if self.floating_panes.panes_are_visible() { return; } + self.dissolve_stack_lists_for_classic_mutation(); self.tiled_panes.toggle_active_pane_fullscreen(client_id); } pub fn toggle_pane_fullscreen(&mut self, pane_id: PaneId) { + if self.pane_is_hidden_stack_list_member(&pane_id) { + self.make_hidden_stack_list_member_visible(pane_id); + } + self.dissolve_stack_lists_for_classic_mutation(); if self.tiled_panes.panes_contain(&pane_id) { self.tiled_panes.toggle_pane_fullscreen(pane_id); } else { @@ -3460,6 +4190,12 @@ impl Tab { pub fn set_force_render(&mut self) { self.tiled_panes.set_force_render(); self.floating_panes.set_force_render(); + for member in self.stack_list_of_member.keys() { + if let Some((_is_scrollback_editor, pane)) = self.suppressed_panes.get_mut(member) { + pane.set_should_render(true); + pane.render_full_viewport(); + } + } } pub fn set_should_clear_display_before_rendering(&mut self) { self.should_clear_display_before_rendering = true; @@ -3515,6 +4251,9 @@ impl Tab { if connected_clients.is_empty() || !self.tiled_panes.has_active_panes() { return Ok(()); } + if self.stacked_pane_list_is_active() { + self.groupify_all(); + } self.update_active_panes_in_pty_thread() .with_context(err_context)?; @@ -3537,6 +4276,8 @@ impl Tab { &self.mouse_help_text_visible, ) .with_context(err_context)?; + self.render_stack_list_headers(output, client_id_override) + .with_context(err_context)?; if (self.floating_panes.panes_are_visible() && self.floating_panes.has_active_panes()) || self.floating_panes.has_pinned_panes() { @@ -3572,7 +4313,7 @@ impl Tab { hide_cursor, ); if self.should_clear_display_before_rendering { - let clear_display = "\u{1b}[2J"; + let clear_display = "\u{1b}[m\u{1b}[2J"; output.add_pre_vte_instruction_to_multiple_clients( connected_clients.iter().copied(), clear_display, @@ -3707,7 +4448,11 @@ impl Tab { PaneId::Terminal(_) => true, }) .count(); - tiled_panes_count + floating_panes_count + 1 + let hidden_stack_list_members_count = self + .suppressed_stack_list_members() + .filter(|(pane_id, _)| matches!(pane_id, PaneId::Terminal(_))) + .count(); + tiled_panes_count + floating_panes_count + hidden_stack_list_members_count + 1 } pub fn has_selectable_panes(&self) -> bool { let selectable_tiled_panes = self.tiled_panes.get_panes().filter(|(_, p)| p.selectable()); @@ -3768,6 +4513,7 @@ impl Tab { self.tiled_panes.toggle_pane_fullscreen(pane_id); } } + self.resize_all_stack_list_hidden_members(); Ok(()) } pub fn resize(&mut self, client_id: ClientId, strategy: ResizeStrategy) -> Result<()> { @@ -3782,6 +4528,7 @@ impl Tab { self.set_force_render(); // we force render here to make sure the panes under the floating pane render and don't leave "garbage" in case of a decrease } } else { + self.dissolve_stack_lists_for_classic_mutation(); match self.tiled_panes.resize_active_pane(client_id, &strategy) { Ok(_) => { self.swap_layouts.set_is_tiled_damaged(); @@ -3896,6 +4643,11 @@ impl Tab { self.focus_pane_down_fullscreen(client_id); return Ok(true); } + if self.stacked_pane_list_is_active() + && self.move_focus_within_stack_list(client_id, true) + { + return Ok(true); + } Ok(self.tiled_panes.move_focus_down(client_id)) } } @@ -3918,6 +4670,11 @@ impl Tab { self.focus_pane_up_fullscreen(client_id); return Ok(true); } + if self.stacked_pane_list_is_active() + && self.move_focus_within_stack_list(client_id, false) + { + return Ok(true); + } Ok(self.tiled_panes.move_focus_up(client_id)) } } @@ -3955,6 +4712,7 @@ impl Tab { self.floating_panes .move_active_pane(search_backwards, &mut self.os_api, client_id); } else { + self.dissolve_stack_lists_for_classic_mutation(); self.tiled_panes .move_active_pane(search_backwards, client_id); } @@ -3970,6 +4728,7 @@ impl Tab { if self.floating_panes.panes_are_visible() { self.floating_panes.move_pane(search_backwards, pane_id); } else { + self.dissolve_stack_lists_for_classic_mutation(); self.tiled_panes.move_pane(search_backwards, pane_id); } } @@ -3985,6 +4744,7 @@ impl Tab { self.floating_panes .move_active_pane(search_backwards, &mut self.os_api, client_id); } else { + self.dissolve_stack_lists_for_classic_mutation(); self.tiled_panes .move_active_pane(search_backwards, client_id); } @@ -4001,6 +4761,7 @@ impl Tab { if self.tiled_panes.fullscreen_is_active() { return; } + self.dissolve_stack_lists_for_classic_mutation(); self.tiled_panes.move_active_pane_down(client_id); } } @@ -4016,6 +4777,7 @@ impl Tab { if self.tiled_panes.fullscreen_is_active() { return; } + self.dissolve_stack_lists_for_classic_mutation(); self.tiled_panes.move_pane_down(pane_id); } } @@ -4031,6 +4793,7 @@ impl Tab { if self.tiled_panes.fullscreen_is_active() { return; } + self.dissolve_stack_lists_for_classic_mutation(); self.tiled_panes.move_active_pane_up(client_id); } } @@ -4046,6 +4809,7 @@ impl Tab { if self.tiled_panes.fullscreen_is_active() { return; } + self.dissolve_stack_lists_for_classic_mutation(); self.tiled_panes.move_pane_up(pane_id); } } @@ -4061,6 +4825,7 @@ impl Tab { if self.tiled_panes.fullscreen_is_active() { return; } + self.dissolve_stack_lists_for_classic_mutation(); self.tiled_panes.move_active_pane_right(client_id); } } @@ -4076,6 +4841,7 @@ impl Tab { if self.tiled_panes.fullscreen_is_active() { return; } + self.dissolve_stack_lists_for_classic_mutation(); self.tiled_panes.move_pane_right(pane_id); } } @@ -4091,6 +4857,7 @@ impl Tab { if self.tiled_panes.fullscreen_is_active() { return; } + self.dissolve_stack_lists_for_classic_mutation(); self.tiled_panes.move_active_pane_left(client_id); } } @@ -4106,11 +4873,13 @@ impl Tab { if self.tiled_panes.fullscreen_is_active() { return; } + self.dissolve_stack_lists_for_classic_mutation(); self.tiled_panes.move_pane_left(pane_id); } } fn close_down_to_max_terminals(&mut self) -> Result<()> { if let Some(max_panes) = self.max_panes { + self.dissolve_stack_lists_for_classic_mutation(); let terminals = self.get_tiled_pane_ids(); for &pid in terminals.iter().skip(max_panes - 1) { self.senders @@ -4226,6 +4995,25 @@ impl Tab { ignore_suppressed_panes: bool, exit_status: Option<i32>, ) { + let id_parks_a_different_pane = self.pane_parked_by(&id).is_some(); + if !ignore_suppressed_panes + && !id_parks_a_different_pane + && self.pane_is_stack_list_member(&id) + { + if self.close_stack_list_member(id, exit_status) { + let _ = self.senders.send_to_plugin(PluginInstruction::Update(vec![( + None, + None, + Event::PaneClosed(id.into()), + )])); + let _ = + self.senders + .send_to_screen(ScreenInstruction::NotifyPaneClosedToSubscribers { + pane_id: id.into(), + }); + return; + } + } // we need to ignore suppressed panes when we toggle a pane to be floating/embedded(tiled) // this is because in that case, while we do use this logic, we're not actually closing the // pane, we're moving it @@ -4271,6 +5059,7 @@ impl Tab { // confusing let _ = self.relayout_tiled_panes(false); } + self.resize_all_stack_list_hidden_members(); closed_pane }; if let Some(exit_status) = exit_status { @@ -4295,6 +5084,17 @@ impl Tab { id: PaneId, dont_swap_if_suppressed: bool, ) -> Option<Box<dyn Pane>> { + let id_parks_a_different_pane = self.pane_parked_by(&id).is_some(); + if self.pane_is_stack_list_member(&id) + && (dont_swap_if_suppressed || !id_parks_a_different_pane) + { + if let StackListRemoval::Removed(removed) = self.remove_stack_list_member(id) { + return removed.map(|mut pane| { + pane.reset_logical_position(); + pane + }); + } + } if !dont_swap_if_suppressed && self.suppressed_panes.contains_key(&id) { // this is done for the scrollback editor return match self.replace_pane_with_suppressed_pane(id) { @@ -4349,6 +5149,7 @@ impl Tab { // confusing let _ = self.relayout_tiled_panes(false); } + self.resize_all_stack_list_hidden_members(); // we do this so that the logical index will not affect ordering in the target tab if let Some(closed_pane) = closed_pane.as_mut() { closed_pane.reset_logical_position(); @@ -4413,6 +5214,9 @@ impl Tab { } else { self.tiled_panes.replace_pane(pane_id, suppressed_pane) }; + if replaced_pane.is_some() { + self.track_stack_list_member_id_swap(pane_id, suppressed_pane_id); + } if let Some(suppressed_pane) = self .floating_panes .get_pane(suppressed_pane_id) @@ -4636,6 +5440,7 @@ impl Tab { completion_tx: Option<NotificationEnd>, ) -> Result<()> { if let PaneId::Terminal(_terminal_pane_id) = pane_id { + self.make_hidden_stack_list_member_visible(pane_id); let mut file = temp_dir(); file.push(format!("{}.dump", Uuid::new_v4())); self.dump_terminal_screen(Some(String::from(file.to_string_lossy())), pane_id, true) @@ -4660,6 +5465,7 @@ impl Tab { completion_tx: Option<NotificationEnd>, ) -> Result<()> { if let PaneId::Terminal(_terminal_pane_id) = pane_id { + self.make_hidden_stack_list_member_visible(pane_id); let mut file = temp_dir(); file.push(format!("{}.dump", Uuid::new_v4())); self.dump_with_ansi_terminal_screen( @@ -4978,16 +5784,24 @@ impl Tab { geom_to_compare_against.contains(point) }; - if search_selectable { - Ok(self - .get_selectable_tiled_panes() + let found_pane_id = if search_selectable { + self.get_selectable_tiled_panes() .find(|(_, p)| pane_contains_point(p, point, &stacked_pane_ids_under_flexible_pane)) - .map(|(&id, _)| id)) + .map(|(&id, _)| id) } else { - Ok(self - .get_tiled_panes() + self.get_tiled_panes() .find(|(_, p)| pane_contains_point(p, point, &stacked_pane_ids_under_flexible_pane)) - .map(|(&id, _)| id)) + .map(|(&id, _)| id) + }; + let resolved_pane_id = found_pane_id.map(|id| self.stack_list_member_at_point(id, point)); + if search_selectable { + Ok(resolved_pane_id.filter(|id| { + self.get_pane_with_id(*id) + .map(|p| p.selectable()) + .unwrap_or(false) + })) + } else { + Ok(resolved_pane_id) } } @@ -5521,6 +6335,9 @@ impl Tab { client_id: ClientId, ) -> Result<()> { // TODO: should error if pane is not selectable + if self.focus_hidden_stack_list_member(pane_id, client_id) { + return Ok(()); + } self.tiled_panes .focus_pane_if_exists(pane_id, client_id) .map(|_| self.hide_floating_panes()) @@ -5556,6 +6373,7 @@ impl Tab { }; if let Some(replaced_pane) = replaced_pane { let is_scrollback_editor = false; + self.track_stack_list_member_id_swap(replaced_pane.pid(), pane_id); self.insert_suppressed_pane( pane_id, (is_scrollback_editor, replaced_pane), @@ -5574,6 +6392,10 @@ impl Tab { }) } pub fn focus_suppressed_pane_for_all_clients(&mut self, pane_id: PaneId) { + if self.make_hidden_stack_list_member_visible(pane_id) { + self.tiled_panes.focus_pane_for_all_clients(pane_id); + return; + } match self.suppressed_panes.remove(&pane_id) { Some(pane) => { self.show_floating_panes(); @@ -5595,8 +6417,14 @@ impl Tab { self.insert_suppressed_pane(pane_id, (false, pane)); } } + fn make_hidden_stack_list_member_visible(&mut self, pane_id: PaneId) -> bool { + self.swap_in_hidden_stack_list_member(pane_id, None) + } pub fn unsuppress_pane(&mut self, pane_id: PaneId, should_float_if_hidden: bool) { // removes a pane from being suppressed (hidden) but does not focus it + if self.make_hidden_stack_list_member_visible(pane_id) { + return; + } match self .suppressed_panes .extract_if(|_key, (_, pane)| pane.pid() == pane_id) @@ -5618,6 +6446,9 @@ impl Tab { } pub fn unsuppress_or_expand_pane(&mut self, pane_id: PaneId, should_float_if_hidden: bool) { // removes a pane from being suppressed (hidden) but does not focus it + if self.make_hidden_stack_list_member_visible(pane_id) { + return; + } match self .suppressed_panes .extract_if(|_key, (_, pane)| pane.pid() == pane_id) @@ -5767,6 +6598,7 @@ impl Tab { if self.tiled_panes.fullscreen_is_active() { self.tiled_panes.unset_fullscreen(); } + self.dissolve_stack_lists_for_classic_mutation(); let should_auto_layout = self.auto_layout && !self.swap_layouts.is_tiled_damaged() && !without_relayout; if self.tiled_panes.has_room_for_new_pane() { @@ -5779,6 +6611,7 @@ impl Tab { } else { self.tiled_panes.insert_pane(pane_id, pane, client_id); } + self.tiled_panes.reapply_pane_frames(); self.set_should_clear_display_before_rendering(); if let Some(client_id) = client_id { self.tiled_panes.focus_pane(pane_id, client_id); @@ -5802,6 +6635,7 @@ impl Tab { if self.tiled_panes.fullscreen_is_active() { self.tiled_panes.unset_fullscreen(); } + self.dissolve_stack_lists_for_classic_mutation(); self.tiled_panes .add_pane_to_stack_of_pane_id(pane_id, pane, root_pane_id); self.set_should_clear_display_before_rendering(); @@ -5819,6 +6653,7 @@ impl Tab { if self.tiled_panes.fullscreen_is_active() { self.tiled_panes.unset_fullscreen(); } + self.dissolve_stack_lists_for_classic_mutation(); self.tiled_panes .add_pane_to_stack_of_active_pane(pane_id, pane, client_id); self.tiled_panes.focus_pane(pane_id, client_id); @@ -5891,6 +6726,9 @@ impl Tab { } pub fn resize_pane_with_id(&mut self, strategy: ResizeStrategy, pane_id: PaneId) -> Result<()> { let err_context = || format!("unable to resize pane"); + if self.pane_is_stack_list_member(&pane_id) { + self.dissolve_stack_lists_for_classic_mutation(); + } if self.floating_panes.panes_contain(&pane_id) { let successfully_resized = self .floating_panes @@ -6017,7 +6855,10 @@ impl Tab { } } pub fn extract_suppressed_panes(&mut self) -> SuppressedPanes { - self.suppressed_panes.drain().collect() + let stack_list_members = &self.stack_list_of_member; + self.suppressed_panes + .extract_if(|key, _| !stack_list_members.contains_key(key)) + .collect() } pub fn add_suppressed_panes(&mut self, mut suppressed_panes: SuppressedPanes) { for (pane_id, suppressed_pane_entry) in suppressed_panes.drain() { @@ -6037,6 +6878,7 @@ impl Tab { } } pub fn has_room_for_stack(&mut self, root_pane_id: PaneId, stack_size: usize) -> bool { + self.dissolve_stack_lists_for_classic_mutation(); if self.floating_panes.panes_contain(&root_pane_id) || self.suppressed_panes.contains_key(&root_pane_id) { @@ -6063,6 +6905,7 @@ impl Tab { // nothing to do return; } + self.dissolve_stack_lists_for_classic_mutation(); self.swap_layouts.set_is_tiled_damaged(); // TODO: verify we can do all the below first if self.pane_is_stacked(root_pane_id) { for pane in panes_to_stack.drain(..) { @@ -6345,6 +7188,9 @@ impl Tab { if self.tiled_panes.fullscreen_is_active() { return; } + if !self.floating_panes.panes_contain(&pane_id) { + self.dissolve_stack_lists_for_classic_mutation(); + } match direction { Some(Direction::Left) => { if self.floating_panes.panes_contain(&pane_id) { @@ -6403,6 +7249,7 @@ impl Tab { if self.floating_panes.panes_contain(&pane_id) { self.floating_panes.move_pane(search_backwards, pane_id); } else { + self.dissolve_stack_lists_for_classic_mutation(); self.tiled_panes.move_pane(search_backwards, pane_id); } } diff --git a/zellij-server/src/tab/mouse_handler.rs b/zellij-server/src/tab/mouse_handler.rs index 61718864..d04b511c 100644 --- a/zellij-server/src/tab/mouse_handler.rs +++ b/zellij-server/src/tab/mouse_handler.rs @@ -417,9 +417,10 @@ impl MouseHandler { client_id: ClientId, ) -> Option<ClickedPaneDetails> { let is_floating = tab.floating_panes.panes_contain(&pane_id); + let is_hidden_stack_list_member = tab.pane_is_hidden_stack_list_member(&pane_id); let pane = Self::get_pane_at(tab, position, false).ok()??; - let on_frame = pane.position_is_on_frame(position); + let on_frame = !is_hidden_stack_list_member && pane.position_is_on_frame(position); let frame_intercepted = on_frame && pane.intercept_mouse_event_on_frame(event, client_id); let edge = if on_frame { pane.get_edge_at_position(position) @@ -1053,6 +1054,10 @@ impl MouseHandler { return Self::execute_update_hover(tab, Some(pane_id), Some(position), client_id); } + if tab.pane_is_hidden_stack_list_member(&pane_id) { + return Self::execute_update_hover(tab, Some(pane_id), Some(position), client_id); + } + let active_pane_id = tab.get_active_pane_id(client_id); if active_pane_id == Some(pane_id) { return Self::execute_update_hover(tab, Some(pane_id), Some(position), client_id); @@ -1543,7 +1548,9 @@ impl MouseHandler { } } if let Some(clicked_pane) = tab.get_pane_id_at(point, true).with_context(err_context)? { - tab.tiled_panes.focus_pane(clicked_pane, client_id); + if !tab.focus_hidden_stack_list_member(clicked_pane, client_id) { + tab.tiled_panes.focus_pane(clicked_pane, client_id); + } tab.set_pane_active_at(clicked_pane); if tab.floating_panes.panes_are_visible() { tab.hide_floating_panes(); @@ -1737,7 +1744,7 @@ impl MouseHandler { .get_pane_id_at(point, search_selectable) .with_context(err_context)? { - Ok(tab.tiled_panes.get_pane_mut(pane_id)) + Ok(tab.get_pane_with_id_mut(pane_id)) } else { Ok(None) } diff --git a/zellij-server/src/tab/unit/layout_applier_tests.rs b/zellij-server/src/tab/unit/layout_applier_tests.rs index 33ad8ee4..f67a9a17 100644 --- a/zellij-server/src/tab/unit/layout_applier_tests.rs +++ b/zellij-server/src/tab/unit/layout_applier_tests.rs @@ -187,6 +187,8 @@ fn create_layout_applier_fixtures( let connected_clients_set = Rc::new(RefCell::new(HashSet::from([client_id]))); let mode_info = Rc::new(RefCell::new(HashMap::new())); let stacked_resize = Rc::new(RefCell::new(false)); + let stacked_pane_list = Rc::new(RefCell::new(false)); + let reserved_top_rows = Rc::new(RefCell::new(HashMap::new())); let session_is_mirrored = true; let draw_pane_frames = PaneFrameStyle::Full; let default_mode_info = ModeInfo::default(); @@ -199,6 +201,8 @@ fn create_layout_applier_fixtures( mode_info.clone(), character_cell_size.clone(), stacked_resize, + stacked_pane_list, + reserved_top_rows, session_is_mirrored, draw_pane_frames, default_mode_info.clone(), @@ -317,6 +321,8 @@ fn create_layout_applier_fixtures_with_receivers( let connected_clients_set = Rc::new(RefCell::new(HashSet::from([client_id]))); let mode_info = Rc::new(RefCell::new(HashMap::new())); let stacked_resize = Rc::new(RefCell::new(false)); + let stacked_pane_list = Rc::new(RefCell::new(false)); + let reserved_top_rows = Rc::new(RefCell::new(HashMap::new())); let session_is_mirrored = true; let draw_pane_frames = PaneFrameStyle::Full; let default_mode_info = ModeInfo::default(); @@ -329,6 +335,8 @@ fn create_layout_applier_fixtures_with_receivers( mode_info.clone(), character_cell_size.clone(), stacked_resize, + stacked_pane_list, + reserved_top_rows, session_is_mirrored, draw_pane_frames, default_mode_info.clone(), diff --git a/zellij-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs index 5b623b00..35a2e9bd 100644 --- a/zellij-server/src/tab/unit/tab_integration_tests.rs +++ b/zellij-server/src/tab/unit/tab_integration_tests.rs @@ -246,6 +246,7 @@ fn create_new_tab(size: Size, default_mode: ModeInfo) -> Tab { size, character_cell_info, stacked_resize, + Rc::new(RefCell::new(false)), sixel_image_store, os_api, senders, @@ -334,6 +335,7 @@ fn create_new_tab_without_pane_frames(size: Size, default_mode: ModeInfo) -> Tab size, character_cell_info, stacked_resize, + Rc::new(RefCell::new(false)), sixel_image_store, os_api, senders, @@ -437,6 +439,7 @@ fn create_new_tab_with_swap_layouts( size, character_cell_info, stacked_resize, + Rc::new(RefCell::new(false)), sixel_image_store, os_api, senders, @@ -545,6 +548,7 @@ fn create_new_tab_with_os_api( size, character_cell_info, stacked_resize, + Rc::new(RefCell::new(false)), sixel_image_store, os_api, senders, @@ -635,6 +639,7 @@ fn create_new_tab_with_layout(size: Size, default_mode: ModeInfo, layout: &str) size, character_cell_info, stacked_resize, + Rc::new(RefCell::new(false)), sixel_image_store, os_api, senders, @@ -739,6 +744,7 @@ fn create_new_tab_with_mock_pty_writer( size, character_cell_info, stacked_resize, + Rc::new(RefCell::new(false)), sixel_image_store, os_api, senders, @@ -834,6 +840,7 @@ fn create_new_tab_with_sixel_support( size, character_cell_size, stacked_resize, + Rc::new(RefCell::new(false)), sixel_image_store, os_api, senders, @@ -12727,6 +12734,7 @@ fn create_new_tab_with_plugin_receiver( size, character_cell_info, stacked_resize, + Rc::new(RefCell::new(false)), sixel_image_store, os_api, senders, @@ -14512,6 +14520,7 @@ fn create_new_tab_with_server_receiver( size, character_cell_info, stacked_resize, + Rc::new(RefCell::new(false)), sixel_image_store, os_api, senders, diff --git a/zellij-server/src/tab/unit/tab_tests.rs b/zellij-server/src/tab/unit/tab_tests.rs index 0d18897e..75a6695b 100644 --- a/zellij-server/src/tab/unit/tab_tests.rs +++ b/zellij-server/src/tab/unit/tab_tests.rs @@ -188,6 +188,7 @@ fn create_new_tab(size: Size, stacked_resize: bool) -> Tab { size, character_cell_info, stacked_resize, + Rc::new(RefCell::new(false)), sixel_image_store, os_api, senders, @@ -275,6 +276,7 @@ fn create_new_tab_with_layout(size: Size, layout: TiledPaneLayout) -> Tab { size, character_cell_info, stacked_resize, + Rc::new(RefCell::new(false)), sixel_image_store, os_api, senders, @@ -368,6 +370,7 @@ fn create_new_tab_with_cell_size( size, character_cell_size, stacked_resize, + Rc::new(RefCell::new(false)), sixel_image_store, os_api, senders, diff --git a/zellij-server/src/ui/pane_boundaries_frame.rs b/zellij-server/src/ui/pane_boundaries_frame.rs index d7abdfd1..e4e182dc 100644 --- a/zellij-server/src/ui/pane_boundaries_frame.rs +++ b/zellij-server/src/ui/pane_boundaries_frame.rs @@ -1,5 +1,7 @@ use crate::output::CharacterChunk; -use crate::panes::{AnsiCode, RcCharacterStyles, TerminalCharacter, EMPTY_TERMINAL_CHARACTER}; +use crate::panes::{ + AnsiCode, CharacterStyles, RcCharacterStyles, TerminalCharacter, EMPTY_TERMINAL_CHARACTER, +}; use crate::ui::boundaries::boundary_type; use crate::ui::hint_text::{ exit_code_segments, hover_segments, rerun_segments, HintExitStatus, HintLevel, HintSegment, @@ -8,45 +10,53 @@ use crate::ui::hint_text::{ use crate::ClientId; use zellij_utils::data::{client_id_to_colors, PaletteColor, Style}; use zellij_utils::errors::prelude::*; -use zellij_utils::pane_size::{Offset, Viewport}; +use zellij_utils::pane_size::{Offset, PaneGeom, Viewport}; use zellij_utils::position::Position; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +fn styled_characters( + characters: &str, + style_character: impl Fn(&mut CharacterStyles), +) -> Vec<TerminalCharacter> { + characters + .chars() + .map(|character| { + let mut styles = RcCharacterStyles::reset(); + styles.update(|styles| style_character(styles)); + TerminalCharacter::new_styled(character, styles) + }) + .collect() +} + fn foreground_color(characters: &str, color: Option<PaletteColor>) -> Vec<TerminalCharacter> { - let mut colored_string = Vec::new(); - for character in characters.chars() { - let mut styles = RcCharacterStyles::reset(); - styles.update(|styles| { - styles.bold = Some(AnsiCode::On); - match color { - Some(palette_color) => { - styles.foreground = Some(AnsiCode::from(palette_color)); - }, - None => {}, - } - }); - let terminal_character = TerminalCharacter::new_styled(character, styles); - colored_string.push(terminal_character); - } - colored_string + styled_characters(characters, |styles| { + styles.bold = Some(AnsiCode::On); + if let Some(palette_color) = color { + styles.foreground = Some(AnsiCode::from(palette_color)); + } + }) +} + +fn dimmed_foreground_color( + characters: &str, + color: Option<PaletteColor>, +) -> Vec<TerminalCharacter> { + styled_characters(characters, |styles| { + styles.dim = Some(AnsiCode::On); + if let Some(palette_color) = color { + styles.foreground = Some(AnsiCode::from(palette_color)); + } + }) } fn background_color(characters: &str, color: Option<PaletteColor>) -> Vec<TerminalCharacter> { - let mut colored_string = Vec::new(); - for character in characters.chars() { - let mut styles = RcCharacterStyles::reset(); - styles.update(|styles| match color { - Some(palette_color) => { - styles.background = Some(AnsiCode::from(palette_color)); - styles.bold(Some(AnsiCode::On)); - }, - None => {}, - }); - let terminal_character = TerminalCharacter::new_styled(character, styles); - colored_string.push(terminal_character); - } - colored_string + styled_characters(characters, |styles| { + if let Some(palette_color) = color { + styles.background = Some(AnsiCode::from(palette_color)); + styles.bold(Some(AnsiCode::On)); + } + }) } #[derive(Debug, Clone, Copy, PartialEq)] @@ -55,6 +65,14 @@ enum ExitStatus { Exited, } +#[derive(Clone, PartialEq)] +pub struct StackListEntry { + pub width: usize, + pub label: String, + pub is_selected: bool, + pub is_emphasized: bool, +} + pub struct FrameParams { pub focused_client: Option<ClientId>, pub is_main_client: bool, // more accurately: is_focused_for_main_client @@ -73,6 +91,9 @@ pub struct FrameParams { pub show_help_text: bool, pub highlight_tooltip: Option<String>, pub omit_title: bool, + pub frame_geom_override: Option<PaneGeom>, + pub stack_list_entry: Option<StackListEntry>, + pub blank_title: bool, } #[derive(Default, PartialEq)] @@ -100,6 +121,8 @@ pub struct PaneFrame { show_help_text: bool, highlight_tooltip: Option<String>, omit_title: bool, + stack_list_entry: Option<StackListEntry>, + color_override: Option<PaletteColor>, } impl PaneFrame { @@ -133,6 +156,8 @@ impl PaneFrame { show_help_text: frame_params.show_help_text, highlight_tooltip: frame_params.highlight_tooltip, omit_title: frame_params.omit_title, + stack_list_entry: frame_params.stack_list_entry, + color_override: None, } } pub fn is_pinned(mut self, is_pinned: bool) -> Self { @@ -150,6 +175,7 @@ impl PaneFrame { } pub fn override_color(&mut self, color: PaletteColor) { self.color = Some(color); + self.color_override = Some(color); } fn client_cursor(&self, client_id: ClientId) -> Vec<TerminalCharacter> { let color = client_id_to_colors(client_id, self.style.colors.multiplayer_user_colors); @@ -715,6 +741,86 @@ impl PaneFrame { .or_else(|| Some(self.title_line_without_middle())) .with_context(|| format!("failed to render title '{}'", self.title)) } + fn render_stack_list_entry(&self, entry: &StackListEntry) -> Vec<TerminalCharacter> { + let usable_cols = self.geom.cols.saturating_sub(2); + let bracket_overhead = "[ ]".width(); + let inner_width = entry + .width + .min(usable_cols.saturating_sub(bracket_overhead)); + let content = if entry.label.width() <= inner_width { + entry.label.clone() + } else { + let truncation_budget = inner_width.saturating_sub(1); + let mut truncated = String::new(); + for character in entry.label.chars() { + if truncated.width() + character.width().unwrap_or(0) > truncation_budget { + break; + } + truncated.push(character); + } + truncated.push('…'); + truncated + }; + let padding = " ".repeat(inner_width.saturating_sub(content.width())); + let padded_content = format!("{}{}", content, padding); + let left_bracket = "[ "; + let right_bracket = " ]"; + let entry_length = left_bracket.width() + padded_content.width() + right_bracket.width(); + let entry_start = usable_cols.saturating_sub(entry_length) / 2; + let selection_sign = "<↓↑> "; + let selection_sign_width = if entry.is_selected && entry_start >= selection_sign.width() { + selection_sign.width() + } else { + 0 + }; + let left_budget = entry_start.saturating_sub(selection_sign_width); + let (mut focus_part, focus_length) = self + .bracketed_focus_indicator(left_budget) + .unwrap_or((vec![], 0)); + let mut line = Vec::with_capacity(usable_cols); + line.append(&mut focus_part); + for _ in focus_length..left_budget { + line.push(EMPTY_TERMINAL_CHARACTER); + } + if selection_sign_width > 0 { + line.append(&mut foreground_color(selection_sign, self.color)); + } + let content_color = if entry.is_emphasized { + self.color + } else { + self.color_override + }; + if entry.is_selected { + line.append(&mut foreground_color(left_bracket, self.color_override)); + line.append(&mut foreground_color(&padded_content, self.color)); + line.append(&mut foreground_color(right_bracket, self.color_override)); + } else { + let unbracketed = format!(" {} ", padded_content); + if entry.is_emphasized { + line.append(&mut foreground_color(&unbracketed, content_color)); + } else { + line.append(&mut dimmed_foreground_color(&unbracketed, content_color)); + } + } + let mut occupied_columns = entry_start + entry_length; + let (mut scroll_part, scroll_length) = self + .bracketed_scroll_indicator(usable_cols.saturating_sub(occupied_columns)) + .unwrap_or((vec![], 0)); + if self.exit_status.is_some() { + let (mut exit_part, exit_length) = self.first_exited_held_title_part_full(); + if occupied_columns + 1 + exit_length + scroll_length <= usable_cols { + line.push(EMPTY_TERMINAL_CHARACTER); + line.append(&mut exit_part); + occupied_columns += 1 + exit_length; + } + } + while occupied_columns < usable_cols.saturating_sub(scroll_length) { + line.push(EMPTY_TERMINAL_CHARACTER); + occupied_columns += 1; + } + line.append(&mut scroll_part); + line + } fn render_one_line_title(&self) -> Result<Vec<TerminalCharacter>> { if self.should_draw_pane_frames { let total_title_length = self.geom.cols.saturating_sub(2); @@ -1168,6 +1274,14 @@ impl PaneFrame { if self.omit_title { return Ok((character_chunks, None)); } + if let Some(entry) = &self.stack_list_entry { + character_chunks.push(CharacterChunk::new( + self.render_stack_list_entry(entry), + self.geom.x + 1, + self.geom.y, + )); + return Ok((character_chunks, None)); + } if self.geom.rows == 1 || !self.should_draw_pane_frames { // we do this explicitly when not drawing pane frames because this should only happen // if this is a stacked pane with pane frames off (and it doesn't necessarily have only diff --git a/zellij-server/src/ui/pane_contents_and_ui.rs b/zellij-server/src/ui/pane_contents_and_ui.rs index f2763a9b..fc72bf0f 100644 --- a/zellij-server/src/ui/pane_contents_and_ui.rs +++ b/zellij-server/src/ui/pane_contents_and_ui.rs @@ -2,11 +2,12 @@ use crate::output::Output; use crate::panes::PaneId; use crate::tab::Pane; use crate::ui::boundaries::Boundaries; -use crate::ui::pane_boundaries_frame::FrameParams; +use crate::ui::pane_boundaries_frame::{FrameParams, StackListEntry}; use crate::ClientId; use std::collections::{HashMap, HashSet}; use zellij_utils::data::{client_id_to_colors, InputMode, PaletteColor, Style}; use zellij_utils::errors::prelude::*; +use zellij_utils::pane_size::PaneGeom; pub struct PaneContentsAndUi<'a> { pane: &'a mut Box<dyn Pane>, output: &'a mut Output, @@ -21,6 +22,10 @@ pub struct PaneContentsAndUi<'a> { current_pane_group: HashMap<ClientId, Vec<PaneId>>, show_help_text: bool, omit_title: bool, + frame_geom_override: Option<PaneGeom>, + stack_list_entry_width: Option<usize>, + stack_list_entry_is_selected: bool, + blank_title: bool, } impl<'a> PaneContentsAndUi<'a> { @@ -69,8 +74,26 @@ impl<'a> PaneContentsAndUi<'a> { current_pane_group, show_help_text, omit_title, + frame_geom_override: None, + stack_list_entry_width: None, + stack_list_entry_is_selected: false, + blank_title: false, } } + pub fn set_frame_geom_override(&mut self, frame_geom_override: Option<PaneGeom>) { + self.frame_geom_override = frame_geom_override; + } + pub fn set_blank_title(&mut self, blank_title: bool) { + self.blank_title = blank_title; + } + pub fn set_stack_list_entry( + &mut self, + stack_list_entry_width: Option<usize>, + stack_list_entry_is_selected: bool, + ) { + self.stack_list_entry_width = stack_list_entry_width; + self.stack_list_entry_is_selected = stack_list_entry_is_selected; + } pub fn render_pane_contents_to_multiple_clients( &mut self, clients: impl Iterator<Item = ClientId>, @@ -243,6 +266,21 @@ impl<'a> PaneContentsAndUi<'a> { None }; let pane_is_stacked = self.pane.current_geom().is_stacked(); + let pane_is_in_group = self + .current_pane_group + .get(&client_id) + .map(|p| p.contains(&self.pane.pid())) + .unwrap_or(false); + let stack_list_entry = self.stack_list_entry_width.map(|width| StackListEntry { + width, + label: self.pane.stack_list_entry_label(), + is_selected: self.stack_list_entry_is_selected, + is_emphasized: pane_is_in_group + || (self + .mouse_is_hovering_over_pane_for_clients + .contains(&client_id) + && !pane_focused_for_client_id), + }); let frame_params = if session_is_mirrored { FrameParams { focused_client, @@ -264,6 +302,9 @@ impl<'a> PaneContentsAndUi<'a> { show_help_text: self.show_help_text, highlight_tooltip: highlight_tooltip.clone(), omit_title: self.omit_title, + frame_geom_override: self.frame_geom_override, + stack_list_entry: stack_list_entry.clone(), + blank_title: self.blank_title, } } else { FrameParams { @@ -286,6 +327,9 @@ impl<'a> PaneContentsAndUi<'a> { show_help_text: self.show_help_text, highlight_tooltip, omit_title: self.omit_title, + frame_geom_override: self.frame_geom_override, + stack_list_entry, + blank_title: self.blank_title, } }; diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs index ee46dad3..a45ec812 100644 --- a/zellij-server/src/unit/screen_tests.rs +++ b/zellij-server/src/unit/screen_tests.rs @@ -306,6 +306,7 @@ fn create_new_screen( layout_dir, explicitly_disable_kitty_keyboard_protocol, stacked_resize, + false, None, false, web_sharing, @@ -695,6 +696,7 @@ impl MockScreen { let config_options = Options::default(); let mut config = Config::default(); config.options.pane_frame_style = Some(PaneFrameStyle::Full); + config.options.stacked_pane_list = Some(false); let main_client_id = 1; std::thread::Builder::new() @@ -5425,6 +5427,7 @@ fn create_new_screen_with_message_capture( layout_dir, explicitly_disable_kitty_keyboard_protocol, stacked_resize, + false, None, false, web_sharing, @@ -8499,6 +8502,7 @@ fn create_new_screen_with_forward_capture(size: Size) -> (Screen, ForwardCapture layout_dir, explicitly_disable_kitty_keyboard_protocol, stacked_resize, + false, None, false, web_sharing, @@ -9082,6 +9086,7 @@ fn create_new_screen_with_theme_capture(size: Size) -> (Screen, ThemeCapture) { None, false, true, + false, None, false, web_sharing, @@ -9561,6 +9566,7 @@ fn create_non_mirrored_screen(size: Size) -> Screen { None, // layout_dir false, // explicitly_disable_kitty_keyboard_protocol true, // stacked_resize + false, None, false, WebSharing::Off, |
