diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-06-10 16:50:55 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-10 16:50:55 +0200 |
| commit | b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0 (patch) | |
| tree | 38c77a381cfb937b428752a588c09a45c81434c9 /zellij-server | |
| parent | feat: PWA support for the web client (#5184) (diff) | |
| download | sl0ppij-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.rs | 16 | ||||
| -rw-r--r-- | zellij-server/src/lib.rs | 63 | ||||
| -rw-r--r-- | zellij-server/src/mobile_mode.rs | 733 | ||||
| -rw-r--r-- | zellij-server/src/panes/active_panes.rs | 25 | ||||
| -rw-r--r-- | zellij-server/src/panes/floating_panes/mod.rs | 40 | ||||
| -rw-r--r-- | zellij-server/src/panes/grid.rs | 17 | ||||
| -rw-r--r-- | zellij-server/src/panes/plugin_pane.rs | 18 | ||||
| -rw-r--r-- | zellij-server/src/panes/tiled_panes/mod.rs | 46 | ||||
| -rw-r--r-- | zellij-server/src/plugins/mod.rs | 10 | ||||
| -rw-r--r-- | zellij-server/src/plugins/wasm_bridge.rs | 74 | ||||
| -rw-r--r-- | zellij-server/src/plugins/zellij_exports.rs | 139 | ||||
| -rw-r--r-- | zellij-server/src/route.rs | 68 | ||||
| -rw-r--r-- | zellij-server/src/screen.rs | 688 | ||||
| -rw-r--r-- | zellij-server/src/tab/mod.rs | 111 | ||||
| -rw-r--r-- | zellij-server/src/tab/mouse_handler.rs | 138 | ||||
| -rw-r--r-- | zellij-server/src/tab/unit/tab_integration_tests.rs | 22 | ||||
| -rw-r--r-- | zellij-server/src/tab/unit/tab_tests.rs | 399 | ||||
| -rw-r--r-- | zellij-server/src/unit/screen_tests.rs | 1322 |
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:?}", + ); +} |
