mirror of
https://github.com/zellij-org/zellij.git
synced 2026-10-11 22:50:07 +00:00
Independent tab sizes (#5133)
* feat: allow independent tab resizing * rustfmt * add pr
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<Size> {
|
||||
// None if there are no client sizes
|
||||
let mut rows: Vec<usize> = 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<usize> = 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<ClientId> {
|
||||
self.clients.keys().copied().collect()
|
||||
}
|
||||
@@ -1155,20 +1127,12 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, 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<dyn ServerOsApi>, 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<dyn ServerOsApi>, 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<dyn ServerOsApi>, 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<dyn ServerOsApi>, 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<dyn ServerOsApi>, 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()
|
||||
|
||||
+10
-20
@@ -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 } => {
|
||||
|
||||
@@ -497,6 +497,9 @@ pub enum ScreenInstruction {
|
||||
completion_tx: Option<NotificationEnd>,
|
||||
},
|
||||
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<usize>, // 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<RefCell<HashMap<ClientId, bool>>>, // bool -> is_web_client
|
||||
/// The indices of this [`Screen`]'s active [`Tab`]s.
|
||||
active_tab_ids: BTreeMap<ClientId, usize>,
|
||||
/// Per-regular-client viewport sizes, used to compute per-tab sizing.
|
||||
client_sizes: HashMap<ClientId, Size>,
|
||||
global_last_active_tab_id: usize,
|
||||
tab_history: BTreeMap<ClientId, Vec<usize>>,
|
||||
pane_history: BTreeMap<ClientId, Vec<PaneId>>,
|
||||
@@ -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<usize> = Vec::new();
|
||||
let mut cols: Vec<usize> = 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ScreenInstruction> = 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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -306,6 +306,7 @@ pub enum ScreenContext {
|
||||
RenameTabWithId,
|
||||
BreakPanesToTabWithId,
|
||||
TerminalResize,
|
||||
RecomputeTabSize,
|
||||
TerminalPixelDimensions,
|
||||
TerminalBackgroundColor,
|
||||
TerminalForegroundColor,
|
||||
|
||||
Reference in New Issue
Block a user