aboutsummaryrefslogtreecommitdiffstats
path: root/zellij-server
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-06-10 16:50:55 +0200
committerGitHub <noreply@github.com>2026-06-10 16:50:55 +0200
commitb6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0 (patch)
tree38c77a381cfb937b428752a588c09a45c81434c9 /zellij-server
parentfeat: PWA support for the web client (#5184) (diff)
downloadsl0ppij-b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0.tar.gz
sl0ppij-b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0.tar.xz
sl0ppij-b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0.zip
Mobile layout (#5241)
* phase 2+3 * initial mobile plugin (phases 4 + 5 + 6) * some bugfixes * implement stage 7 * mobile interface improvements * improve mobile pane/tab picker * more ui improvements and fixes * various mobile fixes * some mobile fixes * mobile keyboard triggering fix * some cleanups * initial plugin keyboard implementation * improve keyboard hit zones * improve keyboard * pinch resize * pinching fixes * implement scrolling + panning * initial keyboard resizing * fix: mobile first render issues * keyboard responsiveness * fix some issues with the keyboard * implement fit * fix mobile scrolling * add tilde cell to compact * improve mobile keyboard * fix starting mobile terminal font to natural keyboard size * fix mobile resize blank screen flash * shadow focus for mobile * more keyboard fixes * improve mobile status bar * fix: render plugins to mobile clients * allow opening new panes/tabs from the mobile plugin * allow configuring * fix dot slash * improve mobile native soft keyboard interaction * switch to native keyboard - initial implementation * dirty hacks, but phone keyboard works now * adjust keyboard help strip * fix cursor in mobile * fix keyboard and help line * rename keyboard to modifier bar * allow starting new session * make clicking session name bring us to new session menu * allow switching to desktop from the mobile ui * mobile welcome screen initial implementation * mobile welcome screen fixes * various welcome screen fixes * more mobile fixes * moar mobile fixes * mobile ui redesign * fix: swallowed backspace bug * switch session screen improvement * touch up some interfaces * refactor: move mobile state to own struct * refactor: move fit resizing to the server * remove dead flash code * some cleanups * refactor: clear fit on close * refactor: improve fit api * refactor: pane sync functions * refactor: mouse * refactor: break into modules * some cleanups for the sessions screen * fix scrolling and other stuff * refactor: sessions screen * refactor: panes screen * refactor: new session screen * refactor: menu screen * refactor: render * cleanups * refactor: plugin structure * remove unused welcome screen path * Rename api * refactor input.js * cleanup useless comments * fit/mobile refactoring * take out the garbage * remove more comments * remove mobile flash on startup/attach * fix: various mobile startup issues * fix space bugs * cleanup * rustfmt * fix windows build * adjust modifier bar ui * add pr
Diffstat (limited to 'zellij-server')
-rw-r--r--zellij-server/src/background_jobs.rs16
-rw-r--r--zellij-server/src/lib.rs63
-rw-r--r--zellij-server/src/mobile_mode.rs733
-rw-r--r--zellij-server/src/panes/active_panes.rs25
-rw-r--r--zellij-server/src/panes/floating_panes/mod.rs40
-rw-r--r--zellij-server/src/panes/grid.rs17
-rw-r--r--zellij-server/src/panes/plugin_pane.rs18
-rw-r--r--zellij-server/src/panes/tiled_panes/mod.rs46
-rw-r--r--zellij-server/src/plugins/mod.rs10
-rw-r--r--zellij-server/src/plugins/wasm_bridge.rs74
-rw-r--r--zellij-server/src/plugins/zellij_exports.rs139
-rw-r--r--zellij-server/src/route.rs68
-rw-r--r--zellij-server/src/screen.rs688
-rw-r--r--zellij-server/src/tab/mod.rs111
-rw-r--r--zellij-server/src/tab/mouse_handler.rs138
-rw-r--r--zellij-server/src/tab/unit/tab_integration_tests.rs22
-rw-r--r--zellij-server/src/tab/unit/tab_tests.rs399
-rw-r--r--zellij-server/src/unit/screen_tests.rs1322
18 files changed, 3682 insertions, 247 deletions
diff --git a/zellij-server/src/background_jobs.rs b/zellij-server/src/background_jobs.rs
index 0ea1704d..c01df526 100644
--- a/zellij-server/src/background_jobs.rs
+++ b/zellij-server/src/background_jobs.rs
@@ -74,6 +74,7 @@ pub enum BackgroundJob {
StopFlashPaneBell(Vec<PaneId>),
FlashTabBell(usize), // usize = tab_id
StopFlashTabBell(usize), // usize = tab_id
+ MobileGateTimeout(ClientId),
Exit,
}
@@ -102,12 +103,14 @@ impl From<&BackgroundJob> for BackgroundJobContext {
BackgroundJob::StopFlashPaneBell(..) => BackgroundJobContext::StopFlashPaneBell,
BackgroundJob::FlashTabBell(..) => BackgroundJobContext::FlashTabBell,
BackgroundJob::StopFlashTabBell(..) => BackgroundJobContext::StopFlashTabBell,
+ BackgroundJob::MobileGateTimeout(..) => BackgroundJobContext::MobileGateTimeout,
BackgroundJob::Exit => BackgroundJobContext::Exit,
}
}
}
static LONG_FLASH_DURATION_MS: u64 = 1000;
+static MOBILE_GATE_FALLBACK_LIFT_TIMEOUT_MS: u64 = 5000;
static FLASH_DURATION_MS: u64 = 400; // Doherty threshold
static PLUGIN_ANIMATION_OFFSET_DURATION_MD: u64 = 500;
static SESSION_METADATA_WRITE_INTERVAL_MS: u64 = 1000;
@@ -621,6 +624,19 @@ pub(crate) fn background_jobs_main(
.senders
.send_to_screen(ScreenInstruction::SetTabBellFlash(tab_id, false));
},
+ BackgroundJob::MobileGateTimeout(client_id) => {
+ runtime.spawn({
+ let senders = bus.senders.clone();
+ async move {
+ tokio::time::sleep(Duration::from_millis(
+ MOBILE_GATE_FALLBACK_LIFT_TIMEOUT_MS,
+ ))
+ .await;
+ let _ =
+ senders.send_to_screen(ScreenInstruction::ForceMobileUngate(client_id));
+ }
+ });
+ },
BackgroundJob::Exit => {
for loading_plugin in loading_plugins.values() {
loading_plugin.store(false, Ordering::SeqCst);
diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs
index 29aabdd0..841fcce6 100644
--- a/zellij-server/src/lib.rs
+++ b/zellij-server/src/lib.rs
@@ -14,6 +14,7 @@ pub mod tab;
pub mod background_jobs;
mod global_async_runtime;
mod logging_pipe;
+mod mobile_mode;
mod pane_groups;
mod plugins;
mod pty;
@@ -984,6 +985,22 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
is_web_client,
);
+ let should_enter_mobile = should_enter_mobile_on_connect(
+ &runtime_config_options,
+ is_web_client,
+ client_attributes.size,
+ );
+ if should_enter_mobile {
+ session_data
+ .read()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_screen(ScreenInstruction::SuppressRenderUntilMobile(client_id))
+ .unwrap();
+ }
+
let default_shell = runtime_config_options.default_shell.map(|shell| {
TerminalAction::RunCommand(RunCommand {
command: shell,
@@ -1080,6 +1097,17 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
.senders
.send_to_plugin(PluginInstruction::AddClient(client_id))
.unwrap();
+
+ if should_enter_mobile {
+ session_data
+ .read()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_screen(ScreenInstruction::EnterMobileMode(client_id, None))
+ .unwrap();
+ }
},
ServerInstruction::AttachClient(
cli_assets,
@@ -1123,6 +1151,19 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
client_attributes.size,
is_web_client,
);
+
+ let should_enter_mobile = should_enter_mobile_on_connect(
+ &runtime_config_options,
+ is_web_client,
+ client_attributes.size,
+ );
+ if should_enter_mobile {
+ session_data
+ .senders
+ .send_to_screen(ScreenInstruction::SuppressRenderUntilMobile(client_id))
+ .unwrap();
+ }
+
session_data
.senders
.send_to_screen(ScreenInstruction::AddClient(
@@ -1149,6 +1190,13 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
None,
))
.unwrap();
+
+ if should_enter_mobile {
+ session_data
+ .senders
+ .send_to_screen(ScreenInstruction::EnterMobileMode(client_id, None))
+ .unwrap();
+ }
},
ServerInstruction::AttachWatcherClient(client_id, terminal_size, is_web_client) => {
// the client_id was inserted into clients upon ipc tunnel initialization
@@ -2197,6 +2245,21 @@ fn should_show_startup_tip(
}
}
+fn should_enter_mobile_on_connect(options: &Options, is_web_client: bool, viewport: Size) -> bool {
+ let mobile_layout = options.mobile_layout.unwrap_or_default();
+ if is_web_client {
+ mobile_layout.may_route_web_client_to_mobile()
+ } else {
+ mobile_layout.should_route_to_mobile(
+ is_web_client,
+ viewport.cols,
+ viewport.rows,
+ options.mobile_threshold_cols.unwrap_or(60),
+ options.mobile_threshold_rows.unwrap_or(30),
+ )
+ }
+}
+
fn report_changes_in_config_file(
config_file_path: PathBuf,
to_server: SenderWithContext<ServerInstruction>,
diff --git a/zellij-server/src/mobile_mode.rs b/zellij-server/src/mobile_mode.rs
new file mode 100644
index 00000000..a59aded0
--- /dev/null
+++ b/zellij-server/src/mobile_mode.rs
@@ -0,0 +1,733 @@
+use std::collections::{BTreeMap, HashMap, HashSet};
+
+use zellij_utils::errors::prelude::*;
+use zellij_utils::input::layout::{Run, RunPluginOrAlias, TiledPaneLayout};
+use zellij_utils::pane_size::Size;
+
+use crate::panes::PaneId;
+use crate::tab::Tab;
+use crate::ClientId;
+
+pub(crate) const FIT_RESIZE_MAX_ITERS: usize = 3;
+
+const MOBILE_PLUGIN_URL: &str = "zellij:mobile";
+
+#[derive(Debug, Default)]
+pub(crate) struct MobileState {
+ mobile_tab_for_client: HashMap<ClientId, usize>,
+ tab_before_mobile_for_client: HashMap<ClientId, usize>,
+ auto_entered_clients: HashSet<ClientId>,
+ fit_override_for_tab: HashMap<usize, FitOverride>,
+}
+
+#[derive(Debug, Clone, Copy)]
+struct FitOverride {
+ owning_client: ClientId,
+ fullscreened_pane: PaneId,
+ embedded_content_size: Size,
+ pane_was_fullscreen_before_fit: bool,
+}
+
+const CLEAR_SCREEN: &str = "\u{1b}[2J\u{1b}[H";
+
+#[derive(Debug, Default)]
+pub(crate) struct MobileRenderGate {
+ awaiting_first_render: HashSet<ClientId>,
+ clients_with_settled_size: HashSet<ClientId>,
+ last_paint_size: HashMap<ClientId, Size>,
+}
+
+impl MobileRenderGate {
+ pub(crate) fn gate(&mut self, client_id: ClientId) {
+ self.awaiting_first_render.insert(client_id);
+ }
+
+ pub(crate) fn is_gated(&self, client_id: ClientId) -> bool {
+ self.awaiting_first_render.contains(&client_id)
+ }
+
+ pub(crate) fn is_empty(&self) -> bool {
+ self.awaiting_first_render.is_empty()
+ }
+
+ pub(crate) fn gated_clients(&self) -> Vec<ClientId> {
+ self.awaiting_first_render.iter().copied().collect()
+ }
+
+ pub(crate) fn ungate(&mut self, client_id: ClientId) {
+ self.awaiting_first_render.remove(&client_id);
+ self.clients_with_settled_size.remove(&client_id);
+ self.last_paint_size.remove(&client_id);
+ }
+
+ pub(crate) fn record_settled_size(&mut self, client_id: ClientId) {
+ self.clients_with_settled_size.insert(client_id);
+ }
+
+ pub(crate) fn record_paint_size(&mut self, client_id: ClientId, size: Size) {
+ self.last_paint_size.insert(client_id, size);
+ }
+
+ pub(crate) fn try_reveal(
+ &mut self,
+ client_id: ClientId,
+ is_web_client: bool,
+ reported_size: Option<Size>,
+ ) -> bool {
+ if !self.is_gated(client_id) {
+ return false;
+ }
+ let paint_size = self.last_paint_size.get(&client_id).copied();
+ let size_settled = self.clients_with_settled_size.contains(&client_id);
+ let ready_to_reveal = match paint_size {
+ None => false,
+ Some(paint_size) if is_web_client => size_settled && reported_size == Some(paint_size),
+ Some(_) => true,
+ };
+ if ready_to_reveal {
+ self.ungate(client_id);
+ }
+ ready_to_reveal
+ }
+
+ pub(crate) fn blank_gated_clients(&self, serialized_output: &mut HashMap<ClientId, String>) {
+ for (client_id, bytes) in serialized_output.iter_mut() {
+ if self.is_gated(*client_id) {
+ *bytes = CLEAR_SCREEN.to_string();
+ }
+ }
+ }
+}
+
+impl MobileState {
+ pub(crate) fn is_in_mobile_mode(&self, client_id: ClientId) -> bool {
+ self.mobile_tab_for_client.contains_key(&client_id)
+ }
+
+ pub(crate) fn mobile_tab_id(&self, client_id: ClientId) -> Option<usize> {
+ self.mobile_tab_for_client.get(&client_id).copied()
+ }
+
+ pub(crate) fn mobile_tab_ids(&self) -> HashSet<usize> {
+ self.mobile_tab_for_client.values().copied().collect()
+ }
+
+ pub(crate) fn mobile_tab_count(&self) -> usize {
+ self.mobile_tab_for_client.len()
+ }
+
+ pub(crate) fn set_previous_tab(&mut self, client_id: ClientId, previous_tab: Option<usize>) {
+ if let Some(previous_tab) = previous_tab {
+ self.tab_before_mobile_for_client
+ .insert(client_id, previous_tab);
+ }
+ }
+
+ pub(crate) fn register_tab(&mut self, client_id: ClientId, mobile_tab_id: usize) {
+ self.mobile_tab_for_client.insert(client_id, mobile_tab_id);
+ }
+
+ pub(crate) fn begin_exit(&mut self, client_id: ClientId) -> Option<(usize, Option<usize>)> {
+ let mobile_tab_id = self.mobile_tab_for_client.remove(&client_id)?;
+ self.auto_entered_clients.remove(&client_id);
+ let previous_tab = self.tab_before_mobile_for_client.remove(&client_id);
+ Some((mobile_tab_id, previous_tab))
+ }
+
+ pub(crate) fn forget_client(&mut self, client_id: ClientId) {
+ self.mobile_tab_for_client.remove(&client_id);
+ self.tab_before_mobile_for_client.remove(&client_id);
+ self.auto_entered_clients.remove(&client_id);
+ }
+
+ pub(crate) fn was_auto_entered(&self, client_id: ClientId) -> bool {
+ self.auto_entered_clients.contains(&client_id)
+ }
+
+ pub(crate) fn mark_auto_entered(&mut self, client_id: ClientId) {
+ self.auto_entered_clients.insert(client_id);
+ }
+
+ pub(crate) fn mobile_tab_layout() -> Result<TiledPaneLayout> {
+ let mut mobile_tab_layout = TiledPaneLayout::default();
+ let mobile_plugin = RunPluginOrAlias::from_url(MOBILE_PLUGIN_URL, &None, None, None)
+ .map_err(|e| anyhow!("invalid mobile plugin url: {e}"))?;
+ mobile_tab_layout.run = Some(Run::Plugin(mobile_plugin));
+ mobile_tab_layout.borderless = Some(true);
+ Ok(mobile_tab_layout)
+ }
+
+ pub(crate) fn clear_shadow_focus(&self, client_id: ClientId, tabs: &mut BTreeMap<usize, Tab>) {
+ let mobile_tab_id = self.mobile_tab_id(client_id);
+ for tab in tabs.values_mut() {
+ if Some(tab.id) == mobile_tab_id {
+ continue;
+ }
+ tab.clear_shadow_focus(client_id);
+ }
+ }
+
+ pub(crate) fn apply_shadow_focus(
+ &self,
+ client_id: ClientId,
+ pane_id: PaneId,
+ tabs: &mut BTreeMap<usize, Tab>,
+ ) -> ShadowFocusOutcome {
+ let mobile_tab_id = self.mobile_tab_id(client_id);
+ for tab in tabs.values() {
+ if Some(tab.id) == mobile_tab_id {
+ continue;
+ }
+ if tab.has_shadow_focus_on(client_id, pane_id) {
+ return ShadowFocusOutcome::AlreadyApplied;
+ }
+ }
+
+ self.clear_shadow_focus(client_id, tabs);
+ for tab in tabs.values_mut() {
+ if Some(tab.id) == mobile_tab_id {
+ continue;
+ }
+ if tab.set_shadow_focus(client_id, pane_id) {
+ break;
+ }
+ }
+ ShadowFocusOutcome::NewlyApplied
+ }
+
+ pub(crate) fn has_fit(&self, tab_id: usize) -> bool {
+ self.fit_override_for_tab.contains_key(&tab_id)
+ }
+
+ pub(crate) fn compute_fit_size(
+ &self,
+ tab_id: usize,
+ tabs: &BTreeMap<usize, Tab>,
+ ) -> Option<Size> {
+ let fit = self.fit_override_for_tab.get(&tab_id)?;
+ let target_tab = tabs.get(&tab_id)?;
+ let viewport = target_tab.get_viewport();
+ let display_area = target_tab.get_display_area();
+ let tab_bar_rows = display_area.rows.saturating_sub(viewport.rows);
+ let tab_bar_cols = display_area.cols.saturating_sub(viewport.cols);
+ let (frame_rows, frame_cols) = target_tab
+ .get_pane_with_id(fit.fullscreened_pane)
+ .map(|pane| {
+ (
+ pane.rows().saturating_sub(pane.get_content_rows()),
+ pane.cols().saturating_sub(pane.get_content_columns()),
+ )
+ })
+ .unwrap_or((0, 0));
+
+ Some(Size {
+ rows: fit.embedded_content_size.rows + tab_bar_rows + frame_rows,
+ cols: fit.embedded_content_size.cols + tab_bar_cols + frame_cols,
+ })
+ }
+
+ pub(crate) fn set_fit(
+ &mut self,
+ client_id: ClientId,
+ tab_id: usize,
+ pane_id: PaneId,
+ embedded_content_size: Size,
+ tabs: &mut BTreeMap<usize, Tab>,
+ ) {
+ if let Some(existing_fit) = self.fit_override_for_tab.get_mut(&tab_id) {
+ existing_fit.embedded_content_size = embedded_content_size;
+ existing_fit.owning_client = client_id;
+ existing_fit.fullscreened_pane = pane_id;
+ return;
+ }
+
+ let pane_was_fullscreen_before_fit = tabs
+ .get(&tab_id)
+ .map(|tab| tab.is_fullscreen_active() && tab.fullscreen_pane_id() == Some(pane_id))
+ .unwrap_or(false);
+ if !pane_was_fullscreen_before_fit {
+ Self::toggle_fullscreen(tabs, tab_id, pane_id);
+ }
+ self.fit_override_for_tab.insert(
+ tab_id,
+ FitOverride {
+ owning_client: client_id,
+ fullscreened_pane: pane_id,
+ embedded_content_size,
+ pane_was_fullscreen_before_fit,
+ },
+ );
+ }
+
+ pub(crate) fn clear_fit_owned_by(
+ &mut self,
+ client_id: ClientId,
+ tabs: &mut BTreeMap<usize, Tab>,
+ ) -> Option<usize> {
+ let tab_id = self
+ .fit_override_for_tab
+ .iter()
+ .find(|(_, fit)| fit.owning_client == client_id)
+ .map(|(&tab_id, _)| tab_id)?;
+ let fit = self.fit_override_for_tab.remove(&tab_id)?;
+ if !fit.pane_was_fullscreen_before_fit {
+ Self::toggle_fullscreen(tabs, tab_id, fit.fullscreened_pane);
+ }
+ Some(tab_id)
+ }
+
+ pub(crate) fn clear_fit_for_pane(&mut self, pane_id: PaneId) -> Option<usize> {
+ let tab_id = self
+ .fit_override_for_tab
+ .iter()
+ .find(|(_, fit)| fit.fullscreened_pane == pane_id)
+ .map(|(&tab_id, _)| tab_id)?;
+ self.fit_override_for_tab.remove(&tab_id);
+ Some(tab_id)
+ }
+
+ pub(crate) fn remove_fit_for_tab(&mut self, tab_id: usize) {
+ self.fit_override_for_tab.remove(&tab_id);
+ }
+
+ pub(crate) fn clear_fits_owned_by(
+ &mut self,
+ client_id: ClientId,
+ tabs: &mut BTreeMap<usize, Tab>,
+ ) -> Vec<usize> {
+ let owned_fits: Vec<(usize, FitOverride)> = self
+ .fit_override_for_tab
+ .iter()
+ .filter(|(_, fit)| fit.owning_client == client_id)
+ .map(|(&tab_id, fit)| (tab_id, *fit))
+ .collect();
+ let mut tabs_to_recompute = Vec::with_capacity(owned_fits.len());
+ for (tab_id, fit) in owned_fits {
+ self.fit_override_for_tab.remove(&tab_id);
+ if !fit.pane_was_fullscreen_before_fit {
+ Self::toggle_fullscreen(tabs, tab_id, fit.fullscreened_pane);
+ }
+ tabs_to_recompute.push(tab_id);
+ }
+ tabs_to_recompute
+ }
+
+ fn toggle_fullscreen(tabs: &mut BTreeMap<usize, Tab>, tab_id: usize, pane_id: PaneId) {
+ if let Some(tab) = tabs.get_mut(&tab_id) {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.toggle_pane_fullscreen(pane_id);
+ }
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ShadowFocusOutcome {
+ NewlyApplied,
+ AlreadyApplied,
+}
+
+#[cfg(test)]
+impl MobileState {
+ pub(crate) fn previous_tab(&self, client_id: ClientId) -> Option<usize> {
+ self.tab_before_mobile_for_client.get(&client_id).copied()
+ }
+
+ pub(crate) fn fit_count(&self) -> usize {
+ self.fit_override_for_tab.len()
+ }
+
+ pub(crate) fn fit_owner(&self, tab_id: usize) -> Option<ClientId> {
+ self.fit_override_for_tab
+ .get(&tab_id)
+ .map(|fit| fit.owning_client)
+ }
+
+ pub(crate) fn fit_pane(&self, tab_id: usize) -> Option<PaneId> {
+ self.fit_override_for_tab
+ .get(&tab_id)
+ .map(|fit| fit.fullscreened_pane)
+ }
+
+ pub(crate) fn fit_embedded_size(&self, tab_id: usize) -> Option<Size> {
+ self.fit_override_for_tab
+ .get(&tab_id)
+ .map(|fit| fit.embedded_content_size)
+ }
+
+ pub(crate) fn fit_pane_was_fullscreen_before(&self, tab_id: usize) -> Option<bool> {
+ self.fit_override_for_tab
+ .get(&tab_id)
+ .map(|fit| fit.pane_was_fullscreen_before_fit)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const CLIENT_A: ClientId = 1;
+ const CLIENT_B: ClientId = 2;
+
+ fn embedded_size() -> Size {
+ Size { rows: 24, cols: 80 }
+ }
+
+ fn no_tabs() -> BTreeMap<usize, Tab> {
+ BTreeMap::new()
+ }
+
+ fn settled_size() -> Size {
+ Size { rows: 25, cols: 31 }
+ }
+
+ #[test]
+ fn try_reveal_is_false_for_an_ungated_client() {
+ let mut gate = MobileRenderGate::default();
+ assert!(!gate.try_reveal(CLIENT_A, true, Some(settled_size())));
+ }
+
+ #[test]
+ fn web_client_is_not_revealed_before_size_settles() {
+ let mut gate = MobileRenderGate::default();
+ gate.gate(CLIENT_A);
+ gate.record_paint_size(CLIENT_A, settled_size());
+ // Paint arrived but no settled size has been reported yet.
+ assert!(!gate.try_reveal(CLIENT_A, true, Some(settled_size())));
+ assert!(gate.is_gated(CLIENT_A));
+ }
+
+ #[test]
+ fn web_client_is_not_revealed_without_a_paint() {
+ let mut gate = MobileRenderGate::default();
+ gate.gate(CLIENT_A);
+ gate.record_settled_size(CLIENT_A);
+ assert!(!gate.try_reveal(CLIENT_A, true, Some(settled_size())));
+ assert!(gate.is_gated(CLIENT_A));
+ }
+
+ #[test]
+ fn web_client_is_not_revealed_when_reported_size_differs_from_paint() {
+ let mut gate = MobileRenderGate::default();
+ gate.gate(CLIENT_A);
+ gate.record_settled_size(CLIENT_A);
+ gate.record_paint_size(CLIENT_A, settled_size());
+ let stale = Size { rows: 38, cols: 46 };
+ assert!(!gate.try_reveal(CLIENT_A, true, Some(stale)));
+ assert!(gate.is_gated(CLIENT_A));
+ }
+
+ #[test]
+ fn web_client_is_revealed_on_settled_matching_paint() {
+ let mut gate = MobileRenderGate::default();
+ gate.gate(CLIENT_A);
+ gate.record_settled_size(CLIENT_A);
+ gate.record_paint_size(CLIENT_A, settled_size());
+ assert!(gate.try_reveal(CLIENT_A, true, Some(settled_size())));
+ assert!(!gate.is_gated(CLIENT_A));
+ }
+
+ #[test]
+ fn non_web_client_is_revealed_on_any_paint() {
+ let mut gate = MobileRenderGate::default();
+ gate.gate(CLIENT_A);
+ gate.record_paint_size(CLIENT_A, embedded_size());
+ // No settled size, mismatched reported size — still revealed for non-web.
+ assert!(gate.try_reveal(CLIENT_A, false, None));
+ assert!(!gate.is_gated(CLIENT_A));
+ }
+
+ #[test]
+ fn blank_gated_clients_replaces_only_gated_output() {
+ let mut gate = MobileRenderGate::default();
+ gate.gate(CLIENT_A);
+ let mut output = HashMap::new();
+ output.insert(CLIENT_A, "real-a".to_string());
+ output.insert(CLIENT_B, "real-b".to_string());
+ gate.blank_gated_clients(&mut output);
+ assert_eq!(output.get(&CLIENT_A).unwrap(), CLEAR_SCREEN);
+ assert_eq!(output.get(&CLIENT_B).unwrap(), "real-b");
+ }
+
+ #[test]
+ fn ungate_clears_settled_and_paint_state() {
+ let mut gate = MobileRenderGate::default();
+ gate.gate(CLIENT_A);
+ gate.record_settled_size(CLIENT_A);
+ gate.record_paint_size(CLIENT_A, settled_size());
+ gate.ungate(CLIENT_A);
+ assert!(!gate.is_gated(CLIENT_A));
+ // A fresh gate must not inherit the prior settled/paint state.
+ gate.gate(CLIENT_A);
+ assert!(!gate.try_reveal(CLIENT_A, true, Some(settled_size())));
+ }
+
+ #[test]
+ fn client_is_not_in_mobile_mode_by_default() {
+ let state = MobileState::default();
+ assert!(!state.is_in_mobile_mode(CLIENT_A));
+ assert_eq!(state.mobile_tab_count(), 0);
+ assert!(state.mobile_tab_ids().is_empty());
+ }
+
+ #[test]
+ fn registering_a_tab_puts_the_client_in_mobile_mode() {
+ let mut state = MobileState::default();
+ state.register_tab(CLIENT_A, 7);
+ assert!(state.is_in_mobile_mode(CLIENT_A));
+ assert_eq!(state.mobile_tab_count(), 1);
+ assert!(state.mobile_tab_ids().contains(&7));
+ }
+
+ #[test]
+ fn mobile_tab_ids_collects_every_clients_tab() {
+ let mut state = MobileState::default();
+ state.register_tab(CLIENT_A, 7);
+ state.register_tab(CLIENT_B, 9);
+ let ids = state.mobile_tab_ids();
+ assert!(ids.contains(&7));
+ assert!(ids.contains(&9));
+ assert_eq!(ids.len(), 2);
+ }
+
+ #[test]
+ fn begin_exit_returns_the_mobile_and_previous_tabs() {
+ let mut state = MobileState::default();
+ state.register_tab(CLIENT_A, 7);
+ state.set_previous_tab(CLIENT_A, Some(3));
+ assert_eq!(state.begin_exit(CLIENT_A), Some((7, Some(3))));
+ }
+
+ #[test]
+ fn set_previous_tab_ignores_none() {
+ let mut state = MobileState::default();
+ state.register_tab(CLIENT_A, 7);
+ state.set_previous_tab(CLIENT_A, None);
+ assert_eq!(state.begin_exit(CLIENT_A), Some((7, None)));
+ }
+
+ #[test]
+ fn begin_exit_returns_none_when_not_in_mobile_mode() {
+ let mut state = MobileState::default();
+ assert_eq!(state.begin_exit(CLIENT_A), None);
+ }
+
+ #[test]
+ fn begin_exit_clears_membership_and_auto_entry() {
+ let mut state = MobileState::default();
+ state.register_tab(CLIENT_A, 7);
+ state.mark_auto_entered(CLIENT_A);
+ state.set_previous_tab(CLIENT_A, Some(3));
+
+ state.begin_exit(CLIENT_A);
+
+ assert!(!state.is_in_mobile_mode(CLIENT_A));
+ assert!(!state.was_auto_entered(CLIENT_A));
+ assert_eq!(state.begin_exit(CLIENT_A), None);
+ }
+
+ #[test]
+ fn forget_client_drops_all_per_client_bookkeeping() {
+ let mut state = MobileState::default();
+ state.register_tab(CLIENT_A, 7);
+ state.set_previous_tab(CLIENT_A, Some(3));
+ state.mark_auto_entered(CLIENT_A);
+
+ state.forget_client(CLIENT_A);
+
+ assert!(!state.is_in_mobile_mode(CLIENT_A));
+ assert!(!state.was_auto_entered(CLIENT_A));
+ }
+
+ #[test]
+ fn auto_entered_marker_round_trips() {
+ let mut state = MobileState::default();
+ assert!(!state.was_auto_entered(CLIENT_A));
+ state.mark_auto_entered(CLIENT_A);
+ assert!(state.was_auto_entered(CLIENT_A));
+ }
+
+ #[test]
+ fn mobile_tab_layout_is_a_borderless_plugin() {
+ let layout = MobileState::mobile_tab_layout().expect("layout builds");
+ assert_eq!(layout.borderless, Some(true));
+ assert!(matches!(layout.run, Some(Run::Plugin(_))));
+ }
+
+ #[test]
+ fn no_fit_override_by_default() {
+ let state = MobileState::default();
+ assert!(!state.has_fit(5));
+ }
+
+ #[test]
+ fn set_fit_records_an_override() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+ assert!(state.has_fit(5));
+ }
+
+ #[test]
+ fn refitting_a_tab_reassigns_ownership() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+ state.set_fit(
+ CLIENT_B,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+
+ assert_eq!(state.clear_fit_owned_by(CLIENT_A, &mut tabs), None);
+ assert_eq!(state.clear_fit_owned_by(CLIENT_B, &mut tabs), Some(5));
+ }
+
+ #[test]
+ fn compute_fit_size_is_none_without_a_target_tab() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+ assert_eq!(state.compute_fit_size(5, &tabs), None);
+ }
+
+ #[test]
+ fn compute_fit_size_is_none_without_a_fit() {
+ let state = MobileState::default();
+ let tabs = no_tabs();
+ assert_eq!(state.compute_fit_size(5, &tabs), None);
+ }
+
+ #[test]
+ fn clear_fit_owned_by_removes_and_returns_the_tab() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+
+ assert_eq!(state.clear_fit_owned_by(CLIENT_A, &mut tabs), Some(5));
+ assert!(!state.has_fit(5));
+ }
+
+ #[test]
+ fn clear_fit_owned_by_is_none_when_client_owns_nothing() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+ assert_eq!(state.clear_fit_owned_by(CLIENT_B, &mut tabs), None);
+ assert!(state.has_fit(5));
+ }
+
+ #[test]
+ fn clear_fit_for_pane_matches_by_fullscreened_pane() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+
+ assert_eq!(state.clear_fit_for_pane(PaneId::Terminal(11)), Some(5));
+ assert!(!state.has_fit(5));
+ }
+
+ #[test]
+ fn clear_fit_for_pane_is_none_when_no_fit_targets_it() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+ assert_eq!(state.clear_fit_for_pane(PaneId::Terminal(99)), None);
+ assert!(state.has_fit(5));
+ }
+
+ #[test]
+ fn remove_fit_for_tab_drops_the_override() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+
+ state.remove_fit_for_tab(5);
+ assert!(!state.has_fit(5));
+ }
+
+ #[test]
+ fn clear_fits_owned_by_removes_only_the_clients_fits() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 1,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+ state.set_fit(
+ CLIENT_A,
+ 2,
+ PaneId::Terminal(12),
+ embedded_size(),
+ &mut tabs,
+ );
+ state.set_fit(
+ CLIENT_B,
+ 3,
+ PaneId::Terminal(13),
+ embedded_size(),
+ &mut tabs,
+ );
+
+ let mut cleared = state.clear_fits_owned_by(CLIENT_A, &mut tabs);
+ cleared.sort_unstable();
+
+ assert_eq!(cleared, vec![1, 2]);
+ assert!(!state.has_fit(1));
+ assert!(!state.has_fit(2));
+ assert!(state.has_fit(3));
+ }
+}
diff --git a/zellij-server/src/panes/active_panes.rs b/zellij-server/src/panes/active_panes.rs
index b0b09b06..01ce65aa 100644
--- a/zellij-server/src/panes/active_panes.rs
+++ b/zellij-server/src/panes/active_panes.rs
@@ -1,11 +1,12 @@
use crate::tab::Pane;
use crate::{os_input_output::ServerOsApi, panes::PaneId, ClientId};
-use std::collections::{BTreeMap, HashMap};
+use std::collections::{BTreeMap, HashMap, HashSet};
#[derive(Clone)]
pub struct ActivePanes {
active_panes: HashMap<ClientId, PaneId>,
+ shadow_clients: HashSet<ClientId>,
os_api: Box<dyn ServerOsApi>,
}
@@ -20,9 +21,20 @@ impl ActivePanes {
let os_api = os_api.clone();
ActivePanes {
active_panes: HashMap::new(),
+ shadow_clients: HashSet::new(),
os_api,
}
}
+ pub fn is_shadow_client(&self, client_id: &ClientId) -> bool {
+ self.shadow_clients.contains(client_id)
+ }
+ pub fn iter_shadow_clients(&self) -> impl Iterator<Item = &ClientId> {
+ self.shadow_clients.iter()
+ }
+ pub fn has_shadow_focus_on(&self, client_id: ClientId, pane_id: PaneId) -> bool {
+ self.shadow_clients.contains(&client_id)
+ && self.active_panes.get(&client_id) == Some(&pane_id)
+ }
pub fn get(&self, client_id: &ClientId) -> Option<&PaneId> {
self.active_panes.get(client_id)
}
@@ -32,15 +44,25 @@ impl ActivePanes {
pane_id: PaneId,
panes: &mut BTreeMap<PaneId, Box<dyn Pane>>,
) {
+ self.shadow_clients.remove(&client_id);
self.unfocus_pane_for_client(client_id, panes);
self.active_panes.insert(client_id, pane_id);
self.focus_pane(pane_id, panes);
}
+ pub fn insert_silent(&mut self, client_id: ClientId, pane_id: PaneId) {
+ self.active_panes.insert(client_id, pane_id);
+ self.shadow_clients.insert(client_id);
+ }
+ pub fn remove_silent(&mut self, client_id: &ClientId) -> Option<PaneId> {
+ self.shadow_clients.remove(client_id);
+ self.active_panes.remove(client_id)
+ }
pub fn clear(&mut self, panes: &mut BTreeMap<PaneId, Box<dyn Pane>>) {
for pane_id in self.active_panes.values() {
self.unfocus_pane(*pane_id, panes);
}
self.active_panes.clear();
+ self.shadow_clients.clear();
}
pub fn is_empty(&self) -> bool {
self.active_panes.is_empty()
@@ -59,6 +81,7 @@ impl ActivePanes {
if let Some(pane_id_to_unfocus) = self.active_panes.get(&client_id) {
self.unfocus_pane(*pane_id_to_unfocus, panes);
}
+ self.shadow_clients.remove(client_id);
self.active_panes.remove(client_id)
}
pub fn unfocus_all_panes(&self, panes: &mut BTreeMap<PaneId, Box<dyn Pane>>) {
diff --git a/zellij-server/src/panes/floating_panes/mod.rs b/zellij-server/src/panes/floating_panes/mod.rs
index ba7b61c5..438e77fe 100644
--- a/zellij-server/src/panes/floating_panes/mod.rs
+++ b/zellij-server/src/panes/floating_panes/mod.rs
@@ -258,6 +258,21 @@ impl FloatingPanes {
pub fn panes_contain(&self, pane_id: &PaneId) -> bool {
self.panes.contains_key(pane_id)
}
+ pub fn set_shadow_focus(&mut self, client_id: ClientId, pane_id: PaneId) {
+ self.active_panes.insert_silent(client_id, pane_id);
+ }
+ pub fn clear_shadow_focus(&mut self, client_id: ClientId) -> Option<PaneId> {
+ self.active_panes.remove_silent(&client_id)
+ }
+ pub fn is_shadow_focus_client(&self, client_id: &ClientId) -> bool {
+ self.active_panes.is_shadow_client(client_id)
+ }
+ pub fn shadow_focus_clients(&self) -> Vec<ClientId> {
+ self.active_panes.iter_shadow_clients().copied().collect()
+ }
+ pub fn has_shadow_focus_on(&self, client_id: ClientId, pane_id: PaneId) -> bool {
+ self.active_panes.has_shadow_focus_on(client_id, pane_id)
+ }
pub fn find_room_for_new_pane(&mut self) -> Option<PaneGeom> {
let display_area = *self.display_area.borrow();
let viewport = *self.viewport.borrow();
@@ -454,7 +469,11 @@ impl FloatingPanes {
let mut active_panes = active_panes.clone();
let multiple_users_exist_in_session =
{ self.connected_clients_in_app.borrow().len() > 1 };
- active_panes.retain(|c_id, _| self.connected_clients.borrow().contains(c_id));
+ let connected = self.connected_clients.borrow();
+ active_panes.retain(|c_id, _| {
+ connected.contains(c_id) || self.active_panes.is_shadow_client(c_id)
+ });
+ drop(connected);
let pane_is_selectable = pane.selectable();
let show_help_text = active_panes.iter().any(|(client_id, pane_id)| {
pane_id == &pane.pid() && help_text_visible.get(client_id).copied().unwrap_or(false)
@@ -903,8 +922,14 @@ impl FloatingPanes {
.last()
.map(|(pane_id, _pane)| **pane_id);
+ let connected_clients: HashSet<ClientId> =
+ self.connected_clients.borrow().iter().copied().collect();
for (client_id, active_pane_id) in active_panes {
if active_pane_id == pane_id {
+ if !connected_clients.contains(&client_id) {
+ self.active_panes.remove_silent(&client_id);
+ continue;
+ }
match next_active_pane_id {
Some(next_active_pane_id) => {
self.active_panes
@@ -1185,10 +1210,17 @@ impl FloatingPanes {
.filter(|(_cid, pid)| **pid == from_pane_id)
.map(|(cid, _pid)| *cid)
.collect();
+ let connected_clients: HashSet<ClientId> =
+ self.connected_clients.borrow().iter().copied().collect();
for client_id in clients_in_pane {
- self.active_panes.remove(&client_id, &mut self.panes);
- self.active_panes
- .insert(client_id, to_pane_id, &mut self.panes);
+ if connected_clients.contains(&client_id) {
+ self.active_panes.remove(&client_id, &mut self.panes);
+ self.active_panes
+ .insert(client_id, to_pane_id, &mut self.panes);
+ } else {
+ self.active_panes.remove_silent(&client_id);
+ self.active_panes.insert_silent(client_id, to_pane_id);
+ }
}
}
pub fn reapply_pane_focus(&mut self) {
diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs
index 9f6134cb..0a659849 100644
--- a/zellij-server/src/panes/grid.rs
+++ b/zellij-server/src/panes/grid.rs
@@ -3325,7 +3325,7 @@ impl Grid {
let s: String = (&row.columns).into_iter().map(|x| x.character).collect();
viewport.push(s);
}
- if get_full_scrollback {
+ let mut contents = if get_full_scrollback {
let mut lines_above_viewport: Vec<String> = Vec::with_capacity(self.lines_above.len());
for row in &self.lines_above {
let s: String = (&row.columns).into_iter().map(|x| x.character).collect();
@@ -3352,7 +3352,9 @@ impl Grid {
)
} else {
PaneContents::new(viewport, self.selection.start, self.selection.end)
- }
+ };
+ contents.cursor = self.visible_cursor_in_viewport();
+ contents
}
pub fn pane_contents_with_ansi(
&self,
@@ -3394,7 +3396,7 @@ impl Grid {
viewport.push(extract_row_with_ansi(row));
}
- if get_full_scrollback {
+ let mut contents = if get_full_scrollback {
let mut lines_above_viewport: Vec<String> = Vec::with_capacity(self.lines_above.len());
for row in &self.lines_above {
lines_above_viewport.push(extract_row_with_ansi(row));
@@ -3418,7 +3420,14 @@ impl Grid {
)
} else {
PaneContents::new(viewport, self.selection.start, self.selection.end)
- }
+ };
+ contents.cursor = self.visible_cursor_in_viewport();
+ contents
+ }
+
+ fn visible_cursor_in_viewport(&self) -> Option<(usize, usize)> {
+ self.cursor_coordinates()
+ .and_then(|(x, y, is_visible)| if is_visible { Some((x, y)) } else { None })
}
}
diff --git a/zellij-server/src/panes/plugin_pane.rs b/zellij-server/src/panes/plugin_pane.rs
index 10e7186c..6f2d5980 100644
--- a/zellij-server/src/panes/plugin_pane.rs
+++ b/zellij-server/src/panes/plugin_pane.rs
@@ -595,6 +595,24 @@ impl Pane for PluginPane {
)]))
.unwrap();
}
+ fn scroll_left(&mut self, count: usize, client_id: ClientId) {
+ self.send_plugin_instructions
+ .send(PluginInstruction::Update(vec![(
+ Some(self.pid),
+ Some(client_id),
+ Event::Mouse(Mouse::ScrollLeft(count)),
+ )]))
+ .unwrap();
+ }
+ fn scroll_right(&mut self, count: usize, client_id: ClientId) {
+ self.send_plugin_instructions
+ .send(PluginInstruction::Update(vec![(
+ Some(self.pid),
+ Some(client_id),
+ Event::Mouse(Mouse::ScrollRight(count)),
+ )]))
+ .unwrap();
+ }
fn clear_screen(&mut self) {
// do nothing
}
diff --git a/zellij-server/src/panes/tiled_panes/mod.rs b/zellij-server/src/panes/tiled_panes/mod.rs
index afb9608c..ff68f750 100644
--- a/zellij-server/src/panes/tiled_panes/mod.rs
+++ b/zellij-server/src/panes/tiled_panes/mod.rs
@@ -1032,7 +1032,10 @@ impl TiledPanes {
} else {
self.active_panes
.iter()
- .filter(|(client_id, _pane_id)| connected_clients.contains(client_id))
+ .filter(|(client_id, _pane_id)| {
+ connected_clients.contains(client_id)
+ || self.active_panes.is_shadow_client(client_id)
+ })
.map(|(client_id, pane_id)| (*client_id, *pane_id))
.collect()
};
@@ -2443,10 +2446,19 @@ impl TiledPanes {
{
self.expand_pane_in_stack(next_active_pane_id);
}
+ let connected_clients: HashSet<ClientId> =
+ self.connected_clients.borrow().iter().copied().collect();
for (client_id, active_pane_id) in active_panes {
if active_pane_id == pane_id {
- self.active_panes
- .insert(client_id, next_active_pane_id, &mut self.panes);
+ if connected_clients.contains(&client_id) {
+ self.active_panes.insert(
+ client_id,
+ next_active_pane_id,
+ &mut self.panes,
+ );
+ } else {
+ self.active_panes.remove_silent(&client_id);
+ }
}
}
},
@@ -2667,6 +2679,21 @@ impl TiledPanes {
pub fn set_active_panes(&mut self, active_panes: ActivePanes) {
self.active_panes = active_panes;
}
+ pub fn set_shadow_focus(&mut self, client_id: ClientId, pane_id: PaneId) {
+ self.active_panes.insert_silent(client_id, pane_id);
+ }
+ pub fn clear_shadow_focus(&mut self, client_id: ClientId) -> Option<PaneId> {
+ self.active_panes.remove_silent(&client_id)
+ }
+ pub fn is_shadow_focus_client(&self, client_id: &ClientId) -> bool {
+ self.active_panes.is_shadow_client(client_id)
+ }
+ pub fn shadow_focus_clients(&self) -> Vec<ClientId> {
+ self.active_panes.iter_shadow_clients().copied().collect()
+ }
+ pub fn has_shadow_focus_on(&self, client_id: ClientId, pane_id: PaneId) -> bool {
+ self.active_panes.has_shadow_focus_on(client_id, pane_id)
+ }
pub fn move_client_focus_to_existing_panes(&mut self) {
let existing_pane_ids: Vec<PaneId> = self.panes.keys().copied().collect();
let nonexisting_panes_that_are_focused = self
@@ -2686,10 +2713,17 @@ impl TiledPanes {
.filter(|(_cid, pid)| **pid == from_pane_id)
.map(|(cid, _pid)| *cid)
.collect();
+ let connected_clients: HashSet<ClientId> =
+ self.connected_clients.borrow().iter().copied().collect();
for client_id in clients_in_pane {
- self.active_panes.remove(&client_id, &mut self.panes);
- self.active_panes
- .insert(client_id, to_pane_id, &mut self.panes);
+ if connected_clients.contains(&client_id) {
+ self.active_panes.remove(&client_id, &mut self.panes);
+ self.active_panes
+ .insert(client_id, to_pane_id, &mut self.panes);
+ } else {
+ self.active_panes.remove_silent(&client_id);
+ self.active_panes.insert_silent(client_id, to_pane_id);
+ }
}
}
fn reset_boundaries(&mut self) {
diff --git a/zellij-server/src/plugins/mod.rs b/zellij-server/src/plugins/mod.rs
index 69c80e77..5cc5835b 100644
--- a/zellij-server/src/plugins/mod.rs
+++ b/zellij-server/src/plugins/mod.rs
@@ -113,6 +113,8 @@ pub enum PluginInstruction {
plugin_ids: Vec<PluginId>,
done_receiving_permissions: bool,
},
+ HoldMobileRender(ClientId),
+ ReleaseMobileRender(ClientId),
ApplyCachedWorkerMessages(PluginId),
PostMessagesToPluginWorker(
PluginId,
@@ -239,6 +241,8 @@ impl From<&PluginInstruction> for PluginContext {
PluginInstruction::NewTab(..) => PluginContext::NewTab,
PluginInstruction::OverrideLayout(..) => PluginContext::OverrideLayout,
PluginInstruction::ApplyCachedEvents { .. } => PluginContext::ApplyCachedEvents,
+ PluginInstruction::HoldMobileRender(..) => PluginContext::HoldMobileRender,
+ PluginInstruction::ReleaseMobileRender(..) => PluginContext::ReleaseMobileRender,
PluginInstruction::ApplyCachedWorkerMessages(..) => {
PluginContext::ApplyCachedWorkerMessages
},
@@ -732,6 +736,12 @@ pub(crate) fn plugin_thread_main(
PluginInstruction::ApplyCachedWorkerMessages(plugin_id) => {
wasm_bridge.apply_cached_worker_messages(plugin_id)?;
},
+ PluginInstruction::HoldMobileRender(client_id) => {
+ wasm_bridge.hold_mobile_render(client_id);
+ },
+ PluginInstruction::ReleaseMobileRender(client_id) => {
+ wasm_bridge.release_mobile_render(client_id, shutdown_send.clone())?;
+ },
PluginInstruction::PostMessagesToPluginWorker(
plugin_id,
client_id,
diff --git a/zellij-server/src/plugins/wasm_bridge.rs b/zellij-server/src/plugins/wasm_bridge.rs
index aaaf1971..13e5ff82 100644
--- a/zellij-server/src/plugins/wasm_bridge.rs
+++ b/zellij-server/src/plugins/wasm_bridge.rs
@@ -177,6 +177,7 @@ pub struct WasmBridge {
plugin_ids_waiting_for_permission_request: HashSet<PluginId>,
cached_events_for_pending_plugins: HashMap<PluginId, Vec<EventOrPipeMessage>>,
cached_resizes_for_pending_plugins: HashMap<PluginId, (usize, usize)>, // (rows, columns)
+ clients_holding_mobile_render_until_size_settled: HashSet<ClientId>,
cached_worker_messages: HashMap<PluginId, Vec<(ClientId, String, String, String)>>, // Vec<clientid,
// worker_name,
// message,
@@ -242,6 +243,7 @@ impl WasmBridge {
path_to_default_shell,
watcher,
next_plugin_id: 0,
+ clients_holding_mobile_render_until_size_settled: HashSet::new(),
cached_events_for_pending_plugins: HashMap::new(),
plugin_ids_waiting_for_permission_request: HashSet::new(),
cached_resizes_for_pending_plugins: HashMap::new(),
@@ -1214,6 +1216,69 @@ impl WasmBridge {
}
Ok(())
}
+ fn plugin_render_is_held_until_size_settled(&self, plugin_id: PluginId) -> bool {
+ let clients_of_plugin: Vec<ClientId> = self
+ .plugin_map
+ .lock()
+ .unwrap()
+ .running_plugins()
+ .iter()
+ .filter(|(pid, _client_id, _running_plugin)| *pid == plugin_id)
+ .map(|(_pid, client_id, _running_plugin)| *client_id)
+ .collect();
+ !clients_of_plugin.is_empty()
+ && clients_of_plugin.iter().all(|client_id| {
+ self.clients_holding_mobile_render_until_size_settled
+ .contains(client_id)
+ })
+ }
+
+ pub fn hold_mobile_render(&mut self, client_id: ClientId) {
+ self.clients_holding_mobile_render_until_size_settled
+ .insert(client_id);
+ }
+
+ pub fn release_mobile_render(
+ &mut self,
+ client_id: ClientId,
+ shutdown_sender: Sender<()>,
+ ) -> Result<()> {
+ if !self
+ .clients_holding_mobile_render_until_size_settled
+ .remove(&client_id)
+ {
+ return Ok(());
+ }
+ let plugins_held_for_client: Vec<PluginId> = self
+ .plugin_map
+ .lock()
+ .unwrap()
+ .running_plugins()
+ .iter()
+ .filter(|(pid, cid, _)| {
+ *cid == client_id
+ && (self.cached_events_for_pending_plugins.contains_key(pid)
+ || self.cached_resizes_for_pending_plugins.contains_key(pid))
+ })
+ .map(|(pid, _cid, _)| *pid)
+ .collect();
+ for plugin_id in &plugins_held_for_client {
+ self.drain_pending_plugin_resize_before_events(*plugin_id, shutdown_sender.clone())?;
+ }
+ Ok(())
+ }
+
+ fn drain_pending_plugin_resize_before_events(
+ &mut self,
+ plugin_id: PluginId,
+ shutdown_sender: Sender<()>,
+ ) -> Result<()> {
+ if let Some((rows, columns)) = self.cached_resizes_for_pending_plugins.remove(&plugin_id) {
+ self.resize_plugin(plugin_id, columns, rows, shutdown_sender.clone())?;
+ }
+ self.apply_cached_events(vec![plugin_id], true, shutdown_sender)
+ }
+
pub fn apply_cached_events(
&mut self,
plugin_ids: Vec<PluginId>,
@@ -1222,6 +1287,9 @@ impl WasmBridge {
) -> Result<()> {
let mut applied_plugin_paths = HashSet::new();
for plugin_id in plugin_ids {
+ if self.plugin_render_is_held_until_size_settled(plugin_id) {
+ continue;
+ }
if !done_receiving_permissions
&& self
.plugin_ids_waiting_for_permission_request
@@ -1247,6 +1315,8 @@ impl WasmBridge {
Ok(())
}
pub fn remove_client(&mut self, client_id: ClientId) {
+ self.clients_holding_mobile_render_until_size_settled
+ .remove(&client_id);
self.connected_clients
.lock()
.unwrap()
@@ -1287,6 +1357,7 @@ impl WasmBridge {
.map(|prev_pane_contents| {
prev_pane_contents.viewport != new_pane_contents.viewport
|| prev_pane_contents.selected_text != new_pane_contents.selected_text
+ || prev_pane_contents.cursor != new_pane_contents.cursor
})
.unwrap_or(true);
if has_changed {
@@ -1305,7 +1376,6 @@ impl WasmBridge {
pane_render_report: PaneRenderReport,
shutdown_sender: Sender<()>,
) -> Result<()> {
- // Plain content (existing behavior)
let changed_panes_per_client = self.get_changed_panes_per_client(
&pane_render_report.all_pane_contents,
self.previous_pane_render_report
@@ -1317,7 +1387,6 @@ impl WasmBridge {
self.update_plugins(updates, shutdown_sender.clone())?;
}
- // ANSI content (new behavior)
if !pane_render_report.all_pane_contents_with_ansi.is_empty() {
let changed_ansi_panes_per_client = self.get_changed_panes_per_client(
&pane_render_report.all_pane_contents_with_ansi,
@@ -2100,6 +2169,7 @@ fn check_event_permission(
| Event::AvailableLayoutInfo(..)
| Event::PluginConfigurationChanged(..)
| Event::HighlightClicked { .. }
+ | Event::SoftKeyboardVisibilityChanged(..)
| Event::InputReceived => PermissionType::ReadApplicationState,
Event::WebServerStatus(..) => PermissionType::StartWebServer,
Event::PaneRenderReport(..) | Event::PaneRenderReportWithAnsi(..) => {
diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs
index da59c6ef..ce53592e 100644
--- a/zellij-server/src/plugins/zellij_exports.rs
+++ b/zellij-server/src/plugins/zellij_exports.rs
@@ -27,10 +27,10 @@ use zellij_utils::data::{
FocusOrCreateTabResponse, GetFocusedPaneInfoResponse, GetPaneCwdResponse, GetPanePidResponse,
GetPaneRunningCommandResponse, HttpVerb, KeyWithModifier, KillSessionsResponse, LayoutInfo,
LayoutMetadata, LayoutParsingError, MessageToPlugin, NewPanePlacement, NewTabResponse,
- OpenCommandPaneBackgroundResponse, OpenCommandPaneFloatingNearPluginResponse,
- OpenCommandPaneFloatingResponse, OpenCommandPaneInPlaceOfPaneIdResponse,
- OpenCommandPaneInPlaceOfPluginResponse, OpenCommandPaneInPlaceResponse,
- OpenCommandPaneNearPluginResponse, OpenCommandPaneResponse,
+ NewTabUnfocusedResponse, NewTiledPaneInTabResponse, OpenCommandPaneBackgroundResponse,
+ OpenCommandPaneFloatingNearPluginResponse, OpenCommandPaneFloatingResponse,
+ OpenCommandPaneInPlaceOfPaneIdResponse, OpenCommandPaneInPlaceOfPluginResponse,
+ OpenCommandPaneInPlaceResponse, OpenCommandPaneNearPluginResponse, OpenCommandPaneResponse,
OpenEditPaneInPlaceOfPaneIdResponse, OpenFileFloatingNearPluginResponse,
OpenFileFloatingResponse, OpenFileInPlaceOfPluginResponse, OpenFileInPlaceResponse,
OpenFileNearPluginResponse, OpenFileResponse, OpenPaneInNewTabResponse,
@@ -53,6 +53,7 @@ use zellij_utils::web_server_commands::shutdown_all_webserver_instances;
use crate::{panes::PaneId, screen::ScreenInstruction};
use kdl::KdlDocument;
+use zellij_utils::pane_size::Size;
use prost::Message;
use zellij_utils::{
@@ -86,7 +87,8 @@ use zellij_utils::{
ProtobufGetPanePidResponse, ProtobufGetPaneRunningCommandResponse,
ProtobufGetSessionEnvironmentVariablesResponse, ProtobufGetSessionListResponse,
ProtobufGetTabInfoResponse, ProtobufHideFloatingPanesResponse,
- ProtobufKillSessionsResponse, ProtobufNewTabResponse, ProtobufNewTabsResponse,
+ ProtobufKillSessionsResponse, ProtobufNewTabResponse, ProtobufNewTabUnfocusedResponse,
+ ProtobufNewTabsResponse, ProtobufNewTiledPaneInTabResponse,
ProtobufOpenCommandPaneBackgroundResponse,
ProtobufOpenCommandPaneFloatingNearPluginResponse,
ProtobufOpenCommandPaneFloatingResponse,
@@ -296,6 +298,12 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) {
context,
} => edit_layout(env, layout_name, context),
PluginCommand::NewTab { name, cwd } => new_tab(env, name, cwd),
+ PluginCommand::NewTabUnfocused { name, cwd } => {
+ new_tab_unfocused(env, name, cwd)
+ },
+ PluginCommand::NewTiledPaneInTab { tab_position } => {
+ new_tiled_pane_in_tab(env, tab_position)
+ },
PluginCommand::GoToNextTab => go_to_next_tab(env),
PluginCommand::GoToPreviousTab => go_to_previous_tab(env),
PluginCommand::Resize(resize_payload) => resize(env, resize_payload),
@@ -447,6 +455,14 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) {
PluginCommand::WatchFilesystem => watch_filesystem(env),
PluginCommand::ListWindowsVolumes => list_windows_volumes(env),
PluginCommand::GetSessionList => get_session_list(env),
+ PluginCommand::SetSoftKeyboard(on) => set_soft_keyboard(env, on),
+ PluginCommand::SetTabFit { tab_id, fit } => set_tab_fit(
+ env,
+ tab_id,
+ fit.map(|(pane_id, size)| (pane_id.into(), size)),
+ ),
+ PluginCommand::ExitMobileMode => exit_mobile_mode(env),
+ PluginCommand::SetShadowFocus(pane_id) => set_shadow_focus(env, pane_id.into()),
PluginCommand::DumpSessionLayout { tab_index } => {
dump_session_layout(env, tab_index)
},
@@ -2806,6 +2822,62 @@ fn new_tab(env: &PluginEnv, name: Option<String>, cwd: Option<String>) {
.non_fatal();
}
+fn new_tab_unfocused(env: &PluginEnv, name: Option<String>, cwd: Option<String>) {
+ let cwd = cwd.map(|c| translate_plugin_path(env, PathBuf::from(c)));
+ let action = Action::NewTab {
+ tiled_layout: None,
+ floating_layouts: vec![],
+ swap_tiled_layouts: None,
+ swap_floating_layouts: None,
+ tab_name: name,
+ should_change_focus_to_new_tab: false,
+ cwd,
+ initial_panes: None,
+ first_pane_unblock_condition: None,
+ };
+ let error_msg = || format!("Failed to open new tab (unfocused)");
+ let result = apply_action!(action, error_msg, env);
+
+ let tab_id: NewTabUnfocusedResponse = result.and_then(|r| r.affected_tab_id);
+
+ let response = ProtobufNewTabUnfocusedResponse::from(tab_id);
+ wasi_write_object(env, &response.encode_to_vec())
+ .with_context(|| format!("failed to write new_tab_unfocused response"))
+ .non_fatal();
+}
+
+fn new_tiled_pane_in_tab(env: &PluginEnv, tab_position: usize) {
+ let error_msg = || format!("failed to open new tiled pane in tab {}", tab_position);
+ let default_shell = env.default_shell.clone().unwrap_or_else(|| {
+ TerminalAction::RunCommand(RunCommand {
+ command: env.path_to_default_shell.clone(),
+ use_terminal_title: true,
+ ..Default::default()
+ })
+ });
+ let run_command_action: Option<RunCommandAction> = match default_shell {
+ TerminalAction::RunCommand(run_command) => Some(run_command.into()),
+ _ => None,
+ };
+ let action = Action::NewTiledPane {
+ direction: None,
+ command: run_command_action,
+ pane_name: None,
+ near_current_pane: false,
+ borderless: None,
+ tab_id: Some(tab_position),
+ };
+ let result = apply_action!(action, error_msg, env);
+
+ let pane_id: NewTiledPaneInTabResponse =
+ result.and_then(|r| r.affected_pane_id).map(|p| p.into());
+
+ let response = ProtobufNewTiledPaneInTabResponse::from(pane_id);
+ wasi_write_object(env, &response.encode_to_vec())
+ .with_context(|| format!("failed to write new_tiled_pane_in_tab response"))
+ .non_fatal();
+}
+
fn go_to_next_tab(env: &PluginEnv) {
let action = Action::GoToNextTab;
let error_msg = || format!("Failed to go to next tab");
@@ -3901,6 +3973,56 @@ fn list_windows_volumes(_env: &PluginEnv) {
log::error!("ListWindowsVolumes is only supported on Windows");
}
+fn set_soft_keyboard(env: &PluginEnv, on: bool) {
+ env.senders
+ .send_to_screen(ScreenInstruction::SetSoftKeyboard {
+ client_id: env.client_id,
+ on,
+ })
+ .with_context(|| {
+ format!(
+ "failed to dispatch SetSoftKeyboard for plugin {}",
+ env.plugin_id
+ )
+ })
+ .non_fatal();
+}
+
+fn set_shadow_focus(env: &PluginEnv, pane_id: PaneId) {
+ env.senders
+ .send_to_screen(ScreenInstruction::SetShadowFocus(env.client_id, pane_id))
+ .with_context(|| {
+ format!(
+ "failed to dispatch SetShadowFocus for plugin {}",
+ env.plugin_id
+ )
+ })
+ .non_fatal();
+}
+
+fn set_tab_fit(env: &PluginEnv, tab_id: usize, fit: Option<(PaneId, Size)>) {
+ env.senders
+ .send_to_screen(ScreenInstruction::SetTabFit {
+ client_id: env.client_id,
+ tab_id,
+ fit,
+ })
+ .with_context(|| format!("failed to dispatch SetTabFit for plugin {}", env.plugin_id))
+ .non_fatal();
+}
+
+fn exit_mobile_mode(env: &PluginEnv) {
+ env.senders
+ .send_to_screen(ScreenInstruction::ExitMobileMode(env.client_id, None))
+ .with_context(|| {
+ format!(
+ "failed to dispatch ExitMobileMode for plugin {}",
+ env.plugin_id
+ )
+ })
+ .non_fatal();
+}
+
#[cfg(windows)]
fn list_windows_volumes(env: &PluginEnv) {
let send_plugin_instructions = env.senders.to_plugin.clone();
@@ -5307,6 +5429,7 @@ fn check_command_permission(
| PluginCommand::OpenTerminalInPlaceOfPlugin(..)
| PluginCommand::OpenPluginPaneInNewTab { .. }
| PluginCommand::OpenPluginPaneFloating { .. }
+ | PluginCommand::NewTiledPaneInTab { .. }
| PluginCommand::OpenTerminalPaneInPlaceOfPaneId(..) => {
PermissionType::OpenTerminalsOrPlugins
},
@@ -5331,6 +5454,7 @@ fn check_command_permission(
| PluginCommand::NewTabsWithLayout(..)
| PluginCommand::NewTabsWithLayoutInfo(..)
| PluginCommand::NewTab { .. }
+ | PluginCommand::NewTabUnfocused { .. }
| PluginCommand::GoToNextTab
| PluginCommand::GoToPreviousTab
| PluginCommand::Resize(..)
@@ -5428,7 +5552,10 @@ fn check_command_permission(
| PluginCommand::ShowFloatingPanes { .. }
| PluginCommand::HideFloatingPanes { .. }
| PluginCommand::SetPaneRegexHighlights(..)
- | PluginCommand::ClearPaneHighlights(..) => PermissionType::ChangeApplicationState,
+ | PluginCommand::ClearPaneHighlights(..)
+ | PluginCommand::SetSoftKeyboard(..)
+ | PluginCommand::SetTabFit { .. }
+ | PluginCommand::ExitMobileMode => PermissionType::ChangeApplicationState,
PluginCommand::UnblockCliPipeInput(..)
| PluginCommand::BlockCliPipeInput(..)
| PluginCommand::CliPipeOutput(..) => PermissionType::ReadCliPipes,
diff --git a/zellij-server/src/route.rs b/zellij-server/src/route.rs
index fa359d2c..4a3ffcc9 100644
--- a/zellij-server/src/route.rs
+++ b/zellij-server/src/route.rs
@@ -28,7 +28,7 @@ use zellij_utils::{
actions::{Action, SearchDirection, SearchOption},
command::TerminalAction,
},
- ipc::{ClientToServerMsg, ExitReason, IpcReceiverWithContext, ServerToClientMsg},
+ ipc::{ClientToServerMsg, ExitReason, IpcReceiverWithContext, ResizeCause, ServerToClientMsg},
};
use crate::ClientId;
@@ -1252,6 +1252,14 @@ pub(crate) fn route_action(
.with_context(err_context)?;
},
Action::ToggleMouseMode => {}, // Handled client side
+ Action::ToggleMobileMode => {
+ senders
+ .send_to_screen(ScreenInstruction::ToggleMobileMode(
+ client_id,
+ Some(NotificationEnd::new(completion_tx)),
+ ))
+ .with_context(err_context)?;
+ },
Action::PreviousSwapLayout => {
senders
.send_to_screen(ScreenInstruction::PreviousSwapLayout(
@@ -2195,7 +2203,7 @@ pub(crate) fn route_thread_main(
should_break = true;
}
},
- ClientToServerMsg::TerminalResize { new_size } => {
+ ClientToServerMsg::TerminalResize { new_size, .. } => {
// For watchers: send size to Screen for rendering adjustments, but
// this does not affect the screen size
send_to_screen_or_retry_queue!(
@@ -2359,7 +2367,7 @@ pub(crate) fn route_thread_main(
}
}
},
- ClientToServerMsg::TerminalResize { new_size } => {
+ ClientToServerMsg::TerminalResize { new_size, cause } => {
// Check if this is a watcher or regular client
if is_watcher {
// For watchers: send size to Screen for tracking, don't affect screen size
@@ -2386,6 +2394,51 @@ pub(crate) fn route_thread_main(
client_id, new_size,
))
});
+ if matches!(cause, ResizeCause::Viewport) {
+ let mobile_options =
+ session_data.read().ok().and_then(|guard| {
+ guard.as_ref().map(|s| {
+ let config = s
+ .session_configuration
+ .get_client_configuration(&client_id);
+ (
+ config
+ .options
+ .mobile_layout
+ .unwrap_or_default(),
+ config
+ .options
+ .mobile_threshold_cols
+ .unwrap_or(60),
+ config
+ .options
+ .mobile_threshold_rows
+ .unwrap_or(30),
+ )
+ })
+ });
+ if let Some((mobile_layout, threshold_cols, threshold_rows)) =
+ mobile_options
+ {
+ let _ = senders.as_ref().map(|s| {
+ s.send_to_screen(
+ ScreenInstruction::ReevaluateMobileMode {
+ client_id,
+ new_size,
+ mobile_layout,
+ threshold_cols,
+ threshold_rows,
+ },
+ )
+ });
+ }
+ } else if matches!(cause, ResizeCause::SizeSettled) {
+ let _ = senders.as_ref().map(|s| {
+ s.send_to_screen(ScreenInstruction::MobileSizeSettled(
+ client_id,
+ ))
+ });
+ }
}
},
ClientToServerMsg::TerminalPixelDimensions { pixel_dimensions } => {
@@ -2606,6 +2659,15 @@ pub(crate) fn route_thread_main(
retry_queue
);
},
+ ClientToServerMsg::SoftKeyboardVisibilityChanged { visible } => {
+ if let Some(senders) = senders.as_ref() {
+ let _ = senders.send_to_plugin(PluginInstruction::Update(vec![(
+ None,
+ Some(client_id),
+ Event::SoftKeyboardVisibilityChanged(visible),
+ )]));
+ }
+ },
}
Ok(should_break)
};
diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs
index 4bf44e34..95c47f5b 100644
--- a/zellij-server/src/screen.rs
+++ b/zellij-server/src/screen.rs
@@ -51,7 +51,7 @@ use zellij_utils::input::command::RunCommand;
use zellij_utils::input::config::Config;
use zellij_utils::input::keybinds::Keybinds;
use zellij_utils::input::mouse::{MouseEvent, MouseEventType};
-use zellij_utils::input::options::Clipboard;
+use zellij_utils::input::options::{Clipboard, MobileLayoutConfiguration};
use zellij_utils::ipc::{ExitReason, ServerToClientMsg};
use zellij_utils::pane_size::{PaneGeom, Size, SizeInPixels};
use zellij_utils::shared::clean_string_from_control_and_linebreak;
@@ -93,6 +93,8 @@ use zellij_utils::{
ipc::{ClientAttributes, PixelDimensions},
};
+use crate::mobile_mode::{MobileRenderGate, MobileState, ShadowFocusOutcome, FIT_RESIZE_MAX_ITERS};
+
/// Parses a namespaced OSC 99 response and extracts the original pane ID
/// and un-namespaced response bytes.
///
@@ -500,8 +502,6 @@ 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),
@@ -562,11 +562,14 @@ pub enum ScreenInstruction {
AddClient(
ClientId,
bool, // is_web_client
- Size, // client viewport size — used for per-tab sizing
+ Size, // client viewport size
Option<usize>, // tab position to focus
Option<(u32, bool)>, // (pane_id, is_plugin) => pane_id to focus
),
RemoveClient(ClientId),
+ SuppressRenderUntilMobile(ClientId),
+ MobileSizeSettled(ClientId),
+ ForceMobileUngate(ClientId),
UpdateSearch(Vec<u8>, ClientId, Option<NotificationEnd>),
SearchDown(ClientId, Option<NotificationEnd>),
SearchUp(ClientId, Option<NotificationEnd>),
@@ -775,6 +778,11 @@ pub enum ScreenInstruction {
PageScrollUpInPaneId(PaneId),
PageScrollDownInPaneId(PaneId),
TogglePaneIdFullscreen(PaneId),
+ SetTabFit {
+ client_id: ClientId,
+ tab_id: usize,
+ fit: Option<(PaneId, Size)>,
+ },
TogglePaneEmbedOrEjectForPaneId(PaneId),
CloseTabWithIndex(usize),
BreakPanesToNewTab {
@@ -871,6 +879,21 @@ pub enum ScreenInstruction {
PreviousSwapLayoutWithTabId(usize, Option<NotificationEnd>),
NextSwapLayoutWithTabId(usize, Option<NotificationEnd>),
MoveTabWithTabId(usize, Direction, Option<NotificationEnd>),
+ EnterMobileMode(ClientId, Option<NotificationEnd>),
+ ExitMobileMode(ClientId, Option<NotificationEnd>),
+ ToggleMobileMode(ClientId, Option<NotificationEnd>),
+ ReevaluateMobileMode {
+ client_id: ClientId,
+ new_size: Size,
+ mobile_layout: MobileLayoutConfiguration,
+ threshold_cols: u16,
+ threshold_rows: u16,
+ },
+ SetSoftKeyboard {
+ client_id: ClientId,
+ on: bool,
+ },
+ SetShadowFocus(ClientId, PaneId),
}
impl From<&ScreenInstruction> for ScreenContext {
@@ -1018,6 +1041,11 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenInstruction::ToggleTab(..) => ScreenContext::ToggleTab,
ScreenInstruction::AddClient(..) => ScreenContext::AddClient,
ScreenInstruction::RemoveClient(..) => ScreenContext::RemoveClient,
+ ScreenInstruction::SuppressRenderUntilMobile(..) => {
+ ScreenContext::SuppressRenderUntilMobile
+ },
+ ScreenInstruction::MobileSizeSettled(..) => ScreenContext::MobileSizeSettled,
+ ScreenInstruction::ForceMobileUngate(..) => ScreenContext::ForceMobileUngate,
ScreenInstruction::UpdateSearch(..) => ScreenContext::UpdateSearch,
ScreenInstruction::SearchDown(..) => ScreenContext::SearchDown,
ScreenInstruction::SearchUp(..) => ScreenContext::SearchUp,
@@ -1105,6 +1133,7 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenInstruction::PageScrollUpInPaneId(..) => ScreenContext::PageScrollUpInPaneId,
ScreenInstruction::PageScrollDownInPaneId(..) => ScreenContext::PageScrollDownInPaneId,
ScreenInstruction::TogglePaneIdFullscreen(..) => ScreenContext::TogglePaneIdFullscreen,
+ ScreenInstruction::SetTabFit { .. } => ScreenContext::SetTabFit,
ScreenInstruction::TogglePaneEmbedOrEjectForPaneId(..) => {
ScreenContext::TogglePaneEmbedOrEjectForPaneId
},
@@ -1228,6 +1257,12 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenContext::NextSwapLayoutWithTabId
},
ScreenInstruction::MoveTabWithTabId(..) => ScreenContext::MoveTabWithTabId,
+ ScreenInstruction::EnterMobileMode(..) => ScreenContext::EnterMobileMode,
+ ScreenInstruction::ExitMobileMode(..) => ScreenContext::ExitMobileMode,
+ ScreenInstruction::ToggleMobileMode(..) => ScreenContext::ToggleMobileMode,
+ ScreenInstruction::ReevaluateMobileMode { .. } => ScreenContext::ReevaluateMobileMode,
+ ScreenInstruction::SetSoftKeyboard { .. } => ScreenContext::SetSoftKeyboard,
+ ScreenInstruction::SetShadowFocus(..) => ScreenContext::SetShadowFocus,
}
}
}
@@ -1314,7 +1349,6 @@ impl RenderBlocker {
}
}
-/// State information for a watcher client
#[derive(Debug, Clone)]
pub(crate) struct WatcherState {
size: Size,
@@ -1376,7 +1410,6 @@ 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>>,
@@ -1429,42 +1462,15 @@ pub(crate) struct Screen {
pane_render_subscribers: HashMap<ClientId, PaneRenderSubscription>,
plugins_need_ansi_pane_contents: bool,
background_plugin_subscriptions: HashMap<(PluginId, ClientId), HashSet<EventType>>,
- /// Monotonic counter used to tag each forwarded host-terminal query
- /// with a unique token. 0 is reserved as a sentinel (see
- /// `STARTUP_SENTINEL_TOKEN`); real forwards start at 1.
next_forward_token: u32,
- /// Map of forwarded-query token → the originating pane plus the
- /// raw query bytes. When the client sends back the reply for
- /// `token`, the server looks up the pane here and writes the
- /// reply bytes to its pty. The retained `query_bytes` feed the
- /// cache-fallback synthesis: if the reply comes back empty
- /// (detached session, client crash, host timeout), we use the
- /// cached bg/fg/pixel/palette state to answer in the host's
- /// stead.
pending_forwarded_queries: HashMap<u32, PendingForwardEntry>,
- /// Serialization queue for forwarded queries. Invariant: at most one
- /// forward is in flight to the client at a time (enforced by
- /// `forward_in_flight`). When the reply (or timeout) closes the
- /// active slot, the next queued forward is dispatched.
forward_queue: VecDeque<PendingForward>,
- /// Token of the forward currently in flight to the (single)
- /// connected client, if any. Used to:
- /// * serialize forwarded queries globally (only one at a time);
- /// * gate slot release on `handle_forwarded_reply_from_host` to a
- /// token-equality match, so a late server-side timeout for an
- /// already-answered forward cannot clobber a queued forward that
- /// the real reply just dispatched.
forward_in_flight_token: Option<u32>,
- /// Last-known host terminal color-palette theme mode (CSI 2031 /
- /// DSR 997). `None` until the host first reports. Used both for
- /// auto-theme switching and to dedupe duplicate notifications.
host_terminal_theme_mode: Option<HostTerminalThemeMode>,
- /// Resolved styling to apply when `host_terminal_theme_mode == Dark`.
- /// `None` disables auto-switch. Refreshed on each reconfigure.
host_theme_dark_styling: Option<Styling>,
- /// Resolved styling to apply when `host_terminal_theme_mode == Light`.
- /// `None` disables auto-switch. Refreshed on each reconfigure.
host_theme_light_styling: Option<Styling>,
+ mobile_state: MobileState,
+ mobile_render_gate: MobileRenderGate,
}
/// A pending forward waiting to be dispatched once the current in-flight
@@ -1619,6 +1625,56 @@ impl Screen {
host_terminal_theme_mode: None,
host_theme_dark_styling: None,
host_theme_light_styling: None,
+ mobile_state: MobileState::default(),
+ mobile_render_gate: MobileRenderGate::default(),
+ }
+ }
+
+ fn client_is_web(&self, client_id: ClientId) -> bool {
+ self.connected_clients
+ .borrow()
+ .get(&client_id)
+ .copied()
+ .unwrap_or(false)
+ }
+
+ fn try_lift_mobile_gate(&mut self, client_id: ClientId) -> bool {
+ let is_web_client = self.client_is_web(client_id);
+ let reported_size = self.client_sizes.get(&client_id).copied();
+ let lifted = self
+ .mobile_render_gate
+ .try_reveal(client_id, is_web_client, reported_size);
+ if lifted {
+ self.force_render_mobile_tab(client_id);
+ }
+ lifted
+ }
+
+ fn force_render_mobile_tab(&mut self, client_id: ClientId) {
+ if let Some(tab_id) = self.mobile_state.mobile_tab_id(client_id) {
+ if let Some(tab) = self.tabs.get_mut(&tab_id) {
+ tab.set_should_clear_display_before_rendering();
+ tab.set_force_render();
+ }
+ }
+ }
+
+ fn ungate_clients_for_mobile_plugin(&mut self, plugin_id: u32) {
+ if self.mobile_render_gate.is_empty() {
+ return;
+ }
+ let Some(tab_id) = self
+ .tabs
+ .values()
+ .find(|tab| tab.has_plugin(plugin_id))
+ .map(|tab| tab.id)
+ else {
+ return;
+ };
+ for client_id in self.mobile_render_gate.gated_clients() {
+ if self.mobile_state.mobile_tab_id(client_id) == Some(tab_id) {
+ self.mobile_render_gate.ungate(client_id);
+ }
}
}
@@ -1664,6 +1720,204 @@ impl Screen {
self.tabs.get(&id).map(|t| t.position)
}
+ pub fn tab_visible_to(&self, client_id: ClientId, tab_id: usize) -> bool {
+ match self.tabs.get(&tab_id) {
+ Some(tab) => match &tab.visible_to {
+ None => true,
+ Some(set) => set.contains(&client_id),
+ },
+ None => false,
+ }
+ }
+
+ pub fn is_in_mobile_mode(&self, client_id: ClientId) -> bool {
+ self.mobile_state.is_in_mobile_mode(client_id)
+ }
+
+ pub(crate) fn set_shadow_focus(&mut self, client_id: ClientId, pane_id: PaneId) -> Result<()> {
+ if let ShadowFocusOutcome::NewlyApplied =
+ self.mobile_state
+ .apply_shadow_focus(client_id, pane_id, &mut self.tabs)
+ {
+ self.log_and_report_session_state()?;
+ self.render(None)?;
+ }
+ Ok(())
+ }
+
+ pub fn enter_mobile_mode(&mut self, client_id: ClientId) -> Result<()> {
+ let err_context = || format!("failed to enter mobile mode for client {client_id}");
+
+ if self.mobile_state.is_in_mobile_mode(client_id) {
+ return Ok(());
+ }
+
+ let prior = self.active_tab_ids.get(&client_id).copied();
+ self.mobile_state.set_previous_tab(client_id, prior);
+
+ self.mobile_state
+ .clear_shadow_focus(client_id, &mut self.tabs);
+
+ let tab_layout = MobileState::mobile_tab_layout().with_context(err_context)?;
+
+ let tab_id = self.get_new_tab_id();
+ self.new_tab(
+ tab_id,
+ (vec![], vec![]),
+ Some("Mobile".to_string()),
+ Some(client_id),
+ )
+ .with_context(err_context)?;
+ if let Some(tab) = self.tabs.get_mut(&tab_id) {
+ let mut set = HashSet::new();
+ set.insert(client_id);
+ tab.visible_to = Some(set);
+ }
+ self.mobile_state.register_tab(client_id, tab_id);
+
+ let is_web_client = self
+ .connected_clients
+ .borrow()
+ .get(&client_id)
+ .copied()
+ .unwrap_or(false);
+
+ let client_is_gated = self.mobile_render_gate.is_gated(client_id);
+ if client_is_gated {
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::HoldMobileRender(client_id))
+ .with_context(err_context)?;
+ }
+
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::NewTab(
+ None,
+ None,
+ Some(tab_layout),
+ vec![],
+ tab_id,
+ None,
+ false,
+ true,
+ (client_id, is_web_client),
+ None,
+ ))
+ .with_context(err_context)?;
+
+ Ok(())
+ }
+
+ pub fn exit_mobile_mode(&mut self, client_id: ClientId) -> Result<()> {
+ let err_context = || format!("failed to exit mobile mode for client {client_id}");
+
+ self.mobile_render_gate.ungate(client_id);
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::ReleaseMobileRender(client_id))
+ .non_fatal();
+
+ let Some((mobile_tab_id, prior_tab_id)) = self.mobile_state.begin_exit(client_id) else {
+ return Ok(());
+ };
+
+ let destination = prior_tab_id
+ .filter(|id| self.tabs.contains_key(id) && self.tab_visible_to(client_id, *id))
+ .or_else(|| {
+ let mobile_tab_ids = self.mobile_state.mobile_tab_ids();
+ self.tabs
+ .values()
+ .filter(|t| {
+ t.id != mobile_tab_id
+ && !mobile_tab_ids.contains(&t.id)
+ && match &t.visible_to {
+ None => true,
+ Some(set) => set.contains(&client_id),
+ }
+ })
+ .min_by_key(|t| t.position)
+ .map(|t| t.id)
+ });
+
+ if let Some(dest_id) = destination {
+ if let Some(dest_pos) = self.get_tab_position_by_id(dest_id) {
+ self.switch_active_tab(dest_pos, None, true, client_id)
+ .with_context(err_context)?;
+ }
+ }
+
+ if self.tabs.contains_key(&mobile_tab_id) {
+ self.close_tab_by_id(mobile_tab_id)
+ .with_context(err_context)?;
+ }
+
+ self.mobile_state
+ .clear_shadow_focus(client_id, &mut self.tabs);
+
+ Ok(())
+ }
+
+ pub fn toggle_mobile_mode(&mut self, client_id: ClientId) -> Result<()> {
+ if self.is_in_mobile_mode(client_id) {
+ self.exit_mobile_mode(client_id)
+ } else {
+ self.enter_mobile_mode(client_id)
+ }
+ }
+
+ pub fn reevaluate_mobile_mode(
+ &mut self,
+ client_id: ClientId,
+ new_size: Size,
+ mobile_layout: MobileLayoutConfiguration,
+ threshold_cols: u16,
+ threshold_rows: u16,
+ ) -> Result<()> {
+ let is_web_client = self
+ .connected_clients
+ .borrow()
+ .get(&client_id)
+ .copied()
+ .unwrap_or(false);
+ let should_be_mobile = mobile_layout.should_route_to_mobile(
+ is_web_client,
+ new_size.cols,
+ new_size.rows,
+ threshold_cols,
+ threshold_rows,
+ );
+ let is_mobile = self.is_in_mobile_mode(client_id);
+ let was_auto = self.mobile_state.was_auto_entered(client_id);
+ if should_be_mobile && !is_mobile {
+ self.enter_mobile_mode(client_id)?;
+ self.mobile_state.mark_auto_entered(client_id);
+ self.render(None)?;
+ } else if !should_be_mobile && is_mobile && was_auto {
+ self.exit_mobile_mode(client_id)?;
+ self.render(None)?;
+ }
+ if !should_be_mobile && self.mobile_render_gate.is_gated(client_id) {
+ self.mobile_render_gate.ungate(client_id);
+ self.render(None)?;
+ }
+ Ok(())
+ }
+
+ fn visible_tab_positions_for_client(&self, client_id: ClientId) -> Vec<usize> {
+ let mut positions: Vec<usize> = self
+ .tabs
+ .values()
+ .filter(|t| match &t.visible_to {
+ None => true,
+ Some(set) => set.contains(&client_id),
+ })
+ .map(|t| t.position)
+ .collect();
+ positions.sort_unstable();
+ positions
+ }
+
fn move_clients_from_closed_tab(
&mut self,
client_ids_and_mode_infos: Vec<(ClientId, ModeInfo)>,
@@ -1870,9 +2124,6 @@ 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)
@@ -1891,7 +2142,11 @@ impl Screen {
/// A helper function to switch to a new tab with specified name. Return true if tab [name] has
/// been created, else false.
fn switch_active_tab_name(&mut self, name: String, client_id: ClientId) -> Result<bool> {
- match self.tabs.values().find(|t| t.name == name) {
+ match self
+ .tabs
+ .values()
+ .find(|t| t.name == name && self.tab_visible_to(client_id, t.id))
+ {
Some(new_tab) => {
self.switch_active_tab(new_tab.position, None, true, client_id)?;
Ok(true)
@@ -1919,7 +2174,14 @@ impl Screen {
match self.get_active_tab(client_id) {
Ok(active_tab) => {
let active_tab_pos = active_tab.position;
- let new_tab_pos = (active_tab_pos + 1) % self.tabs.len();
+ let visible = self.visible_tab_positions_for_client(client_id);
+ if visible.is_empty() {
+ return Ok(());
+ }
+ let new_tab_pos = match visible.iter().position(|p| *p > active_tab_pos) {
+ Some(idx) => visible[idx],
+ None => visible[0],
+ };
return self.switch_active_tab(
new_tab_pos,
should_change_pane_focus,
@@ -1952,10 +2214,13 @@ impl Screen {
match self.get_active_tab(client_id) {
Ok(active_tab) => {
let active_tab_pos = active_tab.position;
- let new_tab_pos = if active_tab_pos == 0 {
- self.tabs.len() - 1
- } else {
- active_tab_pos - 1
+ let visible = self.visible_tab_positions_for_client(client_id);
+ if visible.is_empty() {
+ return Ok(());
+ }
+ let new_tab_pos = match visible.iter().rev().find(|p| **p < active_tab_pos) {
+ Some(p) => *p,
+ None => *visible.last().unwrap(),
};
return self.switch_active_tab(
@@ -1972,7 +2237,13 @@ impl Screen {
}
pub fn go_to_tab(&mut self, tab_index: usize, client_id: ClientId) -> Result<()> {
- self.switch_active_tab(tab_index.saturating_sub(1), None, true, client_id)
+ let target_pos = tab_index.saturating_sub(1);
+ if let Some(target_id) = self.get_tab_id_at_position(target_pos) {
+ if !self.tab_visible_to(client_id, target_id) {
+ return Ok(());
+ }
+ }
+ self.switch_active_tab(target_pos, None, true, client_id)
}
pub fn go_to_tab_name(&mut self, name: String, client_id: ClientId) -> Result<bool> {
@@ -1983,6 +2254,7 @@ impl Screen {
let err_context = || format!("failed to close tab at index {tab_id:?}");
let mut tab_to_close = self.tabs.remove(&tab_id).with_context(err_context)?;
+ self.mobile_state.remove_fit_for_tab(tab_id);
let mut pane_ids = tab_to_close.get_all_pane_ids();
// here we extract the suppressed panes (these are background panes that don't care which
@@ -2083,22 +2355,33 @@ 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}");
+ if self.mobile_state.has_fit(tab_id) {
+ for _ in 0..FIT_RESIZE_MAX_ITERS {
+ let Some(new_size) = self.compute_fit_size(tab_id) else {
+ break;
+ };
+ let Some(tab) = self.tabs.get_mut(&tab_id) else {
+ break;
+ };
+ if tab.size == new_size {
+ break;
+ }
+ tab.resize_whole_tab(new_size).with_context(err_context)?;
+ }
+ if let Some(tab) = self.tabs.get_mut(&tab_id) {
+ tab.set_should_clear_display_before_rendering();
+ tab.set_force_render();
+ }
+ return Ok(());
+ }
+
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() {
@@ -2127,6 +2410,45 @@ impl Screen {
Ok(())
}
+ pub(crate) fn compute_fit_size(&self, tab_id: usize) -> Option<Size> {
+ self.mobile_state.compute_fit_size(tab_id, &self.tabs)
+ }
+
+ pub fn set_tab_fit(
+ &mut self,
+ client_id: ClientId,
+ tab_id: usize,
+ pane_id: PaneId,
+ size: Size,
+ ) -> Result<()> {
+ self.mobile_state
+ .set_fit(client_id, tab_id, pane_id, size, &mut self.tabs);
+ self.recompute_tab_size(tab_id)
+ }
+
+ pub fn exit_fit_mode(&mut self, client_id: ClientId) -> Result<bool> {
+ match self
+ .mobile_state
+ .clear_fit_owned_by(client_id, &mut self.tabs)
+ {
+ Some(tab_id) => {
+ self.recompute_tab_size(tab_id)?;
+ Ok(true)
+ },
+ None => Ok(false),
+ }
+ }
+
+ pub(crate) fn clear_fit_for_closed_pane(&mut self, pane_id: PaneId) -> Result<bool> {
+ match self.mobile_state.clear_fit_for_pane(pane_id) {
+ Some(tab_id) => {
+ self.recompute_tab_size(tab_id)?;
+ Ok(true)
+ },
+ None => Ok(false),
+ }
+ }
+
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 {
@@ -2524,6 +2846,49 @@ impl Screen {
}
}
+ if self.plugins_need_ansi_pane_contents {
+ let all_regular_clients: Vec<ClientId> = self
+ .connected_clients
+ .borrow()
+ .keys()
+ .copied()
+ .filter(|id| !self.watcher_clients.contains_key(id))
+ .collect();
+ if !all_regular_clients.is_empty() {
+ for tab in self.tabs.values() {
+ for pane_id in tab.get_static_and_floating_pane_ids() {
+ if let Some(pane) = tab.get_pane_with_id(pane_id) {
+ match pane_id {
+ PaneId::Terminal(_) => {
+ let contents =
+ pane.pane_contents_with_ansi(None, false, None);
+ output.add_pane_contents_with_ansi(
+ &all_regular_clients,
+ pane_id,
+ contents,
+ );
+ },
+ PaneId::Plugin(_) => {
+ for client_id in &all_regular_clients {
+ let contents = pane.pane_contents_with_ansi(
+ Some(*client_id),
+ false,
+ None,
+ );
+ output.add_pane_contents_with_ansi(
+ &[*client_id],
+ pane_id,
+ contents,
+ );
+ }
+ },
+ }
+ }
+ }
+ }
+ }
+ }
+
let pane_render_report = output.drain_pane_render_report();
// Subscriber delivery — gated behind is_empty() for zero overhead
@@ -2592,12 +2957,16 @@ impl Screen {
}
if non_watcher_output_was_dirty || has_bell {
- let serialized_output = output.serialize().context(err_context)?;
- let _ = self
- .bus
- .senders
- .send_to_server(ServerInstruction::Render(Some(serialized_output)))
- .context(err_context);
+ let mut serialized_output = output.serialize().context(err_context)?;
+ self.mobile_render_gate
+ .blank_gated_clients(&mut serialized_output);
+ if !serialized_output.is_empty() {
+ let _ = self
+ .bus
+ .senders
+ .send_to_server(ServerInstruction::Render(Some(serialized_output)))
+ .context(err_context);
+ }
}
if bell_state_changed {
@@ -2917,6 +3286,7 @@ impl Screen {
});
let tab_name = tab_name.unwrap_or_else(|| String::new());
+ let mobile_tab_count = self.mobile_state.mobile_tab_count();
let position = self.tabs.len();
let mut tab = Tab::new(
@@ -2962,6 +3332,7 @@ impl Screen {
self.mouse_click_through,
self.web_server_ip,
self.web_server_port,
+ mobile_tab_count,
);
for (client_id, mode_info) in &self.mode_info {
tab.change_mode_info(mode_info.clone(), *client_id);
@@ -3052,6 +3423,12 @@ impl Screen {
None
};
+ if !self.active_tab_ids.contains_key(&client_id) {
+ self.connected_clients
+ .borrow_mut()
+ .insert(client_id, is_web_client);
+ }
+
// apply the layout to the new tab
self.tabs
.get_mut(&tab_id)
@@ -3084,9 +3461,6 @@ 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()
@@ -3133,8 +3507,6 @@ impl Screen {
.with_context(|| err_context(tab_index))?
.add_client(client_id, None)
.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(())
@@ -3160,12 +3532,32 @@ impl Screen {
}
}
- for (_, tab) in self.tabs.iter_mut() {
+ self.mobile_state.forget_client(client_id);
+ self.mobile_render_gate.ungate(client_id);
+
+ let to_recompute = self
+ .mobile_state
+ .clear_fits_owned_by(client_id, &mut self.tabs);
+ for tab_id in to_recompute {
+ self.recompute_tab_size(tab_id).with_context(err_context)?;
+ }
+
+ let mut tabs_to_garbage_collect: Vec<usize> = vec![];
+ for (tab_id, tab) in self.tabs.iter_mut() {
tab.remove_client(client_id);
if tab.has_no_connected_clients() {
tab.visible(false).with_context(err_context)?;
}
+ if let Some(set) = tab.visible_to.as_mut() {
+ set.remove(&client_id);
+ if set.is_empty() {
+ tabs_to_garbage_collect.push(*tab_id);
+ }
+ }
}
+
+ self.mobile_state
+ .clear_shadow_focus(client_id, &mut self.tabs);
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;
@@ -3177,12 +3569,15 @@ impl Screen {
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)?;
}
+ for tab_id in tabs_to_garbage_collect {
+ if self.tabs.contains_key(&tab_id) {
+ self.close_tab_by_id(tab_id).with_context(err_context)?;
+ }
+ }
self.log_and_report_session_state()
.with_context(err_context)
}
@@ -3223,13 +3618,18 @@ impl Screen {
let mut plugin_updates = vec![];
let mut tab_infos_for_screen_state = BTreeMap::new();
for tab in self.tabs.values() {
- let all_focused_clients: Vec<ClientId> = self
+ let mut all_focused_clients: Vec<ClientId> = self
.active_tab_ids
.iter()
.filter(|(_c_id, tab_position)| **tab_position == tab.id)
.map(|(c_id, _)| c_id)
.copied()
.collect();
+ for s_id in tab.shadow_focus_clients() {
+ if !all_focused_clients.contains(&s_id) {
+ all_focused_clients.push(s_id);
+ }
+ }
let (active_swap_layout_name, is_swap_layout_dirty) = tab.swap_layout_info();
let tab_viewport = tab.get_viewport();
let tab_display_area = tab.get_display_area();
@@ -3263,17 +3663,29 @@ impl Screen {
for (client_id, active_tab_index) in self.active_tab_ids.iter() {
let mut plugin_tab_updates = vec![];
for tab in self.tabs.values() {
+ if let Some(set) = &tab.visible_to {
+ if !set.contains(client_id) {
+ continue;
+ }
+ }
let other_focused_clients: Vec<ClientId> = if self.session_is_mirrored {
vec![]
} else {
- self.active_tab_ids
+ let mut clients: Vec<ClientId> = self
+ .active_tab_ids
.iter()
.filter(|(c_id, tab_position)| {
**tab_position == tab.id && *c_id != client_id
})
.map(|(c_id, _)| c_id)
.copied()
- .collect()
+ .collect();
+ for s_id in tab.shadow_focus_clients() {
+ if s_id != *client_id && !clients.contains(&s_id) {
+ clients.push(s_id);
+ }
+ }
+ clients
};
let (active_swap_layout_name, is_swap_layout_dirty) = tab.swap_layout_info();
let tab_viewport = tab.get_viewport();
@@ -5231,13 +5643,18 @@ impl Screen {
fn get_tab_info(&self, tab_id: usize) -> Option<TabInfo> {
// Look up tab by its stable ID
self.tabs.get(&tab_id).map(|tab| {
- let all_focused_clients: Vec<ClientId> = self
+ let mut all_focused_clients: Vec<ClientId> = self
.active_tab_ids
.iter()
.filter(|(_c_id, active_tab_id)| **active_tab_id == tab.id)
.map(|(c_id, _)| c_id)
.copied()
.collect();
+ for s_id in tab.shadow_focus_clients() {
+ if !all_focused_clients.contains(&s_id) {
+ all_focused_clients.push(s_id);
+ }
+ }
let (active_swap_layout_name, is_swap_layout_dirty) = tab.swap_layout_info();
let tab_viewport = tab.get_viewport();
let tab_display_area = tab.get_display_area();
@@ -5821,16 +6238,36 @@ pub(crate) fn screen_thread_main(
for plugin_render_asset in plugin_render_assets.iter_mut() {
let plugin_id = plugin_render_asset.plugin_id;
let client_id = plugin_render_asset.client_id;
- let vte_bytes = plugin_render_asset.bytes.drain(..).collect();
+ let vte_bytes: Vec<u8> = plugin_render_asset.bytes.drain(..).collect();
+ let painted_content = !vte_bytes.is_empty();
+ let mobile_tab_id = screen.mobile_state.mobile_tab_id(client_id);
+ let mut painted_tab_id = None;
+ let mut painted_size = None;
let all_tabs = screen.get_tabs_mut();
for tab in all_tabs.values_mut() {
if tab.has_plugin(plugin_id) {
tab.handle_plugin_bytes(plugin_id, client_id, vte_bytes)
.context("failed to process plugin bytes")?;
+ painted_tab_id = Some(tab.id);
+ painted_size =
+ tab.get_pane_with_id(PaneId::Plugin(plugin_id))
+ .map(|pane| Size {
+ rows: pane.get_content_rows(),
+ cols: pane.get_content_columns(),
+ });
break;
}
}
+ if painted_content && painted_tab_id == mobile_tab_id && mobile_tab_id.is_some()
+ {
+ if let Some(painted_size) = painted_size {
+ screen
+ .mobile_render_gate
+ .record_paint_size(client_id, painted_size);
+ }
+ screen.try_lift_mobile_gate(client_id);
+ }
screen.render_blocker.remove_blocking_plugin(plugin_id);
}
screen.render(Some(plugin_render_assets))?;
@@ -7471,9 +7908,6 @@ pub(crate) fn screen_thread_main(
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)| {
@@ -7514,6 +7948,37 @@ pub(crate) fn screen_thread_main(
screen.log_and_report_session_state()?;
screen.render(None)?;
},
+ ScreenInstruction::SuppressRenderUntilMobile(client_id) => {
+ screen.mobile_render_gate.gate(client_id);
+ screen
+ .bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::MobileGateTimeout(client_id))
+ .non_fatal();
+ },
+ ScreenInstruction::MobileSizeSettled(client_id) => {
+ screen.mobile_render_gate.record_settled_size(client_id);
+ screen
+ .bus
+ .senders
+ .send_to_plugin(PluginInstruction::ReleaseMobileRender(client_id))
+ .non_fatal();
+ if screen.try_lift_mobile_gate(client_id) {
+ screen.render(None)?;
+ }
+ },
+ ScreenInstruction::ForceMobileUngate(client_id) => {
+ if screen.mobile_render_gate.is_gated(client_id) {
+ screen
+ .bus
+ .senders
+ .send_to_plugin(PluginInstruction::ReleaseMobileRender(client_id))
+ .non_fatal();
+ screen.mobile_render_gate.ungate(client_id);
+ screen.force_render_mobile_tab(client_id);
+ screen.render(None)?;
+ }
+ },
ScreenInstruction::UpdateSearch(
c,
client_id,
@@ -8161,6 +8626,9 @@ pub(crate) fn screen_thread_main(
screen.log_and_report_session_state()?;
},
ScreenInstruction::UpdatePluginLoadingStage(pid, loading_indication) => {
+ if loading_indication.is_error() {
+ screen.ungate_clients_for_mobile_plugin(pid);
+ }
let found_plugin =
screen.update_plugin_loading_stage(pid, loading_indication.clone());
if !found_plugin {
@@ -9000,7 +9468,7 @@ pub(crate) fn screen_thread_main(
for tab in all_tabs.values_mut() {
if tab.has_pane_with_pid(&pane_id) {
if let PaneId::Terminal(terminal_pane_id) = pane_id {
- tab.scroll_terminal_up(terminal_pane_id);
+ tab.scroll_terminal_up(terminal_pane_id)?;
} else {
// this is because to do this with plugins, we need the client_id -
// which we do not have (yet?) in this context...
@@ -9018,7 +9486,7 @@ pub(crate) fn screen_thread_main(
for tab in all_tabs.values_mut() {
if tab.has_pane_with_pid(&pane_id) {
if let PaneId::Terminal(terminal_pane_id) = pane_id {
- tab.scroll_terminal_down(terminal_pane_id);
+ tab.scroll_terminal_down(terminal_pane_id)?;
} else {
// this is because to do this with plugins, we need the client_id -
// which we do not have (yet?) in this context...
@@ -9111,6 +9579,22 @@ pub(crate) fn screen_thread_main(
}
screen.render(None)?;
},
+ ScreenInstruction::SetTabFit {
+ client_id,
+ tab_id,
+ fit,
+ } => {
+ let changed = match fit {
+ Some((pane_id, size)) => {
+ screen.set_tab_fit(client_id, tab_id, pane_id, size)?;
+ true
+ },
+ None => screen.exit_fit_mode(client_id)?,
+ };
+ if changed {
+ screen.render(None)?;
+ }
+ },
ScreenInstruction::TogglePaneEmbedOrEjectForPaneId(pane_id) => {
let all_tabs = screen.get_tabs_mut();
for tab in all_tabs.values_mut() {
@@ -9491,9 +9975,16 @@ pub(crate) fn screen_thread_main(
},
ScreenInstruction::NotifyPaneClosedToSubscribers { pane_id } => {
screen.notify_pane_closed_to_subscribers(pane_id);
+ screen.clear_fit_for_closed_pane(pane_id.into())?;
},
ScreenInstruction::PluginSubscribedToAnsiPaneContents(has_subscribers) => {
+ let previously_subscribed = screen.plugins_need_ansi_pane_contents;
screen.plugins_need_ansi_pane_contents = has_subscribers;
+ if has_subscribers && !previously_subscribed {
+ if let Err(e) = screen.render(None) {
+ log::error!("Failed to render after ANSI subscription enabled: {:?}", e);
+ }
+ }
},
ScreenInstruction::UpdateBackgroundPluginSubscriptions(
plugin_id,
@@ -9969,6 +10460,47 @@ pub(crate) fn screen_thread_main(
));
}
},
+ ScreenInstruction::EnterMobileMode(client_id, _completion_tx) => {
+ if let Err(e) = screen.enter_mobile_mode(client_id) {
+ screen.mobile_render_gate.ungate(client_id);
+ screen.render(None)?;
+ return Err(e);
+ }
+ screen.mobile_state.mark_auto_entered(client_id);
+ screen.render(None)?;
+ },
+ ScreenInstruction::ExitMobileMode(client_id, _completion_tx) => {
+ screen.exit_mobile_mode(client_id)?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::ToggleMobileMode(client_id, _completion_tx) => {
+ screen.toggle_mobile_mode(client_id)?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::ReevaluateMobileMode {
+ client_id,
+ new_size,
+ mobile_layout,
+ threshold_cols,
+ threshold_rows,
+ } => {
+ screen.reevaluate_mobile_mode(
+ client_id,
+ new_size,
+ mobile_layout,
+ threshold_cols,
+ threshold_rows,
+ )?;
+ },
+ ScreenInstruction::SetSoftKeyboard { client_id, on } => {
+ if let Some(os_input) = &screen.bus.os_input {
+ let _ = os_input
+ .send_to_client(client_id, ServerToClientMsg::SetSoftKeyboard { on });
+ }
+ },
+ ScreenInstruction::SetShadowFocus(client_id, pane_id) => {
+ screen.set_shadow_focus(client_id, pane_id)?;
+ },
}
}
Ok(())
diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs
index 83db1249..a1184635 100644
--- a/zellij-server/src/tab/mod.rs
+++ b/zellij-server/src/tab/mod.rs
@@ -158,11 +158,8 @@ 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,
+ pub visible_to: Option<HashSet<ClientId>>,
tiled_panes: TiledPanes,
floating_panes: FloatingPanes,
suppressed_panes: SuppressedPanes,
@@ -306,6 +303,8 @@ pub trait Pane {
}
fn scroll_up(&mut self, count: usize, client_id: ClientId);
fn scroll_down(&mut self, count: usize, client_id: ClientId);
+ fn scroll_left(&mut self, _count: usize, _client_id: ClientId) {}
+ fn scroll_right(&mut self, _count: usize, _client_id: ClientId) {}
fn clear_scroll(&mut self);
fn is_scrolled(&self) -> bool;
fn active_at(&self) -> Instant;
@@ -763,9 +762,10 @@ impl Tab {
mouse_click_through: bool,
web_server_ip: IpAddr,
web_server_port: u16,
+ mobile_tab_count: usize,
) -> Self {
let name = if name.is_empty() {
- format!("Tab #{}", id + 1)
+ format!("Tab #{}", (id + 1).saturating_sub(mobile_tab_count))
} else {
name
};
@@ -825,6 +825,7 @@ impl Tab {
name: name.clone(),
prev_name: name,
size: initial_size,
+ visible_to: None,
max_panes,
viewport,
display_area,
@@ -2690,6 +2691,36 @@ impl Tab {
pub fn has_non_suppressed_pane_with_pid(&self, pid: &PaneId) -> bool {
self.tiled_panes.panes_contain(pid) || self.floating_panes.panes_contain(pid)
}
+ pub fn set_shadow_focus(&mut self, client_id: ClientId, pane_id: PaneId) -> bool {
+ if self.tiled_panes.panes_contain(&pane_id) {
+ self.tiled_panes.set_shadow_focus(client_id, pane_id);
+ true
+ } else if self.floating_panes.panes_contain(&pane_id) {
+ self.floating_panes.set_shadow_focus(client_id, pane_id);
+ true
+ } else {
+ false
+ }
+ }
+ pub fn clear_shadow_focus(&mut self, client_id: ClientId) {
+ if self.tiled_panes.is_shadow_focus_client(&client_id) {
+ self.tiled_panes.clear_shadow_focus(client_id);
+ }
+ if self.floating_panes.is_shadow_focus_client(&client_id) {
+ self.floating_panes.clear_shadow_focus(client_id);
+ }
+ }
+ pub fn shadow_focus_clients(&self) -> Vec<ClientId> {
+ let mut out = self.tiled_panes.shadow_focus_clients();
+ out.extend(self.floating_panes.shadow_focus_clients());
+ out.sort_unstable();
+ out.dedup();
+ out
+ }
+ pub fn has_shadow_focus_on(&self, client_id: ClientId, pane_id: PaneId) -> bool {
+ self.tiled_panes.has_shadow_focus_on(client_id, pane_id)
+ || self.floating_panes.has_shadow_focus_on(client_id, pane_id)
+ }
pub fn handle_pty_bytes(&mut self, pid: u32, bytes: VteBytes) -> Result<()> {
if self.is_pending {
self.pending_instructions
@@ -3210,6 +3241,9 @@ impl Tab {
pub fn is_fullscreen_active(&self) -> bool {
self.tiled_panes.fullscreen_is_active()
}
+ pub fn fullscreen_pane_id(&self) -> Option<PaneId> {
+ self.tiled_panes.fullscreen_pane_id()
+ }
pub fn are_floating_panes_visible(&self) -> bool {
self.floating_panes.panes_are_visible()
}
@@ -3262,6 +3296,10 @@ impl Tab {
self.floating_panes.set_force_render(); // we do this to make sure pinned panes are
// rendered even if their surface is not visible
}
+ #[cfg(test)]
+ pub fn should_clear_display_before_rendering(&self) -> bool {
+ self.should_clear_display_before_rendering
+ }
pub fn is_sync_panes_active(&self) -> bool {
self.synchronize_is_active
}
@@ -4464,13 +4502,28 @@ impl Tab {
}
}
- pub fn scroll_terminal_up(&mut self, terminal_pane_id: u32) {
- if let Some(terminal_pane) = self.get_pane_with_id_mut(PaneId::Terminal(terminal_pane_id)) {
- let fictitious_client_id = 1; // this is not checked for terminal panes and we
- // don't have an actual client id here
- // TODO: traits were a mistake
- terminal_pane.scroll_up(1, fictitious_client_id);
+ pub fn scroll_terminal_up(&mut self, terminal_pane_id: u32) -> Result<()> {
+ let pane_id = PaneId::Terminal(terminal_pane_id);
+ let err_context = || format!("failed to scroll up pane {pane_id:?}");
+ let fictitious_client_id = 1;
+ let synthetic_position = Position::new(0, 0);
+ let (mouse_sgr, is_alt) = match self.get_pane_with_id(pane_id) {
+ Some(pane) => (
+ pane.mouse_scroll_up(&synthetic_position),
+ pane.is_alternate_mode_active(),
+ ),
+ None => return Ok(()),
+ };
+ if let Some(sgr) = mouse_sgr {
+ self.write_to_pane_id(&None, sgr.into_bytes(), false, pane_id, None, None)
+ .with_context(err_context)?;
+ } else if is_alt {
+ self.write_to_pane_id(&None, b"\x1b[A".to_vec(), false, pane_id, None, None)
+ .with_context(err_context)?;
+ } else if let Some(pane) = self.get_pane_with_id_mut(pane_id) {
+ pane.scroll_up(1, fictitious_client_id);
}
+ Ok(())
}
pub fn scroll_active_terminal_down(&mut self, client_id: ClientId) -> Result<()> {
@@ -4488,13 +4541,37 @@ impl Tab {
Ok(())
}
- pub fn scroll_terminal_down(&mut self, terminal_pane_id: u32) {
- if let Some(terminal_pane) = self.get_pane_with_id_mut(PaneId::Terminal(terminal_pane_id)) {
- let fictitious_client_id = 1; // this is not checked for terminal panes and we
- // don't have an actual client id here
- // TODO: traits were a mistake
- terminal_pane.scroll_down(1, fictitious_client_id);
+ pub fn scroll_terminal_down(&mut self, terminal_pane_id: u32) -> Result<()> {
+ let pane_id = PaneId::Terminal(terminal_pane_id);
+ let err_context = || format!("failed to scroll down pane {pane_id:?}");
+ let fictitious_client_id = 1;
+ let synthetic_position = Position::new(0, 0);
+ let (mouse_sgr, is_alt) = match self.get_pane_with_id(pane_id) {
+ Some(pane) => (
+ pane.mouse_scroll_down(&synthetic_position),
+ pane.is_alternate_mode_active(),
+ ),
+ None => return Ok(()),
+ };
+ if let Some(sgr) = mouse_sgr {
+ self.write_to_pane_id(&None, sgr.into_bytes(), false, pane_id, None, None)
+ .with_context(err_context)?;
+ } else if is_alt {
+ self.write_to_pane_id(&None, b"\x1b[B".to_vec(), false, pane_id, None, None)
+ .with_context(err_context)?;
+ } else {
+ let needs_flush = if let Some(pane) = self.get_pane_with_id_mut(pane_id) {
+ pane.scroll_down(1, fictitious_client_id);
+ !pane.is_scrolled()
+ } else {
+ false
+ };
+ if needs_flush {
+ self.process_pending_vte_events(terminal_pane_id)
+ .with_context(err_context)?;
+ }
}
+ Ok(())
}
pub fn scroll_active_terminal_up_page(&mut self, client_id: ClientId) {
diff --git a/zellij-server/src/tab/mouse_handler.rs b/zellij-server/src/tab/mouse_handler.rs
index 97f4ce27..0397a7f4 100644
--- a/zellij-server/src/tab/mouse_handler.rs
+++ b/zellij-server/src/tab/mouse_handler.rs
@@ -12,9 +12,6 @@ use crate::ClientId;
use super::{Pane, Tab};
-/// Remove the hover pane tracking for `client_id` and clear the hover position
-/// on the previously hovered pane (if any). Returns `true` if a pane was
-/// cleared.
fn clear_hover_for_client(tab: &mut Tab, client_id: ClientId) -> bool {
if let Some(prev_pid) = tab.mouse_hover_pane_id.remove(&client_id) {
if let Some(pane) = tab.get_pane_with_id_mut(prev_pid) {
@@ -148,6 +145,14 @@ enum MouseAction {
pane_id: PaneId,
lines: usize,
},
+ ScrollLeft {
+ pane_id: PaneId,
+ cols: usize,
+ },
+ ScrollRight {
+ pane_id: PaneId,
+ cols: usize,
+ },
ResizeScrollUp {
pane_id: PaneId,
},
@@ -261,14 +266,12 @@ fn edge_and_delta_to_strategies(
},
PaneEdge::TopLeft => {
let mut strategies = vec![];
- // Top edge
let resize_y = if delta_y < 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_y,
direction: Some(Up),
invert_on_boundaries: false,
});
- // Left edge
let resize_x = if delta_x < 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_x,
@@ -279,14 +282,12 @@ fn edge_and_delta_to_strategies(
},
PaneEdge::TopRight => {
let mut strategies = vec![];
- // Top edge
let resize_y = if delta_y < 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_y,
direction: Some(Up),
invert_on_boundaries: false,
});
- // Right edge
let resize_x = if delta_x > 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_x,
@@ -297,14 +298,12 @@ fn edge_and_delta_to_strategies(
},
PaneEdge::BottomLeft => {
let mut strategies = vec![];
- // Bottom edge
let resize_y = if delta_y > 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_y,
direction: Some(Down),
invert_on_boundaries: false,
});
- // Left edge
let resize_x = if delta_x < 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_x,
@@ -315,14 +314,12 @@ fn edge_and_delta_to_strategies(
},
PaneEdge::BottomRight => {
let mut strategies = vec![];
- // Bottom edge
let resize_y = if delta_y > 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_y,
direction: Some(Down),
invert_on_boundaries: false,
});
- // Right edge
let resize_x = if delta_x > 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_x,
@@ -446,10 +443,8 @@ impl MouseHandler {
) -> Result<()> {
let err_context = || format!("failed to start pane resize for pane {pane_id:?}");
- // Determine if floating or tiled
let is_floating = tab.floating_panes.panes_contain(&pane_id);
- // Get current pane geometry
let start_geom = if is_floating {
tab.floating_panes
.get_pane(pane_id)
@@ -478,18 +473,14 @@ impl MouseHandler {
current_position: Position,
_client_id: ClientId,
) -> Result<bool> {
- // bool -> state changed
let err_context = || "failed to continue pane resize with mouse";
- // Extract needed values from resize_state to avoid borrow issues
let (pane_id, edge, is_floating, delta_x, delta_y) =
if let Some(resize_state) = &tab.pane_being_resized_with_mouse {
- // Calculate delta from start_position to current_position
let delta_x = current_position.column() as isize
- resize_state.start_position.column() as isize;
let delta_y = current_position.line() - resize_state.start_position.line();
- // Only proceed if there's a meaningful delta
if delta_x == 0 && delta_y == 0 {
return Ok(false);
}
@@ -505,10 +496,8 @@ impl MouseHandler {
return Ok(true);
};
- // Convert edge + delta to ResizeStrategy
let strategies = edge_and_delta_to_strategies(edge, delta_x, delta_y);
- // Apply appropriate resize function
if is_floating {
Self::resize_floating_pane_with_strategies(
tab,
@@ -527,7 +516,6 @@ impl MouseHandler {
.with_context(err_context)?;
}
- // Update start_position to current position (incremental)
if let Some(resize_state) = tab.pane_being_resized_with_mouse.as_mut() {
resize_state.start_position = current_position;
}
@@ -542,10 +530,8 @@ impl MouseHandler {
final_position: Position,
client_id: ClientId,
) -> Result<bool> {
- // bool -> never_resized
let err_context = || "failed to stop pane resize with mouse";
- // Perform final resize with any remaining delta
let start_geom = tab
.pane_being_resized_with_mouse
.as_ref()
@@ -564,7 +550,6 @@ impl MouseHandler {
_ => false,
};
- // Clear resize state
tab.pane_being_resized_with_mouse = None;
Ok(never_resized)
@@ -595,21 +580,18 @@ impl MouseHandler {
) -> Result<()> {
let err_context = || format!("failed to resize tiled pane {pane_id:?}");
- // Calculate percentage based on total viewport size for 1:1 cell mapping
- // Formula: (delta_cells / total_viewport_cells) * 100.0 = percentage
- // This ensures 1 cell of mouse movement = exactly 1 cell of pane resize
let viewport = tab.viewport.borrow();
let viewport_cols = viewport.cols;
let viewport_rows = viewport.rows;
let change_by_percent = (
if viewport_cols > 0 {
- (change_by.0 / viewport_cols as f64) * 100.0 // cols
+ (change_by.0 / viewport_cols as f64) * 100.0
} else {
0.0
},
if viewport_rows > 0 {
- (change_by.1 / viewport_rows as f64) * 100.0 // rows
+ (change_by.1 / viewport_rows as f64) * 100.0
} else {
0.0
},
@@ -632,8 +614,6 @@ impl MouseHandler {
let err_context = || format!("failed to resize tiled pane {pane_id:?}");
tab.tiled_panes
- // we override the resize percent because it's better UX to have smaller increments
- // with the mouse wheel
.stacked_resize_pane_with_id(pane_id, strategy, Some((5.0, 5.0)))
.with_context(err_context)?;
@@ -663,7 +643,6 @@ impl MouseHandler {
match action {
MouseAction::GroupToggle(pane_id) => {
- // Check if Alt+Click lands on a plugin highlight
if let Some(pane) = tab.get_pane_with_id_mut(pane_id) {
let relative_position = pane.relative_position(&event.position);
if let Some((hit_plugin_id, pattern, matched_string, context)) =
@@ -682,7 +661,6 @@ impl MouseHandler {
return Ok(MouseEffect::state_changed());
}
}
- // No highlight hit — fall through to pane grouping
Ok(MouseEffect::group_toggle(pane_id))
},
MouseAction::GroupAdd(pane_id) => Ok(MouseEffect::group_add(pane_id)),
@@ -775,6 +753,30 @@ impl MouseHandler {
Self::handle_scrollwheel_down(tab, &event.position, lines, client_id)
.with_context(err_context)
},
+ MouseAction::ScrollLeft { pane_id, cols } => {
+ let scroll_right = false;
+ Self::handle_scrollwheel_horizontal(
+ tab,
+ pane_id,
+ &event.position,
+ cols,
+ scroll_right,
+ client_id,
+ )
+ .with_context(err_context)
+ },
+ MouseAction::ScrollRight { pane_id, cols } => {
+ let scroll_right = true;
+ Self::handle_scrollwheel_horizontal(
+ tab,
+ pane_id,
+ &event.position,
+ cols,
+ scroll_right,
+ client_id,
+ )
+ .with_context(err_context)
+ },
MouseAction::ResizeScrollUp { pane_id } => {
Self::handle_resize_scroll_up(tab, pane_id, client_id).with_context(err_context)
},
@@ -873,21 +875,15 @@ impl MouseHandler {
) -> Result<MouseEffect> {
let err_context = || "failed to focus pane and click through";
- // Step 1: Focus the pane (same as execute_focus_pane, but without the
- // floating-pane move-on-click behavior — we want to send the click into
- // the pane, not start moving it)
clear_hover_for_client(tab, client_id);
Self::focus_pane_at(tab, &position, client_id).with_context(err_context)?;
- // Handle unselectable panes the same way execute_focus_pane does
if let Some(pane_at_position) = Self::unselectable_pane_at_position(tab, &position) {
let relative_position = pane_at_position.relative_position(&position);
pane_at_position.start_selection(&relative_position, client_id);
return Ok(MouseEffect::state_changed());
}
- // Step 2: Now that the pane is focused, dispatch the click as if the
- // user clicked on the (now-active) pane.
let active_pane_id = tab
.get_active_pane_id(client_id)
.ok_or_else(|| anyhow!("Failed to find active pane"))
@@ -901,7 +897,6 @@ impl MouseHandler {
let terminal_wants_mouse = pane.terminal_emulator_wants_mouse();
if terminal_wants_mouse {
- // Terminal wants mouse events — send the click to it
let relative_position = pane.relative_position(&click_event.position);
let mut event_for_pane = click_event;
event_for_pane.position = relative_position;
@@ -912,7 +907,6 @@ impl MouseHandler {
}
}
} else {
- // Terminal does not want mouse — start text selection
if let Some(pane) = tab.get_pane_with_id_mut(active_pane_id) {
let relative_position = pane.relative_position(&position);
pane.start_selection(&relative_position, client_id);
@@ -1025,26 +1019,20 @@ impl MouseHandler {
) -> Result<MouseEffect> {
let err_context = || format!("failed to focus pane on hover for client {client_id}");
- // Only focus selectable panes
let is_selectable = tab
.get_pane_with_id(pane_id)
.map(|p| p.selectable())
.unwrap_or(false);
if !is_selectable {
- // Fall back to normal hover behavior for unselectable panes
return Self::execute_update_hover(tab, Some(pane_id), Some(position), client_id);
}
- // When floating panes are visible, only focus floating panes — tiled panes
- // require an explicit click
let floating_visible = tab.floating_panes.panes_are_visible();
let is_floating = tab.floating_panes.get_pane(pane_id).is_some();
if floating_visible && !is_floating {
return Self::execute_update_hover(tab, Some(pane_id), Some(position), client_id);
}
- // Skip stacked one-liner panes (collapsed panes in a stack) — only the
- // expanded main pane in a stack should be focusable via hover
let is_stacked_one_liner = tab
.get_pane_with_id(pane_id)
.map(|p| {
@@ -1056,16 +1044,13 @@ impl MouseHandler {
return Self::execute_update_hover(tab, Some(pane_id), Some(position), client_id);
}
- // Skip if already focused
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);
}
- // Focus the pane
Self::focus_pane_at(tab, &position, client_id).with_context(err_context)?;
- // Clear hover state since the pane is now focused
clear_hover_for_client(tab, client_id);
Ok(MouseEffect::state_changed())
@@ -1099,10 +1084,6 @@ impl MouseHandler {
},
}
- // Clear hover position on previously hovered pane when the hovered
- // pane has changed (or cursor left all panes). Hover position is
- // intentionally not set on unfocused panes so that hover-only plugin
- // highlights only activate on the focused pane.
if let Some(prev_pane_id) = previous_hover_pane_id {
if Some(prev_pane_id) != pane_id {
if let Some(pane) = tab.get_pane_with_id_mut(prev_pane_id) {
@@ -1151,11 +1132,6 @@ impl MouseHandler {
if clear_hover_for_client(tab, client_id) {
should_render = true;
}
- // Update hover position on the active pane during motion events
- // so that on_hover highlights work for the focused pane too.
- // Skip when the terminal application has mouse tracking enabled,
- // because hover highlights are suppressed in that case and the
- // update would only trigger unnecessary re-renders.
if event.event_type == MouseEventType::Motion {
if let Some(pane) = tab.get_pane_with_id_mut(pane_id) {
if !pane.terminal_emulator_wants_mouse() {
@@ -1270,6 +1246,18 @@ impl MouseHandler {
return Ok(MouseAction::NoAction);
}
+ if event.wheel_left || event.wheel_right {
+ if let Some(pane_id) = ctx.pane_id_at_position {
+ if event.wheel_left {
+ return Ok(MouseAction::ScrollLeft { pane_id, cols: 4 });
+ }
+ if event.wheel_right {
+ return Ok(MouseAction::ScrollRight { pane_id, cols: 4 });
+ }
+ }
+ return Ok(MouseAction::NoAction);
+ }
+
let is_ctrl_left_press =
event.ctrl && event.left && event.event_type == MouseEventType::Press;
if is_ctrl_left_press {
@@ -1531,8 +1519,7 @@ impl MouseHandler {
tab.write_to_terminal_at(mouse_event.into_bytes(), point, client_id)
.with_context(err_context)?;
} else if pane.is_alternate_mode_active() {
- // faux scrolling, send UP n times
- // do n separate writes to make sure the sequence gets adjusted for cursor keys mode
+ // separate writes so each sequence gets adjusted for cursor keys mode
for _ in 0..lines {
tab.write_to_terminal_at("\u{1b}[A".as_bytes().to_owned(), point, client_id)
.with_context(err_context)?;
@@ -1562,8 +1549,7 @@ impl MouseHandler {
tab.write_to_terminal_at(mouse_event.into_bytes(), point, client_id)
.with_context(err_context)?;
} else if pane.is_alternate_mode_active() {
- // faux scrolling, send DOWN n times
- // do n separate writes to make sure the sequence gets adjusted for cursor keys mode
+ // separate writes so each sequence gets adjusted for cursor keys mode
for _ in 0..lines {
tab.write_to_terminal_at("\u{1b}[B".as_bytes().to_owned(), point, client_id)
.with_context(err_context)?;
@@ -1581,6 +1567,32 @@ impl MouseHandler {
Ok(MouseEffect::default())
}
+ pub(crate) fn handle_scrollwheel_horizontal(
+ tab: &mut Tab,
+ pane_id: PaneId,
+ point: &Position,
+ cols: usize,
+ scroll_right: bool,
+ client_id: ClientId,
+ ) -> Result<MouseEffect> {
+ let err_context = || {
+ format!(
+ "failed to handle horizontal scrollwheel at position {point:?} for client {client_id}"
+ )
+ };
+ if !matches!(pane_id, PaneId::Plugin(_)) {
+ return Ok(MouseEffect::default());
+ }
+ if let Some(pane) = Self::get_pane_at(tab, point, false).with_context(err_context)? {
+ if scroll_right {
+ pane.scroll_right(cols, client_id);
+ } else {
+ pane.scroll_left(cols, client_id);
+ }
+ }
+ Ok(MouseEffect::default())
+ }
+
fn handle_resize_scroll_up(
tab: &mut Tab,
pane_id: PaneId,
@@ -1601,7 +1613,6 @@ impl MouseHandler {
.with_context(err_context)?;
tab.swap_layouts.set_is_floating_damaged();
} else {
- // we only resize the active pane for tiled panes (better ux)
let active_pane_id = tab
.get_active_pane_id(client_id)
.ok_or_else(|| anyhow!("Failed to find active pane"))?;
@@ -1635,7 +1646,6 @@ impl MouseHandler {
.with_context(err_context)?;
tab.swap_layouts.set_is_floating_damaged();
} else {
- // we only resize the active pane for tiled panes (better ux)
let active_pane_id = tab
.get_active_pane_id(client_id)
.ok_or_else(|| anyhow!("Failed to find active pane"))?;
diff --git a/zellij-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs
index 46e796b6..80b6324e 100644
--- a/zellij-server/src/tab/unit/tab_integration_tests.rs
+++ b/zellij-server/src/tab/unit/tab_integration_tests.rs
@@ -277,6 +277,7 @@ fn create_new_tab(size: Size, default_mode: ModeInfo) -> Tab {
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -364,6 +365,7 @@ fn create_new_tab_without_pane_frames(size: Size, default_mode: ModeInfo) -> Tab
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -466,6 +468,7 @@ fn create_new_tab_with_swap_layouts(
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
let (
base_layout,
@@ -569,6 +572,7 @@ fn create_new_tab_with_os_api(
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -658,6 +662,7 @@ fn create_new_tab_with_layout(size: Size, default_mode: ModeInfo, layout: &str)
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
let pane_ids = tab_layout
.extract_run_instructions()
@@ -761,6 +766,7 @@ fn create_new_tab_with_mock_pty_writer(
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -855,6 +861,7 @@ fn create_new_tab_with_sixel_support(
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -12562,6 +12569,7 @@ fn create_new_tab_with_plugin_receiver(
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -14346,6 +14354,7 @@ fn create_new_tab_with_server_receiver(
false, // mouse_click_through
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
8080,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -14804,14 +14813,10 @@ fn osc99_query_flag_roundtrip() {
#[test]
fn hidden_cursor_still_emits_cup_for_host_terminal_positioning() {
- // When an app hides the cursor the host
- // terminal still needs a CUP sequence so that IME composition candidates and
- // other cursor-position-dependent features appear in the correct location.
let size = Size { cols: 80, rows: 24 };
let client_id = 1;
let mut tab = create_new_tab(size, ModeInfo::default());
- // Move cursor to a known position, then hide it
tab.handle_pty_bytes(1, Vec::from("\u{1b}[10;20H\u{1b}[?25l".as_bytes()))
.unwrap();
@@ -14820,26 +14825,17 @@ fn hidden_cursor_still_emits_cup_for_host_terminal_positioning() {
let serialized = output.serialize().unwrap();
let client_output = serialized.get(&client_id).unwrap();
- // The output must contain a hide-cursor instruction
assert!(
client_output.contains("\u{1b}[?25l"),
"Expected hide-cursor sequence in output"
);
- // The output must contain a CUP sequence positioning the cursor where the app
- // left it (row 10, col 20 — 1-indexed in the pane, plus the content offset
- // from the pane frame). The key point is that a CUP is emitted at all, even
- // though the cursor is hidden.
- //
- // With draw_pane_frames=true the content offset is (1, 1), so the absolute
- // position is row 11, col 21.
assert!(
client_output.contains("\u{1b}[11;21H"),
"Expected CUP sequence for hidden cursor positioning, got: {:?}",
client_output
);
- // The output must NOT contain a show-cursor instruction
assert!(
!client_output.contains("\u{1b}[?25h"),
"Show-cursor sequence must not be present when app has hidden the cursor"
diff --git a/zellij-server/src/tab/unit/tab_tests.rs b/zellij-server/src/tab/unit/tab_tests.rs
index 338035de..1ce0ed72 100644
--- a/zellij-server/src/tab/unit/tab_tests.rs
+++ b/zellij-server/src/tab/unit/tab_tests.rs
@@ -1,10 +1,12 @@
use super::Tab;
use crate::pane_groups::PaneGroups;
use crate::panes::sixel::SixelImageStore;
+use crate::pty_writer::PtyWriteInstruction;
use crate::screen::CopyOptions;
use crate::{os_input_output::ServerOsApi, panes::PaneId, thread_bus::ThreadSenders, ClientId};
use std::net::{IpAddr, Ipv4Addr};
use std::path::PathBuf;
+use zellij_utils::channels::{unbounded, Receiver, SenderWithContext};
use zellij_utils::data::{Direction, NewPanePlacement, Resize, ResizeStrategy, WebSharing};
use zellij_utils::errors::prelude::*;
use zellij_utils::input::layout::{SplitDirection, SplitSize, TiledPaneLayout};
@@ -217,6 +219,7 @@ fn create_new_tab(size: Size, stacked_resize: bool) -> Tab {
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -303,6 +306,7 @@ fn create_new_tab_with_layout(size: Size, layout: TiledPaneLayout) -> Tab {
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
let mut new_terminal_ids = vec![];
for i in 0..layout.extract_run_instructions().len() {
@@ -395,6 +399,7 @@ fn create_new_tab_with_cell_size(
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -16307,3 +16312,397 @@ pub fn cli_rename_active_pane_then_interactive_esc_restores() {
let pane = tab.get_pane_with_id(pane_id).unwrap();
assert_eq!(pane.current_title(), "spark");
}
+
+fn install_pty_writer_capture(
+ tab: &mut Tab,
+) -> Receiver<(PtyWriteInstruction, zellij_utils::errors::ErrorContext)> {
+ let (tx, rx) = unbounded();
+ tab.senders
+ .replace_to_pty_writer(SenderWithContext::new(tx));
+ rx
+}
+
+fn drain_pty_writer(
+ rx: &Receiver<(PtyWriteInstruction, zellij_utils::errors::ErrorContext)>,
+) -> Vec<PtyWriteInstruction> {
+ let mut out = Vec::new();
+ while let Ok((msg, _ctx)) = rx.try_recv() {
+ out.push(msg);
+ }
+ out
+}
+
+#[test]
+pub fn scroll_terminal_up_forwards_sgr_when_pane_tracks_mouse() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+ tab.handle_pty_bytes(1, b"\x1b[?1000h\x1b[?1006h".to_vec())
+ .unwrap();
+
+ tab.scroll_terminal_up(1).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(!writes.is_empty(), "expected at least one pty write");
+ match &writes[0] {
+ PtyWriteInstruction::Write(bytes, terminal_id, _) => {
+ assert_eq!(*terminal_id, 1);
+ let s = String::from_utf8_lossy(bytes);
+ assert!(
+ s.starts_with("\u{1b}[<64;"),
+ "expected SGR wheel-up (button 64) prefix, got {s:?}"
+ );
+ },
+ other => panic!("expected PtyWriteInstruction::Write, got {other:?}"),
+ }
+}
+
+#[test]
+pub fn scroll_terminal_down_forwards_sgr_when_pane_tracks_mouse() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+ tab.handle_pty_bytes(1, b"\x1b[?1000h\x1b[?1006h".to_vec())
+ .unwrap();
+
+ tab.scroll_terminal_down(1).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(!writes.is_empty(), "expected at least one pty write");
+ match &writes[0] {
+ PtyWriteInstruction::Write(bytes, terminal_id, _) => {
+ assert_eq!(*terminal_id, 1);
+ let s = String::from_utf8_lossy(bytes);
+ assert!(
+ s.starts_with("\u{1b}[<65;"),
+ "expected SGR wheel-down (button 65) prefix, got {s:?}"
+ );
+ },
+ other => panic!("expected PtyWriteInstruction::Write, got {other:?}"),
+ }
+}
+
+#[test]
+pub fn scroll_terminal_up_emits_arrow_up_in_alt_mode_without_mouse() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+ tab.handle_pty_bytes(1, b"\x1b[?1049h".to_vec()).unwrap();
+
+ tab.scroll_terminal_up(1).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(!writes.is_empty(), "expected at least one pty write");
+ match &writes[0] {
+ PtyWriteInstruction::Write(bytes, terminal_id, _) => {
+ assert_eq!(*terminal_id, 1);
+ assert_eq!(bytes.as_slice(), b"\x1b[A");
+ },
+ other => panic!("expected PtyWriteInstruction::Write, got {other:?}"),
+ }
+}
+
+#[test]
+pub fn scroll_terminal_down_emits_arrow_down_in_alt_mode_without_mouse() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+ tab.handle_pty_bytes(1, b"\x1b[?1049h".to_vec()).unwrap();
+
+ tab.scroll_terminal_down(1).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(!writes.is_empty(), "expected at least one pty write");
+ match &writes[0] {
+ PtyWriteInstruction::Write(bytes, terminal_id, _) => {
+ assert_eq!(*terminal_id, 1);
+ assert_eq!(bytes.as_slice(), b"\x1b[B");
+ },
+ other => panic!("expected PtyWriteInstruction::Write, got {other:?}"),
+ }
+}
+
+#[test]
+pub fn scroll_terminal_up_walks_scrollback_in_regular_mode_no_pty_write() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+
+ tab.scroll_terminal_up(1).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(
+ writes.is_empty(),
+ "expected no pty writes in regular mode, got {writes:?}"
+ );
+}
+
+#[test]
+pub fn scroll_terminal_down_walks_scrollback_in_regular_mode_no_pty_write() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+
+ tab.scroll_terminal_down(1).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(
+ writes.is_empty(),
+ "expected no pty writes in regular mode, got {writes:?}"
+ );
+}
+
+#[test]
+pub fn scroll_terminal_up_nonexistent_pane_id_is_a_noop() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+
+ tab.scroll_terminal_up(999).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(writes.is_empty());
+}
+
+#[test]
+pub fn scroll_terminal_down_nonexistent_pane_id_is_a_noop() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+
+ tab.scroll_terminal_down(999).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(writes.is_empty());
+}
+
+#[test]
+pub fn set_shadow_focus_returns_true_when_pane_is_in_tab() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ tab.vertical_split(PaneId::Terminal(2), None, 1, None, None)
+ .unwrap();
+
+ let placed = tab.set_shadow_focus(99, PaneId::Terminal(2));
+
+ assert!(
+ placed,
+ "pane is in the tab — should report a successful placement"
+ );
+ assert!(
+ tab.has_shadow_focus_on(99, PaneId::Terminal(2)),
+ "shadow focus must be recorded on the target pane",
+ );
+}
+
+#[test]
+pub fn set_shadow_focus_returns_false_when_pane_not_in_tab() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+
+ let placed = tab.set_shadow_focus(99, PaneId::Terminal(42));
+
+ assert!(!placed, "no pane 42 in this tab — placement must fail");
+ assert!(
+ !tab.has_shadow_focus_on(99, PaneId::Terminal(42)),
+ "nothing should have been recorded",
+ );
+ assert!(
+ tab.shadow_focus_clients().is_empty(),
+ "no shadow markers should exist",
+ );
+}
+
+#[test]
+pub fn clear_shadow_focus_removes_marker_and_entry() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ tab.set_shadow_focus(99, PaneId::Terminal(1));
+ assert_eq!(tab.shadow_focus_clients(), vec![99]);
+
+ tab.clear_shadow_focus(99);
+
+ assert!(
+ tab.shadow_focus_clients().is_empty(),
+ "marker should be gone after clear",
+ );
+ assert!(
+ !tab.has_shadow_focus_on(99, PaneId::Terminal(1)),
+ "the active_panes entry should be gone too",
+ );
+}
+
+#[test]
+pub fn clear_shadow_focus_is_a_noop_when_absent() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+
+ tab.clear_shadow_focus(99);
+
+ assert!(
+ tab.tiled_panes.pane_id_is_focused(&PaneId::Terminal(1)),
+ "real client's focus on pane 1 must survive an unrelated clear",
+ );
+}
+
+#[test]
+pub fn has_shadow_focus_on_is_pane_specific() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ tab.vertical_split(PaneId::Terminal(2), None, 1, None, None)
+ .unwrap();
+
+ tab.set_shadow_focus(99, PaneId::Terminal(2));
+
+ assert!(tab.has_shadow_focus_on(99, PaneId::Terminal(2)));
+ assert!(
+ !tab.has_shadow_focus_on(99, PaneId::Terminal(1)),
+ "must NOT match a different pane id",
+ );
+ assert!(
+ !tab.has_shadow_focus_on(7, PaneId::Terminal(2)),
+ "must NOT match a different client id",
+ );
+}
+
+#[test]
+pub fn has_shadow_focus_on_does_not_match_real_focus() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let tab = create_new_tab(size, true);
+
+ assert!(
+ tab.tiled_panes.pane_id_is_focused(&PaneId::Terminal(1)),
+ "precondition: client 1 has real focus on pane 1",
+ );
+ assert!(
+ !tab.has_shadow_focus_on(1, PaneId::Terminal(1)),
+ "real focus must not register as shadow focus",
+ );
+}
+
+#[test]
+pub fn shadow_focus_clients_lists_only_marked_clients() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ tab.vertical_split(PaneId::Terminal(2), None, 1, None, None)
+ .unwrap();
+
+ tab.set_shadow_focus(99, PaneId::Terminal(1));
+ tab.set_shadow_focus(100, PaneId::Terminal(2));
+
+ let mut clients = tab.shadow_focus_clients();
+ clients.sort_unstable();
+ assert_eq!(clients, vec![99, 100]);
+}
+
+#[test]
+pub fn real_focus_supersedes_shadow_marker() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ tab.set_shadow_focus(99, PaneId::Terminal(1));
+ assert_eq!(tab.shadow_focus_clients(), vec![99]);
+
+ tab.tiled_panes.focus_pane(PaneId::Terminal(1), 99);
+
+ assert!(
+ tab.shadow_focus_clients().is_empty(),
+ "real focus must clear any prior shadow marker for the same client",
+ );
+ assert!(
+ tab.tiled_panes.pane_id_is_focused(&PaneId::Terminal(1)),
+ "real focus must still be recorded",
+ );
+}
+
+#[test]
+pub fn moving_shadow_focus_updates_pane_target() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ tab.vertical_split(PaneId::Terminal(2), None, 1, None, None)
+ .unwrap();
+
+ tab.set_shadow_focus(99, PaneId::Terminal(1));
+ tab.set_shadow_focus(99, PaneId::Terminal(2));
+
+ assert!(tab.has_shadow_focus_on(99, PaneId::Terminal(2)));
+ assert!(
+ !tab.has_shadow_focus_on(99, PaneId::Terminal(1)),
+ "previous shadow target must be overwritten",
+ );
+ assert_eq!(
+ tab.shadow_focus_clients(),
+ vec![99],
+ "still only one shadow client",
+ );
+}
+
+#[test]
+pub fn closing_a_pane_silently_drops_shadow_clients() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ tab.vertical_split(PaneId::Terminal(2), None, 1, None, None)
+ .unwrap();
+ tab.set_shadow_focus(99, PaneId::Terminal(2));
+ assert!(tab.has_shadow_focus_on(99, PaneId::Terminal(2)));
+
+ tab.close_pane(PaneId::Terminal(2), false, None);
+
+ assert!(
+ tab.shadow_focus_clients().is_empty(),
+ "shadow client must be dropped when its pane is closed",
+ );
+}
diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs
index 18837be6..3e0a65db 100644
--- a/zellij-server/src/unit/screen_tests.rs
+++ b/zellij-server/src/unit/screen_tests.rs
@@ -9469,17 +9469,6 @@ fn empty_reply_with_paused_pane_drains_buffer_without_phantom_write() {
);
}
-// --- 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();
@@ -9554,11 +9543,8 @@ fn recompute_tab_size_takes_independent_min_across_axes() {
};
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 {
@@ -9582,13 +9568,9 @@ fn recompute_tab_size_isolates_tabs_with_different_viewers() {
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(
@@ -9626,8 +9608,6 @@ fn switching_tabs_recomputes_source_and_destination() {
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,
@@ -9643,8 +9623,6 @@ fn switching_tabs_recomputes_source_and_destination() {
"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!(
@@ -9664,10 +9642,6 @@ fn switching_tabs_recomputes_source_and_destination() {
#[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,
@@ -9677,7 +9651,6 @@ fn break_pane_to_new_tab_recomputes_source_and_destination() {
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,
@@ -9686,8 +9659,6 @@ fn break_pane_to_new_tab_recomputes_source_and_destination() {
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,
@@ -9703,9 +9674,6 @@ fn break_pane_to_new_tab_recomputes_source_and_destination() {
"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
@@ -9722,14 +9690,10 @@ fn break_pane_to_new_tab_recomputes_source_and_destination() {
.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 },
@@ -9744,9 +9708,6 @@ fn break_pane_to_new_tab_recomputes_source_and_destination() {
#[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,
@@ -9756,7 +9717,6 @@ fn moving_panes_between_tabs_with_focus_change_recomputes_both() {
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,
@@ -9765,7 +9725,6 @@ fn moving_panes_between_tabs_with_focus_change_recomputes_both() {
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,
@@ -9781,7 +9740,6 @@ fn moving_panes_between_tabs_with_focus_change_recomputes_both() {
"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();
@@ -9799,11 +9757,6 @@ fn moving_panes_between_tabs_with_focus_change_recomputes_both() {
.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");
@@ -9830,7 +9783,6 @@ fn detaching_client_grows_vacated_tab_back() {
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 {
@@ -9846,7 +9798,6 @@ fn detaching_client_grows_vacated_tab_back() {
"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,
@@ -9857,3 +9808,1276 @@ fn detaching_client_grows_vacated_tab_back() {
"Tab grows back to fit the remaining viewer after the smaller one detaches"
);
}
+
+fn add_second_pane_to_active_tab(screen: &mut Screen, pid: u32) {
+ let active_tab = screen.get_active_tab_mut(1).unwrap();
+ active_tab
+ .new_pane(
+ PaneId::Terminal(pid),
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(1),
+ None,
+ )
+ .unwrap();
+}
+
+fn setup_mobile_fit(screen: &mut Screen, client: ClientId, mobile_tab_idx: usize, plugin_id: u32) {
+ let run = RunPluginOrAlias::from_url("zellij:mobile", &None, None, None).unwrap();
+ // Bus::empty() has no to_plugin sender, but PluginPane::new requires one; leak the
+ // receiver so the injected sender's channel stays alive for the test's lifetime.
+ let (to_plugin, plugin_receiver): ChannelWithContext<PluginInstruction> = channels::unbounded();
+ Box::leak(Box::new(plugin_receiver));
+ screen
+ .bus
+ .senders
+ .replace_to_plugin(SenderWithContext::new(to_plugin));
+ screen
+ .new_tab(
+ mobile_tab_idx,
+ (vec![], vec![]),
+ Some("Mobile".to_string()),
+ Some(client),
+ )
+ .expect("TEST");
+ let tab = screen.tabs.get_mut(&mobile_tab_idx).expect("mobile tab");
+ tab.new_pane(
+ PaneId::Plugin(plugin_id),
+ None,
+ Some(Run::Plugin(run)),
+ false,
+ true,
+ NewPanePlacement::NoPreference {
+ borderless: Some(true),
+ },
+ Some(client),
+ None,
+ )
+ .expect("TEST");
+ screen.mobile_state.register_tab(client, mobile_tab_idx);
+}
+
+#[test]
+fn fit_override_resizes_tab() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+
+ let mobile_client = 1;
+ setup_mobile_fit(&mut screen, mobile_client, 9, 100);
+ let size = Size { rows: 12, cols: 60 };
+ screen
+ .set_tab_fit(mobile_client, 0, PaneId::Terminal(1), size)
+ .expect("TEST");
+
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ screen.compute_fit_size(0).unwrap(),
+ "Tab adopts the server-derived fit size"
+ );
+ assert_eq!(screen.mobile_state.fit_owner(0), Some(mobile_client));
+ assert_eq!(screen.mobile_state.fit_pane(0), Some(PaneId::Terminal(1)));
+ assert_eq!(screen.mobile_state.fit_embedded_size(0), Some(size));
+}
+
+#[test]
+fn fit_cleared_when_target_pane_closes() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+
+ let mobile_client = 1;
+ setup_mobile_fit(&mut screen, mobile_client, 9, 100);
+ screen
+ .set_tab_fit(
+ mobile_client,
+ 0,
+ PaneId::Terminal(1),
+ Size { rows: 12, cols: 60 },
+ )
+ .expect("TEST");
+ assert!(
+ screen.mobile_state.has_fit(0),
+ "Pre-condition: fit installed on tab 0"
+ );
+
+ assert!(
+ !screen
+ .clear_fit_for_closed_pane(PaneId::Terminal(2))
+ .expect("TEST"),
+ "Closing a non-target pane does not clear the fit"
+ );
+ assert!(
+ screen.mobile_state.has_fit(0),
+ "Fit survives an unrelated pane close"
+ );
+
+ screen
+ .tabs
+ .get_mut(&0)
+ .unwrap()
+ .close_pane(PaneId::Terminal(1), false, None);
+ assert!(
+ screen
+ .clear_fit_for_closed_pane(PaneId::Terminal(1))
+ .expect("TEST"),
+ "Closing the target pane clears the fit"
+ );
+ assert!(
+ !screen.mobile_state.has_fit(0),
+ "Fit override is dropped once its target pane is gone"
+ );
+}
+
+#[test]
+fn fit_override_captures_fullscreen_state() {
+ let initial_size = Size { cols: 80, rows: 20 };
+
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+ {
+ let tab = screen.get_active_tab_mut(1).unwrap();
+ tab.toggle_pane_fullscreen(PaneId::Terminal(1));
+ assert!(tab.is_fullscreen_active());
+ }
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ assert!(
+ screen
+ .mobile_state
+ .fit_pane_was_fullscreen_before(0)
+ .expect("fit installed"),
+ "Pre-existing fullscreen recorded so exit/disconnect won't toggle it off"
+ );
+
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+ assert!(
+ !screen.tabs.get(&0).unwrap().is_fullscreen_active(),
+ "Pre-condition: no fullscreen"
+ );
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ assert!(
+ !screen
+ .mobile_state
+ .fit_pane_was_fullscreen_before(0)
+ .expect("fit installed"),
+ "Fit toggled fullscreen on itself; exit path will revert it"
+ );
+ assert!(
+ screen.tabs.get(&0).unwrap().is_fullscreen_active(),
+ "Entering fit fullscreens the target pane"
+ );
+}
+
+#[test]
+fn exit_fit_reverts_size_and_fullscreen() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+ screen.set_client_size(1, Size { cols: 80, rows: 20 });
+ screen.recompute_tab_size(0).expect("TEST");
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ screen.compute_fit_size(0).unwrap(),
+ "Pre-condition: override installed"
+ );
+ assert!(screen.tabs.get(&0).unwrap().is_fullscreen_active());
+
+ assert!(screen.exit_fit_mode(1).expect("TEST"));
+
+ assert!(screen.mobile_state.fit_count() == 0, "Override cleared");
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ Size { cols: 80, rows: 20 },
+ "Tab grew back to its lone viewer's size"
+ );
+ assert!(
+ !screen.tabs.get(&0).unwrap().is_fullscreen_active(),
+ "Fit-induced fullscreen reverted on exit"
+ );
+}
+
+#[test]
+fn exit_fit_preserves_pre_fit_fullscreen() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+ {
+ let tab = screen.get_active_tab_mut(1).unwrap();
+ tab.toggle_pane_fullscreen(PaneId::Terminal(1));
+ }
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+
+ screen.exit_fit_mode(1).expect("TEST");
+
+ assert!(screen.mobile_state.fit_count() == 0, "Override cleared");
+ assert!(
+ screen.tabs.get(&0).unwrap().is_fullscreen_active(),
+ "Pre-existing fullscreen preserved across exit"
+ );
+}
+
+#[test]
+fn set_tab_fit_update_changes_tab_size() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ let before = screen.tabs.get(&0).unwrap().size;
+
+ let new_size = Size { rows: 11, cols: 60 };
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), new_size)
+ .expect("TEST");
+
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ screen.compute_fit_size(0).unwrap(),
+ "Tab tracks the server-derived size for the new embedded size"
+ );
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size.rows,
+ before.rows - 1,
+ "One fewer embedded row shrinks the target tab by one row"
+ );
+ assert_eq!(screen.mobile_state.fit_embedded_size(0), Some(new_size));
+}
+
+#[test]
+fn disconnect_clears_fit_for_owning_client() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ screen.add_client(2, false).expect("TEST");
+ screen.set_client_size(1, Size { cols: 40, rows: 10 });
+ screen.set_client_size(2, Size { cols: 80, rows: 20 });
+ screen.recompute_tab_size(0).expect("TEST");
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ screen.compute_fit_size(0).unwrap()
+ );
+
+ screen.remove_client(1).expect("TEST");
+
+ assert!(
+ screen.mobile_state.fit_count() == 0,
+ "Override owned by the leaving client cleared"
+ );
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ Size { cols: 80, rows: 20 },
+ "Tab grew back to fit the remaining viewer (client 2)"
+ );
+ assert!(
+ !screen.tabs.get(&0).unwrap().is_fullscreen_active(),
+ "Fit-induced fullscreen reverted on disconnect"
+ );
+}
+
+#[test]
+fn disconnect_only_clears_own_fits() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ 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");
+ screen.set_client_size(1, Size { cols: 40, rows: 10 });
+ screen.set_client_size(2, Size { cols: 40, rows: 10 });
+ screen.switch_active_tab(0, None, true, 1).expect("TEST");
+ screen.switch_active_tab(1, None, true, 2).expect("TEST");
+ setup_mobile_fit(&mut screen, 1, 8, 100);
+ setup_mobile_fit(&mut screen, 2, 9, 101);
+ let size = Size { rows: 12, cols: 60 };
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), size)
+ .expect("TEST");
+ screen
+ .set_tab_fit(2, 1, PaneId::Terminal(2), size)
+ .expect("TEST");
+ let surviving_size = screen.compute_fit_size(1).unwrap();
+
+ screen.remove_client(1).expect("TEST");
+
+ assert!(
+ !screen.mobile_state.has_fit(0),
+ "Disconnecting client's entry cleared"
+ );
+ assert_eq!(
+ screen.mobile_state.fit_owner(1),
+ Some(2),
+ "Other client's entry survives"
+ );
+ assert_eq!(screen.mobile_state.fit_embedded_size(1), Some(size));
+ assert_eq!(
+ screen.tabs.get(&1).unwrap().size,
+ surviving_size,
+ "Tab still pinned to surviving client's override"
+ );
+}
+
+#[test]
+fn override_emits_display_clear() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ screen.add_client(2, false).expect("TEST");
+ screen.set_client_size(1, Size { cols: 40, rows: 10 });
+ screen.set_client_size(2, Size { cols: 80, rows: 20 });
+ screen.recompute_tab_size(0).expect("TEST");
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+
+ assert!(
+ screen
+ .tabs
+ .get(&0)
+ .unwrap()
+ .should_clear_display_before_rendering(),
+ "Tab must request a display-clear so a larger desktop viewer's \
+ viewport outside the fit area is wiped before the next render"
+ );
+}
+
+#[test]
+fn recompute_tab_size_short_circuit() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ screen.add_client(2, false).expect("TEST");
+ screen.set_client_size(1, Size { cols: 40, rows: 10 });
+ screen.set_client_size(2, Size { cols: 80, rows: 20 });
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ let override_size = screen.compute_fit_size(0).unwrap();
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ override_size,
+ "Pre-condition: override active"
+ );
+
+ screen.set_client_size(
+ 1,
+ Size {
+ cols: 100,
+ rows: 30,
+ },
+ );
+ screen.set_client_size(
+ 2,
+ Size {
+ cols: 120,
+ rows: 40,
+ },
+ );
+ screen.recompute_tab_size(0).expect("TEST");
+
+ let after = screen.tabs.get(&0).unwrap().size;
+ assert_eq!(
+ after,
+ screen.compute_fit_size(0).unwrap(),
+ "Override wins against the min-of-viewers path"
+ );
+ assert!(
+ after.rows < 30 || after.cols < 100,
+ "Override is smaller than the min-of-viewers size"
+ );
+}
+
+#[test]
+fn fit_collision_last_writer_wins() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+ screen.add_client(2, false).expect("TEST");
+ setup_mobile_fit(&mut screen, 1, 8, 100);
+ setup_mobile_fit(&mut screen, 2, 9, 101);
+
+ let size_a = Size { rows: 12, cols: 60 };
+ let size_b = Size { rows: 10, cols: 50 };
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), size_a)
+ .expect("TEST");
+ screen
+ .set_tab_fit(2, 0, PaneId::Terminal(2), size_b)
+ .expect("TEST");
+
+ assert_eq!(
+ screen.mobile_state.fit_owner(0),
+ Some(2),
+ "Second writer owns the entry"
+ );
+ assert_eq!(screen.mobile_state.fit_pane(0), Some(PaneId::Terminal(2)));
+ assert_eq!(screen.mobile_state.fit_embedded_size(0), Some(size_b));
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ screen.compute_fit_size(0).unwrap(),
+ "Tab size matches the second writer's override"
+ );
+}
+
+#[test]
+fn fit_update_after_collision_reclaims_ownership() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+ screen.add_client(2, false).expect("TEST");
+ setup_mobile_fit(&mut screen, 1, 8, 100);
+ setup_mobile_fit(&mut screen, 2, 9, 101);
+
+ let size = Size { rows: 12, cols: 60 };
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), size)
+ .expect("TEST");
+ screen
+ .set_tab_fit(2, 0, PaneId::Terminal(2), size)
+ .expect("TEST");
+ assert_eq!(
+ screen.mobile_state.fit_owner(0),
+ Some(2),
+ "Pre-condition: client 2 displaced client 1"
+ );
+
+ let reclaim_size = Size { rows: 9, cols: 40 };
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), reclaim_size)
+ .expect("TEST");
+
+ assert_eq!(
+ screen.mobile_state.fit_owner(0),
+ Some(1),
+ "Ownership reattributed to caller"
+ );
+ assert_eq!(screen.mobile_state.fit_embedded_size(0), Some(reclaim_size));
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ screen.compute_fit_size(0).unwrap(),
+ "Tab size follows the reclaimed override"
+ );
+
+ screen.remove_client(2).expect("TEST");
+ assert!(
+ screen.mobile_state.has_fit(0),
+ "Override survives the original (now non-owning) client's disconnect"
+ );
+ screen.remove_client(1).expect("TEST");
+ assert!(
+ screen.mobile_state.fit_count() == 0,
+ "Override clears when the reclaim-owner disconnects"
+ );
+}
+
+#[test]
+fn fit_tab_close_cleans_state() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ new_tab(&mut screen, 2, 1);
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ assert!(
+ screen.mobile_state.has_fit(0),
+ "Pre-condition: fit installed"
+ );
+
+ screen.close_tab_by_id(0).expect("TEST");
+
+ assert!(
+ !screen.mobile_state.has_fit(0),
+ "fit_states entry removed when its tab is closed"
+ );
+ assert!(
+ !screen.tabs.contains_key(&0),
+ "Pre-condition: tab is actually gone"
+ );
+}
+
+#[test]
+fn disconnect_safe_with_orphan_fit() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+
+ {
+ let tab = screen.get_active_tab_mut(1).unwrap();
+ tab.close_pane(PaneId::Terminal(1), false, None);
+ }
+ assert!(
+ screen.mobile_state.has_fit(0),
+ "Pre-condition: orphan entry present (cleanup deliberately skipped)"
+ );
+
+ screen
+ .remove_client(1)
+ .expect("Disconnect must not panic on dead pane_id");
+ assert!(
+ screen.mobile_state.fit_count() == 0,
+ "Disconnect cleanup clears the orphan"
+ );
+}
+
+#[test]
+fn fit_three_viewers_override_persists() {
+ 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");
+ screen.add_client(3, false).expect("TEST");
+ screen.set_client_size(1, Size { cols: 40, rows: 10 });
+ screen.set_client_size(
+ 2,
+ Size {
+ cols: 100,
+ rows: 30,
+ },
+ );
+ screen.set_client_size(
+ 3,
+ Size {
+ cols: 200,
+ rows: 60,
+ },
+ );
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+ screen
+ .tabs
+ .get_mut(&9)
+ .unwrap()
+ .resize_whole_tab(Size { cols: 40, rows: 10 })
+ .expect("TEST");
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ let override_size = screen.compute_fit_size(0).unwrap();
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ override_size,
+ "Override wins over the three viewers' minimum"
+ );
+ assert!(
+ override_size.rows < 60 && override_size.cols < 200,
+ "Override is smaller than the desktop viewers' size"
+ );
+
+ screen.remove_client(2).expect("TEST");
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ override_size,
+ "Override survives non-owner disconnect"
+ );
+ assert!(screen.mobile_state.has_fit(0));
+
+ screen.remove_client(1).expect("TEST");
+ assert!(screen.mobile_state.fit_count() == 0);
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ Size {
+ cols: 200,
+ rows: 60
+ },
+ "Tab grew to fit the surviving desktop client"
+ );
+}
+
+#[test]
+fn fit_resize_repush_updates_tab() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ let before = screen.tabs.get(&0).unwrap().size;
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 11, cols: 60 })
+ .expect("TEST");
+
+ let after = screen.tabs.get(&0).unwrap().size;
+ assert_eq!(
+ after,
+ screen.compute_fit_size(0).unwrap(),
+ "Target tab tracks the re-derived fit size after the re-push"
+ );
+ assert_eq!(
+ after.rows,
+ before.rows - 1,
+ "One fewer embedded row shrinks the target tab by exactly one row"
+ );
+ assert_eq!(
+ screen.mobile_state.fit_embedded_size(0),
+ Some(Size { rows: 11, cols: 60 }),
+ "Stored embedded size follows the re-push"
+ );
+}
+
+#[test]
+pub fn render_report_writes_per_client_plugin_pane_contents_in_fallback() {
+ use crate::plugins::PluginRenderAsset;
+ use zellij_utils::data::PaneId as DataPaneId;
+
+ let size = Size { cols: 80, rows: 20 };
+ let mut initial_layout = TiledPaneLayout::default();
+ let mut plugin_pane_layout = TiledPaneLayout::default();
+ plugin_pane_layout.run = Some(Run::Plugin(RunPluginOrAlias::RunPlugin(RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: RunPluginLocation::File(PathBuf::from("/path/to/fake/plugin")),
+ configuration: Default::default(),
+ ..Default::default()
+ })));
+ initial_layout.children_split_direction = SplitDirection::Vertical;
+ initial_layout.children = vec![plugin_pane_layout];
+
+ let mut mock_screen = MockScreen::new(size);
+ let screen_thread = mock_screen.run(Some(initial_layout), vec![]);
+
+ let received_plugin_instructions = Arc::new(Mutex::new(vec![]));
+ let plugin_receiver = mock_screen.plugin_receiver.take().unwrap();
+ let plugin_thread = log_actions_in_thread!(
+ received_plugin_instructions,
+ PluginInstruction::Exit,
+ plugin_receiver
+ );
+
+ let first_client_id: ClientId = mock_screen.main_client_id;
+ let second_client_id: ClientId = 2;
+ let _ = mock_screen.to_screen.send(ScreenInstruction::AddClient(
+ second_client_id,
+ false,
+ size,
+ None,
+ None,
+ ));
+ std::thread::sleep(std::time::Duration::from_millis(50));
+
+ let plugin_id: u32 = 1;
+ let bytes_for_client_1 = b"client one render payload".to_vec();
+ let bytes_for_client_2 = b"client two render payload".to_vec();
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::PluginBytes(vec![
+ PluginRenderAsset::new(plugin_id, first_client_id, bytes_for_client_1.clone()),
+ PluginRenderAsset::new(plugin_id, second_client_id, bytes_for_client_2.clone()),
+ ]));
+ std::thread::sleep(std::time::Duration::from_millis(50));
+
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::PluginSubscribedToAnsiPaneContents(true));
+ std::thread::sleep(std::time::Duration::from_millis(200));
+
+ mock_screen.teardown(vec![plugin_thread, screen_thread]);
+
+ let captured = received_plugin_instructions.lock().unwrap();
+ let last_report = captured
+ .iter()
+ .rev()
+ .find_map(|i| match i {
+ PluginInstruction::PaneRenderReport(r) => Some(r.clone()),
+ _ => None,
+ })
+ .expect("expected at least one PaneRenderReport on the plugin bus");
+
+ let plugin_pane_id = DataPaneId::Plugin(plugin_id);
+ let client_1_contents = last_report
+ .all_pane_contents_with_ansi
+ .get(&first_client_id)
+ .and_then(|p| p.get(&plugin_pane_id))
+ .cloned()
+ .expect("client 1 must have plugin-pane contents in the report");
+ let client_2_contents = last_report
+ .all_pane_contents_with_ansi
+ .get(&second_client_id)
+ .and_then(|p| p.get(&plugin_pane_id))
+ .cloned()
+ .expect("client 2 must have plugin-pane contents in the report");
+
+ assert!(
+ !client_1_contents.viewport.is_empty(),
+ "client 1's plugin-pane viewport must not be empty — the fallback \
+ must read from the client's per-client grid, not return \
+ Default::default() via a `None` client_id lookup"
+ );
+ assert!(
+ !client_2_contents.viewport.is_empty(),
+ "client 2's plugin-pane viewport must not be empty — same reason \
+ as client 1, but this is the path that was broken pre-fix for \
+ the mobile-UI case"
+ );
+ assert_ne!(
+ client_1_contents.viewport, client_2_contents.viewport,
+ "per-client plugin grids must surface distinct viewports when \
+ distinct PluginBytes were delivered to each client"
+ );
+
+ let joined_viewport_1 = client_1_contents.viewport.join("");
+ let joined_viewport_2 = client_2_contents.viewport.join("");
+ assert!(
+ joined_viewport_1.contains("client one render payload"),
+ "client 1's viewport must contain its bytes; got {:?}",
+ joined_viewport_1
+ );
+ assert!(
+ joined_viewport_2.contains("client two render payload"),
+ "client 2's viewport must contain its bytes; got {:?}",
+ joined_viewport_2
+ );
+}
+
+const MOBILE_BASE_SIZE: Size = Size {
+ cols: 121,
+ rows: 40,
+};
+const MOBILE_SMALL: Size = Size { cols: 40, rows: 20 };
+const MOBILE_LARGE: Size = Size {
+ cols: 200,
+ rows: 200,
+};
+const MOBILE_THRESHOLDS: (u16, u16) = (60, 30);
+
+fn setup_mobile_screen() -> Screen {
+ let mut screen = create_new_screen(MOBILE_BASE_SIZE, true, true);
+ new_tab(&mut screen, 1, 0);
+ screen
+}
+
+#[test]
+fn reevaluate_mobile_routes_web_client_in_web_mode() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 10;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_SMALL,
+ MobileLayoutConfiguration::Web,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(
+ screen.is_in_mobile_mode(client),
+ "web client + small viewport in Web mode must enter mobile",
+ );
+ assert!(
+ screen.mobile_state.was_auto_entered(client),
+ "auto-entry must be marked so a later resize can auto-demote",
+ );
+}
+
+#[test]
+fn reevaluate_mobile_skips_terminal_client_in_web_mode() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 11;
+ screen
+ .add_client(client, /* is_web_client */ false)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_SMALL,
+ MobileLayoutConfiguration::Web,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(
+ !screen.is_in_mobile_mode(client),
+ "terminal client in Web mode must NOT enter mobile even with small viewport",
+ );
+}
+
+#[test]
+fn reevaluate_mobile_routes_terminal_client_in_always_mode() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 12;
+ screen
+ .add_client(client, /* is_web_client */ false)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_SMALL,
+ MobileLayoutConfiguration::Always,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(
+ screen.is_in_mobile_mode(client),
+ "Always mode must route terminal clients too on size match",
+ );
+}
+
+#[test]
+fn reevaluate_mobile_zero_threshold_forces_entry() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 13;
+ screen
+ .add_client(client, /* is_web_client */ false)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_LARGE,
+ MobileLayoutConfiguration::Always,
+ 0,
+ 0,
+ )
+ .expect("TEST");
+ assert!(
+ screen.is_in_mobile_mode(client),
+ "0 breakpoint under Always must route regardless of viewport size",
+ );
+}
+
+#[test]
+fn reevaluate_mobile_never_never_routes() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 14;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_SMALL,
+ MobileLayoutConfiguration::Never,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(
+ !screen.is_in_mobile_mode(client),
+ "Never must never auto-route, even for a small web client",
+ );
+}
+
+#[test]
+fn reevaluate_mobile_auto_demotes_after_growth() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 15;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_SMALL,
+ MobileLayoutConfiguration::Web,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(screen.is_in_mobile_mode(client));
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_LARGE,
+ MobileLayoutConfiguration::Web,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(
+ !screen.is_in_mobile_mode(client),
+ "auto-entered client must auto-demote when viewport grows",
+ );
+}
+
+#[test]
+fn reevaluate_mobile_preserves_manual_entry_when_viewport_grows() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 16;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen.enter_mobile_mode(client).expect("TEST");
+ assert!(screen.is_in_mobile_mode(client));
+ assert!(
+ !screen.mobile_state.was_auto_entered(client),
+ "manual entry must not be marked as auto",
+ );
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_LARGE,
+ MobileLayoutConfiguration::Web,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(
+ screen.is_in_mobile_mode(client),
+ "manually-entered client must NOT be auto-demoted on resize",
+ );
+}
+
+#[test]
+fn enter_mobile_mode_populates_consolidated_state() {
+ let mut screen = setup_mobile_screen();
+ let client = 20;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen.enter_mobile_mode(client).expect("TEST");
+
+ assert!(
+ screen.mobile_state.is_in_mobile_mode(client),
+ "entering mobile mode must record the client's mobile tab",
+ );
+ assert_eq!(
+ screen.mobile_state.previous_tab(client),
+ Some(0),
+ "the seeded tab 0 must be stashed as the prior tab",
+ );
+ assert!(
+ screen.is_in_mobile_mode(client),
+ "is_in_mobile_mode must reflect the new mobile_state.tabs entry",
+ );
+ assert!(
+ !screen.mobile_state.was_auto_entered(client),
+ "manual enter_mobile_mode must not mark the client as auto-entered",
+ );
+}
+
+#[test]
+fn exit_mobile_mode_clears_all_consolidated_state() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 21;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_SMALL,
+ MobileLayoutConfiguration::Web,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(screen.mobile_state.is_in_mobile_mode(client));
+ assert!(screen.mobile_state.was_auto_entered(client));
+
+ screen.exit_mobile_mode(client).expect("TEST");
+
+ assert!(
+ !screen.mobile_state.is_in_mobile_mode(client),
+ "exit must remove the client's mobile tab entry",
+ );
+ assert!(
+ !screen.mobile_state.previous_tab(client).is_some(),
+ "exit must drop the stashed prior-tab entry",
+ );
+ assert!(
+ !screen.mobile_state.was_auto_entered(client),
+ "exit must clear the auto-entered marker",
+ );
+ assert!(
+ !screen.is_in_mobile_mode(client),
+ "client must no longer be in mobile mode after exit",
+ );
+}
+
+#[test]
+fn remove_client_clears_all_consolidated_state() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 22;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_SMALL,
+ MobileLayoutConfiguration::Web,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(screen.mobile_state.is_in_mobile_mode(client));
+ assert!(screen.mobile_state.was_auto_entered(client));
+
+ screen.remove_client(client).expect("TEST");
+
+ assert!(
+ !screen.mobile_state.is_in_mobile_mode(client),
+ "disconnect must remove the client's mobile tab entry",
+ );
+ assert!(
+ !screen.mobile_state.previous_tab(client).is_some(),
+ "disconnect must drop the stashed prior-tab entry",
+ );
+ assert!(
+ !screen.mobile_state.was_auto_entered(client),
+ "disconnect must clear the auto-entered marker",
+ );
+}
+
+#[test]
+fn mobile_state_tracks_clients_independently() {
+ let mut screen = setup_mobile_screen();
+ let client_a = 23;
+ let client_b = 24;
+ screen
+ .add_client(client_a, /* is_web_client */ true)
+ .expect("TEST");
+ screen
+ .add_client(client_b, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen.enter_mobile_mode(client_a).expect("TEST");
+ screen.enter_mobile_mode(client_b).expect("TEST");
+
+ let tab_a = screen
+ .mobile_state
+ .mobile_tab_id(client_a)
+ .expect("client A must have a mobile tab");
+ let tab_b = screen
+ .mobile_state
+ .mobile_tab_id(client_b)
+ .expect("client B must have a mobile tab");
+ assert_ne!(
+ tab_a, tab_b,
+ "each mobile client must get its own dedicated tab",
+ );
+
+ screen.remove_client(client_a).expect("TEST");
+
+ assert!(
+ !screen.mobile_state.is_in_mobile_mode(client_a),
+ "removed client's entry must be gone",
+ );
+ assert!(
+ screen.mobile_state.is_in_mobile_mode(client_b),
+ "the other client's mobile state must be untouched",
+ );
+}
+
+#[test]
+fn render_gate_can_be_set_and_lifted() {
+ let mut screen = setup_mobile_screen();
+ let client = 40;
+
+ assert!(!screen.mobile_render_gate.is_gated(client));
+ screen.mobile_render_gate.gate(client);
+ assert!(screen.mobile_render_gate.is_gated(client));
+ screen.mobile_render_gate.ungate(client);
+ assert!(!screen.mobile_render_gate.is_gated(client));
+}
+
+#[test]
+fn remove_client_lifts_render_gate() {
+ let mut screen = setup_mobile_screen();
+ let client = 41;
+ screen
+ .add_client(client, /* is_web_client */ false)
+ .expect("TEST");
+ screen.mobile_render_gate.gate(client);
+
+ screen.remove_client(client).expect("TEST");
+
+ assert!(
+ !screen.mobile_render_gate.is_gated(client),
+ "a removed client must never stay gated",
+ );
+}
+
+#[test]
+fn exit_mobile_mode_lifts_render_gate() {
+ let mut screen = setup_mobile_screen();
+ let client = 42;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+ screen.enter_mobile_mode(client).expect("TEST");
+ screen.mobile_render_gate.gate(client);
+
+ screen.exit_mobile_mode(client).expect("TEST");
+
+ assert!(
+ !screen.mobile_render_gate.is_gated(client),
+ "leaving mobile mode must never leave the client gated",
+ );
+}
+
+#[test]
+fn first_mobile_plugin_paint_lifts_only_the_owning_client() {
+ let mut screen = setup_mobile_screen();
+ let owner = 43;
+ let other = 44;
+ let mobile_tab_idx = 9;
+ let plugin_id = 900;
+ screen
+ .add_client(owner, /* is_web_client */ true)
+ .expect("TEST");
+ setup_mobile_fit(&mut screen, owner, mobile_tab_idx, plugin_id);
+ screen.mobile_render_gate.gate(owner);
+ screen.mobile_render_gate.gate(other);
+
+ screen.ungate_clients_for_mobile_plugin(plugin_id);
+
+ assert!(
+ !screen.mobile_render_gate.is_gated(owner),
+ "the client whose mobile plugin painted must be ungated",
+ );
+ assert!(
+ screen.mobile_render_gate.is_gated(other),
+ "a client unrelated to the painting plugin must stay gated",
+ );
+}
+
+fn capture_plugin_channel(screen: &mut Screen) -> Receiver<(PluginInstruction, ErrorContext)> {
+ let (to_plugin, plugin_receiver): ChannelWithContext<PluginInstruction> = channels::unbounded();
+ screen
+ .bus
+ .senders
+ .replace_to_plugin(SenderWithContext::new(to_plugin));
+ plugin_receiver
+}
+
+fn drain_plugin_instructions(
+ receiver: &Receiver<(PluginInstruction, ErrorContext)>,
+) -> Vec<PluginInstruction> {
+ let mut instructions = vec![];
+ while let Ok((instruction, _err_ctx)) = receiver.try_recv() {
+ instructions.push(instruction);
+ }
+ instructions
+}
+
+#[test]
+fn entering_mobile_while_gated_holds_the_plugin_render() {
+ let mut screen = setup_mobile_screen();
+ let client = 50;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+ let plugin_receiver = capture_plugin_channel(&mut screen);
+
+ screen.mobile_render_gate.gate(client); // SuppressRenderUntilMobile does this
+ screen.enter_mobile_mode(client).expect("TEST");
+
+ let sent = drain_plugin_instructions(&plugin_receiver);
+ assert!(
+ sent.iter()
+ .any(|i| matches!(i, PluginInstruction::HoldMobileRender(c) if *c == client)),
+ "entering mobile while gated must hold the plugin render; sent={sent:?}",
+ );
+}
+
+#[test]
+fn entering_mobile_without_a_gate_does_not_hold() {
+ let mut screen = setup_mobile_screen();
+ let client = 51;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+ let plugin_receiver = capture_plugin_channel(&mut screen);
+
+ // No SuppressRenderUntilMobile: the client is not gated.
+ screen.enter_mobile_mode(client).expect("TEST");
+
+ let sent = drain_plugin_instructions(&plugin_receiver);
+ assert!(
+ !sent
+ .iter()
+ .any(|i| matches!(i, PluginInstruction::HoldMobileRender(_))),
+ "a non-gated client must not hold the plugin render; sent={sent:?}",
+ );
+}
+
+#[test]
+fn exiting_mobile_releases_the_plugin_render() {
+ let mut screen = setup_mobile_screen();
+ let client = 52;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+ screen.enter_mobile_mode(client).expect("TEST");
+ let plugin_receiver = capture_plugin_channel(&mut screen);
+
+ screen.exit_mobile_mode(client).expect("TEST");
+
+ let sent = drain_plugin_instructions(&plugin_receiver);
+ assert!(
+ sent.iter()
+ .any(|i| matches!(i, PluginInstruction::ReleaseMobileRender(c) if *c == client)),
+ "exiting mobile must release the plugin render; sent={sent:?}",
+ );
+}