diff --git a/CHANGELOG.md b/CHANGELOG.md index ce261a51b..cd712cdef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] +* feat: allow tabs to have different sizes if clients aren't focused on the same one (https://github.com/zellij-org/zellij/pull/5133) ## [0.44.2] - 2026-05-05 * fix: idle CPU + disk i/o, CommandChanged plugin Event, GetSessionList plugin command (https://github.com/zellij-org/zellij/pull/5063) diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs index dea778f42..1cbd02085 100644 --- a/zellij-server/src/lib.rs +++ b/zellij-server/src/lib.rs @@ -642,34 +642,6 @@ impl SessionState { pub fn set_client_data(&mut self, client_id: ClientId, size: Size, is_web_client: bool) { self.clients.insert(client_id, Some((size, is_web_client))); } - pub fn min_client_terminal_size(&self) -> Option { - // None if there are no client sizes - let mut rows: Vec = self - .clients - .values() - .filter_map(|size_and_is_web_client| { - size_and_is_web_client.map(|(size, _is_web_client)| size.rows) - }) - .collect(); - rows.sort_unstable(); - let mut cols: Vec = self - .clients - .values() - .filter_map(|size_and_is_web_client| { - size_and_is_web_client.map(|(size, _is_web_client)| size.cols) - }) - .collect(); - cols.sort_unstable(); - let min_rows = rows.first(); - let min_cols = cols.first(); - match (min_rows, min_cols) { - (Some(min_rows), Some(min_cols)) => Some(Size { - rows: *min_rows, - cols: *min_cols, - }), - _ => None, - } - } pub fn client_ids(&self) -> Vec { self.clients.keys().copied().collect() } @@ -1155,20 +1127,12 @@ pub fn start_server(mut os_input: Box, socket_path: PathBuf) { client_attributes.size, is_web_client, ); - let min_size = session_state - .read() - .unwrap() - .min_client_terminal_size() - .unwrap(); - session_data - .senders - .send_to_screen(ScreenInstruction::TerminalResize(min_size)) - .unwrap(); session_data .senders .send_to_screen(ScreenInstruction::AddClient( client_id, is_web_client, + client_attributes.size, tab_position_to_focus, pane_id_to_focus, )) @@ -1319,17 +1283,6 @@ pub fn start_server(mut os_input: Box, socket_path: PathBuf) { // Handle regular client removal remove_client!(client_id, os_input, session_state, session_data); drop(completion_tx); // prevent deadlock with route thread - if let Some(min_size) = session_state.read().unwrap().min_client_terminal_size() - { - session_data - .write() - .unwrap() - .as_ref() - .unwrap() - .senders - .send_to_screen(ScreenInstruction::TerminalResize(min_size)) - .unwrap(); - } session_data .write() .unwrap() @@ -1393,17 +1346,6 @@ pub fn start_server(mut os_input: Box, socket_path: PathBuf) { } else { // Handle regular client removal remove_client!(client_id, os_input, session_state, session_data); - if let Some(min_size) = session_state.read().unwrap().min_client_terminal_size() - { - session_data - .write() - .unwrap() - .as_ref() - .unwrap() - .senders - .send_to_screen(ScreenInstruction::TerminalResize(min_size)) - .unwrap(); - } session_data .write() .unwrap() @@ -1430,16 +1372,6 @@ pub fn start_server(mut os_input: Box, socket_path: PathBuf) { }, ); remove_client!(client_id, os_input, session_state, session_data); - if let Some(min_size) = session_state.read().unwrap().min_client_terminal_size() { - session_data - .write() - .unwrap() - .as_ref() - .unwrap() - .senders - .send_to_screen(ScreenInstruction::TerminalResize(min_size)) - .unwrap(); - } }, ServerInstruction::KillSession => { let client_ids = session_state.read().unwrap().client_ids(); @@ -1488,17 +1420,6 @@ pub fn start_server(mut os_input: Box, socket_path: PathBuf) { // by us having to wait for session_data to send cleanup // signals to the various threads for client_id in client_ids { - if let Some(min_size) = session_state.read().unwrap().min_client_terminal_size() - { - session_data - .write() - .unwrap() - .as_ref() - .unwrap() - .senders - .send_to_screen(ScreenInstruction::TerminalResize(min_size)) - .unwrap(); - } session_data .write() .unwrap() @@ -1643,17 +1564,6 @@ pub fn start_server(mut os_input: Box, socket_path: PathBuf) { remove_client!(client_id, os_input, session_state, session_data); drop(completion_tx); // do not deadlock with route thread - if let Some(min_size) = session_state.read().unwrap().min_client_terminal_size() - { - session_data - .write() - .unwrap() - .as_ref() - .unwrap() - .senders - .send_to_screen(ScreenInstruction::TerminalResize(min_size)) - .unwrap(); - } session_data .write() .unwrap() diff --git a/zellij-server/src/route.rs b/zellij-server/src/route.rs index 849e34050..9fad8ddc3 100644 --- a/zellij-server/src/route.rs +++ b/zellij-server/src/route.rs @@ -2427,26 +2427,16 @@ pub(crate) fn route_thread_main( .to_anyhow() .with_context(err_context)? .set_client_size(client_id, new_size); - // min_client_terminal_size() skips clients whose - // entry is still None — i.e. new_client() was - // called but set_client_data() hasn't been yet. - // set_client_size() above is a no-op in that - // case (it doesn't upgrade None to Some). This - // can happen if a resize arrives before the - // initial connection setup completes; the server - // will query the terminal size once it does. - if let Some(min_size) = session_state - .read() - .to_anyhow() - .with_context(err_context)? - .min_client_terminal_size() - { - let _ = senders.as_ref().map(|s| { - s.send_to_screen(ScreenInstruction::TerminalResize( - min_size, - )) - }); - } + // Per-tab sizing: Screen's RecomputeTabSize + // handler is a no-op for clients without an + // active tab yet (i.e. resizes arriving + // before AddClient is processed), so no + // session-level gating is needed. + let _ = senders.as_ref().map(|s| { + s.send_to_screen(ScreenInstruction::RecomputeTabSize( + client_id, new_size, + )) + }); } }, ClientToServerMsg::TerminalPixelDimensions { pixel_dimensions } => { diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs index fd9a70148..c12b060d3 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -497,6 +497,9 @@ pub enum ScreenInstruction { completion_tx: Option, }, TerminalResize(Size), + /// Update a regular client's known viewport size and recompute the size of + /// that client's active tab. `(client_id, new_size)`. + RecomputeTabSize(ClientId, Size), TerminalPixelDimensions(PixelDimensions), TerminalBackgroundColor(String), TerminalForegroundColor(String), @@ -551,6 +554,7 @@ pub enum ScreenInstruction { AddClient( ClientId, bool, // is_web_client + Size, // client viewport size — used for per-tab sizing Option, // tab position to focus Option<(u32, bool)>, // (pane_id, is_plugin) => pane_id to focus ), @@ -975,6 +979,7 @@ impl From<&ScreenInstruction> for ScreenContext { ScreenInstruction::RenameTabWithId(..) => ScreenContext::RenameTabWithId, ScreenInstruction::BreakPanesToTabWithId { .. } => ScreenContext::BreakPanesToTabWithId, ScreenInstruction::TerminalResize(..) => ScreenContext::TerminalResize, + ScreenInstruction::RecomputeTabSize(..) => ScreenContext::RecomputeTabSize, ScreenInstruction::TerminalPixelDimensions(..) => { ScreenContext::TerminalPixelDimensions }, @@ -1368,6 +1373,8 @@ pub(crate) struct Screen { connected_clients: Rc>>, // bool -> is_web_client /// The indices of this [`Screen`]'s active [`Tab`]s. active_tab_ids: BTreeMap, + /// Per-regular-client viewport sizes, used to compute per-tab sizing. + client_sizes: HashMap, global_last_active_tab_id: usize, tab_history: BTreeMap>, pane_history: BTreeMap>, @@ -1554,6 +1561,7 @@ impl Screen { style: client_attributes.style, connected_clients: Rc::new(RefCell::new(HashMap::new())), active_tab_ids: BTreeMap::new(), + client_sizes: HashMap::new(), global_last_active_tab_id: 0, tabs: BTreeMap::new(), terminal_emulator_colors: Rc::new(RefCell::new(Palette::default())), @@ -1859,6 +1867,14 @@ impl Screen { .send_to_background_jobs(BackgroundJob::StopFlashTabBell(tab_id)); } + // Both the source and destination tabs may have changed + // their viewer sets; per-tab sizing is independent so each + // is recomputed against its current viewers. + self.recompute_tab_size(current_tab_index) + .with_context(err_context)?; + self.recompute_tab_size(new_tab_index) + .with_context(err_context)?; + self.log_and_report_session_state() .with_context(err_context)?; return self.render(None).with_context(err_context); @@ -2064,6 +2080,50 @@ impl Screen { } } + /// Record the viewport size most recently reported by `client_id`. Used as + /// input to per-tab size computation; does not by itself trigger a resize. + pub fn set_client_size(&mut self, client_id: ClientId, size: Size) { + self.client_sizes.insert(client_id, size); + } + + /// Recompute the size of `tab_id` from the viewports of every client whose + /// `active_tab_ids` entry equals `tab_id`. `rows` and `cols` are sorted + /// independently — each axis takes the minimum across viewers. If the tab + /// has no viewers the size is left untouched (the tab retains its most + /// recent viewer-derived dimensions). When the computed size differs from + /// the tab's current size, the tab is resized (via `resize_whole_tab`) + /// and a force-render is scheduled. + pub fn recompute_tab_size(&mut self, tab_id: usize) -> Result<()> { + let err_context = || format!("failed to recompute size for tab {tab_id}"); + + let mut rows: Vec = Vec::new(); + let mut cols: Vec = Vec::new(); + for (client_id, active_tab) in self.active_tab_ids.iter() { + if *active_tab == tab_id { + if let Some(size) = self.client_sizes.get(client_id) { + rows.push(size.rows); + cols.push(size.cols); + } + } + } + if rows.is_empty() || cols.is_empty() { + return Ok(()); + } + rows.sort_unstable(); + cols.sort_unstable(); + let new_size = Size { + rows: rows[0], + cols: cols[0], + }; + if let Some(tab) = self.tabs.get_mut(&tab_id) { + if tab.size != new_size { + tab.resize_whole_tab(new_size).with_context(err_context)?; + tab.set_force_render(); + } + } + Ok(()) + } + pub fn update_pixel_dimensions(&mut self, pixel_dimensions: PixelDimensions) { self.pixel_dimensions.merge(pixel_dimensions); if let Some(character_cell_size) = self.pixel_dimensions.character_cell_size { @@ -3021,6 +3081,11 @@ impl Screen { .with_context(err_context)?; } + // The new tab was just resized to `self.size` above as a default; if + // any clients have been moved onto it, recompute to fit their actual + // viewports. + self.recompute_tab_size(tab_id).with_context(err_context)?; + self.log_and_report_session_state() .and_then(|_| self.render(None)) .with_context(err_context) @@ -3064,7 +3129,12 @@ impl Screen { .get_mut(&tab_index) .with_context(|| err_context(tab_index))? .add_client(client_id, None) - .with_context(|| err_context(tab_index)) + .with_context(|| err_context(tab_index))?; + // Resize the newly-active tab to the min of all viewers (this client + // may shrink it, or may be the first viewer of an empty tab). + self.recompute_tab_size(tab_index) + .with_context(|| err_context(tab_index))?; + Ok(()) } pub fn remove_client(&mut self, client_id: ClientId) -> Result<()> { @@ -3093,15 +3163,23 @@ impl Screen { tab.visible(false).with_context(err_context)?; } } - if self.active_tab_ids.contains_key(&client_id) { - self.global_last_active_tab_id = *self.active_tab_ids.get(&client_id).unwrap(); + let previously_active_tab_id = self.active_tab_ids.get(&client_id).copied(); + if let Some(prev_tab_id) = previously_active_tab_id { + self.global_last_active_tab_id = prev_tab_id; self.active_tab_ids.remove(&client_id); } if self.tab_history.contains_key(&client_id) { self.tab_history.remove(&client_id); } self.connected_clients.borrow_mut().remove(&client_id); + self.client_sizes.remove(&client_id); self.pane_render_subscribers.remove(&client_id); + // The vacated tab may have lost its smallest viewer; recompute so it + // can grow back to fit the remaining clients (no-op if none remain). + if let Some(prev_tab_id) = previously_active_tab_id { + self.recompute_tab_size(prev_tab_id) + .with_context(err_context)?; + } self.log_and_report_session_state() .with_context(err_context) } @@ -7222,6 +7300,15 @@ pub(crate) fn screen_thread_main( screen.log_and_report_session_state()?; // update tabs so that the ui indication will be send to the plugins screen.render(None)?; }, + ScreenInstruction::RecomputeTabSize(client_id, new_size) => { + screen.set_client_size(client_id, new_size); + let active_tab_id = screen.active_tab_ids.get(&client_id).copied(); + if let Some(tab_id) = active_tab_id { + screen.recompute_tab_size(tab_id)?; + screen.log_and_report_session_state()?; + screen.render(None)?; + } + }, ScreenInstruction::TerminalPixelDimensions(pixel_dimensions) => { screen.update_pixel_dimensions(pixel_dimensions); }, @@ -7331,9 +7418,14 @@ pub(crate) fn screen_thread_main( ScreenInstruction::AddClient( client_id, is_web_client, + client_size, tab_position_to_focus, pane_id_to_focus, ) => { + // Record the client's viewport BEFORE add_client so that + // add_client's internal recompute sees this client's size and + // sizes the destination tab against all of its viewers. + screen.set_client_size(client_id, client_size); screen.add_client(client_id, is_web_client)?; let pane_id = pane_id_to_focus.map(|(pane_id, is_plugin)| { if is_plugin { diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index 1b8f12e90..83db1249c 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -158,6 +158,11 @@ pub(crate) struct Tab { pub position: usize, pub name: String, pub prev_name: String, + /// The tab's current viewport size, computed as `min(rows)` and `min(cols)` + /// independently across the clients whose `active_tab_id` equals this tab. + /// When the tab has no viewers it retains its most recent value (the + /// recompute path simply skips empty tabs). + pub size: Size, tiled_panes: TiledPanes, floating_panes: FloatingPanes, suppressed_panes: SuppressedPanes, @@ -769,6 +774,7 @@ impl Tab { if let Some(client_id) = client_id { connected_clients.insert(client_id); } + let initial_size = display_area; let viewport: Viewport = display_area.into(); let viewport = Rc::new(RefCell::new(viewport)); let display_area = Rc::new(RefCell::new(display_area)); @@ -818,6 +824,7 @@ impl Tab { suppressed_panes: HashMap::new(), name: name.clone(), prev_name: name, + size: initial_size, max_panes, viewport, display_area, @@ -3504,6 +3511,7 @@ impl Tab { } pub fn resize_whole_tab(&mut self, new_screen_size: Size) -> Result<()> { let err_context = || format!("failed to resize whole tab (id {})", self.id); + self.size = new_screen_size; // If a tiled pane is fullscreen, exit fullscreen first so that *all* // tiled panes (including the currently hidden ones) participate in the // resize/relayout. We re-enter fullscreen on the same pane after the diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs index 1638af267..e31816d30 100644 --- a/zellij-server/src/unit/screen_tests.rs +++ b/zellij-server/src/unit/screen_tests.rs @@ -9455,3 +9455,392 @@ fn empty_reply_with_paused_pane_drains_buffer_without_phantom_write() { "queue fully consumed by post-resume processing" ); } + +// --- per-tab viewport sizing (Stage 1 of mobile_view_plan.md) ------------- + +// All five tests below exercise `Screen::recompute_tab_size`, which the +// production code invokes from `ScreenInstruction::AddClient`, +// `ScreenInstruction::RecomputeTabSize` (runtime resize), `remove_client`, +// and `switch_active_tab`. Tests drive the helper directly so the assertions +// don't depend on instruction-bus plumbing. + +/// Variant of `create_new_screen` with `session_is_mirrored = false`. The +/// payoff of per-tab sizing only shows up when clients can sit on different +/// tabs; mirrored mode forces every client to share the active tab. +fn create_non_mirrored_screen(size: Size) -> Screen { + let mut bus: Bus = Bus::empty(); + let fake_os_input = FakeInputOutput::default(); + bus.os_input = Some(Box::new(fake_os_input)); + let client_attributes = ClientAttributes { + size, + ..Default::default() + }; + let mut mode_info = ModeInfo::default(); + mode_info.session_name = Some("zellij-test".into()); + Screen::new( + bus, + &client_attributes, + None, // max_panes + mode_info, + false, // draw_pane_frames + true, // auto_layout + false, // session_is_mirrored + CopyOptions::default(), + false, // debug + Box::new(Layout::default()), + None, // default_layout_name + PathBuf::from("my_default_shell"), + true, // session_serialization + false, // serialize_pane_viewport + None, // scrollback_lines_to_serialize + true, // styled_underlines + true, // osc8_hyperlinks + true, // arrow_fonts + None, // layout_dir + false, // explicitly_disable_kitty_keyboard_protocol + true, // stacked_resize + None, + false, + WebSharing::Off, + true, // advanced_mouse_actions + true, // mouse_hover_effects + true, // visual_bell + false, // focus_follows_mouse + false, // mouse_click_through + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), + 8080, + ) +} + +#[test] +fn recompute_tab_size_uses_lone_viewer_size() { + let initial_size = Size { + cols: 200, + rows: 60, + }; + let mut screen = create_new_screen(initial_size, true, true); + new_tab(&mut screen, 1, 0); + + let client_id = 1; + let client_size = Size { cols: 80, rows: 24 }; + screen.set_client_size(client_id, client_size); + screen.recompute_tab_size(0).expect("TEST"); + + assert_eq!( + screen.tabs.get(&0).unwrap().size, + client_size, + "Tab adopts its lone viewer's size" + ); +} + +#[test] +fn recompute_tab_size_takes_independent_min_across_axes() { + let initial_size = Size { + cols: 200, + rows: 60, + }; + let mut screen = create_new_screen(initial_size, true, true); + new_tab(&mut screen, 1, 0); + // Drop the second client onto the same tab as client 1. + screen.add_client(2, false).expect("TEST"); + + // Wide-but-short and narrow-but-tall: rows and cols must min independently, + // not be paired by client. + screen.set_client_size( + 1, + Size { + cols: 200, + rows: 24, + }, + ); + screen.set_client_size(2, Size { cols: 80, rows: 60 }); + screen.recompute_tab_size(0).expect("TEST"); + + assert_eq!( + screen.tabs.get(&0).unwrap().size, + Size { cols: 80, rows: 24 }, + "Tab size = (min cols across viewers, min rows across viewers)" + ); +} + +#[test] +fn recompute_tab_size_isolates_tabs_with_different_viewers() { + let initial_size = Size { + cols: 200, + rows: 60, + }; + // Non-mirrored: per-tab sizing's whole point is letting clients sit on + // different tabs without one dragging the other's grid. + let mut screen = create_non_mirrored_screen(initial_size); + new_tab(&mut screen, 1, 0); + new_tab(&mut screen, 2, 1); + // Client 1 is on tab 1 (the most recent). Pull client 2 onto tab 0 so + // tabs have one viewer each. + screen.add_client(2, false).expect("TEST"); + screen.set_client_size(1, Size { cols: 80, rows: 24 }); + screen.set_client_size( + 2, + Size { + cols: 160, + rows: 50, + }, + ); + screen.switch_active_tab(0, None, true, 2).expect("TEST"); + + assert_eq!( + screen.tabs.get(&1).unwrap().size, + Size { cols: 80, rows: 24 }, + "Tab 1 sized to its lone viewer (client 1)" + ); + assert_eq!( + screen.tabs.get(&0).unwrap().size, + Size { + cols: 160, + rows: 50, + }, + "Tab 0 sized to its lone viewer (client 2), unaffected by client 1's smaller viewport" + ); +} + +#[test] +fn switching_tabs_recomputes_source_and_destination() { + let initial_size = Size { + cols: 200, + rows: 60, + }; + let mut screen = create_non_mirrored_screen(initial_size); + new_tab(&mut screen, 1, 0); + new_tab(&mut screen, 2, 1); + screen.add_client(2, false).expect("TEST"); + + // Both clients start on tab 1 (most recently created). Give them + // different sizes; the smaller wins on tab 1. + screen.set_client_size(1, Size { cols: 80, rows: 24 }); + screen.set_client_size( + 2, + Size { + cols: 160, + rows: 50, + }, + ); + screen.recompute_tab_size(1).expect("TEST"); + assert_eq!( + screen.tabs.get(&1).unwrap().size, + Size { cols: 80, rows: 24 }, + "Both clients on tab 1 → it sizes to the smaller" + ); + + // Move the smaller client to tab 0. Tab 1 must grow back to fit its + // remaining viewer; tab 0 must shrink to fit the arriving one. + screen.switch_active_tab(0, None, true, 1).expect("TEST"); + + assert_eq!( + screen.tabs.get(&1).unwrap().size, + Size { + cols: 160, + rows: 50, + }, + "Source tab grows back to fit its remaining viewer" + ); + assert_eq!( + screen.tabs.get(&0).unwrap().size, + Size { cols: 80, rows: 24 }, + "Destination tab shrinks to fit the arriving smaller viewer" + ); +} + +#[test] +fn break_pane_to_new_tab_recomputes_source_and_destination() { + // `break_pane_to_new_tab` extracts the active pane and switches the + // requesting client to an adjacent tab via `switch_tab_next`/`prev`. That + // routes through `switch_active_tab`, so per-tab sizing must follow the + // moving client. + let initial_size = Size { + cols: 200, + rows: 60, + }; + let mut screen = create_non_mirrored_screen(initial_size); + new_tab(&mut screen, 1, 0); + new_tab(&mut screen, 2, 1); + screen.add_client(2, false).expect("TEST"); + + // Park each client on its own tab with its own viewport. + screen.set_client_size(1, Size { cols: 80, rows: 24 }); + screen.set_client_size( + 2, + Size { + cols: 160, + rows: 50, + }, + ); + // After new_tab(2), client 1 is on tab id=1 (the new one). Pull it back + // onto tab id=0 so the two clients are split across the two tabs. + screen.switch_active_tab(0, None, true, 1).expect("TEST"); + assert_eq!( + screen.tabs.get(&0).unwrap().size, + Size { cols: 80, rows: 24 }, + "Pre-condition: tab 0 sized to client 1" + ); + assert_eq!( + screen.tabs.get(&1).unwrap().size, + Size { + cols: 160, + rows: 50, + }, + "Pre-condition: tab 1 sized to client 2" + ); + + // Give tab 0 a second pane so break_pane_to_new_tab has something to + // extract — but the per-tab sizing assertions are about clients, not + // panes; the second pane is just to satisfy the function's preconditions. + { + let active_tab = screen.get_active_tab_mut(1).unwrap(); + active_tab + .new_pane( + PaneId::Terminal(99), + None, + None, + false, + true, + NewPanePlacement::default(), + Some(1), + None, + ) + .unwrap(); + } + + // Move client 1's active pane rightward into tab 1. The function + // synchronously calls switch_tab_next → switch_active_tab(client 1, tab 1). + screen + .break_pane_to_new_tab(Direction::Right, 1) + .expect("TEST"); + + // Tab 1 now has both clients → recomputes to the smaller viewport. + // Tab 0 has no viewers → its size is left untouched. + assert_eq!( + screen.tabs.get(&1).unwrap().size, + Size { cols: 80, rows: 24 }, + "Destination tab shrinks to fit the arriving smaller viewer" + ); + assert_eq!( + screen.tabs.get(&0).unwrap().size, + Size { cols: 80, rows: 24 }, + "Source tab is empty — recompute is a no-op, last viewer-derived size is preserved" + ); +} + +#[test] +fn moving_panes_between_tabs_with_focus_change_recomputes_both() { + // `break_multiple_panes_to_tab_with_index` with `should_change_focus = true` + // calls `go_to_tab` on the requesting client. That hits `switch_active_tab`, + // so the source tab grows back and the destination shrinks to fit. + let initial_size = Size { + cols: 200, + rows: 60, + }; + let mut screen = create_non_mirrored_screen(initial_size); + new_tab(&mut screen, 1, 0); + new_tab(&mut screen, 2, 1); + screen.add_client(2, false).expect("TEST"); + + // Both clients on tab 1 (the most recent), small client wins. + screen.set_client_size(1, Size { cols: 80, rows: 24 }); + screen.set_client_size( + 2, + Size { + cols: 160, + rows: 50, + }, + ); + // Park client 2 alone on tab 0 so the two tabs are differently sized. + screen.switch_active_tab(0, None, true, 2).expect("TEST"); + assert_eq!( + screen.tabs.get(&0).unwrap().size, + Size { + cols: 160, + rows: 50, + }, + "Pre-condition: tab 0 sized to client 2" + ); + assert_eq!( + screen.tabs.get(&1).unwrap().size, + Size { cols: 80, rows: 24 }, + "Pre-condition: tab 1 sized to client 1" + ); + + // Add a movable pane on tab 1 (where client 1 lives). + let pane_to_move = PaneId::Terminal(42); + { + let active_tab = screen.get_active_tab_mut(1).unwrap(); + active_tab + .new_pane( + pane_to_move, + None, + None, + false, + true, + NewPanePlacement::default(), + Some(1), + None, + ) + .unwrap(); + } + + // Move the pane onto tab at position 0 AND switch client 1's focus there. + // Client 1 leaves tab 1 → tab 1 grows back to client 2's size... wait, + // client 2 already left tab 1 above. Tab 1 will be empty, so it keeps its + // last size. Tab 0 gains client 1 alongside client 2 → recomputes to the + // smaller of the two viewports. + screen + .break_multiple_panes_to_tab_with_index(vec![pane_to_move], 0, true, 1) + .expect("TEST"); + + assert_eq!( + screen.tabs.get(&0).unwrap().size, + Size { cols: 80, rows: 24 }, + "Destination tab shrinks to min(viewers) once the smaller client arrives" + ); + assert_eq!( + screen.tabs.get(&1).unwrap().size, + Size { cols: 80, rows: 24 }, + "Source tab is empty — last viewer-derived size is preserved" + ); +} + +#[test] +fn detaching_client_grows_vacated_tab_back() { + let initial_size = Size { + cols: 200, + rows: 60, + }; + let mut screen = create_new_screen(initial_size, true, true); + new_tab(&mut screen, 1, 0); + screen.add_client(2, false).expect("TEST"); + + // Tab 0 has both clients; the smaller one defines its size. + screen.set_client_size( + 1, + Size { + cols: 160, + rows: 50, + }, + ); + screen.set_client_size(2, Size { cols: 80, rows: 24 }); + screen.recompute_tab_size(0).expect("TEST"); + assert_eq!( + screen.tabs.get(&0).unwrap().size, + Size { cols: 80, rows: 24 }, + "Smaller viewer wins while both clients are on the tab" + ); + + // The smaller client leaves; the tab must grow to fit the remaining one. + screen.remove_client(2).expect("TEST"); + assert_eq!( + screen.tabs.get(&0).unwrap().size, + Size { + cols: 160, + rows: 50, + }, + "Tab grows back to fit the remaining viewer after the smaller one detaches" + ); +} diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs index 6aacabb70..6fa151415 100644 --- a/zellij-utils/src/errors.rs +++ b/zellij-utils/src/errors.rs @@ -306,6 +306,7 @@ pub enum ScreenContext { RenameTabWithId, BreakPanesToTabWithId, TerminalResize, + RecomputeTabSize, TerminalPixelDimensions, TerminalBackgroundColor, TerminalForegroundColor,