diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-07-27 18:24:37 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-27 18:24:37 +0200 |
| commit | ea07e2d5b6bbc9ea6f9c765b0838b3e91156a58d (patch) | |
| tree | 4a8fac8a9b27f2a8b32b3af61a3b5419b9937c81 | |
| parent | Switch from the unmaintained daemonize crate to a maintained fork (#4512) (diff) | |
| download | sl0ppij-ea07e2d5b6bbc9ea6f9c765b0838b3e91156a58d.tar.gz sl0ppij-ea07e2d5b6bbc9ea6f9c765b0838b3e91156a58d.tar.xz sl0ppij-ea07e2d5b6bbc9ea6f9c765b0838b3e91156a58d.zip | |
Nested session handling (#5417)
* fix: theme query and updated pixel dimensions
* add no-ui-fullscreen key/action/capability
* protocol core
* integration test harness
* fix integration tests
* fix flaky tests
* nested ui (dimming) and directional focus initial implementation
* integration tests for nested ui dimming and directional focus
* initial full screen nesteed implementation
* fix: keep hidden floating panes hidden across nested no-ui fullscreen
* fix integration tests
* no ui fullscreen integration tests
* hardening behavior/tests
* fix(nested): re-derive descend/dim on host tab switch and new-tab so clicking dimmed chrome ascends
* initial modal implementation
* initial ui improvements
* fix integration tests
* improve nested selection ui
* nested session modal refactoring and tests
* improve integration test speed somewhat
* some ui adjustments
* simplify full/auto nested session handling
* hints in compact-bar
* guest stdin leak fix
* allow floating panes to be fullscreen
* make nested session handling configurable
* protocol reannounce
* refactor: naming confusion
* improve ux somewhat
* rustfmt
* merge fixes
* improve integration test rate limiting (fix ci)
* add pr
169 files changed, 15113 insertions, 479 deletions
diff --git a/.config/nextest.toml b/.config/nextest.toml index 7c915027..b8ca3337 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,3 +1,7 @@ [profile.default] slow-timeout = { period = "30s", terminate-after = 2 } fail-fast = false + +[[profile.default.overrides]] +filter = 'binary(nested_fullscreen) + binary(nested_sessions) + binary(nested_hardening) + binary(nested_guest_modal) + binary(nested_status_hints) + binary(startup_host_query)' +threads-required = 4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4572300a..7def5c1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) * fix: occasional input spam when pressing ESC and holding the mouse over slow SSH connections (https://github.com/zellij-org/zellij/pull/5323) * chore: switch cassowary deps to maintained fork kasuari (https://github.com/zellij-org/zellij/pull/5416) * chore: switch unmaintained daemonize with maintained fork daemonix (https://github.com/zellij-org/zellij/pull/4512) +* feat: handle nested Zellij sessions (https://github.com/zellij-org/zellij/pull/5417) ## [0.44.3] - 2026-05-13 * fix(windows): bump windows-sys to 0.59 to align manifest with code, fixing source builds via `cargo install`/`cargo binstall` (https://github.com/zellij-org/zellij/pull/5139) @@ -5130,6 +5130,7 @@ version = "0.45.0" dependencies = [ "anyhow", "backtrace", + "base64 0.13.0", "bitflags 2.11.0", "clap", "clap_complete", diff --git a/default-plugins/about/src/main.rs b/default-plugins/about/src/main.rs index 42f87055..c0d1f398 100644 --- a/default-plugins/about/src/main.rs +++ b/default-plugins/about/src/main.rs @@ -311,7 +311,6 @@ impl App { ) .unwrap(), )]); - let _ = show_floating_panes(None); } } } diff --git a/default-plugins/compact-bar/src/line.rs b/default-plugins/compact-bar/src/line.rs index 089b6ed3..aae8aec0 100644 --- a/default-plugins/compact-bar/src/line.rs +++ b/default-plugins/compact-bar/src/line.rs @@ -5,13 +5,25 @@ use crate::{LinePart, TabRenderData, ARROW_SEPARATOR}; use zellij_tile::prelude::*; use zellij_tile_utils::style; +pub struct TabLineOutput { + pub parts: Vec<LinePart>, + pub breadcrumb_range: Option<(usize, usize)>, +} + pub fn tab_line( mode_info: &ModeInfo, tab_data: TabRenderData, cols: usize, toggle_tooltip_key: Option<String>, tooltip_is_active: bool, -) -> Vec<LinePart> { +) -> TabLineOutput { + let dimmed = mode_info.session_ascended == Some(true) || mode_info.session_dimmed == Some(true); + let breadcrumb_ancestry = if mode_info.host_fullscreen == Some(true) { + mode_info.session_ancestry.clone() + } else { + Vec::new() + }; + let nested_hint = NestedSessionHint::from_mode_info(mode_info); let config = TabLineConfig { session_name: mode_info.session_name.to_owned(), hide_session_name: mode_info.style.hide_session_name, @@ -20,6 +32,9 @@ pub fn tab_line( is_swap_layout_dirty: tab_data.is_swap_layout_dirty, toggle_tooltip_key, tooltip_is_active, + dimmed, + breadcrumb_ancestry, + nested_hint, }; let builder = TabLineBuilder::new(config, mode_info.style.colors, mode_info.capabilities, cols); @@ -27,6 +42,29 @@ pub fn tab_line( } #[derive(Debug, Clone)] +pub enum NestedSessionHint { + None, + Ascend(Vec<KeyWithModifier>), + Descend(Vec<KeyWithModifier>), +} + +impl NestedSessionHint { + fn from_mode_info(mode_info: &ModeInfo) -> Self { + if mode_info.session_dimmed == Some(true) { + NestedSessionHint::Ascend(mode_info.nested_ascend_keys.clone()) + } else if mode_info.session_ascended == Some(true) { + NestedSessionHint::Descend(mode_info.nested_descend_keys.clone()) + } else { + NestedSessionHint::None + } + } + + fn is_active(&self) -> bool { + !matches!(self, NestedSessionHint::None) + } +} + +#[derive(Debug, Clone)] pub struct TabLineConfig { pub session_name: Option<String>, pub hide_session_name: bool, @@ -35,24 +73,38 @@ pub struct TabLineConfig { pub is_swap_layout_dirty: bool, pub toggle_tooltip_key: Option<String>, pub tooltip_is_active: bool, + pub dimmed: bool, + pub breadcrumb_ancestry: Vec<String>, + pub nested_hint: NestedSessionHint, } fn calculate_total_length(parts: &[LinePart]) -> usize { parts.iter().map(|p| p.len).sum() } +fn dim_style(bg: PaletteColor) -> ansi_term::Style { + ansi_term::Style::new() + .on(match bg { + PaletteColor::Rgb((r, g, b)) => ansi_term::Color::RGB(r, g, b), + PaletteColor::EightBit(c) => ansi_term::Color::Fixed(c), + }) + .dimmed() +} + struct TabLinePopulator { cols: usize, palette: Styling, capabilities: PluginCapabilities, + dimmed: bool, } impl TabLinePopulator { - fn new(cols: usize, palette: Styling, capabilities: PluginCapabilities) -> Self { + fn new(cols: usize, palette: Styling, capabilities: PluginCapabilities, dimmed: bool) -> Self { Self { cols, palette, capabilities, + dimmed, } } @@ -218,9 +270,15 @@ impl TabLinePopulator { background: self.palette.text_selected.emphasis_0, }; + let text_style = if self.dimmed { + style!(colors.text, colors.background).italic() + } else { + style!(colors.text, colors.background).bold() + }; + let styled_parts = [ style!(colors.separator, colors.background).paint(separator), - style!(colors.text, colors.background).bold().paint(text), + text_style.paint(text), style!(colors.background, colors.separator).paint(separator), ]; @@ -264,22 +322,43 @@ enum TabAction { Finish, } -struct TabLinePrefixBuilder { +struct TabLinePrefixBuilder<'a> { palette: Styling, cols: usize, + dimmed: bool, + breadcrumb_ancestry: &'a [String], +} + +struct TabLinePrefix { + parts: Vec<LinePart>, + breadcrumb_range: Option<(usize, usize)>, } -impl TabLinePrefixBuilder { - fn new(palette: Styling, cols: usize) -> Self { - Self { palette, cols } +impl<'a> TabLinePrefixBuilder<'a> { + fn new(palette: Styling, cols: usize, dimmed: bool, breadcrumb_ancestry: &'a [String]) -> Self { + Self { + palette, + cols, + dimmed, + breadcrumb_ancestry, + } } - fn build(&self, session_name: Option<&str>, mode: InputMode) -> Vec<LinePart> { + fn build(&self, session_name: Option<&str>, mode: InputMode) -> TabLinePrefix { let mut parts = vec![self.create_zellij_part()]; let mut used_len = parts.get(0).map_or(0, |p| p.len); + let mut breadcrumb_range = None; if let Some(name) = session_name { - if let Some(name_part) = self.create_session_name_part(name, used_len) { + if !self.breadcrumb_ancestry.is_empty() { + if let Some((mut breadcrumb_parts, range, breadcrumb_len)) = + self.create_breadcrumb_parts(name, used_len) + { + used_len += breadcrumb_len; + breadcrumb_range = Some(range); + parts.append(&mut breadcrumb_parts); + } + } else if let Some(name_part) = self.create_session_name_part(name, used_len) { used_len += name_part.len; parts.push(name_part); } @@ -289,18 +368,81 @@ impl TabLinePrefixBuilder { parts.push(mode_part); } - parts + TabLinePrefix { + parts, + breadcrumb_range, + } + } + + fn create_breadcrumb_parts( + &self, + name: &str, + used_len: usize, + ) -> Option<(Vec<LinePart>, (usize, usize), usize)> { + let bg_color = self.palette.text_unselected.background; + let text_color = self.palette.text_unselected.base; + let ancestor_text = format!("({} ▸ ", self.breadcrumb_ancestry.join(" ▸ ")); + let ancestor_len = ancestor_text.width(); + let name_text = name.to_string(); + let name_len = name_text.width(); + let closing_text = ") ".to_string(); + let closing_len = closing_text.width(); + let total_len = ancestor_len + name_len + closing_len; + + if self.cols.saturating_sub(used_len) < total_len { + return None; + } + + let ancestor_style = if self.dimmed { + dim_style(bg_color) + } else { + style!(text_color, bg_color).italic() + }; + let name_style = if self.dimmed { + dim_style(bg_color) + } else { + style!(self.palette.text_unselected.emphasis_0, bg_color).bold() + }; + let closing_style = if self.dimmed { + dim_style(bg_color) + } else { + style!(text_color, bg_color).bold() + }; + + let ancestor_start = used_len; + let ancestor_end = ancestor_start + ancestor_len; + let parts = vec![ + LinePart { + part: ancestor_style.paint(ancestor_text).to_string(), + len: ancestor_len, + tab_index: None, + }, + LinePart { + part: name_style.paint(name_text).to_string(), + len: name_len, + tab_index: None, + }, + LinePart { + part: closing_style.paint(closing_text).to_string(), + len: closing_len, + tab_index: None, + }, + ]; + + Some((parts, (ancestor_start, ancestor_end), total_len)) } fn create_zellij_part(&self) -> LinePart { let prefix_text = " Zellij "; let colors = self.get_text_colors(); + let text_style = if self.dimmed { + style!(colors.text, colors.background).italic() + } else { + style!(colors.text, colors.background).bold() + }; LinePart { - part: style!(colors.text, colors.background) - .bold() - .paint(prefix_text) - .to_string(), + part: text_style.paint(prefix_text).to_string(), len: prefix_text.chars().count(), tab_index: None, } @@ -312,11 +454,13 @@ impl TabLinePrefixBuilder { if self.cols.saturating_sub(used_len) >= name_part_len { let colors = self.get_text_colors(); + let text_style = if self.dimmed { + style!(colors.text, colors.background).italic() + } else { + style!(colors.text, colors.background).bold() + }; Some(LinePart { - part: style!(colors.text, colors.background) - .bold() - .paint(name_part) - .to_string(), + part: text_style.paint(name_part).to_string(), len: name_part_len, tab_index: None, }) @@ -331,18 +475,23 @@ impl TabLinePrefixBuilder { if self.cols.saturating_sub(used_len) >= mode_len { let colors = self.get_text_colors(); - let style = match mode { - InputMode::Locked => { - style!(self.palette.text_unselected.emphasis_3, colors.background) - }, - InputMode::Normal => { - style!(self.palette.text_unselected.emphasis_2, colors.background) - }, - _ => style!(self.palette.text_unselected.emphasis_0, colors.background), + let style = if self.dimmed { + style!(colors.text, colors.background).italic() + } else { + match mode { + InputMode::Locked => { + style!(self.palette.text_unselected.emphasis_3, colors.background) + }, + InputMode::Normal => { + style!(self.palette.text_unselected.emphasis_2, colors.background) + }, + _ => style!(self.palette.text_unselected.emphasis_0, colors.background), + } + .bold() }; Some(LinePart { - part: style.bold().paint(mode_text).to_string(), + part: style.paint(mode_text).to_string(), len: mode_len, tab_index: None, }) @@ -363,17 +512,26 @@ impl TabLinePrefixBuilder { struct RightSideElementsBuilder { palette: Styling, capabilities: PluginCapabilities, + dimmed: bool, } impl RightSideElementsBuilder { - fn new(palette: Styling, capabilities: PluginCapabilities) -> Self { + fn new(palette: Styling, capabilities: PluginCapabilities, dimmed: bool) -> Self { Self { palette, capabilities, + dimmed, } } fn build(&self, config: &TabLineConfig, available_space: usize) -> Vec<LinePart> { + if config.nested_hint.is_active() { + if let Some(hint) = self.create_nested_hint(&config.nested_hint, available_space) { + return vec![hint]; + } + return Vec::new(); + } + let mut elements = Vec::new(); if let Some(ref tooltip_key) = config.toggle_tooltip_key { @@ -387,13 +545,77 @@ impl RightSideElementsBuilder { elements } + fn create_nested_hint(&self, hint: &NestedSessionHint, max_len: usize) -> Option<LinePart> { + let (prefix, keys) = match hint { + NestedSessionHint::Ascend(keys) => ("Ascend: ", keys), + NestedSessionHint::Descend(keys) => ("Descend: ", keys), + NestedSessionHint::None => return None, + }; + + let bg = self.palette.text_unselected.background; + let key_color = self.palette.text_unselected.emphasis_0; + let sep_color = self.palette.text_unselected.base; + let keys_text: Vec<String> = if keys.is_empty() { + vec!["<unbound>".to_string()] + } else { + keys.iter().map(|key| format!("<{}>", key)).collect() + }; + + let prefix_len = prefix.width() + 1; + let keys_display_len: usize = keys_text.iter().map(|k| k.width()).sum::<usize>() + + keys_text.len().saturating_sub(1) * 3; + + if !keys_text.is_empty() && prefix_len + keys_display_len <= max_len { + let prefix_style = dim_style(bg).italic(); + let mut part = prefix_style.paint(format!(" {}", prefix)).to_string(); + for (i, key) in keys_text.iter().enumerate() { + if i > 0 { + part.push_str(&style!(sep_color, bg).paint(" + ").to_string()); + } + if keys.is_empty() { + part.push_str(&dim_style(bg).italic().paint(key).to_string()); + } else { + part.push_str(&style!(key_color, bg).bold().paint(key).to_string()); + } + } + return Some(LinePart { + part, + len: prefix_len + keys_display_len, + tab_index: None, + }); + } + + let message = match hint { + NestedSessionHint::Ascend(_) => " nested session", + NestedSessionHint::Descend(_) => " host session", + NestedSessionHint::None => return None, + }; + let message_len = message.width(); + if message_len <= max_len { + let style = dim_style(bg).italic(); + return Some(LinePart { + part: style.paint(message).to_string(), + len: message_len, + tab_index: None, + }); + } + + None + } + fn create_tooltip_indicator(&self, toggle_key: &str, is_active: bool) -> LinePart { let key_text = toggle_key; - let key = Text::new(key_text).color_all(3).opaque(); + let key = if self.dimmed { + Text::new(key_text).disabled().opaque() + } else { + Text::new(key_text).color_all(3).opaque() + }; let ribbon_text = "Tooltip"; let mut ribbon = Text::new(ribbon_text); - if is_active { + if self.dimmed { + ribbon = ribbon.disabled(); + } else if is_active { ribbon = ribbon.selected(); } @@ -459,6 +681,14 @@ impl RightSideElementsBuilder { colors: &SwapLayoutColors, separator: &str, ) -> (String, String, String) { + if self.dimmed { + let text_style = style!(colors.bg, colors.fg).italic(); + return ( + style!(colors.bg, colors.fg).paint(separator).to_string(), + text_style.paint(layout_name).to_string(), + style!(colors.fg, colors.bg).paint(separator).to_string(), + ); + } match mode { InputMode::Locked => ( style!(colors.bg, colors.fg).paint(separator).to_string(), @@ -517,22 +747,37 @@ impl TabLineBuilder { } } - pub fn build(self, all_tabs: Vec<LinePart>, active_tab_index: usize) -> Vec<LinePart> { + pub fn build(self, all_tabs: Vec<LinePart>, active_tab_index: usize) -> TabLineOutput { let (tabs_before_active, active_tab, tabs_after_active) = self.split_tabs(all_tabs, active_tab_index); - let prefix_builder = TabLinePrefixBuilder::new(self.palette, self.cols); let session_name = if self.config.hide_session_name { None } else { self.config.session_name.as_deref() }; + let breadcrumb_ancestry: &[String] = if self.config.hide_session_name { + &[] + } else { + &self.config.breadcrumb_ancestry + }; + let prefix_builder = TabLinePrefixBuilder::new( + self.palette, + self.cols, + self.config.dimmed, + breadcrumb_ancestry, + ); - let mut prefix = prefix_builder.build(session_name, self.config.mode); + let prefix_result = prefix_builder.build(session_name, self.config.mode); + let mut prefix = prefix_result.parts; + let breadcrumb_range = prefix_result.breadcrumb_range; let prefix_len = calculate_total_length(&prefix); if prefix_len + active_tab.len > self.cols { - return prefix; + return TabLineOutput { + parts: prefix, + breadcrumb_range, + }; } let mut tabs_to_render = vec![active_tab]; @@ -540,6 +785,7 @@ impl TabLineBuilder { self.cols.saturating_sub(prefix_len), self.palette, self.capabilities, + self.config.dimmed, ); let mut tabs_before = tabs_before_active; @@ -549,7 +795,10 @@ impl TabLineBuilder { prefix.append(&mut tabs_to_render); self.add_right_side_elements(&mut prefix); - prefix + TabLineOutput { + parts: prefix, + breadcrumb_range, + } } fn split_tabs( @@ -573,7 +822,8 @@ impl TabLineBuilder { let current_len = calculate_total_length(prefix); if current_len < self.cols { - let right_builder = RightSideElementsBuilder::new(self.palette, self.capabilities); + let right_builder = + RightSideElementsBuilder::new(self.palette, self.capabilities, self.config.dimmed); let available_space = self.cols.saturating_sub(current_len); let mut right_elements = right_builder.build(&self.config, available_space); diff --git a/default-plugins/compact-bar/src/main.rs b/default-plugins/compact-bar/src/main.rs index 3a8446a5..29975d9a 100644 --- a/default-plugins/compact-bar/src/main.rs +++ b/default-plugins/compact-bar/src/main.rs @@ -41,6 +41,7 @@ struct State { // Display state mode_info: ModeInfo, tab_line: Vec<LinePart>, + breadcrumb_range: Option<(usize, usize)>, display_area_rows: usize, display_area_cols: usize, @@ -278,13 +279,23 @@ impl State { } match mouse_event { - Mouse::LeftClick(_, col) => self.handle_tab_click(col), + Mouse::LeftClick(_, col) => { + if self.click_is_on_breadcrumb(col) { + focus_host_session(); + } else { + self.handle_tab_click(col); + } + }, Mouse::ScrollUp(_) => self.scroll_tab_up(), Mouse::ScrollDown(_) => self.scroll_tab_down(), _ => {}, } } + fn click_is_on_breadcrumb(&self, col: usize) -> bool { + matches!(self.breadcrumb_range, Some((start, end)) if col >= start && col < end) + } + fn handle_clipboard_copy(&mut self, copy_destination: CopyDestination) -> bool { if self.is_tooltip { return false; @@ -514,13 +525,15 @@ impl State { } let tab_data = self.prepare_tab_data(); - self.tab_line = tab_line( + let tab_line_output = tab_line( &self.mode_info, tab_data, cols, self.toggle_tooltip_key.clone(), self.tooltip_is_active, ); + self.tab_line = tab_line_output.parts; + self.breadcrumb_range = tab_line_output.breadcrumb_range; let output = self .tab_line @@ -536,6 +549,8 @@ impl State { let mut active_swap_layout_name = None; let mut is_swap_layout_dirty = false; let mut is_alternate_tab = false; + let dimmed = self.mode_info.session_ascended == Some(true) + || self.mode_info.session_dimmed == Some(true); for tab in &self.tabs { let tab_name = self.get_tab_display_name(tab); @@ -554,6 +569,7 @@ impl State { is_alternate_tab, self.mode_info.style.colors, self.mode_info.capabilities, + dimmed, ); is_alternate_tab = !is_alternate_tab; diff --git a/default-plugins/compact-bar/src/tab.rs b/default-plugins/compact-bar/src/tab.rs index 8d4ada96..ac1602c3 100644 --- a/default-plugins/compact-bar/src/tab.rs +++ b/default-plugins/compact-bar/src/tab.rs @@ -27,6 +27,7 @@ pub fn render_tab( is_alternate_tab: bool, palette: Styling, separator: &str, + dimmed: bool, ) -> LinePart { let focused_clients = tab.other_focused_clients.as_slice(); let separator_width = separator.width(); @@ -54,12 +55,20 @@ pub fn render_tab( palette.ribbon_unselected.base }; let separator_fill_color = palette.text_unselected.background; + let background_color = if dimmed { + palette.ribbon_unselected.background + } else { + background_color + }; + let text_style = if dimmed { + style!(palette.ribbon_unselected.base, background_color).italic() + } else { + style!(foreground_color, background_color).bold() + }; let left_separator = style!(separator_fill_color, background_color).paint(separator); let mut tab_text_len = text.width() + (separator_width * 2) + 2; // + 2 for padding - let tab_styled_text = style!(foreground_color, background_color) - .bold() - .paint(format!(" {} ", text)); + let tab_styled_text = text_style.paint(format!(" {} ", text)); let right_separator = style!(background_color, separator_fill_color).paint(separator); let tab_styled_text = if !focused_clients.is_empty() { @@ -67,15 +76,9 @@ pub fn render_tab( cursors(focused_clients, palette.multiplayer_user_colors); tab_text_len += extra_length; let mut s = String::new(); - let cursor_beginning = style!(foreground_color, background_color) - .bold() - .paint("[") - .to_string(); + let cursor_beginning = text_style.paint("[").to_string(); let cursor_section = ANSIStrings(&cursor_section).to_string(); - let cursor_end = style!(foreground_color, background_color) - .bold() - .paint("]") - .to_string(); + let cursor_end = text_style.paint("]").to_string(); s.push_str(&left_separator.to_string()); s.push_str(&tab_styled_text.to_string()); s.push_str(&cursor_beginning); @@ -100,6 +103,7 @@ pub fn tab_style( mut is_alternate_tab: bool, palette: Styling, capabilities: PluginCapabilities, + dimmed: bool, ) -> LinePart { let separator = tab_separator(capabilities); @@ -116,7 +120,7 @@ pub fn tab_style( is_alternate_tab = false; } - render_tab(tabname, tab, is_alternate_tab, palette, separator) + render_tab(tabname, tab, is_alternate_tab, palette, separator, dimmed) } pub(crate) fn get_tab_to_focus( diff --git a/default-plugins/configuration/src/presets.rs b/default-plugins/configuration/src/presets.rs index 4eb65042..a577baf2 100644 --- a/default-plugins/configuration/src/presets.rs +++ b/default-plugins/configuration/src/presets.rs @@ -34,6 +34,7 @@ keybinds clear-defaults=true {{ bind "s" {{ NewPane "stacked"; SwitchToMode "Locked"; }} bind "x" {{ CloseFocus; SwitchToMode "Locked"; }} bind "f" {{ ToggleFocusFullscreen; SwitchToMode "Locked"; }} + bind "Shift f" {{ ToggleFocusNoUiFullscreen; SwitchToMode "Locked"; }} bind "z" {{ TogglePaneFrames; SwitchToMode "Locked"; }} bind "w" {{ ToggleFloatingPanes; SwitchToMode "Locked"; }} bind "e" {{ TogglePaneEmbedOrFloating; SwitchToMode "Locked"; }} @@ -120,6 +121,9 @@ keybinds clear-defaults=true {{ session {{ bind "o" {{ SwitchToMode "Normal"; }} bind "d" {{ Detach; }} + bind "]" {{ FocusHostSession; SwitchToMode "Normal"; }} + bind "[" {{ FocusGuestSession; SwitchToMode "Normal"; }} + bind "f" {{ ToggleHostFullscreen; SwitchToMode "Normal"; }} bind "w" {{ LaunchOrFocusPlugin "session-manager" {{ floating true @@ -255,6 +259,7 @@ keybinds clear-defaults=true {{ bind "s" {{ NewPane "stacked"; SwitchToMode "Normal"; }} bind "x" {{ CloseFocus; SwitchToMode "Normal"; }} bind "f" {{ ToggleFocusFullscreen; SwitchToMode "Normal"; }} + bind "Shift f" {{ ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; }} bind "z" {{ TogglePaneFrames; SwitchToMode "Normal"; }} bind "w" {{ ToggleFloatingPanes; SwitchToMode "Normal"; }} bind "e" {{ TogglePaneEmbedOrFloating; SwitchToMode "Normal"; }} @@ -343,6 +348,9 @@ keybinds clear-defaults=true {{ bind "{primary_modifier} o" {{ SwitchToMode "Normal"; }} bind "{primary_modifier} s" {{ SwitchToMode "Scroll"; }} bind "d" {{ Detach; }} + bind "]" {{ FocusHostSession; SwitchToMode "Normal"; }} + bind "[" {{ FocusGuestSession; SwitchToMode "Normal"; }} + bind "f" {{ ToggleHostFullscreen; SwitchToMode "Normal"; }} bind "w" {{ LaunchOrFocusPlugin "session-manager" {{ floating true @@ -487,6 +495,7 @@ keybinds clear-defaults=true {{ bind "s" {{ NewPane "stacked"; SwitchToMode "Normal"; }} bind "x" {{ CloseFocus; SwitchToMode "Normal"; }} bind "f" {{ ToggleFocusFullscreen; SwitchToMode "Normal"; }} + bind "Shift f" {{ ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; }} bind "z" {{ TogglePaneFrames; SwitchToMode "Normal"; }} bind "w" {{ ToggleFloatingPanes; SwitchToMode "Normal"; }} bind "e" {{ TogglePaneEmbedOrFloating; SwitchToMode "Normal"; }} @@ -566,6 +575,9 @@ keybinds clear-defaults=true {{ }} session {{ bind "d" {{ Detach; }} + bind "]" {{ FocusHostSession; SwitchToMode "Normal"; }} + bind "[" {{ FocusGuestSession; SwitchToMode "Normal"; }} + bind "f" {{ ToggleHostFullscreen; SwitchToMode "Normal"; }} bind "w" {{ LaunchOrFocusPlugin "session-manager" {{ floating true @@ -690,6 +702,7 @@ keybinds clear-defaults=true {{ bind "s" {{ NewPane "stacked"; SwitchToMode "Normal"; }} bind "x" {{ CloseFocus; SwitchToMode "Normal"; }} bind "f" {{ ToggleFocusFullscreen; SwitchToMode "Normal"; }} + bind "Shift f" {{ ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; }} bind "z" {{ TogglePaneFrames; SwitchToMode "Normal"; }} bind "w" {{ ToggleFloatingPanes; SwitchToMode "Normal"; }} bind "e" {{ TogglePaneEmbedOrFloating; SwitchToMode "Normal"; }} @@ -770,6 +783,9 @@ keybinds clear-defaults=true {{ bind "{primary_modifier} o" {{ SwitchToMode "Normal"; }} bind "{primary_modifier} s" {{ SwitchToMode "Scroll"; }} bind "d" {{ Detach; }} + bind "]" {{ FocusHostSession; SwitchToMode "Normal"; }} + bind "[" {{ FocusGuestSession; SwitchToMode "Normal"; }} + bind "f" {{ ToggleHostFullscreen; SwitchToMode "Normal"; }} bind "w" {{ LaunchOrFocusPlugin "session-manager" {{ floating true @@ -900,6 +916,7 @@ keybinds clear-defaults=true {{ bind "s" {{ NewPane "stacked"; SwitchToMode "Normal"; }} bind "x" {{ CloseFocus; SwitchToMode "Normal"; }} bind "f" {{ ToggleFocusFullscreen; SwitchToMode "Normal"; }} + bind "Shift f" {{ ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; }} bind "z" {{ TogglePaneFrames; SwitchToMode "Normal"; }} bind "w" {{ ToggleFloatingPanes; SwitchToMode "Normal"; }} bind "e" {{ TogglePaneEmbedOrFloating; SwitchToMode "Normal"; }} @@ -974,6 +991,9 @@ keybinds clear-defaults=true {{ }} session {{ bind "d" {{ Detach; }} + bind "]" {{ FocusHostSession; SwitchToMode "Normal"; }} + bind "[" {{ FocusGuestSession; SwitchToMode "Normal"; }} + bind "f" {{ ToggleHostFullscreen; SwitchToMode "Normal"; }} bind "w" {{ LaunchOrFocusPlugin "session-manager" {{ floating true @@ -1085,6 +1105,7 @@ keybinds clear-defaults=true {{ bind "s" {{ NewPane "stacked"; SwitchToMode "Normal"; }} bind "x" {{ CloseFocus; SwitchToMode "Normal"; }} bind "f" {{ ToggleFocusFullscreen; SwitchToMode "Normal"; }} + bind "Shift f" {{ ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; }} bind "z" {{ TogglePaneFrames; SwitchToMode "Normal"; }} bind "w" {{ ToggleFloatingPanes; SwitchToMode "Normal"; }} bind "e" {{ TogglePaneEmbedOrFloating; SwitchToMode "Normal"; }} @@ -1173,6 +1194,9 @@ keybinds clear-defaults=true {{ bind "{primary_modifier} o" {{ SwitchToMode "Normal"; }} bind "{primary_modifier} s" {{ SwitchToMode "Scroll"; }} bind "d" {{ Detach; }} + bind "]" {{ FocusHostSession; SwitchToMode "Normal"; }} + bind "[" {{ FocusGuestSession; SwitchToMode "Normal"; }} + bind "f" {{ ToggleHostFullscreen; SwitchToMode "Normal"; }} bind "w" {{ LaunchOrFocusPlugin "session-manager" {{ floating true diff --git a/default-plugins/status-bar/src/first_line.rs b/default-plugins/status-bar/src/first_line.rs index b5c688ad..c2f5b477 100644 --- a/default-plugins/status-bar/src/first_line.rs +++ b/default-plugins/status-bar/src/first_line.rs @@ -613,7 +613,7 @@ pub fn first_line( separator: &str, ) -> LinePart { let supports_arrow_fonts = !help.capabilities.arrow_fonts; - let colored_elements = color_elements(help.style.colors, !supports_arrow_fonts); + let colored_elements = color_elements(help.style.colors, !supports_arrow_fonts, false); let binds = &help.get_mode_keybinds(); // Unselect all by default let mut default_keys = vec![ @@ -755,7 +755,7 @@ mod tests { fn colored_elements() -> ColoredElements { let palette = Styling::default(); - color_elements(palette, false) + color_elements(palette, false, false) } // Strip style information from `LinePart` and return a raw String instead diff --git a/default-plugins/status-bar/src/main.rs b/default-plugins/status-bar/src/main.rs index 673d7c8c..f12da1cc 100644 --- a/default-plugins/status-bar/src/main.rs +++ b/default-plugins/status-bar/src/main.rs @@ -18,9 +18,9 @@ use zellij_tile_utils::{palette_match, style}; use first_line::first_line; use one_line_ui::one_line_ui; use second_line::{ - floating_panes_are_visible, fullscreen_panes_to_hide, keybinds, - locked_floating_panes_are_visible, locked_fullscreen_panes_to_hide, system_clipboard_error, - text_copied_hint, + ascended_to_host_session_hint, descended_into_nested_session_hint, floating_panes_are_visible, + fullscreen_panes_to_hide, keybinds, locked_floating_panes_are_visible, + locked_fullscreen_panes_to_hide, system_clipboard_error, text_copied_hint, }; use tip::utils::get_cached_tip_name; @@ -94,7 +94,33 @@ pub struct SegmentStyle { // we need different colors from palette for the default theme // plus here we can add new sources in the future, like Theme // that can be defined in the config perhaps -fn color_elements(palette: Styling, different_color_alternates: bool) -> ColoredElements { +fn color_elements( + palette: Styling, + different_color_alternates: bool, + dimmed: bool, +) -> ColoredElements { + if dimmed { + let background = palette.text_unselected.background; + let foreground = palette.text_unselected.base; + let ribbon_background = palette.ribbon_unselected.background; + let italic_on_ribbon = style!(palette.ribbon_unselected.base, ribbon_background).italic(); + let dim_segment = SegmentStyle { + prefix_separator: style!(background, ribbon_background), + char_left_separator: italic_on_ribbon, + char_shortcut: italic_on_ribbon, + char_right_separator: italic_on_ribbon, + styled_text: italic_on_ribbon, + suffix_separator: style!(ribbon_background, background), + }; + return ColoredElements { + selected: dim_segment, + unselected: dim_segment, + unselected_alternate: dim_segment, + disabled: dim_segment, + superkey_prefix: style!(foreground, background).italic(), + superkey_suffix_separator: style!(background, background), + }; + } let background = palette.text_unselected.background; let foreground = palette.text_unselected.base; let alternate_background_color = if different_color_alternates { @@ -344,6 +370,8 @@ impl ZellijPlugin for State { let active_tab = self.tabs.iter().find(|t| t.active); let first_line = first_line(&self.mode_info, active_tab, cols, separator); let second_line = self.second_line(cols); + let show_nested_session_hint = self.mode_info.session_dimmed.unwrap_or(false) + || self.mode_info.session_ascended.unwrap_or(false); // [48;5;238m is white background, [0K is so that it fills the rest of the line // [m is background reset, [0K is so that it clears the rest of the line @@ -352,7 +380,9 @@ impl ZellijPlugin for State { if rows > 1 { println!("{}\u{1b}[48;2;{};{};{}m\u{1b}[0K", first_line, r, g, b); } else { - if self.mode_info.mode == InputMode::Normal { + if show_nested_session_hint { + print!("\u{1b}[m{}\u{1b}[0K", second_line); + } else if self.mode_info.mode == InputMode::Normal { print!("{}\u{1b}[48;2;{};{};{}m\u{1b}[0K", first_line, r, g, b); } else { print!("\u{1b}[m{}\u{1b}[0K", second_line); @@ -363,7 +393,9 @@ impl ZellijPlugin for State { if rows > 1 { println!("{}\u{1b}[48;5;{}m\u{1b}[0K", first_line, color); } else { - if self.mode_info.mode == InputMode::Normal { + if show_nested_session_hint { + print!("\u{1b}[m{}\u{1b}[0K", second_line); + } else if self.mode_info.mode == InputMode::Normal { print!("{}\u{1b}[48;5;{}m\u{1b}[0K", first_line, color); } else { print!("\u{1b}[m{}\u{1b}[0K", second_line); @@ -382,6 +414,12 @@ impl State { fn second_line(&self, cols: usize) -> LinePart { let active_tab = self.tabs.iter().find(|t| t.active); + if self.mode_info.session_dimmed.unwrap_or(false) { + return descended_into_nested_session_hint(&self.mode_info, cols); + } + if self.mode_info.session_ascended.unwrap_or(false) { + return ascended_to_host_session_hint(&self.mode_info, cols); + } if let Some(copy_destination) = self.text_copy_destination { text_copied_hint(copy_destination) } else if self.display_system_clipboard_failure { diff --git a/default-plugins/status-bar/src/one_line_ui.rs b/default-plugins/status-bar/src/one_line_ui.rs index ae57a223..62992af6 100644 --- a/default-plugins/status-bar/src/one_line_ui.rs +++ b/default-plugins/status-bar/src/one_line_ui.rs @@ -9,7 +9,10 @@ use zellij_tile::prelude::*; use zellij_tile_utils::{palette_match, style}; use crate::first_line::{to_char, KeyAction, KeyMode, KeyShortcut}; -use crate::second_line::{system_clipboard_error, text_copied_hint}; +use crate::second_line::{ + ascended_to_host_session_hint, descended_into_nested_session_hint, system_clipboard_error, + text_copied_hint, +}; use crate::{action_key, action_key_group, color_elements, MORE_MSG, TO_NORMAL}; use crate::{ColoredElements, LinePart}; use unicode_width::UnicodeWidthStr; @@ -25,6 +28,16 @@ pub fn one_line_ui( new_pane_hovered: bool, floating_hovered: bool, ) -> (LinePart, Option<(usize, usize)>, Option<(usize, usize)>) { + if help.session_dimmed.unwrap_or(false) { + return ( + descended_into_nested_session_hint(help, max_len), + None, + None, + ); + } + if help.session_ascended.unwrap_or(false) { + return (ascended_to_host_session_hint(help, max_len), None, None); + } if let Some(text_copied_to_clipboard_destination) = text_copied_to_clipboard_destination { return ( text_copied_hint(text_copied_to_clipboard_destination), @@ -528,7 +541,11 @@ fn render_mode_key_indicators( ) -> Option<LinePart> { let mut line_part_to_render = LinePart::default(); let supports_arrow_fonts = !help.capabilities.arrow_fonts; - let colored_elements = color_elements(help.style.colors, !supports_arrow_fonts); + let colored_elements = color_elements( + help.style.colors, + !supports_arrow_fonts, + help.session_dimmed.unwrap_or(false), + ); let default_keys = if base_mode_is_locked { base_mode_locked_mode_indicators(help) } else { @@ -742,13 +759,14 @@ fn render_common_modifiers( ) }; + let prefix = if mode_info.session_dimmed.unwrap_or(false) { + serialize_text(&Text::new(&prefix_text).disabled().opaque()) + } else { + serialize_text(&Text::new(&prefix_text).opaque()) + }; let suffix_separator = palette.superkey_suffix_separator.paint(separator); - line_part_to_render.part = format!( - "{}{}{}", - line_part_to_render.part, - serialize_text(&Text::new(&prefix_text).opaque()), - suffix_separator - ); + line_part_to_render.part = + format!("{}{}{}", line_part_to_render.part, prefix, suffix_separator); line_part_to_render.len += prefix_text.chars().count() + separator.chars().count(); } @@ -761,7 +779,11 @@ fn render_secondary_info( ) -> Option<(LinePart, Option<(usize, usize)>, Option<(usize, usize)>)> { let mut secondary_info = LinePart::default(); let supports_arrow_fonts = !help.capabilities.arrow_fonts; - let colored_elements = color_elements(help.style.colors, !supports_arrow_fonts); + let colored_elements = color_elements( + help.style.colors, + !supports_arrow_fonts, + help.session_dimmed.unwrap_or(false), + ); let (secondary_keybinds, new_pane_range, floating_range) = secondary_keybinds(&help, tab_info, max_len, new_pane_hovered, floating_hovered); secondary_info.append(&secondary_keybinds); @@ -1011,6 +1033,7 @@ fn secondary_keybinds( .join("-") ), 0, + help.session_dimmed.unwrap_or(false), ); secondary_info.append(&modifier_str); let new_pane_key_to_display: Vec<KeyWithModifier> = new_pane_key_to_display @@ -1145,6 +1168,7 @@ fn secondary_keybinds( .join("-") ), 0, + help.session_dimmed.unwrap_or(false), ); short_line.append(&modifier_str); let new_pane_key_to_display: Vec<KeyWithModifier> = new_pane_key_to_display @@ -1213,11 +1237,12 @@ fn secondary_keybinds( if short_line.len <= max_len { (short_line, new_pane_range, floating_range) } else if max_len >= 3 { - let part = serialize_text( - &Text::new(format!("{:>width$}", "...", width = max_len)) - .color_range(0, ..) - .opaque(), - ); + let overflow_text = Text::new(format!("{:>width$}", "...", width = max_len)); + let part = if help.session_dimmed.unwrap_or(false) { + serialize_text(&overflow_text.disabled().opaque()) + } else { + serialize_text(&overflow_text.color_range(0, ..).opaque()) + }; let len = max_len; (LinePart { part, len }, None, None) } else { @@ -1232,8 +1257,12 @@ fn secondary_keybinds( } } -fn text_as_line_part_with_emphasis(text: String, emphases_index: usize) -> LinePart { - let part = serialize_text(&Text::new(&text).color_range(emphases_index, ..).opaque()); +fn text_as_line_part_with_emphasis(text: String, emphases_index: usize, dimmed: bool) -> LinePart { + let part = if dimmed { + serialize_text(&Text::new(&text).disabled().opaque()) + } else { + serialize_text(&Text::new(&text).color_range(emphases_index, ..).opaque()) + }; LinePart { part, len: text.width(), @@ -1279,11 +1308,14 @@ fn add_shortcut_hovered( keys: &Vec<KeyWithModifier>, key_color_index: Option<usize>, ) -> LinePart { + if help.session_dimmed.unwrap_or(false) { + return add_shortcut(help, text, keys, false, key_color_index); + } let mut ret = LinePart::default(); if keys.is_empty() { return ret; } - ret.append(&style_key_with_modifier(&keys, key_color_index)); + ret.append(&style_key_with_modifier(&keys, key_color_index, false)); let palette = help.style.colors; let supports_arrow_fonts = !help.capabilities.arrow_fonts; let ribbon_bg = palette.ribbon_unselected.emphasis_1; @@ -1306,6 +1338,9 @@ fn add_shortcut_with_inline_key_hovered( text: &str, key: Vec<KeyWithModifier>, ) -> LinePart { + if help.session_dimmed.unwrap_or(false) { + return add_shortcut_with_inline_key(help, text, key, false); + } let mut ret = LinePart::default(); if key.is_empty() { return ret; @@ -1360,14 +1395,17 @@ fn add_shortcut( selected: bool, key_color_index: Option<usize>, ) -> LinePart { + let dimmed = help.session_dimmed.unwrap_or(false); let mut ret = LinePart::default(); if keys.is_empty() { return ret; } - ret.append(&style_key_with_modifier(&keys, key_color_index)); // TODO: alternate - // - let ribbon = if selected { + ret.append(&style_key_with_modifier(&keys, key_color_index, dimmed)); // TODO: alternate + // + let ribbon = if dimmed { + serialize_ribbon(&Text::new(format!("{}", text)).disabled()) + } else if selected { serialize_ribbon(&Text::new(format!("{}", text)).selected()) } else { serialize_ribbon(&Text::new(format!("{}", text))) @@ -1420,7 +1458,9 @@ fn add_shortcut_with_inline_key( .join(key_separator) ); - let ribbon = if is_selected { + let ribbon = if help.session_dimmed.unwrap_or(false) { + serialize_ribbon(&Text::new(format!("<{}> {}", key_string, text)).disabled()) + } else if is_selected { serialize_ribbon( &Text::new(format!("<{}> {}", key_string, text)) .color_range(0, 1..key_string.width() + 1) @@ -1461,7 +1501,9 @@ fn add_shortcut_with_key_only( .join("-") ); - let ribbon = if is_selected { + let ribbon = if help.session_dimmed.unwrap_or(false) { + serialize_ribbon(&Text::new(format!("{}", key_string)).disabled()) + } else if is_selected { serialize_ribbon( &Text::new(format!("{}", key_string)) .color_range(0, ..) @@ -1489,6 +1531,37 @@ fn add_keygroup_separator(help: &ModeInfo, max_len: usize) -> Option<LinePart> { }; let palette = help.style.colors; + if help.session_dimmed.unwrap_or(false) { + let mut ret = LinePart::default(); + let dim_style = Style::new() + .fg(palette_match!(palette.text_unselected.base)) + .on(palette_match!(palette.text_unselected.background)) + .italic(); + let mut bits: Vec<ANSIString> = vec![]; + let mode_help_text = match help.mode { + InputMode::RenamePane => Some("RENAMING PANE"), + InputMode::RenameTab => Some("RENAMING TAB"), + InputMode::EnterSearch => Some("ENTERING SEARCH TERM"), + InputMode::Search => Some("SEARCHING"), + _ => None, + }; + if let Some(mode_help_text) = mode_help_text { + bits.push(dim_style.paint(format!(" {} ", mode_help_text))); + ret.len += mode_help_text.width() + 2; + } + bits.push(dim_style.paint(format!("{}", separator))); + bits.push(dim_style.paint(format!(" "))); + bits.push(dim_style.paint(format!("{}", separator))); + ret.part = format!("{}{}", ret.part, ANSIStrings(&bits)); + ret.len += 3; + + if ret.len <= max_len { + return Some(ret); + } else { + return None; + } + } + let mut ret = LinePart::default(); let separator_color = palette_match!(palette.text_unselected.emphasis_0); @@ -1916,7 +1989,11 @@ fn configuration_key(keymap: &[(KeyWithModifier, Vec<Action>)]) -> Vec<KeyWithMo } } -fn style_key_with_modifier(keyvec: &[KeyWithModifier], color_index: Option<usize>) -> LinePart { +fn style_key_with_modifier( + keyvec: &[KeyWithModifier], + color_index: Option<usize>, + dimmed: bool, +) -> LinePart { if keyvec.is_empty() { return LinePart::default(); } @@ -1956,7 +2033,9 @@ fn style_key_with_modifier(keyvec: &[KeyWithModifier], color_index: Option<usize if no_common_modifier || key.len() == 1 { let key_string_text = format!(" {} ", key.join(key_separator)); - let text = if let Some(color_index) = color_index { + let text = if dimmed { + Text::new(&key_string_text).disabled().opaque() + } else if let Some(color_index) = color_index { Text::new(&key_string_text) .color_range(color_index, ..) .opaque() @@ -1970,7 +2049,9 @@ fn style_key_with_modifier(keyvec: &[KeyWithModifier], color_index: Option<usize } else { let key_string_without_modifier = format!("{}", key.join(key_separator)); let key_string_text = format!(" {} <{}> ", modifier_str, key_string_without_modifier); - let text = if let Some(color_index) = color_index { + let text = if dimmed { + Text::new(&key_string_text).disabled().opaque() + } else if let Some(color_index) = color_index { Text::new(&key_string_text) .color_range(color_index, ..modifier_str.width() + 1) .color_range( diff --git a/default-plugins/status-bar/src/second_line.rs b/default-plugins/status-bar/src/second_line.rs index 921f3c75..63f845a6 100644 --- a/default-plugins/status-bar/src/second_line.rs +++ b/default-plugins/status-bar/src/second_line.rs @@ -368,6 +368,60 @@ pub fn keybinds(help: &ModeInfo, tip_name: &str, max_width: usize) -> LinePart { best_effort_shortcut_list(help, tip_body.short, max_width) } +pub fn descended_into_nested_session_hint(help: &ModeInfo, max_len: usize) -> LinePart { + nested_session_status_hint(help, "Ascend: ", &help.nested_ascend_keys, max_len) +} + +pub fn ascended_to_host_session_hint(help: &ModeInfo, max_len: usize) -> LinePart { + nested_session_status_hint(help, "Descend: ", &help.nested_descend_keys, max_len) +} + +fn nested_session_status_hint( + help: &ModeInfo, + label: &str, + keys: &[KeyWithModifier], + max_len: usize, +) -> LinePart { + let palette = help.style.colors; + let text_color = palette_match!(palette.text_unselected.base); + let styled_label = Style::new().dimmed().italic().paint(format!(" {}", label)); + let mut key_bits: Vec<ANSIString> = vec![]; + if keys.is_empty() { + key_bits.push(Style::new().dimmed().italic().paint("<unbound>")); + } else { + for (i, key) in keys.iter().enumerate() { + if i > 0 { + key_bits.push(Style::new().fg(text_color).paint(" + ")); + } + key_bits.extend(style_key_with_modifier( + std::slice::from_ref(key), + &palette, + None, + )); + } + } + let mut bits: Vec<ANSIString> = vec![styled_label]; + bits.extend(key_bits.clone()); + let part = ANSIStrings(&bits); + let len = unstyled_len(&part); + if len <= max_len { + return LinePart { + part: part.to_string(), + len, + }; + } + let part = ANSIStrings(&key_bits); + let len = unstyled_len(&part); + if len <= max_len { + LinePart { + part: part.to_string(), + len, + } + } else { + LinePart::default() + } +} + pub fn text_copied_hint(copy_destination: CopyDestination) -> LinePart { let hint = match copy_destination { CopyDestination::Command => "Text piped to external command", diff --git a/default-plugins/tab-bar/src/line.rs b/default-plugins/tab-bar/src/line.rs index 84c5b3aa..d9f7a8da 100644 --- a/default-plugins/tab-bar/src/line.rs +++ b/default-plugins/tab-bar/src/line.rs @@ -11,6 +11,15 @@ fn get_current_title_len(current_title: &[LinePart]) -> usize { current_title.iter().map(|p| p.len).sum() } +fn dim_style(bg: PaletteColor) -> ansi_term::Style { + ansi_term::Style::new() + .on(match bg { + PaletteColor::Rgb((r, g, b)) => ansi_term::Color::RGB(r, g, b), + PaletteColor::EightBit(c) => ansi_term::Color::Fixed(c), + }) + .dimmed() +} + // move elements from before_active and after_active into tabs_to_render while they fit in cols // adds collapsed_tabs to the left and right if there's left over tabs that don't fit fn populate_tabs_in_tab_line( @@ -20,6 +29,7 @@ fn populate_tabs_in_tab_line( cols: usize, palette: Styling, capabilities: PluginCapabilities, + dimmed: bool, ) { let mut middle_size = get_current_title_len(tabs_to_render); @@ -36,6 +46,7 @@ fn populate_tabs_in_tab_line( palette, tab_separator(capabilities), left_more_tab_index, + dimmed, ); // right_more_tab_index is the first tab to the right of the rightmost visible tab @@ -45,6 +56,7 @@ fn populate_tabs_in_tab_line( palette, tab_separator(capabilities), right_more_tab_index, + dimmed, ); let total_size = collapsed_left.len + middle_size + collapsed_right.len; @@ -113,6 +125,7 @@ fn left_more_message( palette: Styling, separator: &str, tab_index: usize, + dimmed: bool, ) -> LinePart { if tab_count_to_the_left == 0 { return LinePart::default(); @@ -129,10 +142,13 @@ fn left_more_message( palette.ribbon_unselected.base, palette.text_unselected.background, ); + let text_style = if dimmed { + style!(text_color, palette.ribbon_unselected.background).italic() + } else { + style!(text_color, palette.ribbon_unselected.background).bold() + }; let left_separator = style!(sep_color, palette.ribbon_unselected.background).paint(separator); - let more_styled_text = style!(text_color, palette.ribbon_unselected.background) - .bold() - .paint(more_text); + let more_styled_text = text_style.paint(more_text); let right_separator = style!(palette.ribbon_unselected.background, sep_color).paint(separator); let more_styled_text = ANSIStrings(&[left_separator, more_styled_text, right_separator]).to_string(); @@ -148,6 +164,7 @@ fn right_more_message( palette: Styling, separator: &str, tab_index: usize, + dimmed: bool, ) -> LinePart { if tab_count_to_the_right == 0 { return LinePart::default(); @@ -163,10 +180,13 @@ fn right_more_message( palette.ribbon_unselected.base, palette.text_unselected.background, ); + let text_style = if dimmed { + style!(text_color, palette.ribbon_unselected.background).italic() + } else { + style!(text_color, palette.ribbon_unselected.background).bold() + }; let left_separator = style!(sep_color, palette.ribbon_unselected.background).paint(separator); - let more_styled_text = style!(text_color, palette.ribbon_unselected.background) - .bold() - .paint(more_text); + let more_styled_text = text_style.paint(more_text); let right_separator = style!(palette.ribbon_unselected.background, sep_color).paint(separator); let more_styled_text = ANSIStrings(&[left_separator, more_styled_text, right_separator]).to_string(); @@ -177,23 +197,80 @@ fn right_more_message( } } -fn tab_line_prefix(session_name: Option<&str>, palette: Styling, cols: usize) -> Vec<LinePart> { +fn tab_line_prefix( + session_name: Option<&str>, + palette: Styling, + cols: usize, + dimmed: bool, + breadcrumb_ancestry: &[String], +) -> (Vec<LinePart>, Option<(usize, usize)>) { let prefix_text = " Zellij ".to_string(); let running_text_len = prefix_text.chars().count(); let text_color = palette.text_unselected.base; let bg_color = palette.text_unselected.background; - let prefix_styled_text = style!(text_color, bg_color).bold().paint(prefix_text); + let prefix_style = if dimmed { + dim_style(bg_color) + } else { + style!(text_color, bg_color).bold() + }; + let prefix_styled_text = prefix_style.paint(prefix_text); let mut parts = vec![LinePart { part: prefix_styled_text.to_string(), len: running_text_len, tab_index: None, }]; + let mut breadcrumb_range = None; + if !breadcrumb_ancestry.is_empty() { + if let Some(name) = session_name { + let ancestor_text = format!("({} ▸ ", breadcrumb_ancestry.join(" ▸ ")); + let ancestor_len = ancestor_text.width(); + let name_text = name.to_string(); + let name_len = name_text.width(); + let closing_text = ") ".to_string(); + let closing_len = closing_text.width(); + let ancestor_style = if dimmed { + dim_style(bg_color) + } else { + style!(text_color, bg_color).italic() + }; + let name_style = if dimmed { + dim_style(bg_color) + } else { + style!(palette.text_unselected.emphasis_0, bg_color).bold() + }; + let closing_style = if dimmed { + dim_style(bg_color) + } else { + style!(text_color, bg_color).bold() + }; + if cols.saturating_sub(running_text_len) >= ancestor_len + name_len + closing_len { + let ancestor_start = running_text_len; + let ancestor_end = ancestor_start + ancestor_len; + breadcrumb_range = Some((ancestor_start, ancestor_end)); + parts.push(LinePart { + part: ancestor_style.paint(ancestor_text).to_string(), + len: ancestor_len, + tab_index: None, + }); + parts.push(LinePart { + part: name_style.paint(name_text).to_string(), + len: name_len, + tab_index: None, + }); + parts.push(LinePart { + part: closing_style.paint(closing_text).to_string(), + len: closing_len, + tab_index: None, + }); + } + } + return (parts, breadcrumb_range); + } if let Some(name) = session_name { let name_part = format!("({}) ", name); let name_part_len = name_part.width(); - let text_color = palette.text_unselected.base; - let name_part_styled_text = style!(text_color, bg_color).bold().paint(name_part); + let name_part_styled_text = prefix_style.paint(name_part); if cols.saturating_sub(running_text_len) >= name_part_len { parts.push(LinePart { part: name_part_styled_text.to_string(), @@ -202,7 +279,7 @@ fn tab_line_prefix(session_name: Option<&str>, palette: Styling, cols: usize) -> }) } } - parts + (parts, breadcrumb_range) } pub fn tab_separator(capabilities: PluginCapabilities) -> &'static str { @@ -229,7 +306,13 @@ pub fn tab_line( hint: Option<&BTreeMap<usize, StyledText>>, is_alternate_tab: bool, new_tab_button_is_hovered: bool, -) -> (Vec<LinePart>, Option<(usize, usize)>) { + dimmed: bool, + breadcrumb_ancestry: &[String], +) -> ( + Vec<LinePart>, + Option<(usize, usize)>, + Option<(usize, usize)>, +) { let mut tabs_after_active = all_tabs.split_off(active_tab_index); let mut tabs_before_active = all_tabs; let active_tab = if !tabs_after_active.is_empty() { @@ -237,9 +320,9 @@ pub fn tab_line( } else { tabs_before_active.pop().unwrap() }; - let mut prefix = match hide_session_name { - true => tab_line_prefix(None, palette, cols), - false => tab_line_prefix(session_name, palette, cols), + let (mut prefix, breadcrumb_range) = match hide_session_name { + true => tab_line_prefix(None, palette, cols, dimmed, &[]), + false => tab_line_prefix(session_name, palette, cols, dimmed, breadcrumb_ancestry), }; let mut swap_layout_indicator = if hide_swap_layout_indicator { @@ -251,17 +334,19 @@ pub fn tab_line( tab_info.is_swap_layout_dirty, mode_info, !capabilities.arrow_fonts, + dimmed, ) }) }; if swap_layout_indicator.is_none() { - swap_layout_indicator = active_pane_scroll.map(|scroll| scroll_status(scroll, palette)); + swap_layout_indicator = + active_pane_scroll.map(|scroll| scroll_status(scroll, palette, dimmed)); } // Drop indicator if it would cause wrapping (active tab always rendered unconditionally) let prefix_len = get_current_title_len(&prefix); let hint_budget = cols.saturating_sub(prefix_len + active_tab.len); - if let Some(hint_part) = hint.and_then(|hint| hint_line_part(hint, hint_budget)) { + if let Some(hint_part) = hint.and_then(|hint| hint_line_part(hint, hint_budget, dimmed)) { swap_layout_indicator = Some(hint_part); } let indicator_len = swap_layout_indicator.as_ref().map(|s| s.len).unwrap_or(0); @@ -280,6 +365,7 @@ pub fn tab_line( cols.saturating_sub(non_tab_len), palette, capabilities, + dimmed, ); prefix.append(&mut tabs_to_render); prefix.append(&mut vec![LinePart { @@ -303,7 +389,8 @@ pub fn tab_line( } else { palette.ribbon_unselected.background }; - let new_tab_button = new_tab_button_line_part(palette, capabilities, new_tab_button_background); + let new_tab_button = + new_tab_button_line_part(palette, capabilities, new_tab_button_background, dimmed); let used = prefix.iter().map(|p| p.len).sum::<usize>(); let current_indicator_len = swap_layout_indicator.as_ref().map(|s| s.len).unwrap_or(0); let room = cols @@ -340,7 +427,7 @@ pub fn tab_line( prefix.push(swap_layout_indicator); } - (prefix, new_tab_button_range) + (prefix, new_tab_button_range, breadcrumb_range) } fn swap_layout_status( @@ -348,6 +435,7 @@ fn swap_layout_status( is_swap_layout_dirty: bool, mode_info: &ModeInfo, supports_arrow_fonts: bool, + dimmed: bool, ) -> Option<LinePart> { match swap_layout_name { Some(swap_layout_name) => { @@ -356,11 +444,12 @@ fn swap_layout_status( &mode_keybinds, &[&[Action::PreviousSwapLayout], &[Action::NextSwapLayout]], ); - let mut text = style_key_with_modifier(&prev_next_keys, Some(0)); + let mut text = style_key_with_modifier(&prev_next_keys, Some(0), dimmed); text.append(&ribbon_as_line_part( &swap_layout_name.to_uppercase(), !is_swap_layout_dirty, supports_arrow_fonts, + dimmed, )); Some(text) }, @@ -368,13 +457,22 @@ fn swap_layout_status( } } -fn hint_line_part(hint: &BTreeMap<usize, StyledText>, max_len: usize) -> Option<LinePart> { +fn hint_line_part( + hint: &BTreeMap<usize, StyledText>, + max_len: usize, + dimmed: bool, +) -> Option<LinePart> { let fitting_variant = hint .range(..=max_len) .next_back() .map(|(_width, styled_text)| styled_text)?; let len = fitting_variant.text.width(); - let part = serialize_text(&Text::from(fitting_variant.clone())); + let text = if dimmed { + Text::from(fitting_variant.clone()).disabled() + } else { + Text::from(fitting_variant.clone()) + }; + let part = serialize_text(&text); Some(LinePart { part, len, @@ -382,12 +480,17 @@ fn hint_line_part(hint: &BTreeMap<usize, StyledText>, max_len: usize) -> Option< }) } -fn scroll_status(scroll: (usize, usize), palette: Styling) -> LinePart { +fn scroll_status(scroll: (usize, usize), palette: Styling, dimmed: bool) -> LinePart { let (position, length) = scroll; let text = format!("[ SCROLL {}/{} ]", position, length); let text_color = palette.text_unselected.base; let bg_color = palette.text_unselected.background; - let styled_text = style!(text_color, bg_color).bold().paint(&text); + let text_style = if dimmed { + style!(text_color, bg_color).italic() + } else { + style!(text_color, bg_color).bold() + }; + let styled_text = text_style.paint(&text); LinePart { part: styled_text.to_string(), len: text.width(), @@ -399,17 +502,26 @@ fn new_tab_button_line_part( palette: Styling, capabilities: PluginCapabilities, background_color: PaletteColor, + dimmed: bool, ) -> LinePart { let separator = tab_separator(capabilities); let separator_width = separator.width(); let foreground_color = palette.ribbon_unselected.base; let separator_fill_color = palette.text_unselected.background; + let background_color = if dimmed { + palette.ribbon_unselected.background + } else { + background_color + }; + let text_style = if dimmed { + style!(foreground_color, background_color).italic() + } else { + style!(foreground_color, background_color).bold() + }; let left_separator = style!(separator_fill_color, background_color).paint(separator); let right_separator = style!(background_color, separator_fill_color).paint(separator); let text = "+"; - let styled_text = style!(foreground_color, background_color) - .bold() - .paint(format!(" {} ", text)); + let styled_text = text_style.paint(format!(" {} ", text)); let part = ANSIStrings(&[left_separator, styled_text, right_separator]).to_string(); let len = text.width() + (separator_width * 2) + 2; LinePart { @@ -419,8 +531,15 @@ fn new_tab_button_line_part( } } -pub fn ribbon_as_line_part(text: &str, is_selected: bool, supports_arrow_fonts: bool) -> LinePart { - let ribbon_text = if is_selected { +pub fn ribbon_as_line_part( + text: &str, + is_selected: bool, + supports_arrow_fonts: bool, + dimmed: bool, +) -> LinePart { + let ribbon_text = if dimmed { + Text::new(text).disabled() + } else if is_selected { Text::new(text).selected() } else { Text::new(text) @@ -437,7 +556,11 @@ pub fn ribbon_as_line_part(text: &str, is_selected: bool, supports_arrow_fonts: } } -pub fn style_key_with_modifier(keyvec: &[KeyWithModifier], color_index: Option<usize>) -> LinePart { +pub fn style_key_with_modifier( + keyvec: &[KeyWithModifier], + color_index: Option<usize>, + dimmed: bool, +) -> LinePart { if keyvec.is_empty() { return LinePart::default(); } @@ -477,7 +600,9 @@ pub fn style_key_with_modifier(keyvec: &[KeyWithModifier], color_index: Option<u if no_common_modifier || key.len() == 1 { let key_string_text = format!(" {} ", key.join(key_separator)); - let text = if let Some(color_index) = color_index { + let text = if dimmed { + Text::new(&key_string_text).disabled().opaque() + } else if let Some(color_index) = color_index { Text::new(&key_string_text) .color_range(color_index, ..) .opaque() @@ -492,7 +617,9 @@ pub fn style_key_with_modifier(keyvec: &[KeyWithModifier], color_index: Option<u } else { let key_string_without_modifier = format!("{}", key.join(key_separator)); let key_string_text = format!(" {} <{}> ", modifier_str, key_string_without_modifier); - let text = if let Some(color_index) = color_index { + let text = if dimmed { + Text::new(&key_string_text).disabled().opaque() + } else if let Some(color_index) = color_index { Text::new(&key_string_text) .color_range(color_index, ..modifier_str.width() + 1) .color_range( diff --git a/default-plugins/tab-bar/src/main.rs b/default-plugins/tab-bar/src/main.rs index 4b521969..d55cd800 100644 --- a/default-plugins/tab-bar/src/main.rs +++ b/default-plugins/tab-bar/src/main.rs @@ -35,6 +35,7 @@ struct State { cached_keybinds: KeybindsVec, active_pane_scroll: Option<(usize, usize)>, new_tab_button_range: Option<(usize, usize)>, + breadcrumb_range: Option<(usize, usize)>, hovered_tab_idx: Option<usize>, hovered_new_tab_button: bool, hint_text: Option<BTreeMap<usize, StyledText>>, @@ -133,6 +134,12 @@ impl ZellijPlugin for State { }, Event::Mouse(me) => match me { Mouse::LeftClick(_, col) => { + if let Some((start, end)) = self.breadcrumb_range { + if col >= start && col < end { + focus_host_session(); + return should_render; + } + } if let Some((start, end)) = self.new_tab_button_range { if col >= start && col < end { new_tab::<&str>(None, None); @@ -191,6 +198,8 @@ impl ZellijPlugin for State { if self.tabs.is_empty() { return; } + let dimmed = self.mode_info.session_ascended == Some(true) + || self.mode_info.session_dimmed == Some(true); let mut all_tabs: Vec<LinePart> = vec![]; let mut active_tab_index = 0; let mut is_alternate_tab = false; @@ -212,6 +221,7 @@ impl ZellijPlugin for State { is_hovered, self.mode_info.style.colors, self.mode_info.capabilities, + dimmed, ); is_alternate_tab = !is_alternate_tab; all_tabs.push(tab); @@ -225,7 +235,12 @@ impl ZellijPlugin for State { } else { self.hint_text.as_ref() }; - let (line, new_tab_button_range) = tab_line( + let breadcrumb_ancestry: Vec<String> = if self.mode_info.host_fullscreen == Some(true) { + self.mode_info.session_ancestry.clone() + } else { + vec![] + }; + let (line, new_tab_button_range, breadcrumb_range) = tab_line( self.mode_info.session_name.as_deref(), all_tabs, active_tab_index, @@ -241,9 +256,12 @@ impl ZellijPlugin for State { hint_text, is_alternate_tab, self.hovered_new_tab_button, + dimmed, + &breadcrumb_ancestry, ); self.tab_line = line; self.new_tab_button_range = new_tab_button_range; + self.breadcrumb_range = breadcrumb_range; let output = self .tab_line diff --git a/default-plugins/tab-bar/src/tab.rs b/default-plugins/tab-bar/src/tab.rs index 3db1bbb6..df44dbad 100644 --- a/default-plugins/tab-bar/src/tab.rs +++ b/default-plugins/tab-bar/src/tab.rs @@ -27,6 +27,7 @@ pub fn render_tab( is_hovered: bool, palette: Styling, separator: &str, + dimmed: bool, ) -> LinePart { let focused_clients = tab.other_focused_clients.as_slice(); let separator_width = separator.width(); @@ -58,27 +59,30 @@ pub fn render_tab( }; let separator_fill_color = palette.text_unselected.background; + let background_color = if dimmed { + palette.ribbon_unselected.background + } else { + background_color + }; + let text_style = if dimmed { + style!(palette.ribbon_unselected.base, background_color).italic() + } else { + style!(foreground_color, background_color).bold() + }; let left_separator = style!(separator_fill_color, background_color).paint(separator); + let right_separator_style = style!(background_color, separator_fill_color); let mut tab_text_len = text.width() + (separator_width * 2) + 2; // +2 for padding - let tab_styled_text = style!(foreground_color, background_color) - .bold() - .paint(format!(" {} ", text)); + let tab_styled_text = text_style.paint(format!(" {} ", text)); - let right_separator = style!(background_color, separator_fill_color).paint(separator); + let right_separator = right_separator_style.paint(separator); let tab_styled_text = if !focused_clients.is_empty() { let (cursor_section, extra_length) = cursors(focused_clients, palette.multiplayer_user_colors); tab_text_len += extra_length + 2; // 2 for cursor_beginning and cursor_end let mut s = String::new(); - let cursor_beginning = style!(foreground_color, background_color) - .bold() - .paint("[") - .to_string(); + let cursor_beginning = text_style.paint("[").to_string(); let cursor_section = ANSIStrings(&cursor_section).to_string(); - let cursor_end = style!(foreground_color, background_color) - .bold() - .paint("]") - .to_string(); + let cursor_end = text_style.paint("]").to_string(); s.push_str(&left_separator.to_string()); s.push_str(&tab_styled_text.to_string()); s.push_str(&cursor_beginning); @@ -104,6 +108,7 @@ pub fn tab_style( is_hovered: bool, palette: Styling, capabilities: PluginCapabilities, + dimmed: bool, ) -> LinePart { let separator = tab_separator(capabilities); @@ -127,6 +132,7 @@ pub fn tab_style( is_hovered, palette, separator, + dimmed, ) } diff --git a/example/alt-centered-config.kdl b/example/alt-centered-config.kdl index b9803880..7f579432 100644 --- a/example/alt-centered-config.kdl +++ b/example/alt-centered-config.kdl @@ -46,6 +46,7 @@ keybinds { bind "n" { NewPane; } bind "x" { CloseFocus; } bind "f" { ToggleFocusFullscreen; } + bind "Shift f" { ToggleFocusNoUiFullscreen; } bind "z" { TogglePaneFrames; } } diff --git a/example/config.kdl b/example/config.kdl index cbfb7a63..e73a3677 100644 --- a/example/config.kdl +++ b/example/config.kdl @@ -29,6 +29,7 @@ keybinds { bind "r" { NewPane "Right"; SwitchToMode "Normal"; } bind "x" { CloseFocus; SwitchToMode "Normal"; } bind "f" { ToggleFocusFullscreen; SwitchToMode "Normal"; } + bind "Shift f" { ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; } bind "z" { TogglePaneFrames; SwitchToMode "Normal"; } bind "w" { ToggleFloatingPanes; SwitchToMode "Normal"; } bind "e" { TogglePaneEmbedOrFloating; SwitchToMode "Normal"; } diff --git a/example/default.kdl b/example/default.kdl index eb198c94..614bcf96 100644 --- a/example/default.kdl +++ b/example/default.kdl @@ -33,6 +33,7 @@ keybinds { bind "s" { NewPane "stacked"; SwitchToMode "Normal"; } bind "x" { CloseFocus; SwitchToMode "Normal"; } bind "f" { ToggleFocusFullscreen; SwitchToMode "Normal"; } + bind "Shift f" { ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; } bind "z" { TogglePaneFrames; SwitchToMode "Normal"; } bind "w" { ToggleFloatingPanes; SwitchToMode "Normal"; } bind "e" { TogglePaneEmbedOrFloating; SwitchToMode "Normal"; } diff --git a/xtask/src/build.rs b/xtask/src/build.rs index d2469b59..210b8e4b 100644 --- a/xtask/src/build.rs +++ b/xtask/src/build.rs @@ -45,30 +45,14 @@ pub fn build(sh: &Shell, flags: flags::Build) -> anyhow::Result<()> { crate::status(msg); eprintln!("{}", msg); - let mut base_cmd = cmd!(sh, "{cargo} build --target wasm32-wasip1"); - if flags.release { - base_cmd = base_cmd.arg("--release"); - } - for member in &plugin_members { - let plugin_name = member - .crate_name - .rsplit_once('/') - .context("Cannot determine plugin name from crate path")? - .1; - base_cmd = base_cmd.args(["-p", plugin_name]); - } - base_cmd.run().context("failed to build plugins")?; - if flags.release { + build_plugins_release_into_assets(sh, &plugin_members)?; + } else { + let mut base_cmd = cmd!(sh, "{cargo} build --target wasm32-wasip1"); for member in &plugin_members { - let plugin_name = member - .crate_name - .rsplit_once('/') - .context("Cannot determine plugin name from crate path")? - .1; - move_plugin_to_assets(sh, plugin_name)?; + base_cmd = base_cmd.args(["-p", plugin_name_of(member)?]); } - } else { + base_cmd.run().context("failed to build plugins")?; write_plugin_stamp(sh); } } @@ -126,40 +110,30 @@ fn plugins_force() -> bool { std::env::var_os("ZELLIJ_FORCE_PLUGINS").is_some() } -fn plugin_stamp_path() -> PathBuf { - PathBuf::from( - std::env::var_os("CARGO_TARGET_DIR") - .unwrap_or(crate::project_root().join("target").into_os_string()), - ) - .join(".xtask-plugins-stamp") -} - -fn write_plugin_stamp(sh: &Shell) { - let _ = sh.write_file(plugin_stamp_path(), b""); +fn plugin_asset_path(plugin_name: &str) -> PathBuf { + crate::asset_dir() + .join("plugins") + .join(plugin_name) + .with_extension("wasm") } -fn plugin_sources_changed() -> bool { - let stamp_time = match std::fs::metadata(plugin_stamp_path()).and_then(|m| m.modified()) { - Ok(stamp_time) => stamp_time, - Err(_) => return true, - }; +fn newest_plugin_source_time() -> Option<std::time::SystemTime> { let root = crate::project_root(); ["default-plugins", "zellij-tile", "zellij-tile-utils"] .iter() - .any(|dir| dir_changed_since(&root.join(dir), stamp_time)) + .filter_map(|dir| newest_file_time(&root.join(dir))) + .max() } -fn dir_changed_since(dir: &Path, since: std::time::SystemTime) -> bool { - let entries = match std::fs::read_dir(dir) { - Ok(entries) => entries, - Err(_) => return false, - }; +fn newest_file_time(dir: &Path) -> Option<std::time::SystemTime> { + let entries = std::fs::read_dir(dir).ok()?; + let mut newest: Option<std::time::SystemTime> = None; for entry in entries.flatten() { + let path = entry.path(); let file_type = match entry.file_type() { Ok(file_type) => file_type, Err(_) => continue, }; - let path = entry.path(); if file_type.is_dir() { if path .file_name() @@ -168,16 +142,102 @@ fn dir_changed_since(dir: &Path, since: std::time::SystemTime) -> bool { { continue; } - if dir_changed_since(&path, since) { - return true; + if let Some(child) = newest_file_time(&path) { + newest = Some(newest.map_or(child, |current| current.max(child))); } } else if let Ok(modified) = entry.metadata().and_then(|m| m.modified()) { - if modified > since { - return true; - } + newest = Some(newest.map_or(modified, |current| current.max(modified))); } } - false + newest +} + +pub fn ensure_plugin_assets(sh: &Shell) -> anyhow::Result<()> { + let plugin_members: Vec<&WorkspaceMember> = crate::workspace_members() + .iter() + .filter(|m| m.build && m.crate_name.contains("plugins")) + .collect(); + if plugin_members.is_empty() { + return Ok(()); + } + + let newest_source = newest_plugin_source_time(); + let stale = plugins_force() + || plugin_members.iter().any(|member| { + let plugin_name = match member.crate_name.rsplit_once('/') { + Some((_, name)) => name, + None => return true, + }; + let asset_time = std::fs::metadata(plugin_asset_path(plugin_name)) + .and_then(|m| m.modified()) + .ok(); + match (asset_time, newest_source) { + (Some(asset_time), Some(newest_source)) => asset_time < newest_source, + _ => true, + } + }); + + if !stale { + let msg = ">> Plugin assets up to date, skipping plugin build"; + crate::status(msg); + eprintln!("{}", msg); + return Ok(()); + } + + let msg = ">> Building plugin assets (release)"; + crate::status(msg); + eprintln!("{}", msg); + + build_plugins_release_into_assets(sh, &plugin_members) +} + +fn build_plugins_release_into_assets( + sh: &Shell, + plugin_members: &[&WorkspaceMember], +) -> anyhow::Result<()> { + let cargo = crate::cargo()?; + let mut base_cmd = cmd!(sh, "{cargo} build --target wasm32-wasip1 --release"); + for member in plugin_members { + let plugin_name = plugin_name_of(member)?; + base_cmd = base_cmd.args(["-p", plugin_name]); + } + base_cmd.run().context("failed to build plugin assets")?; + + for member in plugin_members { + move_plugin_to_assets(sh, plugin_name_of(member)?)?; + } + Ok(()) +} + +fn plugin_name_of(member: &WorkspaceMember) -> anyhow::Result<&'static str> { + Ok(member + .crate_name + .rsplit_once('/') + .context("Cannot determine plugin name from crate path")? + .1) +} + +fn plugin_stamp_path() -> PathBuf { + PathBuf::from( + std::env::var_os("CARGO_TARGET_DIR") + .unwrap_or(crate::project_root().join("target").into_os_string()), + ) + .join(".xtask-plugins-stamp") +} + +fn write_plugin_stamp(sh: &Shell) { + let _ = sh.write_file(plugin_stamp_path(), b""); +} + +fn plugin_sources_changed() -> bool { + let stamp_time = match std::fs::metadata(plugin_stamp_path()).and_then(|m| m.modified()) { + Ok(stamp_time) => stamp_time, + Err(_) => return true, + }; + match newest_plugin_source_time() { + Some(newest_source) => newest_source > stamp_time, + None => true, + } } pub fn proto(sh: &Shell) -> anyhow::Result<()> { @@ -205,6 +265,11 @@ fn run_proto_codegen(sh: &Shell, force: bool) { "src/web_server_contract", "generated_web_server_api.rs", ), + ( + "assets/prost_nested_session", + "src/nested_session_contract", + "generated_nested_session_api.rs", + ), ]; for (out_subdir, src_subdir, include_file) in specs { diff --git a/xtask/src/integration_test.rs b/xtask/src/integration_test.rs index 88eb1154..077ed53d 100644 --- a/xtask/src/integration_test.rs +++ b/xtask/src/integration_test.rs @@ -12,6 +12,8 @@ pub fn integration_test(sh: &Shell, flags: flags::IntegrationTest) -> anyhow::Re crate::status(msg); println!("{}", msg); + crate::build::ensure_plugin_assets(sh).context(err_context)?; + let profile = if flags.no_opt { "dev" } else { "dev-opt" }; let nextest_available = cmd!(sh, "{cargo} nextest --version") diff --git a/zellij-client/src/input_handler.rs b/zellij-client/src/input_handler.rs index 45e8d9ce..3c9d269c 100644 --- a/zellij-client/src/input_handler.rs +++ b/zellij-client/src/input_handler.rs @@ -1,7 +1,8 @@ //! Main input logic. use crate::{ - os_input_output::ClientOsApi, stdin_ansi_parser::AnsiStdinInstruction, ClientId, - ClientInstruction, CommandIsExecuting, InputInstruction, + nested_reannounce::NestedReannounce, os_input_output::ClientOsApi, + stdin_ansi_parser::AnsiStdinInstruction, ClientId, ClientInstruction, CommandIsExecuting, + InputInstruction, }; use zellij_utils::{ channels::{Receiver, SenderWithContext, OPENCALLS}, @@ -15,6 +16,7 @@ use zellij_utils::{ options::Options, }, ipc::{ClientToServerMsg, ExitReason}, + nested_session::{self, NestedSessionMessage}, position::Position, vendored::termwiz::input::{ InputEvent, Modifiers, MouseButtons, MouseEvent as TermwizMouseEvent, @@ -35,6 +37,7 @@ struct InputHandler { receive_input_instructions: Receiver<(InputInstruction, ErrorContext)>, mouse_old_event: MouseEvent, mouse_mode_active: bool, + nested_reannounce: Option<NestedReannounce>, } fn termwiz_mouse_convert(original_event: &mut MouseEvent, event: &TermwizMouseEvent) { @@ -136,6 +139,7 @@ impl InputHandler { mode: InputMode, // TODO: we can probably get rid of this now that we're tracking it on the // server instead receive_input_instructions: Receiver<(InputInstruction, ErrorContext)>, + nested_reannounce: Option<NestedReannounce>, ) -> Self { InputHandler { mode, @@ -148,6 +152,7 @@ impl InputHandler { receive_input_instructions, mouse_old_event: MouseEvent::new(), mouse_mode_active: false, + nested_reannounce, } } @@ -269,6 +274,12 @@ impl InputHandler { reply_bytes, }); }, + Ok(( + InputInstruction::NestedSessionFrameFromHost(payload_bytes), + _error_context, + )) => { + self.handle_nested_session_frame_from_host(payload_bytes); + }, Ok((InputInstruction::Exit, _error_context)) => { self.should_exit = true; }, @@ -329,6 +340,29 @@ impl InputHandler { }, } } + fn handle_nested_session_frame_from_host(&mut self, payload_bytes: Vec<u8>) { + if let Some(nested_reannounce) = &self.nested_reannounce { + nested_reannounce.note_host_contact(); + } + match nested_session::decode_payload(&payload_bytes) { + Some(NestedSessionMessage::Ping) => { + self.send_client_instructions + .send(ClientInstruction::EmitNestedSessionFrame( + nested_session::encode_payload(&NestedSessionMessage::Pong), + )) + .unwrap(); + }, + Some(_) => { + self.os_input + .send_to_server(ClientToServerMsg::NestedSessionFrameFromHost { + payload_bytes, + }); + }, + None => { + log::debug!("dropping undecodable nested session frame from host"); + }, + } + } fn handle_mouse_event(&mut self, mouse_event: &MouseEvent) { self.dispatch_action( Action::MouseEvent { @@ -450,6 +484,7 @@ pub(crate) fn input_loop( send_client_instructions: SenderWithContext<ClientInstruction>, default_mode: InputMode, receive_input_instructions: Receiver<(InputInstruction, ErrorContext)>, + nested_reannounce: Option<NestedReannounce>, ) { let _handler = InputHandler::new( os_input, @@ -459,6 +494,7 @@ pub(crate) fn input_loop( send_client_instructions, default_mode, receive_input_instructions, + nested_reannounce, ) .handle_input(); } diff --git a/zellij-client/src/lib.rs b/zellij-client/src/lib.rs index 2db4617f..1df213a2 100644 --- a/zellij-client/src/lib.rs +++ b/zellij-client/src/lib.rs @@ -11,6 +11,7 @@ pub mod cli_client; mod command_is_executing; mod input_handler; mod keyboard_parser; +mod nested_reannounce; pub mod old_config_converter; #[cfg(feature = "web_server_capability")] pub mod remote_attach; @@ -157,6 +158,7 @@ use zellij_utils::{ errors::{ClientContext, ContextType, ErrorInstruction}, input::{cli_assets::CliAssets, config::Config, options::Options}, ipc::{ClientToServerMsg, ExitReason, ResizeCause, ServerToClientMsg}, + nested_session, pane_size::Size, vendored::termwiz::input::InputEvent, }; @@ -186,6 +188,7 @@ pub(crate) enum ClientInstruction { token: u32, query_bytes: Vec<u8>, }, + EmitNestedSessionFrame(Vec<u8>), } impl From<ServerToClientMsg> for ClientInstruction { @@ -211,6 +214,9 @@ impl From<ServerToClientMsg> for ClientInstruction { ServerToClientMsg::ForwardQueryToHost { token, query_bytes } => { ClientInstruction::ForwardQueryToHost { token, query_bytes } }, + ServerToClientMsg::EmitNestedSessionFrame { payload_bytes } => { + ClientInstruction::EmitNestedSessionFrame(payload_bytes) + }, // Subscribe-only messages — not handled by regular interactive clients ServerToClientMsg::PaneRenderUpdate { .. } => ClientInstruction::UnblockInputThread, ServerToClientMsg::SubscribedPaneClosed { .. } => ClientInstruction::UnblockInputThread, @@ -238,6 +244,7 @@ impl From<&ClientInstruction> for ClientContext { ClientInstruction::RenamedSession(..) => ClientContext::RenamedSession, ClientInstruction::ConfigFileUpdated => ClientContext::ConfigFileUpdated, ClientInstruction::ForwardQueryToHost { .. } => ClientContext::ForwardQueryToHost, + ClientInstruction::EmitNestedSessionFrame(..) => ClientContext::EmitNestedSessionFrame, } } } @@ -523,6 +530,7 @@ pub(crate) enum InputInstruction { token: u32, reply_bytes: Vec<u8>, }, + NestedSessionFrameFromHost(Vec<u8>), Exit, } @@ -530,6 +538,7 @@ pub(crate) enum InputInstruction { pub async fn run_remote_client_terminal_loop( os_input: Box<dyn ClientOsApi>, mut connections: remote_attach::WebSocketConnections, + nested_session_name: Option<String>, ) -> Result<Option<ConnectToSession>, RemoteClientError> { use crate::os_input_output::{AsyncSignals, AsyncStdin}; @@ -563,15 +572,52 @@ pub async fn run_remote_client_terminal_loop( log::error!("Failed to send resize message: {}", e); } + let mut nested_frame_extractor = nested_session::NestedFrameExtractor::new(); + let mut last_heard_from_host = std::time::Instant::now(); + let mut reannounce_check = tokio::time::interval(std::time::Duration::from_millis( + nested_session::reannounce_check_interval_ms(), + )); + reannounce_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { tokio::select! { // Handle stdin input result = async_stdin.read() => { match result { Ok(buf) if !buf.is_empty() => { - if let Err(e) = connections.terminal_ws.send(Message::Binary(buf)).await { - log::error!("Failed to send stdin to terminal WebSocket: {}", e); - break; + let (cleaned, nested_frames) = nested_frame_extractor.extract(&buf); + for payload_bytes in nested_frames { + last_heard_from_host = std::time::Instant::now(); + match nested_session::decode_payload(&payload_bytes) { + Some(nested_session::NestedSessionMessage::Ping) => { + let mut stdout = os_input.get_stdout_writer(); + let _ = stdout.write_all(&nested_session::encode_frame( + &nested_session::NestedSessionMessage::Pong, + )); + let _ = stdout.flush(); + }, + Some(_) => { + let control_msg = Message::Text( + serde_json::to_string(&WebClientToWebServerControlMessage { + web_client_id: connections.web_client_id.clone(), + payload: WebClientToWebServerControlMessagePayload::NestedSessionFrameFromHost { + payload_bytes, + }, + }) + .unwrap(), + ); + if let Err(e) = connections.control_ws.send(control_msg).await { + log::error!("Failed to forward nested session frame over control WebSocket: {}", e); + } + }, + None => {}, + } + } + if !cleaned.is_empty() { + if let Err(e) = connections.terminal_ws.send(Message::Binary(cleaned)).await { + log::error!("Failed to send stdin to terminal WebSocket: {}", e); + break; + } } } Ok(_) => { @@ -585,6 +631,26 @@ pub async fn run_remote_client_terminal_loop( } } + _ = reannounce_check.tick() => { + if let Some(session_name) = &nested_session_name { + if last_heard_from_host.elapsed() + >= std::time::Duration::from_millis(nested_session::reannounce_silence_ms()) + { + let announce = nested_session::NestedSessionMessage::Announce { + session_name: session_name.clone(), + capabilities: vec![nested_session::NestedSessionCapability::NestedControl], + }; + let mut stdout = os_input.get_stdout_writer(); + if stdout + .write_all(&nested_session::encode_frame(&announce)) + .is_ok() + { + let _ = stdout.flush(); + } + } + } + } + // Handle signals Some(signal) = async_signals.recv() => { match signal { @@ -721,6 +787,14 @@ pub fn start_remote_client( ) -> Result<Option<ConnectToSession>, RemoteClientError> { info!("Starting Zellij client!"); + let is_nested_inside_zellij_pane = os_input.env_variable("ZELLIJ").is_some(); + let remote_session_name = remote_session_url + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or_default() + .to_owned(); + let runtime = crate::async_runtime(async_worker_tasks); let connections = remote_attach::attach_to_remote_session( @@ -756,6 +830,16 @@ pub fn start_remote_client( os_input.set_raw_mode(); stdout.write_all(ENABLE_BRACKETED_PASTE.as_bytes()).unwrap(); + if is_nested_inside_zellij_pane { + let announce = nested_session::NestedSessionMessage::Announce { + session_name: remote_session_name.clone(), + capabilities: vec![nested_session::NestedSessionCapability::NestedControl], + }; + stdout + .write_all(&nested_session::encode_frame(&announce)) + .unwrap(); + let _ = stdout.flush(); + } std::panic::set_hook({ use zellij_utils::errors::handle_panic; @@ -785,11 +869,25 @@ pub fn start_remote_client( std::process::exit(exit_status); }; + let nested_session_name = if is_nested_inside_zellij_pane { + Some(remote_session_name.clone()) + } else { + None + }; runtime.block_on(run_remote_client_terminal_loop( os_input.clone(), connections, + nested_session_name, ))?; + if is_nested_inside_zellij_pane { + let mut stdout = os_input.get_stdout_writer(); + let _ = stdout.write_all(&nested_session::encode_frame( + &nested_session::NestedSessionMessage::Bye, + )); + let _ = stdout.flush(); + } + let exit_msg = String::from("Bye from Zellij!"); if reconnect_to_session.is_none() { @@ -822,6 +920,9 @@ pub fn start_client( } info!("Starting Zellij client!"); + let is_nested_inside_zellij_pane = os_input.env_variable("ZELLIJ").is_some(); + let own_session_name = info.get_session_name().to_owned(); + let explicitly_disable_kitty_keyboard_protocol = config_options .support_kitty_keyboard_protocol .map(|e| !e) @@ -1043,6 +1144,22 @@ pub fn start_client( os_input.set_raw_mode(); let mut stdout = os_input.get_stdout_writer(); stdout.write_all(ENABLE_BRACKETED_PASTE.as_bytes()).unwrap(); + let nested_reannounce = if is_nested_inside_zellij_pane { + let announce = nested_session::NestedSessionMessage::Announce { + session_name: own_session_name.clone(), + capabilities: vec![nested_session::NestedSessionCapability::NestedControl], + }; + stdout + .write_all(&nested_session::encode_frame(&announce)) + .unwrap(); + let _ = stdout.flush(); + Some(crate::nested_reannounce::NestedReannounce::spawn( + os_input.clone(), + own_session_name.clone(), + )) + } else { + None + }; let (send_client_instructions, receive_client_instructions): ChannelWithContext< ClientInstruction, @@ -1116,6 +1233,7 @@ pub fn start_client( let command_is_executing = command_is_executing.clone(); let os_input = os_input.clone(); let default_mode = config_options.default_mode.unwrap_or_default(); + let nested_reannounce = nested_reannounce.clone(); move || { input_loop( os_input, @@ -1125,6 +1243,7 @@ pub fn start_client( send_client_instructions, default_mode, receive_input_instructions, + nested_reannounce, ) } }); @@ -1142,6 +1261,10 @@ pub fn start_client( new_size: os_api.get_terminal_size(), cause: ResizeCause::Viewport, }); + #[cfg(not(windows))] + let _ = os_api + .get_stdout_writer() + .write(crate::stdin_handler::PIXEL_SIZE_QUERY.as_bytes()); } }), Box::new({ @@ -1340,12 +1463,32 @@ pub fn start_client( let _ = out.write_all(&blob); let _ = out.flush(); }, + ClientInstruction::EmitNestedSessionFrame(payload_bytes) => { + if is_nested_inside_zellij_pane { + let frame = nested_session::encode_frame_from_payload(&payload_bytes); + let mut out = os_input.get_stdout_writer(); + let _ = out.write_all(&frame); + let _ = out.flush(); + } + }, _ => {}, } } + if let Some(nested_reannounce) = &nested_reannounce { + nested_reannounce.stop(); + } + router_thread.join().unwrap(); + if is_nested_inside_zellij_pane { + let mut stdout = os_input.get_stdout_writer(); + let _ = stdout.write_all(&nested_session::encode_frame( + &nested_session::NestedSessionMessage::Bye, + )); + let _ = stdout.flush(); + } + if reconnect_to_session.is_none() { let goodbye_message = terminal_teardown_message( &exit_msg, diff --git a/zellij-client/src/nested_reannounce.rs b/zellij-client/src/nested_reannounce.rs new file mode 100644 index 00000000..a151f72f --- /dev/null +++ b/zellij-client/src/nested_reannounce.rs @@ -0,0 +1,63 @@ +use crate::os_input_output::ClientOsApi; +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use zellij_utils::nested_session::{ + self, reannounce_check_interval_ms, reannounce_silence_ms, NestedSessionCapability, + NestedSessionMessage, +}; + +#[derive(Clone)] +pub struct NestedReannounce { + last_heard_from_host: Arc<Mutex<Instant>>, + stop: Arc<AtomicBool>, +} + +impl NestedReannounce { + pub fn spawn(os_input: Box<dyn ClientOsApi>, session_name: String) -> Self { + let handle = NestedReannounce { + last_heard_from_host: Arc::new(Mutex::new(Instant::now())), + stop: Arc::new(AtomicBool::new(false)), + }; + let last_heard_from_host = handle.last_heard_from_host.clone(); + let stop = handle.stop.clone(); + let _ = std::thread::Builder::new() + .name("nested_reannounce".to_string()) + .spawn(move || { + let check_interval = Duration::from_millis(reannounce_check_interval_ms()); + loop { + std::thread::sleep(check_interval); + if stop.load(Ordering::Relaxed) { + break; + } + let silent_for = { + let last = last_heard_from_host.lock().unwrap(); + last.elapsed() + }; + if silent_for >= Duration::from_millis(reannounce_silence_ms()) { + let announce = NestedSessionMessage::Announce { + session_name: session_name.clone(), + capabilities: vec![NestedSessionCapability::NestedControl], + }; + let mut stdout = os_input.get_stdout_writer(); + if stdout + .write_all(&nested_session::encode_frame(&announce)) + .is_ok() + { + let _ = stdout.flush(); + } + } + } + }); + handle + } + + pub fn note_host_contact(&self) { + *self.last_heard_from_host.lock().unwrap() = Instant::now(); + } + + pub fn stop(&self) { + self.stop.store(true, Ordering::Relaxed); + } +} diff --git a/zellij-client/src/stdin_ansi_parser.rs b/zellij-client/src/stdin_ansi_parser.rs index d0fb4c32..c70f9e93 100644 --- a/zellij-client/src/stdin_ansi_parser.rs +++ b/zellij-client/src/stdin_ansi_parser.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use zellij_utils::{ data::HostTerminalThemeMode, ipc::PixelDimensions, + nested_session, pane_size::SizeInPixels, vendored::termwiz::input::{InputEvent, InputParser}, }; @@ -187,6 +188,7 @@ pub struct ParseOutput { /// Residue bytes that were not classified as host replies. These are /// the bytes the caller should feed to the keyboard parser. pub residue: Vec<u8>, + pub nested_frames: Vec<Vec<u8>>, /// `true` when the parser still holds buffered partial OSC/CSI /// bytes that need an idle-flush to release. The caller uses this /// to schedule a finalize tick even when the current chunk @@ -264,6 +266,7 @@ pub struct StdinAnsiParser { /// Same for CSI device-control reports. partial_csi: Vec<u8>, partial_paste: Vec<u8>, + nested_frame_extractor: nested_session::NestedFrameExtractor, in_bracketed_paste: bool, } @@ -285,6 +288,7 @@ impl StdinAnsiParser { partial_osc: Vec::new(), partial_csi: Vec::new(), partial_paste: Vec::new(), + nested_frame_extractor: nested_session::NestedFrameExtractor::new(), in_bracketed_paste: false, } } @@ -349,6 +353,9 @@ impl StdinAnsiParser { /// are the bytes the caller should feed to the keyboard parser. pub fn feed(&mut self, bytes: &[u8]) -> ParseOutput { let mut out = ParseOutput::default(); + let (bytes, nested_frames) = self.nested_frame_extractor.extract(bytes); + let bytes = &bytes[..]; + out.nested_frames = nested_frames; // Collect events first (borrow-splits the InputParser across the // callback and the post-processing mutations). let mut events = Vec::new(); @@ -431,13 +438,21 @@ impl StdinAnsiParser { out.residue = residue; out.has_partial_state = !self.partial_osc.is_empty() || !self.partial_csi.is_empty() - || !self.partial_paste.is_empty(); + || !self.partial_paste.is_empty() + || !self.nested_frame_extractor.partial_bytes().is_empty(); out } pub fn pending_partial(&self) -> PendingPartial { if !self.partial_paste.is_empty() { PendingPartial::ReplyInProgress + } else if self.partial_csi.is_empty() + && self.partial_osc.is_empty() + && self.nested_frame_extractor.partial_bytes() == [0x1b] + { + PendingPartial::LoneEsc + } else if !self.nested_frame_extractor.partial_bytes().is_empty() { + PendingPartial::ReplyInProgress } else if self.partial_csi.is_empty() && self.partial_osc.is_empty() { PendingPartial::None } else if self.partial_csi.is_empty() && self.partial_osc == [0x1b] { @@ -450,6 +465,13 @@ impl StdinAnsiParser { pub fn finalize_lone_esc(&mut self) -> Vec<u8> { if self.partial_csi.is_empty() && self.partial_paste.is_empty() + && self.partial_osc.is_empty() + && self.nested_frame_extractor.partial_bytes() == [0x1b] + { + self.nested_frame_extractor.take_partial() + } else if self.partial_csi.is_empty() + && self.partial_paste.is_empty() + && self.nested_frame_extractor.partial_bytes().is_empty() && self.partial_osc == [0x1b] { std::mem::take(&mut self.partial_osc) @@ -460,12 +482,17 @@ impl StdinAnsiParser { pub fn finalize_force(&mut self) -> Vec<u8> { self.in_bracketed_paste = false; + let mut partial_nested_frame = self.nested_frame_extractor.take_partial(); let mut out = Vec::with_capacity( - self.partial_osc.len() + self.partial_csi.len() + self.partial_paste.len(), + self.partial_osc.len() + + self.partial_csi.len() + + self.partial_paste.len() + + partial_nested_frame.len(), ); out.append(&mut self.partial_osc); out.append(&mut self.partial_csi); out.append(&mut self.partial_paste); + out.append(&mut partial_nested_frame); out } diff --git a/zellij-client/src/stdin_ansi_parser_tests.rs b/zellij-client/src/stdin_ansi_parser_tests.rs index 19fd5698..77d796eb 100644 --- a/zellij-client/src/stdin_ansi_parser_tests.rs +++ b/zellij-client/src/stdin_ansi_parser_tests.rs @@ -1225,3 +1225,107 @@ fn unsolicited_theme_notification_classifies_without_outstanding_query() { other => panic!("expected HostTerminalThemeChanged, got {:?}", other), } } + +fn announce_frame_and_payload() -> (Vec<u8>, Vec<u8>) { + use zellij_utils::nested_session::{ + encode_frame, encode_payload, NestedSessionCapability, NestedSessionMessage, + }; + let message = NestedSessionMessage::Announce { + session_name: "guest-session".to_owned(), + capabilities: vec![NestedSessionCapability::NestedControl], + }; + (encode_frame(&message), encode_payload(&message)) +} + +#[test] +fn nested_frame_in_one_chunk_is_extracted_with_no_residue() { + let (frame, payload) = announce_frame_and_payload(); + let mut p = StdinAnsiParser::new(); + let out = p.feed(&frame); + assert_eq!(out.nested_frames, vec![payload]); + assert!(out.residue.is_empty(), "residue: {:?}", out.residue); + assert_eq!(p.pending_partial(), PendingPartial::None); +} + +#[test] +fn nested_frame_split_at_every_boundary_is_extracted() { + let (frame, payload) = announce_frame_and_payload(); + for split_at in 1..frame.len() { + let mut p = StdinAnsiParser::new(); + let first = p.feed(&frame[..split_at]); + let second = p.feed(&frame[split_at..]); + let mut extracted = first.nested_frames.clone(); + extracted.extend(second.nested_frames.clone()); + assert_eq!(extracted, vec![payload.clone()], "split at {}", split_at); + assert!( + first.residue.is_empty() && second.residue.is_empty(), + "split at {}: residue {:?} / {:?}", + split_at, + first.residue, + second.residue + ); + } +} + +#[test] +fn keystrokes_around_nested_frame_survive_as_residue() { + let (frame, payload) = announce_frame_and_payload(); + let mut chunk = b"abc".to_vec(); + chunk.extend_from_slice(&frame); + chunk.extend_from_slice(b"def"); + let mut p = StdinAnsiParser::new(); + let out = p.feed(&chunk); + assert_eq!(out.nested_frames, vec![payload]); + assert_eq!(out.residue, b"abcdef".to_vec()); +} + +#[test] +fn nested_frame_with_garbage_base64_is_stripped_without_payload() { + let mut p = StdinAnsiParser::new(); + let out = p.feed(b"\x1bP26661n!!!not-base64!!!\x1b\\"); + assert!(out.nested_frames.is_empty()); + assert!(out.residue.is_empty(), "residue: {:?}", out.residue); +} + +#[test] +fn foreign_dcs_passes_through_untouched() { + let foreign_dcs = b"\x1bP1$qm\x1b\\q".to_vec(); + let mut p = StdinAnsiParser::new(); + let out = p.feed(&foreign_dcs); + assert!(out.nested_frames.is_empty()); + assert_eq!(out.residue, foreign_dcs); +} + +#[test] +fn lone_esc_claimed_by_frame_prepass_reroutes_on_next_chunk() { + let mut p = StdinAnsiParser::new(); + let first = p.feed(b"\x1b"); + assert!(first.residue.is_empty()); + assert!(first.has_partial_state); + assert_eq!(p.pending_partial(), PendingPartial::LoneEsc); + let second = p.feed(b"abc"); + assert_eq!(second.residue, b"\x1babc".to_vec()); + assert_eq!(p.pending_partial(), PendingPartial::None); +} + +#[test] +fn unterminated_frame_header_prefix_is_released_by_force_drain() { + let mut p = StdinAnsiParser::new(); + let out = p.feed(b"\x1bP26"); + assert!(out.residue.is_empty()); + assert!(out.has_partial_state); + assert_eq!(p.pending_partial(), PendingPartial::ReplyInProgress); + assert_eq!(p.finalize_force(), b"\x1bP26".to_vec()); + assert_eq!(p.pending_partial(), PendingPartial::None); +} + +#[test] +fn two_nested_frames_in_one_chunk_are_both_extracted() { + let (frame, payload) = announce_frame_and_payload(); + let mut chunk = frame.clone(); + chunk.extend_from_slice(&frame); + let mut p = StdinAnsiParser::new(); + let out = p.feed(&chunk); + assert_eq!(out.nested_frames, vec![payload.clone(), payload]); + assert!(out.residue.is_empty()); +} diff --git a/zellij-client/src/stdin_handler.rs b/zellij-client/src/stdin_handler.rs index 29b7de7e..681b49b5 100644 --- a/zellij-client/src/stdin_handler.rs +++ b/zellij-client/src/stdin_handler.rs @@ -97,7 +97,7 @@ pub(crate) fn stdin_loop( }); let mut needs_finalization = false; let mut reply_in_progress_since: Option<Instant> = None; - loop { + 'stdin: loop { match if needs_finalization { stdin_rx.recv_timeout(LONE_ESC_FLUSH_INTERVAL) } else { @@ -132,6 +132,10 @@ pub(crate) fn stdin_loop( let _ = send_input_instructions .send(InputInstruction::DesktopNotificationResponse(payload)); } + for payload_bytes in parse_output.nested_frames { + let _ = send_input_instructions + .send(InputInstruction::NestedSessionFrameFromHost(payload_bytes)); + } let residue = parse_output.residue; if residue.is_empty() { schedule_finalization( @@ -153,13 +157,16 @@ pub(crate) fn stdin_loop( // continuation completes the sequence. match kitty_parser.feed(&residue) { KittyParseOutcome::Complete(key_with_modifier) => { - send_input_instructions + if send_input_instructions .send(InputInstruction::KeyWithModifierEvent( key_with_modifier, current_buffer.drain(..).collect(), true, )) - .unwrap(); + .is_err() + { + break 'stdin; + } schedule_finalization( &stdin_ansi_parser, false, @@ -196,9 +203,12 @@ pub(crate) fn stdin_loop( for (input_event, consumed) in events.into_iter() { let take = consumed.min(current_buffer.len()); let raw_bytes: Vec<u8> = current_buffer.drain(..take).collect(); - send_input_instructions + if send_input_instructions .send(InputInstruction::KeyEvent(input_event, raw_bytes)) - .unwrap(); + .is_err() + { + break 'stdin; + } } schedule_finalization( @@ -317,12 +327,22 @@ fn build_startup_query_string() -> String { // <ESC>]11;?<ESC>\ => get background color // <ESC>]10;?<ESC>\ => get foreground color // <ESC>[?2026$p => get synchronised output mode - String::from("\u{1b}[14t\u{1b}[16t\u{1b}]11;?\u{1b}\u{5c}\u{1b}]10;?\u{1b}\u{5c}\u{1b}[?2026$p") + format!( + "{}\u{1b}]11;?\u{1b}\u{5c}\u{1b}]10;?\u{1b}\u{5c}\u{1b}[?2026$p", + PIXEL_SIZE_QUERY + ) } +pub(crate) const PIXEL_SIZE_QUERY: &str = "\u{1b}[14t\u{1b}[16t"; + #[cfg(test)] mod tests { - use super::build_startup_query_string; + use super::{build_startup_query_string, PIXEL_SIZE_QUERY}; + + #[test] + fn pixel_size_query_probes_text_area_and_character_cell() { + assert_eq!(PIXEL_SIZE_QUERY, "\u{1b}[14t\u{1b}[16t"); + } #[test] fn startup_query_has_no_palette_register_loop() { diff --git a/zellij-client/src/unit/terminal_loop_tests.rs b/zellij-client/src/unit/terminal_loop_tests.rs index f6d3fade..9bb864c8 100644 --- a/zellij-client/src/unit/terminal_loop_tests.rs +++ b/zellij-client/src/unit/terminal_loop_tests.rs @@ -351,7 +351,9 @@ async fn test_stdin_forwarded_to_terminal_websocket() { let os_input = Box::new(TestClientOsApi::new(stdin_rx, signal_rx)); let loop_handle = - tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await }); + tokio::spawn( + async move { run_remote_client_terminal_loop(os_input, connections, None).await }, + ); let test_data = b"hello from stdin\n".to_vec(); stdin_tx.send(test_data.clone()).unwrap(); @@ -420,7 +422,9 @@ async fn test_terminal_output_written_to_stdout() { let os_input = Box::new(os_input); let loop_handle = - tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await }); + tokio::spawn( + async move { run_remote_client_terminal_loop(os_input, connections, None).await }, + ); let test_output = "Hello from terminal"; server @@ -490,7 +494,9 @@ async fn test_resize_signal_sends_control_message() { let os_input = Box::new(TestClientOsApi::new(stdin_rx, signal_rx)); let loop_handle = - tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await }); + tokio::spawn( + async move { run_remote_client_terminal_loop(os_input, connections, None).await }, + ); tokio::time::sleep(Duration::from_millis(200)).await; @@ -576,7 +582,9 @@ async fn test_quit_signal_exits_loop() { let os_input = Box::new(TestClientOsApi::new(stdin_rx, signal_rx)); let loop_handle = - tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await }); + tokio::spawn( + async move { run_remote_client_terminal_loop(os_input, connections, None).await }, + ); signal_tx.send(SignalEvent::Quit).unwrap(); @@ -629,7 +637,9 @@ async fn test_websocket_close_exits_loop() { let os_input = Box::new(TestClientOsApi::new(stdin_rx, signal_rx)); let loop_handle = - tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await }); + tokio::spawn( + async move { run_remote_client_terminal_loop(os_input, connections, None).await }, + ); server .terminal_to_client_tx @@ -687,7 +697,9 @@ async fn test_control_message_handling() { let os_input = Box::new(os_input); let loop_handle = - tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await }); + tokio::spawn( + async move { run_remote_client_terminal_loop(os_input, connections, None).await }, + ); tokio::time::sleep(Duration::from_millis(200)).await; @@ -746,3 +758,148 @@ async fn test_control_message_handling() { .expect("Loop didn't exit") .unwrap(); } + +async fn connect_mock_ws(port: u16) -> WebSocketConnections { + let terminal_url = format!("ws://127.0.0.1:{}/ws/terminal", port); + let control_url = format!("ws://127.0.0.1:{}/ws/control", port); + let terminal_tcp = TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let (terminal_ws, _) = tokio_tungstenite::client_async_with_config( + &terminal_url, + MaybeTls::Plain(terminal_tcp), + None, + ) + .await + .unwrap(); + let control_tcp = TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let (control_ws, _) = tokio_tungstenite::client_async_with_config( + &control_url, + MaybeTls::Plain(control_tcp), + None, + ) + .await + .unwrap(); + WebSocketConnections { + terminal_ws, + control_ws, + web_client_id: "test-nested".to_string(), + } +} + +#[tokio::test] +#[serial] +async fn test_nested_ping_frame_is_answered_locally_and_stripped_from_forwarded_stdin() { + let (port, server, _server_handle) = mock_ws_server::MockWsServer::start().await; + let connections = connect_mock_ws(port).await; + + let (stdin_tx, stdin_rx) = mpsc::unbounded_channel(); + let (_signal_tx, signal_rx) = mpsc::unbounded_channel(); + let os_input = TestClientOsApi::new(stdin_rx, signal_rx); + let stdout_buffer = os_input.stdout_buffer.clone(); + let os_input = Box::new(os_input); + + let loop_handle = + tokio::spawn( + async move { run_remote_client_terminal_loop(os_input, connections, None).await }, + ); + + let mut chunk = b"abc".to_vec(); + chunk.extend_from_slice(&zellij_utils::nested_session::encode_frame( + &zellij_utils::nested_session::NestedSessionMessage::Ping, + )); + chunk.extend_from_slice(b"def"); + stdin_tx.send(chunk).unwrap(); + + tokio::time::sleep(Duration::from_millis(200)).await; + let received = tokio::time::timeout( + Duration::from_secs(1), + server.client_to_terminal_rx.lock().unwrap().recv(), + ) + .await + .expect("Timeout") + .expect("No message"); + match received { + Message::Binary(data) => assert_eq!(data, b"abcdef".to_vec()), + _ => panic!("Expected Binary message, got: {:?}", received), + } + + let pong_frame = zellij_utils::nested_session::encode_frame( + &zellij_utils::nested_session::NestedSessionMessage::Pong, + ); + let stdout_bytes = stdout_buffer.lock().unwrap().clone(); + assert!( + stdout_bytes + .windows(pong_frame.len()) + .any(|window| window == &pong_frame[..]), + "expected a pong frame on stdout, got: {:?}", + stdout_bytes + ); + + drop(stdin_tx); + let _ = tokio::time::timeout(Duration::from_secs(2), loop_handle) + .await + .expect("Loop didn't exit") + .unwrap(); +} + +#[tokio::test] +#[serial] +async fn test_nested_announce_ack_is_relayed_over_control_websocket() { + let (port, server, _server_handle) = mock_ws_server::MockWsServer::start().await; + let connections = connect_mock_ws(port).await; + + let (stdin_tx, stdin_rx) = mpsc::unbounded_channel(); + let (_signal_tx, signal_rx) = mpsc::unbounded_channel(); + let os_input = Box::new(TestClientOsApi::new(stdin_rx, signal_rx)); + + let loop_handle = + tokio::spawn( + async move { run_remote_client_terminal_loop(os_input, connections, None).await }, + ); + + let announce_ack = zellij_utils::nested_session::NestedSessionMessage::AnnounceAck { + ancestry: vec!["outer-host".to_owned()], + capabilities: vec![zellij_utils::nested_session::NestedSessionCapability::NestedControl], + descend_keys: vec![], + }; + stdin_tx + .send(zellij_utils::nested_session::encode_frame(&announce_ack)) + .unwrap(); + + tokio::time::sleep(Duration::from_millis(200)).await; + let mut relayed_payload = None; + loop { + let received = tokio::time::timeout( + Duration::from_secs(1), + server.client_to_control_rx.lock().unwrap().recv(), + ) + .await + .expect("Timeout") + .expect("No message"); + if let Message::Text(text) = received { + let control_msg: WebClientToWebServerControlMessage = + serde_json::from_str(&text).unwrap(); + if let WebClientToWebServerControlMessagePayload::NestedSessionFrameFromHost { + payload_bytes, + } = control_msg.payload + { + assert_eq!(control_msg.web_client_id, "test-nested"); + relayed_payload = Some(payload_bytes); + break; + } + } + } + assert_eq!( + zellij_utils::nested_session::decode_payload(&relayed_payload.unwrap()), + Some(announce_ack) + ); + + drop(stdin_tx); + let _ = tokio::time::timeout(Duration::from_secs(2), loop_handle) + .await + .expect("Loop didn't exit") + .unwrap(); +} diff --git a/zellij-client/src/web_client/control_message.rs b/zellij-client/src/web_client/control_message.rs index 8ab333f9..7771d3e9 100644 --- a/zellij-client/src/web_client/control_message.rs +++ b/zellij-client/src/web_client/control_message.rs @@ -16,6 +16,7 @@ pub enum WebClientToWebServerControlMessagePayload { TerminalSizeSettled(Size), TerminalMetrics(TerminalMetricsPayload), SoftKeyboardVisibilityChanged { visible: bool }, + NestedSessionFrameFromHost { payload_bytes: Vec<u8> }, } #[derive(Serialize, Deserialize, Debug, Clone)] diff --git a/zellij-client/src/web_client/server_listener.rs b/zellij-client/src/web_client/server_listener.rs index 9daa8fa8..c66a1286 100644 --- a/zellij-client/src/web_client/server_listener.rs +++ b/zellij-client/src/web_client/server_listener.rs @@ -255,6 +255,7 @@ pub fn zellij_server_listener( // Subscribe-only messages — not relevant for web clients Some(ServerToClientMsg::PaneRenderUpdate { .. }) => {}, Some(ServerToClientMsg::SubscribedPaneClosed { .. }) => {}, + Some(ServerToClientMsg::EmitNestedSessionFrame { .. }) => {}, Some(ServerToClientMsg::ForwardQueryToHost { token, .. }) => { // Reply immediately with empty reply_bytes. // This is the existing convention that signals diff --git a/zellij-client/src/web_client/websocket_handlers.rs b/zellij-client/src/web_client/websocket_handlers.rs index 11c81a9e..cbf6eefd 100644 --- a/zellij-client/src/web_client/websocket_handlers.rs +++ b/zellij-client/src/web_client/websocket_handlers.rs @@ -99,6 +99,9 @@ async fn handle_ws_control( WebClientToWebServerControlMessagePayload::SoftKeyboardVisibilityChanged { visible, } => ClientToServerMsg::SoftKeyboardVisibilityChanged { visible }, + WebClientToWebServerControlMessagePayload::NestedSessionFrameFromHost { + payload_bytes, + } => ClientToServerMsg::NestedSessionFrameFromHost { payload_bytes }, }; let _ = client_connection.send_to_server(client_msg); diff --git a/zellij-integration-tests/src/client_screen.rs b/zellij-integration-tests/src/client_screen.rs index 479c9b56..7bdbc9be 100644 --- a/zellij-integration-tests/src/client_screen.rs +++ b/zellij-integration-tests/src/client_screen.rs @@ -4,6 +4,7 @@ use std::rc::Rc; use std::sync::{Arc, Condvar, Mutex}; use std::time::Instant; +use zellij_server::panes::terminal_character::AnsiCode; use zellij_server::panes::{LinkHandler, TerminalPane}; use zellij_utils::data::{Palette, Style}; use zellij_utils::pane_size::{Dimension, PaneGeom, Size, SizeInPixels}; @@ -18,6 +19,7 @@ struct ReceivedBytes { struct ReceivedBytesWithChangeSignal { received_bytes: Mutex<ReceivedBytes>, change_signal: Condvar, + stdout_tap: Mutex<Option<crossbeam::channel::Sender<Vec<u8>>>>, } #[derive(Clone)] @@ -40,12 +42,47 @@ impl ClientScreen { }) } + pub fn set_stdout_tap(&self, sender: crossbeam::channel::Sender<Vec<u8>>) { + *self.inner.stdout_tap.lock().unwrap() = Some(sender); + } + pub fn snapshot(&self) -> GridSnapshot { let size = *self.size.lock().unwrap(); let bytes = self.inner.received_bytes.lock().unwrap().bytes.clone(); render_bytes(&bytes, size) } + pub fn wait_until_raw_output(&self, what: &str, predicate: impl Fn(&[u8]) -> bool) -> Vec<u8> { + let deadline = Instant::now() + crate::default_timeout(); + let mut received_bytes = self.inner.received_bytes.lock().unwrap(); + loop { + if predicate(&received_bytes.bytes) { + return received_bytes.bytes.clone(); + } + let now = Instant::now(); + if now >= deadline { + panic!( + "timed out waiting for: {}\n=== (received {} stdout bytes, generation {}) ===\n=== zellij log tail ({}) ===\n{}", + what, + received_bytes.bytes.len(), + received_bytes.generation, + crate::test_env::log_file_path().display(), + crate::test_env::log_tail(40), + ); + } + let last_generation = received_bytes.generation; + let (guard, _) = self + .inner + .change_signal + .wait_timeout(received_bytes, deadline - now) + .unwrap(); + received_bytes = guard; + if received_bytes.generation == last_generation { + continue; + } + } + } + pub fn wait_until( &self, what: &str, @@ -95,6 +132,9 @@ impl std::io::Write for ClientScreenWriter { let mut received_bytes = self.inner.received_bytes.lock().unwrap(); received_bytes.bytes.extend_from_slice(buf); received_bytes.generation += 1; + if let Some(stdout_tap) = self.inner.stdout_tap.lock().unwrap().as_ref() { + let _ = stdout_tap.send(buf.to_vec()); + } self.inner.change_signal.notify_all(); Ok(buf.len()) } @@ -129,16 +169,86 @@ impl CoordBuilder { } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CellStyle { + pub dim: bool, + pub italic: bool, + pub bold: bool, +} + #[derive(Clone, Debug)] pub struct GridSnapshot { pub text: String, pub cursor: Option<CursorPosition>, + pub styles: Vec<Vec<CellStyle>>, } impl GridSnapshot { pub fn contains(&self, needle: &str) -> bool { self.text.contains(needle) } + pub fn cell_style(&self, x: usize, y: usize) -> CellStyle { + self.styles + .get(y) + .and_then(|row| row.get(x)) + .copied() + .unwrap_or_default() + } + pub fn char_is_dim(&self, x: usize, y: usize) -> bool { + self.cell_style(x, y).dim + } + pub fn char_is_italic(&self, x: usize, y: usize) -> bool { + self.cell_style(x, y).italic + } + pub fn char_is_bold(&self, x: usize, y: usize) -> bool { + self.cell_style(x, y).bold + } + pub fn row_has_italic(&self, y: usize) -> bool { + self.styles + .get(y) + .map_or(false, |row| row.iter().any(|cell| cell.italic)) + } + pub fn row_has_bold(&self, y: usize) -> bool { + self.styles + .get(y) + .map_or(false, |row| row.iter().any(|cell| cell.bold)) + } + pub fn row_count(&self) -> usize { + self.styles.len() + } + pub fn row_of_line(&self, needle: &str) -> Option<usize> { + self.text.lines().position(|line| line.contains(needle)) + } + pub fn region_has_dim( + &self, + x_range: std::ops::Range<usize>, + y_range: std::ops::Range<usize>, + ) -> bool { + for y in y_range { + for x in x_range.clone() { + if self.char_is_dim(x, y) { + return true; + } + } + } + false + } + pub fn line_has_dim(&self, needle: &str) -> bool { + match self.row_of_line(needle) { + Some(y) => self + .styles + .get(y) + .map_or(false, |row| row.iter().any(|cell| cell.dim)), + None => false, + } + } + pub fn char_dim_of(&self, needle: &str) -> Option<bool> { + self.text.lines().enumerate().find_map(|(y, line)| { + line.find(needle) + .map(|byte_index| line[..byte_index].chars().count()) + .map(|x| self.char_is_dim(x, y)) + }) + } pub fn cursor_is_at(&self, coord: Coord) -> bool { self.cursor == Some(CursorPosition { @@ -180,9 +290,17 @@ fn render_bytes(bytes: &[u8], win_size: Size) -> GridSnapshot { } }); let mut text = String::new(); + let mut styles: Vec<Vec<CellStyle>> = Vec::new(); let output_lines = terminal_pane.read_buffer_as_lines(); for (line_index, line) in output_lines.iter().enumerate() { + let mut style_row: Vec<CellStyle> = Vec::with_capacity(line.len()); for (character_index, terminal_character) in line.iter().enumerate() { + let character_style = &terminal_character.styles; + style_row.push(CellStyle { + dim: matches!(character_style.dim, Some(AnsiCode::On)), + italic: matches!(character_style.italic, Some(AnsiCode::On)), + bold: matches!(character_style.bold, Some(AnsiCode::On)), + }); let character_position = CursorPosition { x: character_index, y: line_index, @@ -193,11 +311,16 @@ fn render_bytes(bytes: &[u8], win_size: Size) -> GridSnapshot { } text.push(terminal_character.character); } + styles.push(style_row); if line_index != output_lines.len() - 1 { text.push('\n'); } } - GridSnapshot { text, cursor } + GridSnapshot { + text, + cursor, + styles, + } } fn build_terminal_pane(win_size: Size) -> TerminalPane { diff --git a/zellij-integration-tests/src/fake_client_os_api.rs b/zellij-integration-tests/src/fake_client_os_api.rs index d341758a..8dcd1793 100644 --- a/zellij-integration-tests/src/fake_client_os_api.rs +++ b/zellij-integration-tests/src/fake_client_os_api.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -23,6 +24,7 @@ pub struct FakeClientOsApi { receive_instructions_from_server: Arc<Mutex<Option<IpcReceiverWithContext<ServerToClientMsg>>>>, session_name: Arc<Mutex<Option<String>>>, server_spawner: Arc<Mutex<Option<ServerSpawner>>>, + env: Arc<Mutex<HashMap<String, String>>>, } impl Clone for FakeClientOsApi { @@ -36,6 +38,7 @@ impl Clone for FakeClientOsApi { receive_instructions_from_server: self.receive_instructions_from_server.clone(), session_name: self.session_name.clone(), server_spawner: self.server_spawner.clone(), + env: self.env.clone(), } } } @@ -58,6 +61,14 @@ impl FakeClientOsApi { initial_size: Size, server_spawner: Option<ServerSpawner>, ) -> (Self, FakeClientHandle) { + Self::new_with_env(initial_size, server_spawner, HashMap::new()) + } + + pub fn new_with_env( + initial_size: Size, + server_spawner: Option<ServerSpawner>, + env: HashMap<String, String>, + ) -> (Self, FakeClientHandle) { let size = Arc::new(Mutex::new(initial_size)); let client_screen = ClientScreen::new(size.clone()); let (stdin_tx, stdin_rx) = crossbeam::channel::unbounded(); @@ -71,6 +82,7 @@ impl FakeClientOsApi { receive_instructions_from_server: Arc::new(Mutex::new(None)), session_name: Arc::new(Mutex::new(None)), server_spawner: Arc::new(Mutex::new(server_spawner)), + env: Arc::new(Mutex::new(env)), }; let fake_client_handle = FakeClientHandle { client_screen, @@ -168,13 +180,19 @@ impl ClientOsApi for FakeClientOsApi { default_palette() } fn enable_mouse(&self) -> anyhow::Result<()> { + use std::io::Write; + const ENABLE_MOUSE_SUPPORT: &str = + "\u{1b}[?1000h\u{1b}[?1002h\u{1b}[?1003h\u{1b}[?1015h\u{1b}[?1006h"; + let mut writer = self.client_screen.writer(); + writer.write_all(ENABLE_MOUSE_SUPPORT.as_bytes())?; + writer.flush()?; Ok(()) } fn disable_mouse(&self) -> anyhow::Result<()> { Ok(()) } - fn env_variable(&self, _name: &str) -> Option<String> { - None + fn env_variable(&self, name: &str) -> Option<String> { + self.env.lock().unwrap().get(name).cloned() } fn should_install_panic_hook(&self) -> bool { false diff --git a/zellij-integration-tests/src/fake_pty.rs b/zellij-integration-tests/src/fake_pty.rs index 9191c077..90aa03e1 100644 --- a/zellij-integration-tests/src/fake_pty.rs +++ b/zellij-integration-tests/src/fake_pty.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Condvar, Mutex, MutexGuard}; -use std::time::Instant; +use std::time::{Duration, Instant}; use async_trait::async_trait; use zellij_server::os_input_output::AsyncReader; @@ -19,6 +19,7 @@ pub(crate) struct FakePtyState { pub quit_cb: Option<QuitCb>, pub exited: bool, pub echo: bool, + pub stdin_tap: Option<crossbeam::channel::Sender<Vec<u8>>>, } impl FakePtyState { @@ -116,6 +117,7 @@ impl SharedPtys { quit_cb, exited: false, echo: true, + stdin_tap: None, }, ); fake_pty_registry.spawn_queue.push_back(terminal_id); @@ -172,6 +174,18 @@ impl SharedPtys { }); } + pub(crate) fn set_stdin_tap( + &self, + terminal_id: u32, + sender: crossbeam::channel::Sender<Vec<u8>>, + ) { + self.mutate(|fake_pty_registry| { + if let Some(fake_pty_state) = fake_pty_registry.fake_pty_states.get_mut(&terminal_id) { + fake_pty_state.stdin_tap = Some(sender); + } + }); + } + pub(crate) fn write_output(&self, terminal_id: u32, bytes: &[u8]) { self.read(|fake_pty_registry| { if let Some(fake_pty_state) = fake_pty_registry.fake_pty_states.get(&terminal_id) { @@ -192,6 +206,9 @@ impl SharedPtys { let _ = output_tx.send(bytes.to_vec()); } } + if let Some(stdin_tap) = fake_pty_state.stdin_tap.as_ref() { + let _ = stdin_tap.send(bytes.to_vec()); + } true }, None => false, @@ -228,6 +245,32 @@ impl SharedPtys { fake_pty_registry = guard; } } + + pub(crate) fn wait_for_change( + &self, + terminal_id: u32, + last_seen: (u16, u16), + ) -> Option<(u16, u16)> { + let mut fake_pty_registry = self.lock_registry(); + loop { + match fake_pty_registry.fake_pty_states.get(&terminal_id) { + None => return None, + Some(fake_pty_state) => { + if let Some((cols, rows)) = fake_pty_state.size { + if (cols, rows) != last_seen { + return Some((cols, rows)); + } + } + }, + } + let (guard, _) = self + .inner + .change_signal + .wait_timeout(fake_pty_registry, Duration::from_millis(200)) + .unwrap(); + fake_pty_registry = guard; + } + } } struct FakeAsyncReader { @@ -302,6 +345,16 @@ impl FakePtyHandle { }) } + pub fn try_output(&self, bytes: &[u8]) { + self.shared_ptys.write_output(self.terminal_id, bytes); + } + + pub fn tap_stdin(&self) -> crossbeam::channel::Receiver<Vec<u8>> { + let (sender, receiver) = crossbeam::channel::unbounded(); + self.shared_ptys.set_stdin_tap(self.terminal_id, sender); + receiver + } + pub fn exit(&self, exit_status: Option<i32>) { self.shared_ptys .exit_terminal(self.terminal_id, exit_status); @@ -362,4 +415,9 @@ impl FakePtyHandle { .filter(|(cols, rows)| predicate(*cols, *rows)) }) } + + pub fn wait_for_size_change(&self, last_seen: (u16, u16)) -> Option<(u16, u16)> { + self.shared_ptys + .wait_for_change(self.terminal_id, last_seen) + } } diff --git a/zellij-integration-tests/src/lib.rs b/zellij-integration-tests/src/lib.rs index 18f652fb..4a278d0e 100644 --- a/zellij-integration-tests/src/lib.rs +++ b/zellij-integration-tests/src/lib.rs @@ -6,16 +6,24 @@ pub mod fake_pty; pub mod fake_server_os_api; pub mod harness; pub mod keys; +pub mod nested; pub mod runner; pub mod test_env; -pub use client_screen::{col, ClientScreen, Coord, CoordBuilder, CursorPosition, GridSnapshot}; +pub use client_screen::{ + col, CellStyle, ClientScreen, Coord, CoordBuilder, CursorPosition, GridSnapshot, +}; pub use fake_pty::FakePtyHandle; pub use harness::{ claim_first_terminal_and_wait_for_prompt, split_down_and_wait_for_prompt, split_right_and_wait_for_prompt, start_zellij, PROMPT, TERMINAL_SIZE, }; -pub use runner::{normalized, TestClient, TestRunner, TestSession}; +pub use nested::{ + composite_contains_settled_guest_grid, guest_ascended_bar_settled, host_descended_bar_settled, + host_descended_bar_with_ascend_keys_settled, wait_for_settled_composite, + NestedDepthThreeHarness, NestedHarness, +}; +pub use runner::{assert_same_rendered_grid, normalized, TestClient, TestRunner, TestSession}; pub use zellij_utils::data::LayoutInfo; pub use zellij_utils::pane_size::Size; diff --git a/zellij-integration-tests/src/nested.rs b/zellij-integration-tests/src/nested.rs new file mode 100644 index 00000000..3a363fbe --- /dev/null +++ b/zellij-integration-tests/src/nested.rs @@ -0,0 +1,697 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use crossbeam::channel::{Receiver, Sender}; +use zellij_utils::nested_session::{decode_payload, NestedFrameExtractor, NestedSessionMessage}; + +use crate::client_screen::GridSnapshot; +use crate::fake_pty::FakePtyHandle; +use crate::runner::{GuestResizer, TestRunner, TestSession}; +use crate::Size; + +pub fn host_chrome_undimmed_after_guest_exit(host_grid: &GridSnapshot) -> bool { + !host_grid.line_has_dim("Pane #2") && host_grid.char_dim_of("test-").map_or(true, |dim| !dim) +} + +pub fn composite_contains_settled_guest_grid( + host_grid: &GridSnapshot, + guest_grid: &GridSnapshot, +) -> bool { + let host_lines: Vec<Vec<char>> = host_grid + .text + .lines() + .map(|line| line.chars().collect()) + .collect(); + let guest_lines: Vec<Vec<char>> = guest_grid + .text + .lines() + .map(|line| line.chars().collect()) + .collect(); + let guest_rows = guest_lines.len(); + let guest_cols = guest_lines.iter().map(|line| line.len()).max().unwrap_or(0); + let host_rows = host_lines.len(); + let host_cols = host_lines.iter().map(|line| line.len()).max().unwrap_or(0); + if guest_rows == 0 || guest_cols == 0 || guest_rows > host_rows || guest_cols > host_cols { + return false; + } + for row_offset in 0..=(host_rows - guest_rows) { + 'next_column_offset: for col_offset in 0..=(host_cols - guest_cols) { + for (guest_row, guest_line) in guest_lines.iter().enumerate() { + let host_line = &host_lines[row_offset + guest_row]; + for (guest_col, guest_char) in guest_line.iter().enumerate() { + let guest_cell_is_cursor = guest_grid.cursor.map_or(false, |cursor| { + cursor.x == guest_col && cursor.y == guest_row + }); + if guest_cell_is_cursor { + continue; + } + let host_char = host_line + .get(col_offset + guest_col) + .copied() + .unwrap_or(' '); + if host_char != *guest_char { + continue 'next_column_offset; + } + } + } + return true; + } + } + false +} + +pub fn host_descended_bar_settled(host_grid: &GridSnapshot) -> bool { + host_grid + .lines() + .last() + .map_or(false, |last_line| last_line.contains("Ascend:")) +} + +pub fn host_descended_bar_with_ascend_keys_settled(host_grid: &GridSnapshot) -> bool { + host_grid.lines().last().map_or(false, |last_line| { + last_line.contains("Ascend:") && !last_line.contains("<unbound>") + }) +} + +pub fn guest_ascended_bar_settled(guest_grid: &GridSnapshot) -> bool { + guest_grid + .lines() + .last() + .map_or(false, |last_line| last_line.contains("Descend:")) +} + +pub fn wait_for_settled_composite( + host: &TestSession, + guest: &TestSession, + what: &str, + guest_settled: impl Fn(&GridSnapshot) -> bool, + host_settled: impl Fn(&GridSnapshot) -> bool, +) -> GridSnapshot { + host.wait_until(what, |host_grid| { + let guest_grid = guest.snapshot(); + guest_settled(&guest_grid) + && host_settled(host_grid) + && composite_contains_settled_guest_grid(host_grid, &guest_grid) + }) +} + +pub const GUEST_PING_INTERVAL_MS: u64 = 100; +pub const GUEST_PONG_TIMEOUT_MS: u64 = 4000; + +#[derive(Clone, Default)] +pub struct FrameLog { + inner: Arc<FrameLogInner>, +} + +#[derive(Default)] +struct FrameLogInner { + messages: Mutex<Vec<NestedSessionMessage>>, + signal: Condvar, +} + +impl FrameLog { + fn push(&self, message: NestedSessionMessage) { + self.inner.messages.lock().unwrap().push(message); + self.inner.signal.notify_all(); + } + + pub fn count(&self, mut matcher: impl FnMut(&NestedSessionMessage) -> bool) -> usize { + self.inner + .messages + .lock() + .unwrap() + .iter() + .filter(|message| matcher(message)) + .count() + } + + pub fn mark(&self) -> usize { + self.inner.messages.lock().unwrap().len() + } + + pub fn wait_for(&self, what: &str, matcher: impl FnMut(&NestedSessionMessage) -> bool) { + self.wait_for_after(0, what, matcher); + } + + pub fn wait_for_after( + &self, + since: usize, + what: &str, + mut matcher: impl FnMut(&NestedSessionMessage) -> bool, + ) { + let deadline = Instant::now() + crate::default_timeout(); + let mut messages = self.inner.messages.lock().unwrap(); + loop { + if messages.iter().skip(since).any(|message| matcher(message)) { + return; + } + let now = Instant::now(); + if now >= deadline { + panic!( + "timed out waiting for nested frame: {} (after frame index {})\nframes seen: {:?}\n=== zellij log tail ({}) ===\n{}", + what, + since, + *messages, + crate::test_env::log_file_path().display(), + crate::test_env::log_tail(40), + ); + } + let (guard, _) = self + .inner + .signal + .wait_timeout(messages, deadline - now) + .unwrap(); + messages = guard; + } + } +} + +pub struct NestedHarness { + pub host: TestSession, + pub guest: TestSession, + pub host_pane: FakePtyHandle, + guest_to_host: FrameLog, + host_to_guest: FrameLog, + frozen: Arc<AtomicBool>, + _forwarders: Vec<JoinHandle<()>>, +} + +fn set_nested_timing_env() { + std::env::set_var( + "ZELLIJ_NESTED_PING_INTERVAL_MS", + GUEST_PING_INTERVAL_MS.to_string(), + ); + std::env::set_var( + "ZELLIJ_NESTED_PONG_TIMEOUT_MS", + GUEST_PONG_TIMEOUT_MS.to_string(), + ); +} + +struct GuestBridge { + guest: TestSession, + guest_to_host: FrameLog, + host_to_guest: FrameLog, + forwarders: Vec<JoinHandle<()>>, +} + +fn bridge_guest_into_pane_for_host( + host_pane: &FakePtyHandle, + host_session_name: &str, + frozen: Arc<AtomicBool>, +) -> GuestBridge { + bridge_guest_into_pane_for_host_with_config(host_pane, host_session_name, frozen, "") +} + +fn bridge_guest_into_pane_for_host_with_config( + host_pane: &FakePtyHandle, + host_session_name: &str, + frozen: Arc<AtomicBool>, + guest_config_kdl: &str, +) -> GuestBridge { + host_pane.disable_echo(); + let (guest_cols, guest_rows) = host_pane + .wait_for_size("host guest-pane to be sized", |cols, rows| { + cols > 0 && rows > 0 + }); + + let (guest_stdout_tx, guest_stdout_rx) = crossbeam::channel::unbounded(); + let guest = TestRunner::new(Size { + cols: guest_cols as usize, + rows: guest_rows as usize, + }) + .as_nested_guest(host_session_name) + .with_config(guest_config_kdl) + .with_stdout_tap(guest_stdout_tx) + .skip_concurrency_slot() + .start(); + + let host_stdin_rx = host_pane.tap_stdin(); + let guest_stdin_tx = guest.stdin_sender(); + let guest_resizer = guest.resize_sender(); + + let guest_to_host = FrameLog::default(); + let host_to_guest = FrameLog::default(); + + let forwarders = vec![ + spawn_guest_to_host( + guest_stdout_rx, + host_pane.clone(), + frozen, + guest_to_host.clone(), + ), + spawn_host_to_guest(host_stdin_rx, guest_stdin_tx, host_to_guest.clone()), + spawn_pane_resize_to_guest(host_pane.clone(), guest_resizer, guest_cols, guest_rows), + ]; + + GuestBridge { + guest, + guest_to_host, + host_to_guest, + forwarders, + } +} + +impl NestedHarness { + pub fn start(host_size: Size) -> Self { + Self::start_with_host_config(host_size, "") + } + + pub fn start_with_host_config(host_size: Size, host_config_kdl: &str) -> Self { + Self::start_with_host_and_guest_config(host_size, host_config_kdl, "") + } + + pub fn start_with_host_and_guest_config( + host_size: Size, + host_config_kdl: &str, + guest_config_kdl: &str, + ) -> Self { + set_nested_timing_env(); + + let host = TestRunner::new(host_size) + .with_config(host_config_kdl) + .start(); + host.wait_for_app_load(); + let host_pane = host.expect_pty_spawn(); + let host_session_name = host.session_name().to_string(); + + let frozen = Arc::new(AtomicBool::new(false)); + let bridge = bridge_guest_into_pane_for_host_with_config( + &host_pane, + &host_session_name, + frozen.clone(), + guest_config_kdl, + ); + + NestedHarness { + host, + guest: bridge.guest, + host_pane, + guest_to_host: bridge.guest_to_host, + host_to_guest: bridge.host_to_guest, + frozen, + _forwarders: bridge.forwarders, + } + } + + pub fn guest_to_host(&self) -> &FrameLog { + &self.guest_to_host + } + + pub fn host_to_guest(&self) -> &FrameLog { + &self.host_to_guest + } + + pub fn mark_host_to_guest(&self) -> usize { + self.host_to_guest.mark() + } + + pub fn mark_guest_to_host(&self) -> usize { + self.guest_to_host.mark() + } + + pub fn wait_for_guest_to_announce(&self) { + self.guest_to_host.wait_for( + "the inner (guest) session to announce itself to the outer (host) session it is running inside", + |message| matches!(message, NestedSessionMessage::Announce { .. }), + ); + } + + pub fn wait_for_host_to_acknowledge_guest(&self) { + self.host_to_guest.wait_for( + "the outer (host) session to acknowledge the guest session running inside one of its panes", + |message| matches!(message, NestedSessionMessage::AnnounceAck { .. }), + ); + } + + pub fn wait_for_host_to_ping_guest(&self) { + self.host_to_guest.wait_for( + "the outer (host) session to ping the guest running inside its pane to check it is still alive", + |message| matches!(message, NestedSessionMessage::Ping), + ); + } + + pub fn wait_for_guest_to_reply_to_ping(&self) { + self.guest_to_host.wait_for( + "the inner (guest) session to answer the host's ping and prove it is still alive", + |message| matches!(message, NestedSessionMessage::Pong), + ); + } + + pub fn wait_for_host_to_release_guest_focus(&self) { + self.wait_for_host_to_reclaim_focus_after_guest_exit(); + } + + pub fn wait_for_host_to_reclaim_focus_after_guest_exit(&self) { + self.host.wait_until( + "the outer (host) session to notice the guest is gone and reclaim focus (undim its chrome)", + host_chrome_undimmed_after_guest_exit, + ); + } + + pub fn wait_for_host_to_descend_into_guest(&self) { + self.wait_for_host_to_descend_into_guest_after(0); + } + + pub fn wait_for_host_to_descend_into_guest_after(&self, since: usize) { + self.host_to_guest.wait_for_after( + since, + "the outer (host) session to descend into the guest pane and route the client's keys down into it", + |message| matches!(message, NestedSessionMessage::FocusGained { .. }), + ); + } + + pub fn descend_into_guest_via_modal(&self) { + let descended = self.mark_host_to_guest(); + self.host.send_stdin(b"2"); + self.wait_for_host_to_descend_into_guest_after(descended); + } + + pub fn zoom_into_guest_via_modal(&self) { + let descended = self.mark_host_to_guest(); + self.host.send_stdin(b"1"); + self.wait_for_host_to_descend_into_guest_after(descended); + } + + pub fn wait_for_host_to_ascend_from_guest(&self) { + self.wait_for_host_to_ascend_from_guest_after(0); + } + + pub fn wait_for_host_to_ascend_from_guest_after(&self, since: usize) { + self.host_to_guest.wait_for_after( + since, + "the outer (host) session to ascend back out of the guest pane and take its client's keys back", + |message| matches!(message, NestedSessionMessage::FocusLost), + ); + } + + pub fn wait_for_guest_to_request_host_focus(&self) { + self.wait_for_guest_to_request_host_focus_after(0); + } + + pub fn wait_for_guest_to_request_host_focus_after(&self, since: usize) { + self.guest_to_host.wait_for_after( + since, + "the inner (guest) session to ask its host to take focus back (the FocusHostSession binding)", + |message| matches!(message, NestedSessionMessage::FocusHost { .. }), + ); + } + + pub fn wait_until_host_composites_settled_guest( + &self, + what: &str, + guest_settled: impl Fn(&GridSnapshot) -> bool, + host_settled: impl Fn(&GridSnapshot) -> bool, + ) -> GridSnapshot { + wait_for_settled_composite(&self.host, &self.guest, what, guest_settled, host_settled) + } + + pub fn focus_gained_count(&self) -> usize { + self.host_to_guest + .count(|message| matches!(message, NestedSessionMessage::FocusGained { .. })) + } + + pub fn focus_lost_count(&self) -> usize { + self.host_to_guest + .count(|message| matches!(message, NestedSessionMessage::FocusLost)) + } + + pub fn freeze_guest(&self) { + self.frozen.store(true, Ordering::SeqCst); + } + + fn host_ping_count(&self) -> usize { + self.host_to_guest + .count(|message| matches!(message, NestedSessionMessage::Ping)) + } + + pub fn assert_host_stops_pinging_frozen_guest(&self) { + let stable_window = Duration::from_millis(6 * GUEST_PING_INTERVAL_MS); + let deadline = Instant::now() + + Duration::from_millis(GUEST_PONG_TIMEOUT_MS) + + crate::default_timeout(); + loop { + let pings_before = self.host_ping_count(); + thread::sleep(stable_window); + let pings_after = self.host_ping_count(); + if pings_before == pings_after { + return; + } + if Instant::now() >= deadline { + panic!( + "the outer host kept pinging the guest after it went silent; \ + the host never noticed the guest was gone and cleared it \ + (pings before={pings_before}, after={pings_after})", + ); + } + } + } +} + +pub struct NestedDepthThreeHarness { + pub outer: TestSession, + pub middle: TestSession, + pub inner: TestSession, + pub middle_pane: FakePtyHandle, + pub inner_pane: FakePtyHandle, + outer_to_middle: FrameLog, + middle_to_inner: FrameLog, + inner_to_middle: FrameLog, + _frozen: Arc<AtomicBool>, + _forwarders: Vec<JoinHandle<()>>, +} + +impl NestedDepthThreeHarness { + pub fn start_depth_three(outer_size: Size) -> Self { + set_nested_timing_env(); + + let outer = TestRunner::new(outer_size).start(); + outer.wait_for_app_load(); + let middle_pane = outer.expect_pty_spawn(); + let outer_session_name = outer.session_name().to_string(); + + let frozen = Arc::new(AtomicBool::new(false)); + let outer_to_middle_bridge = + bridge_guest_into_pane_for_host(&middle_pane, &outer_session_name, frozen.clone()); + let middle = outer_to_middle_bridge.guest; + let outer_to_middle = outer_to_middle_bridge.host_to_guest; + + middle.wait_for_app_load(); + let inner_pane = middle.expect_pty_spawn(); + let middle_session_name = middle.session_name().to_string(); + + let middle_to_inner_bridge = + bridge_guest_into_pane_for_host(&inner_pane, &middle_session_name, frozen.clone()); + let inner = middle_to_inner_bridge.guest; + let middle_to_inner = middle_to_inner_bridge.host_to_guest; + let inner_to_middle = middle_to_inner_bridge.guest_to_host; + + let mut forwarders = outer_to_middle_bridge.forwarders; + forwarders.extend(middle_to_inner_bridge.forwarders); + + NestedDepthThreeHarness { + outer, + middle, + inner, + middle_pane, + inner_pane, + outer_to_middle, + middle_to_inner, + inner_to_middle, + _frozen: frozen, + _forwarders: forwarders, + } + } + + pub fn outer_to_middle_frames(&self) -> &FrameLog { + &self.outer_to_middle + } + + pub fn middle_to_inner_frames(&self) -> &FrameLog { + &self.middle_to_inner + } + + pub fn inner_to_middle_frames(&self) -> &FrameLog { + &self.inner_to_middle + } + + pub fn wait_for_outer_to_descend_into_middle(&self) { + self.outer_to_middle.wait_for( + "the outer host to descend into the middle guest pane", + |message| matches!(message, NestedSessionMessage::FocusGained { .. }), + ); + } + + pub fn wait_for_middle_to_descend_into_inner(&self) { + self.middle_to_inner.wait_for( + "the middle guest to descend into the inner guest pane", + |message| matches!(message, NestedSessionMessage::FocusGained { .. }), + ); + } + + pub fn descend_outer_into_middle_via_modal(&self) { + let descended = self.mark_outer_to_middle(); + self.outer.send_stdin(b"2"); + self.wait_for_outer_to_descend_into_middle_after(descended); + } + + pub fn descend_middle_into_inner_via_modal(&self) { + self.middle_to_inner.wait_for( + "the inner guest to announce itself to the middle host before descending", + |message| matches!(message, NestedSessionMessage::AnnounceAck { .. }), + ); + let descended = self.mark_middle_to_inner(); + self.outer.send_stdin(b"2"); + self.wait_for_middle_to_descend_into_inner_after(descended); + } + + pub fn zoom_middle_into_inner_via_modal(&self) { + self.middle_to_inner.wait_for( + "the inner guest to announce itself to the middle host before zooming", + |message| matches!(message, NestedSessionMessage::AnnounceAck { .. }), + ); + let descended = self.mark_middle_to_inner(); + self.outer.send_stdin(b"1"); + self.wait_for_middle_to_descend_into_inner_after(descended); + } + + pub fn boot_and_descend_depth_three(&self) { + self.descend_outer_into_middle_via_modal(); + self.middle.wait_for_app_load(); + self.descend_middle_into_inner_via_modal(); + } + + pub fn wait_for_outer_to_descend_into_middle_after(&self, since: usize) { + self.outer_to_middle.wait_for_after( + since, + "the outer host to descend back into the middle guest pane", + |message| matches!(message, NestedSessionMessage::FocusGained { .. }), + ); + } + + pub fn wait_for_middle_to_descend_into_inner_after(&self, since: usize) { + self.middle_to_inner.wait_for_after( + since, + "the middle guest to descend back into the inner guest pane", + |message| matches!(message, NestedSessionMessage::FocusGained { .. }), + ); + } + + pub fn mark_outer_to_middle(&self) -> usize { + self.outer_to_middle.mark() + } + + pub fn mark_middle_to_inner(&self) -> usize { + self.middle_to_inner.mark() + } + + pub fn mark_inner_to_middle(&self) -> usize { + self.inner_to_middle.mark() + } + + pub fn wait_for_outer_to_ascend_from_middle_after(&self, since: usize) { + self.outer_to_middle.wait_for_after( + since, + "the outer host to ascend back out of the middle guest pane", + |message| matches!(message, NestedSessionMessage::FocusLost), + ); + } + + pub fn wait_for_outer_to_reclaim_focus_after_middle_exit(&self) { + self.outer.wait_until( + "the outer host to notice the middle guest is gone and reclaim focus (undim its chrome)", + host_chrome_undimmed_after_guest_exit, + ); + } + + pub fn wait_for_middle_to_ascend_from_inner_after(&self, since: usize) { + self.middle_to_inner.wait_for_after( + since, + "the middle guest to ascend back out of the inner guest pane", + |message| matches!(message, NestedSessionMessage::FocusLost), + ); + } + + pub fn wait_for_inner_to_request_host_focus_after(&self, since: usize) { + self.inner_to_middle.wait_for_after( + since, + "the inner guest to ask its middle host to take focus back (the FocusHostSession binding)", + |message| matches!(message, NestedSessionMessage::FocusHost { .. }), + ); + } +} + +fn spawn_guest_to_host( + guest_stdout_rx: Receiver<Vec<u8>>, + host_pane: FakePtyHandle, + frozen: Arc<AtomicBool>, + log: FrameLog, +) -> JoinHandle<()> { + thread::Builder::new() + .name("nested_guest_to_host".to_string()) + .spawn(move || { + let mut extractor = NestedFrameExtractor::new(); + while let Ok(bytes) = guest_stdout_rx.recv() { + if frozen.load(Ordering::SeqCst) { + continue; + } + let (_residue, payloads) = extractor.extract(&bytes); + for payload in payloads { + if let Some(message) = decode_payload(&payload) { + log.push(message); + } + } + host_pane.try_output(&bytes); + } + }) + .unwrap() +} + +fn spawn_host_to_guest( + host_stdin_rx: Receiver<Vec<u8>>, + guest_stdin_tx: Sender<Vec<u8>>, + log: FrameLog, +) -> JoinHandle<()> { + thread::Builder::new() + .name("nested_host_to_guest".to_string()) + .spawn(move || { + let mut extractor = NestedFrameExtractor::new(); + while let Ok(bytes) = host_stdin_rx.recv() { + let (_residue, payloads) = extractor.extract(&bytes); + for payload in payloads { + if let Some(message) = decode_payload(&payload) { + log.push(message); + } + } + if guest_stdin_tx.send(bytes).is_err() { + break; + } + } + }) + .unwrap() +} + +fn spawn_pane_resize_to_guest( + host_pane: FakePtyHandle, + guest_resizer: GuestResizer, + initial_cols: u16, + initial_rows: u16, +) -> JoinHandle<()> { + thread::Builder::new() + .name("nested_pane_resize_to_guest".to_string()) + .spawn(move || { + let mut last_seen = (initial_cols, initial_rows); + while let Some((cols, rows)) = host_pane.wait_for_size_change(last_seen) { + last_seen = (cols, rows); + let resized = guest_resizer.resize(Size { + cols: cols as usize, + rows: rows as usize, + }); + if !resized { + break; + } + } + }) + .unwrap() +} diff --git a/zellij-integration-tests/src/runner.rs b/zellij-integration-tests/src/runner.rs index cee765a9..57c994b8 100644 --- a/zellij-integration-tests/src/runner.rs +++ b/zellij-integration-tests/src/runner.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; +use std::sync::mpsc; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; +use std::time::Duration; use zellij_client::os_input_output::SignalEvent; use zellij_client::ClientInfo; @@ -21,6 +23,9 @@ pub struct TestRunner { size: Size, extra_config_kdl: String, layout: Option<LayoutInfo>, + env: std::collections::HashMap<String, String>, + stdout_tap: Option<crossbeam::channel::Sender<Vec<u8>>>, + skip_concurrency_slot: bool, } impl TestRunner { @@ -29,6 +34,9 @@ impl TestRunner { size, extra_config_kdl: String::new(), layout: None, + env: std::collections::HashMap::new(), + stdout_tap: None, + skip_concurrency_slot: false, } } @@ -43,6 +51,22 @@ impl TestRunner { self } + pub fn as_nested_guest(mut self, host_session_name: &str) -> Self { + self.env + .insert("ZELLIJ".to_string(), host_session_name.to_string()); + self + } + + pub fn with_stdout_tap(mut self, sender: crossbeam::channel::Sender<Vec<u8>>) -> Self { + self.stdout_tap = Some(sender); + self + } + + pub fn skip_concurrency_slot(mut self) -> Self { + self.skip_concurrency_slot = true; + self + } + pub fn start(self) -> TestSession { test_env::init(); let session_name = test_env::unique_session_name(); @@ -57,14 +81,21 @@ impl TestRunner { let (config, default_layout_info, config_options, _, _) = Setup::from_cli_args(&cli_args).expect("failed to load harness config"); - let concurrency_slot = test_env::acquire_concurrency_slot(); + let concurrency_slot = if self.skip_concurrency_slot { + None + } else { + Some(test_env::acquire_concurrency_slot()) + }; let fake_server_os_api = FakeServerOsApi::default(); let server_thread: Arc<Mutex<Option<JoinHandle<()>>>> = Arc::new(Mutex::new(None)); let server_spawner = in_process_server_spawner(fake_server_os_api.clone(), &server_thread); let (fake_client_os_api, fake_client_handle) = - FakeClientOsApi::new(self.size, Some(server_spawner)); + FakeClientOsApi::new_with_env(self.size, Some(server_spawner), self.env); + if let Some(stdout_tap) = self.stdout_tap { + fake_client_handle.client_screen.set_stdout_tap(stdout_tap); + } let layout_info = self.layout.or(default_layout_info); let client_thread = spawn_client_thread( fake_client_os_api, @@ -194,6 +225,19 @@ pub struct TestClient { thread: Option<JoinHandle<Option<ConnectToSession>>>, } +#[derive(Clone)] +pub struct GuestResizer { + size: Arc<Mutex<Size>>, + signal_tx: crossbeam::channel::Sender<SignalEvent>, +} + +impl GuestResizer { + pub fn resize(&self, new_size: Size) -> bool { + *self.size.lock().unwrap() = new_size; + self.signal_tx.send(SignalEvent::Resize).is_ok() + } +} + impl TestClient { pub fn send_stdin(&self, bytes: &[u8]) { self.fake_client_handle @@ -217,6 +261,12 @@ impl TestClient { .wait_until(what, predicate) } + pub fn wait_until_raw_output(&self, what: &str, predicate: impl Fn(&[u8]) -> bool) -> Vec<u8> { + self.fake_client_handle + .client_screen + .wait_until_raw_output(what, predicate) + } + pub fn snapshot(&self) -> GridSnapshot { self.fake_client_handle.client_screen.snapshot() } @@ -227,14 +277,47 @@ impl TestClient { } fn join(&mut self) -> Option<ConnectToSession> { - self.thread - .take() - .and_then(|join_handle: JoinHandle<Option<ConnectToSession>>| { - join_handle.join().expect("client thread panicked") - }) + self.thread.take().and_then(join_client_thread_with_timeout) } } +fn join_client_thread_with_timeout( + join_handle: JoinHandle<Option<ConnectToSession>>, +) -> Option<ConnectToSession> { + let (done_tx, done_rx) = mpsc::channel(); + std::thread::Builder::new() + .name("nested_client_join_watchdog".to_string()) + .spawn(move || { + let result = join_handle.join(); + let _ = done_tx.send(result); + }) + .unwrap(); + match done_rx.recv_timeout(CLIENT_JOIN_TIMEOUT) { + Ok(Ok(reconnect)) => reconnect, + Ok(Err(_)) => panic!("client thread panicked"), + Err(mpsc::RecvTimeoutError::Timeout) => None, + Err(mpsc::RecvTimeoutError::Disconnected) => None, + } +} + +fn join_server_thread_with_timeout(join_handle: JoinHandle<()>) { + let (done_tx, done_rx) = mpsc::channel(); + std::thread::Builder::new() + .name("nested_server_join_watchdog".to_string()) + .spawn(move || { + let result = join_handle.join(); + let _ = done_tx.send(result); + }) + .unwrap(); + match done_rx.recv_timeout(CLIENT_JOIN_TIMEOUT) { + Ok(Ok(())) => {}, + Ok(Err(_)) => panic!("server thread panicked"), + Err(_) => {}, + } +} + +const CLIENT_JOIN_TIMEOUT: Duration = Duration::from_secs(10); + pub struct TestSession { session_name: String, size: Size, @@ -244,7 +327,7 @@ pub struct TestSession { fake_server_os_api: FakeServerOsApi, server_thread: Arc<Mutex<Option<JoinHandle<()>>>>, main_client: TestClient, - _concurrency_slot: test_env::ConcurrencySlot, + _concurrency_slot: Option<test_env::ConcurrencySlot>, } pub struct CliClientHandle { @@ -279,10 +362,21 @@ impl TestSession { self.main_client.send_stdin(bytes); } + pub fn stdin_sender(&self) -> crossbeam::channel::Sender<Vec<u8>> { + self.main_client.fake_client_handle.stdin_tx.clone() + } + pub fn resize(&self, new_size: Size) { self.main_client.resize(new_size); } + pub fn resize_sender(&self) -> GuestResizer { + GuestResizer { + size: self.main_client.fake_client_handle.size.clone(), + signal_tx: self.main_client.fake_client_handle.signal_tx.clone(), + } + } + pub fn wait_until( &self, what: &str, @@ -291,13 +385,17 @@ impl TestSession { self.main_client.wait_until(what, predicate) } + pub fn wait_until_raw_output(&self, what: &str, predicate: impl Fn(&[u8]) -> bool) -> Vec<u8> { + self.main_client.wait_until_raw_output(what, predicate) + } + pub fn snapshot(&self) -> GridSnapshot { self.main_client.snapshot() } pub fn wait_for_app_load(&self) -> GridSnapshot { self.main_client.wait_until("app to load", |grid_snapshot| { - grid_snapshot.status_bar_appears() + (grid_snapshot.status_bar_appears() || grid_snapshot.contains("Descend:")) && grid_snapshot.tab_bar_appears() && grid_snapshot.cursor.is_some() }) @@ -504,7 +602,7 @@ impl TestSession { self.main_client.join(); } if let Some(server_thread) = self.server_thread.lock().unwrap().take() { - server_thread.join().expect("server thread panicked"); + join_server_thread_with_timeout(server_thread); } } @@ -532,6 +630,15 @@ pub fn normalized(grid_snapshot: &GridSnapshot) -> String { strip_trailing_whitespace(&text) } +pub fn assert_same_rendered_grid(actual: &GridSnapshot, expected: &GridSnapshot, what: &str) { + let actual = normalized(actual); + let expected = normalized(expected); + assert!( + actual == expected, + "{what}\n--- expected grid ---\n{expected}\n--- actual grid ---\n{actual}" + ); +} + fn strip_tip_indication(text: &str) -> String { regex::Regex::new(r" Tip: [^\n]*") .unwrap() diff --git a/zellij-integration-tests/tests/about_pane.rs b/zellij-integration-tests/tests/about_pane.rs new file mode 100644 index 00000000..9c203563 --- /dev/null +++ b/zellij-integration-tests/tests/about_pane.rs @@ -0,0 +1,59 @@ +#![cfg(unix)] + +use zellij_integration_tests::{ + keys, FakePtyHandle, Size, TestRunner, TestSession, PROMPT, TERMINAL_SIZE, +}; + +const PIN_MARKER: &str = "PIN [ ]"; + +fn start_zellij_with_startup_tip() -> (TestSession, FakePtyHandle) { + let zellij = TestRunner::new(TERMINAL_SIZE) + .with_config("show_startup_tips true") + .start(); + let terminal = zellij.expect_pty_spawn(); + terminal.output(PROMPT); + (zellij, terminal) +} + +#[test] +fn hidden_startup_tip_stays_hidden_after_terminal_resize() { + let (mut zellij, terminal) = start_zellij_with_startup_tip(); + zellij.wait_until("startup tip floating pane shown", |grid_snapshot| { + grid_snapshot.contains(PIN_MARKER) + }); + zellij.send_stdin(&keys::alt('f')); + zellij.wait_until("floating panes hidden", |grid_snapshot| { + !grid_snapshot.contains(PIN_MARKER) && grid_snapshot.status_bar_appears() + }); + + zellij.resize(Size { + cols: 140, + rows: 30, + }); + terminal.wait_for_size( + "terminal pane resized to the larger display", + |cols, _rows| cols > 120, + ); + zellij.wait_until("display settled at the new size", |grid_snapshot| { + grid_snapshot.status_bar_appears() + }); + + zellij.send_stdin(&keys::ctrl('p')); + zellij.wait_until("pane mode entered after the resize", |grid_snapshot| { + grid_snapshot + .lines() + .last() + .map_or(false, |last_line| last_line.contains("Fullscreen")) + }); + zellij.send_stdin(&keys::ESC); + let settled = zellij.wait_until("normal mode restored after the resize", |grid_snapshot| { + grid_snapshot.status_bar_appears() + }); + + assert!( + !settled.contains(PIN_MARKER), + "hidden startup tip floating pane must stay hidden after a terminal resize\n{}", + settled.text, + ); + zellij.quit(); +} diff --git a/zellij-integration-tests/tests/app.rs b/zellij-integration-tests/tests/app.rs index 45e57512..45f50c22 100644 --- a/zellij-integration-tests/tests/app.rs +++ b/zellij-integration-tests/tests/app.rs @@ -61,6 +61,34 @@ fn resize_terminal_window() { } #[test] +fn resize_re_queries_pixel_sizes() { + let mut zellij = TestRunner::new(TERMINAL_SIZE).start(); + claim_first_terminal_and_wait_for_prompt(&zellij); + + zellij.wait_until_raw_output("startup pixel size query", |bytes| { + count_pixel_size_queries(bytes) == 1 + }); + + zellij.resize(Size { + cols: 100, + rows: 24, + }); + + zellij.wait_until_raw_output("pixel size re-query after resize", |bytes| { + count_pixel_size_queries(bytes) >= 2 + }); + zellij.quit(); +} + +fn count_pixel_size_queries(client_stdout_bytes: &[u8]) -> usize { + let pixel_size_query = b"\x1b[14t\x1b[16t"; + client_stdout_bytes + .windows(pixel_size_query.len()) + .filter(|window| window == pixel_size_query) + .count() +} + +#[test] fn status_bar_loads_custom_keybindings() { let zellij = TestRunner::new(TERMINAL_SIZE) .with_config(CHANGED_KEYS_CONFIG) diff --git a/zellij-integration-tests/tests/nested_fullscreen.rs b/zellij-integration-tests/tests/nested_fullscreen.rs new file mode 100644 index 00000000..adb98644 --- /dev/null +++ b/zellij-integration-tests/tests/nested_fullscreen.rs @@ -0,0 +1,1147 @@ +#![cfg(unix)] + +use insta::assert_snapshot; +use zellij_integration_tests::{ + claim_first_terminal_and_wait_for_prompt, col, host_descended_bar_with_ascend_keys_settled, + keys, normalized, split_right_and_wait_for_prompt, start_zellij, GridSnapshot, + NestedDepthThreeHarness, NestedHarness, TERMINAL_SIZE, +}; +use zellij_utils::cli::CliAction; +use zellij_utils::nested_session::NestedSessionMessage; + +const ASCEND_KEY: &[u8] = b"]"; +const DESCEND_KEY: &[u8] = b"["; +const ARROW_LEFT: &[u8] = b"\x1b[D"; + +fn sgr_mouse_report(column: usize, line: usize, button: u8) -> Vec<u8> { + format!("\u{1b}[<{};{};{}M", button, column, line).into_bytes() +} + +fn last_line_contains(grid_snapshot: &GridSnapshot, needle: &str) -> bool { + grid_snapshot + .lines() + .last() + .map_or(false, |last_line| last_line.contains(needle)) +} + +fn session_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool { + last_line_contains(grid_snapshot, "SESSION") && last_line_contains(grid_snapshot, "Detach") +} + +fn pane_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool { + last_line_contains(grid_snapshot, "Fullscreen") +} + +fn guest_normal_mode_bar_settled(guest_grid: &GridSnapshot) -> bool { + guest_grid.lines().last().map_or(false, |last_line| { + if last_line.contains("LOCK") { + return true; + } + let tokens: Vec<String> = last_line + .replace('\u{e0b0}', " ") + .split_whitespace() + .map(|token| token.to_owned()) + .collect(); + ["g", "p", "t", "n", "h", "s", "o", "q"] + .iter() + .all(|mode_letter| tokens.iter().any(|token| token == mode_letter)) + }) +} + +fn guest_ui_settled(guest_grid: &GridSnapshot) -> bool { + let lines = guest_grid.lines(); + lines + .first() + .map_or(false, |first_line| first_line.contains("Tab #1")) + && last_line_contains(guest_grid, "Ctrl +") + && guest_normal_mode_bar_settled(guest_grid) +} + +fn guest_fullscreen_breadcrumb_line(guest_grid: &GridSnapshot) -> bool { + guest_grid.lines().first().map_or(false, |first_line| { + first_line.contains('▸') && first_line.contains('(') + }) +} + +fn breadcrumb_for(ancestry: &[&str], name: &str) -> String { + format!("({} ▸ {}) ", ancestry.join(" ▸ "), name) +} + +fn ancestor_segment_range(first_line: &str) -> (usize, usize) { + let start = first_line.find('(').expect("breadcrumb open paren present"); + let after_arrow = first_line[start..] + .rfind('▸') + .expect("breadcrumb arrow present"); + let end = start + after_arrow + '▸'.len_utf8(); + ( + first_line[..start].chars().count(), + first_line[..end].chars().count() + 1, + ) +} + +fn boot_and_descend_on_first_load(nested: &NestedHarness) { + nested.wait_for_guest_to_announce(); + nested.wait_for_host_to_acknowledge_guest(); + nested.descend_into_guest_via_modal(); + nested.guest.wait_for_app_load(); + nested.wait_for_host_to_ping_guest(); + nested.wait_for_guest_to_reply_to_ping(); + nested + .guest + .wait_until("guest settled in normal mode", guest_ui_settled); +} + +fn enter_fullscreen_from_descended_guest(nested: &NestedHarness) { + nested.host.send_stdin(&keys::ctrl('o')); + nested.guest.wait_until( + "guest entered session mode before the fullscreen bind", + session_mode_bar_settled, + ); + nested.host.send_stdin(&keys::key('f')); +} + +fn host_own_name_present(host_grid: &GridSnapshot, host_session_name: &str) -> bool { + let host_own_slot = format!("({})", host_session_name); + host_grid.contains(&host_own_slot) +} + +fn depth_three_descend(nested: &NestedDepthThreeHarness) { + nested.boot_and_descend_depth_three(); + nested.inner.wait_for_app_load(); + nested + .inner + .wait_until("inner settled in normal mode", guest_ui_settled); +} + +fn enter_fullscreen_from_descended_inner(nested: &NestedDepthThreeHarness) { + nested.outer.send_stdin(&keys::ctrl('o')); + nested.inner.wait_until( + "inner entered session mode before the fullscreen bind", + session_mode_bar_settled, + ); + nested.outer.send_stdin(&keys::key('f')); +} + +#[test] +fn guest_fullscreen_fills_the_host_display_and_hides_chrome() { + let nested = NestedHarness::start(TERMINAL_SIZE); + let host_session_name = nested.host.session_name().to_string(); + let guest_session_name = nested.guest.session_name().to_string(); + + boot_and_descend_on_first_load(&nested); + + let entered = nested.mark_guest_to_host(); + enter_fullscreen_from_descended_guest(&nested); + nested.guest_to_host().wait_for_after( + entered, + "the descended guest asks its host to fullscreen its pane", + |message| { + matches!( + message, + NestedSessionMessage::ToggleHostFullscreen { fullscreen: true } + ) + }, + ); + + let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name); + let fullscreen_grid = nested.host.wait_until( + "guest content fills the whole host display without host chrome", + |host_grid| { + host_grid.contains(&expected_breadcrumb) + && !host_own_name_present(host_grid, &host_session_name) + && host_grid.lines().first().map_or(false, |first_line| { + first_line.contains(&expected_breadcrumb) && first_line.contains("Tab #1") + }) + && host_grid.cursor_is_at(col(0).row(1)) + }, + ); + assert!( + fullscreen_grid.row_of_line("Pane #1").is_none(), + "no stray host pane-title line while the guest is fullscreened" + ); + assert_snapshot!( + "depth2_guest_fullscreen_fills_display", + normalized(&fullscreen_grid) + ); +} + +#[test] +fn toggle_off_restores_the_host_layout_exactly() { + let nested = NestedHarness::start(TERMINAL_SIZE); + + boot_and_descend_on_first_load(&nested); + + let host_session_name = nested.host.session_name().to_string(); + let guest_session_name = nested.guest.session_name().to_string(); + let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name); + + let before_fullscreen = + nested + .host + .wait_until("host chrome present before fullscreen", |host_grid| { + host_grid.status_bar_appears() + && host_grid.tab_bar_appears() + && host_descended_bar_with_ascend_keys_settled(host_grid) + && host_own_name_present(host_grid, &host_session_name) + && !guest_fullscreen_breadcrumb_line(host_grid) + }); + + let entered = nested.mark_guest_to_host(); + enter_fullscreen_from_descended_guest(&nested); + nested + .guest_to_host() + .wait_for_after(entered, "guest asks host to fullscreen", |message| { + matches!( + message, + NestedSessionMessage::ToggleHostFullscreen { fullscreen: true } + ) + }); + nested + .host + .wait_until("host chrome hidden while fullscreen", |host_grid| { + host_grid.contains(&expected_breadcrumb) + && !host_own_name_present(host_grid, &host_session_name) + }); + + let exited = nested.mark_guest_to_host(); + nested.host.send_stdin(&keys::ctrl('o')); + nested.guest.wait_until( + "guest entered session mode before the toggle-off bind", + session_mode_bar_settled, + ); + nested.host.send_stdin(&keys::key('f')); + nested.guest_to_host().wait_for_after( + exited, + "guest asks host to exit fullscreen", + |message| { + matches!( + message, + NestedSessionMessage::ToggleHostFullscreen { fullscreen: false } + ) + }, + ); + + let after_fullscreen = nested.host.wait_until( + "host chrome and layout restored after toggle-off", + |host_grid| { + host_grid.status_bar_appears() + && host_grid.tab_bar_appears() + && host_descended_bar_with_ascend_keys_settled(host_grid) + && host_own_name_present(host_grid, &host_session_name) + && !guest_fullscreen_breadcrumb_line(host_grid) + }, + ); + assert_eq!( + after_fullscreen.lines().last(), + before_fullscreen.lines().last(), + "host status bar restored exactly after fullscreen exit" + ); + assert!( + after_fullscreen + .lines() + .first() + .map_or(false, |first_line| !first_line.contains('▸')), + "breadcrumb gone from the host tab-bar after fullscreen exit" + ); +} + +#[test] +fn fullscreen_state_reaches_the_guest_on_enter_and_exit() { + let nested = NestedHarness::start(TERMINAL_SIZE); + + boot_and_descend_on_first_load(&nested); + + let entered = nested.mark_host_to_guest(); + enter_fullscreen_from_descended_guest(&nested); + nested.host_to_guest().wait_for_after( + entered, + "the guest is told it entered host fullscreen", + |message| { + matches!( + message, + NestedSessionMessage::FullscreenState { fullscreen: true } + ) + }, + ); + nested + .guest + .wait_until("guest breadcrumb shown while fullscreen", |guest_grid| { + guest_fullscreen_breadcrumb_line(guest_grid) + }); + + let exited = nested.mark_host_to_guest(); + nested.host.send_stdin(&keys::ctrl('o')); + nested.guest.wait_until( + "guest entered session mode before the toggle-off bind", + session_mode_bar_settled, + ); + nested.host.send_stdin(&keys::key('f')); + nested.host_to_guest().wait_for_after( + exited, + "the guest is told it left host fullscreen", + |message| { + matches!( + message, + NestedSessionMessage::FullscreenState { fullscreen: false } + ) + }, + ); + nested + .guest + .wait_until("guest breadcrumb cleared after exit", |guest_grid| { + !guest_fullscreen_breadcrumb_line(guest_grid) + }); +} + +fn column_of(first_line: &str, needle: &str) -> usize { + let byte_index = first_line.find(needle).expect("needle present on line"); + first_line[..byte_index].chars().count() +} + +#[test] +fn breadcrumb_is_present_only_while_fullscreened() { + let nested = NestedHarness::start(TERMINAL_SIZE); + let host_session_name = nested.host.session_name().to_string(); + let guest_session_name = nested.guest.session_name().to_string(); + + boot_and_descend_on_first_load(&nested); + + let before = nested + .guest + .wait_until("guest tab-bar settled before fullscreen", guest_ui_settled); + assert!( + !guest_fullscreen_breadcrumb_line(&before) + && before + .lines() + .first() + .map_or(true, |first_line| !first_line.contains('▸')), + "no breadcrumb on the guest tab-bar before fullscreen" + ); + + enter_fullscreen_from_descended_guest(&nested); + + let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name); + let fullscreened = nested.guest.wait_until( + "guest tab-bar shows the breadcrumb while fullscreened", + |guest_grid| { + guest_grid.lines().first().map_or(false, |first_line| { + first_line.contains(&expected_breadcrumb) + }) + }, + ); + let first_line = fullscreened.lines().into_iter().next().unwrap(); + assert!( + first_line.contains(&format!("{})", guest_session_name)), + "the guest's own name is present in the breadcrumb" + ); + + let ancestor_column = column_of(&first_line, &host_session_name); + assert!( + fullscreened.char_is_italic(ancestor_column, 0), + "the ancestor segment of the breadcrumb is styled italic" + ); + let name_column = column_of(&first_line, &guest_session_name); + assert!( + fullscreened.char_is_bold(name_column, 0), + "the guest's own name in the breadcrumb is styled bold" + ); + assert!( + !fullscreened.char_is_dim(ancestor_column, 0), + "the ancestor segment is not dimmed" + ); + assert_snapshot!( + "depth2_breadcrumb_tab_bar_line", + normalized(&fullscreened).lines().next().unwrap() + ); + + let exited = nested.mark_host_to_guest(); + nested.host.send_stdin(&keys::ctrl('o')); + nested.guest.wait_until( + "guest entered session mode before the toggle-off bind", + session_mode_bar_settled, + ); + nested.host.send_stdin(&keys::key('f')); + nested.host_to_guest().wait_for_after( + exited, + "guest told it left host fullscreen", + |message| { + matches!( + message, + NestedSessionMessage::FullscreenState { fullscreen: false } + ) + }, + ); + nested.guest.wait_until( + "guest breadcrumb gone after fullscreen exit", + |guest_grid| { + guest_grid + .lines() + .first() + .map_or(false, |first_line| !first_line.contains('▸')) + }, + ); +} + +#[test] +fn nested_but_tiled_guest_shows_no_breadcrumb() { + let nested = NestedHarness::start(TERMINAL_SIZE); + + boot_and_descend_on_first_load(&nested); + + let tiled = nested + .guest + .wait_until("nested guest tab-bar settled while tiled", guest_ui_settled); + assert!( + tiled + .lines() + .first() + .map_or(true, |first_line| { !first_line.contains('▸') }), + "a nested but not fullscreened guest shows no breadcrumb" + ); +} + +#[test] +fn standalone_no_ui_fullscreen_shows_no_breadcrumb() { + let mut zellij = start_zellij(); + claim_first_terminal_and_wait_for_prompt(&zellij); + split_right_and_wait_for_prompt(&zellij); + + zellij.send_stdin(&keys::ctrl('p')); + zellij.send_stdin(&keys::key('F')); + + let fullscreened = zellij.wait_until( + "standalone pane covers the display without chrome", + |grid| { + !grid.tab_bar_appears() + && !grid.status_bar_appears() + && grid.cursor_is_at(col(2).row(0)) + }, + ); + assert!( + !fullscreened.contains("▸"), + "a standalone (non-nested) NoUi fullscreen shows no breadcrumb" + ); + zellij.quit(); +} + +#[test] +fn innermost_fullscreen_fills_the_whole_outer_terminal_and_zooms_the_chain() { + let nested = NestedDepthThreeHarness::start_depth_three(TERMINAL_SIZE); + let outer_session_name = nested.outer.session_name().to_string(); + let middle_session_name = nested.middle.session_name().to_string(); + let inner_session_name = nested.inner.session_name().to_string(); + + depth_three_descend(&nested); + + let bubbled = nested.mark_inner_to_middle(); + let outer_told = nested.mark_outer_to_middle(); + let middle_told = nested.mark_middle_to_inner(); + enter_fullscreen_from_descended_inner(&nested); + + nested.inner_to_middle_frames().wait_for_after( + bubbled, + "inner asks the middle host to fullscreen it", + |message| { + matches!( + message, + NestedSessionMessage::ToggleHostFullscreen { fullscreen: true } + ) + }, + ); + nested.outer_to_middle_frames().wait_for_after( + outer_told, + "outer tells the middle it is in host fullscreen", + |message| { + matches!( + message, + NestedSessionMessage::FullscreenState { fullscreen: true } + ) + }, + ); + nested.middle_to_inner_frames().wait_for_after( + middle_told, + "middle tells the inner it is in host fullscreen", + |message| { + matches!( + message, + NestedSessionMessage::FullscreenState { fullscreen: true } + ) + }, + ); + + let expected_breadcrumb = breadcrumb_for( + &[&outer_session_name, &middle_session_name], + &inner_session_name, + ); + let fullscreen_grid = nested.outer.wait_until( + "inner content fills the whole outer display with all three ancestry segments", + |outer_grid| { + outer_grid.contains(&expected_breadcrumb) + && !host_own_name_present(outer_grid, &outer_session_name) + && !host_own_name_present(outer_grid, &middle_session_name) + && outer_grid.lines().first().map_or(false, |first_line| { + first_line.contains(&expected_breadcrumb) && first_line.contains("Tab #1") + }) + && outer_grid.cursor_is_at(col(0).row(1)) + }, + ); + assert_snapshot!( + "depth3_inner_fullscreen_breadcrumb", + normalized(&fullscreen_grid) + ); +} + +#[test] +fn zoom_modal_while_outer_is_ascended_still_fullscreens_the_outer() { + let nested = NestedDepthThreeHarness::start_depth_three(TERMINAL_SIZE); + let outer_session_name = nested.outer.session_name().to_string(); + let middle_session_name = nested.middle.session_name().to_string(); + let inner_session_name = nested.inner.session_name().to_string(); + + nested.descend_outer_into_middle_via_modal(); + nested.middle.wait_for_app_load(); + + let ascended = nested.mark_outer_to_middle(); + nested.outer.send_stdin(&keys::ctrl('o')); + nested.middle.wait_until( + "middle entered session mode before the ascend key", + session_mode_bar_settled, + ); + nested.outer.send_stdin(ASCEND_KEY); + nested.wait_for_outer_to_ascend_from_middle_after(ascended); + nested.outer.wait_until( + "outer restored its own chrome after ascending out of the middle", + |outer_grid| host_own_name_present(outer_grid, &outer_session_name), + ); + + let outer_told = nested.mark_outer_to_middle(); + nested.zoom_middle_into_inner_via_modal(); + nested.outer_to_middle_frames().wait_for_after( + outer_told, + "outer honors the fullscreen chain even though it is ascended out of the middle", + |message| { + matches!( + message, + NestedSessionMessage::FullscreenState { fullscreen: true } + ) + }, + ); + + let expected_breadcrumb = breadcrumb_for( + &[&outer_session_name, &middle_session_name], + &inner_session_name, + ); + nested.outer.wait_until( + "the ascended outer drops its own chrome and shows the full nested fullscreen breadcrumb", + |outer_grid| { + outer_grid.contains(&expected_breadcrumb) + && !host_own_name_present(outer_grid, &outer_session_name) + && !host_own_name_present(outer_grid, &middle_session_name) + }, + ); +} + +fn ascend_inner_to_middle(nested: &NestedDepthThreeHarness) { + let requested = nested.mark_inner_to_middle(); + let ascended = nested.mark_middle_to_inner(); + nested.outer.send_stdin(&keys::ctrl('o')); + nested.inner.wait_until( + "inner entered session mode before ascending to the middle", + session_mode_bar_settled, + ); + nested.outer.send_stdin(ASCEND_KEY); + nested.wait_for_inner_to_request_host_focus_after(requested); + nested.wait_for_middle_to_ascend_from_inner_after(ascended); +} + +fn redescend_middle_into_inner(nested: &NestedDepthThreeHarness) { + let descended = nested.mark_middle_to_inner(); + nested.outer.send_stdin(&keys::ctrl('p')); + nested.middle.wait_until( + "middle entered pane mode before re-descending into the inner", + |middle_grid| { + last_line_contains(middle_grid, "PANE") && last_line_contains(middle_grid, "Move") + }, + ); + nested.outer.send_stdin(ARROW_LEFT); + nested.outer.send_stdin(&keys::ctrl('o')); + nested.middle.wait_until( + "middle entered session mode before the descend key", + session_mode_bar_settled, + ); + nested.outer.send_stdin(DESCEND_KEY); + nested.wait_for_middle_to_descend_into_inner_after(descended); +} + +#[test] +fn fullscreen_from_inner_while_middle_already_fullscreen_does_not_cancel() { + let nested = NestedDepthThreeHarness::start_depth_three(TERMINAL_SIZE); + let outer_session_name = nested.outer.session_name().to_string(); + let middle_session_name = nested.middle.session_name().to_string(); + let inner_session_name = nested.inner.session_name().to_string(); + + depth_three_descend(&nested); + + ascend_inner_to_middle(&nested); + + nested.outer.send_stdin(&keys::ctrl('p')); + nested.middle.wait_until( + "middle entered pane mode before splitting a sibling", + |middle_grid| { + last_line_contains(middle_grid, "PANE") && last_line_contains(middle_grid, "Move") + }, + ); + nested.outer.send_stdin(&keys::key('r')); + let middle_sibling = nested.middle.expect_pty_spawn(); + middle_sibling.output(b"MIDDLE-SIBLING "); + nested.middle.wait_until( + "middle spawned a sibling pane next to the inner guest", + |middle_grid| middle_grid.contains("MIDDLE-SIBLING"), + ); + + let outer_told_true = nested.mark_outer_to_middle(); + nested.outer.send_stdin(&keys::ctrl('o')); + nested.middle.wait_until( + "middle entered session mode before fullscreening itself", + session_mode_bar_settled, + ); + nested.outer.send_stdin(&keys::key('f')); + nested.outer_to_middle_frames().wait_for_after( + outer_told_true, + "outer fullscreens the middle pane and tells the middle it is fullscreen", + |message| { + matches!( + message, + NestedSessionMessage::FullscreenState { fullscreen: true } + ) + }, + ); + + let middle_breadcrumb = breadcrumb_for(&[&outer_session_name], &middle_session_name); + nested.outer.wait_until( + "outer display fullscreened by the middle before the inner enters fullscreen", + |outer_grid| { + outer_grid.contains(&middle_breadcrumb) + && !host_own_name_present(outer_grid, &outer_session_name) + }, + ); + + redescend_middle_into_inner(&nested); + + let cancels_before = nested.outer_to_middle_frames().count(|message| { + matches!( + message, + NestedSessionMessage::FullscreenState { fullscreen: false } + ) + }); + let bubbled = nested.mark_inner_to_middle(); + enter_fullscreen_from_descended_inner(&nested); + nested.inner_to_middle_frames().wait_for_after( + bubbled, + "inner asks the middle to fullscreen it", + |message| { + matches!( + message, + NestedSessionMessage::ToggleHostFullscreen { fullscreen: true } + ) + }, + ); + + let inner_breadcrumb = breadcrumb_for( + &[&outer_session_name, &middle_session_name], + &inner_session_name, + ); + let final_grid = nested.outer.wait_until( + "the whole chain ends fullscreen with the inner content covering the outer display", + |outer_grid| { + outer_grid.contains(&inner_breadcrumb) + && !host_own_name_present(outer_grid, &outer_session_name) + && !host_own_name_present(outer_grid, &middle_session_name) + && outer_grid.cursor_is_at(col(0).row(1)) + }, + ); + + let cancels_after = nested.outer_to_middle_frames().count(|message| { + matches!( + message, + NestedSessionMessage::FullscreenState { fullscreen: false } + ) + }); + assert_eq!( + cancels_before, cancels_after, + "entering fullscreen at the inner while the middle is already fullscreen must not emit a spurious exit to the outer" + ); + assert_eq!( + nested.inner_to_middle_frames().count(|message| matches!( + message, + NestedSessionMessage::ToggleHostFullscreen { fullscreen: false } + )), + 0, + "no ToggleHostFullscreen{{false}} is emitted while entering fullscreen" + ); + assert!( + final_grid.contains(&inner_breadcrumb), + "the outer display is fullscreen by the inner content at the end" + ); +} + +const FLOATING_MARKER: &str = "HOST-FLOAT"; + +fn ascend_via_focus_host_binding(nested: &NestedHarness) { + let requested = nested.mark_guest_to_host(); + let ascended = nested.mark_host_to_guest(); + nested.guest.wait_until( + "guest settled in normal mode before the mode key is passed through", + guest_ui_settled, + ); + nested.host.send_stdin(&keys::ctrl('o')); + nested.guest.wait_until( + "guest entered session mode after the passed-through mode key", + session_mode_bar_settled, + ); + nested.host.send_stdin(ASCEND_KEY); + nested.wait_for_guest_to_request_host_focus_after(requested); + nested.wait_for_host_to_ascend_from_guest_after(ascended); +} + +fn split_host_sibling(nested: &NestedHarness) { + nested.host.send_stdin(&keys::ctrl('p')); + nested.host.wait_until( + "host entered pane mode before the split key", + pane_mode_bar_settled, + ); + nested.host.send_stdin(&keys::key('r')); + let sibling = nested.host.expect_pty_spawn(); + sibling.output(b"HOST-SIBLING "); + nested.host.wait_until( + "host returned to normal mode after the split key", + |host_grid| host_grid.contains("HOST-SIBLING"), + ); +} + +fn descend_into_guest_on_the_left(nested: &NestedHarness) { + let descended = nested.mark_host_to_guest(); + nested.host.send_stdin(&keys::ctrl('p')); + nested.host.wait_until( + "host entered pane mode before the focus-left key", + pane_mode_bar_settled, + ); + nested.host.send_stdin(ARROW_LEFT); + nested.host.send_stdin(&keys::ctrl('o')); + nested.host.wait_until( + "host entered session mode before the descend key", + session_mode_bar_settled, + ); + nested.host.send_stdin(DESCEND_KEY); + nested.wait_for_host_to_descend_into_guest_after(descended); +} + +#[test] +fn host_floating_layer_is_hidden_during_nested_fullscreen_and_reappears_on_exit() { + let nested = NestedHarness::start(TERMINAL_SIZE); + + boot_and_descend_on_first_load(&nested); + ascend_via_focus_host_binding(&nested); + split_host_sibling(&nested); + + let observer = nested.host.attach_client(TERMINAL_SIZE); + observer.wait_until( + "observer host client attached focused on the sibling host pane", + |host_grid| host_grid.contains("HOST-SIBLING") && last_line_contains(host_grid, "LOCK"), + ); + + observer.send_stdin(&keys::ctrl('p')); + observer.wait_until( + "observer entered pane mode before the floating pane", + |host_grid| host_grid.contains("PANE") && host_grid.contains("Move"), + ); + observer.send_stdin(&keys::key('w')); + let floating = nested.host.expect_pty_spawn(); + floating.output(FLOATING_MARKER.as_bytes()); + observer.wait_until( + "the host floating pane spawned and is visible on the observer", + |host_grid| host_grid.contains(FLOATING_MARKER), + ); + + observer.send_stdin(&keys::alt('f')); + observer.wait_until("observer hid the host floating pane", |host_grid| { + !host_grid.contains(FLOATING_MARKER) + }); + + descend_into_guest_on_the_left(&nested); + nested + .guest + .wait_until("guest settled after descend", guest_ui_settled); + + let entered = nested.mark_guest_to_host(); + enter_fullscreen_from_descended_guest(&nested); + nested + .guest_to_host() + .wait_for_after(entered, "guest asks host to fullscreen", |message| { + matches!( + message, + NestedSessionMessage::ToggleHostFullscreen { fullscreen: true } + ) + }); + let host_session_name = nested.host.session_name().to_string(); + let guest_session_name = nested.guest.session_name().to_string(); + let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name); + nested + .host + .wait_until("guest fullscreened over the host display", |host_grid| { + host_grid.contains(&expected_breadcrumb) + && !host_own_name_present(host_grid, &host_session_name) + }); + + observer.send_stdin(&keys::alt('f')); + observer.wait_until( + "the host floating layer stays hidden while the guest covers the display, even though show_panes was set", + |host_grid| !host_grid.contains(FLOATING_MARKER) && !host_grid.contains("HOST-SIBLING"), + ); + + let exited = nested.mark_guest_to_host(); + nested.host.send_stdin(&keys::ctrl('o')); + nested.guest.wait_until( + "guest entered session mode before the toggle-off bind", + session_mode_bar_settled, + ); + nested.host.send_stdin(&keys::key('f')); + nested.guest_to_host().wait_for_after( + exited, + "guest asks host to exit fullscreen", + |message| { + matches!( + message, + NestedSessionMessage::ToggleHostFullscreen { fullscreen: false } + ) + }, + ); + + observer.wait_until( + "the host floating pane renders again on the observer after fullscreen exit", + |host_grid| host_grid.contains(FLOATING_MARKER) && host_grid.status_bar_appears(), + ); +} + +#[test] +fn restoring_the_innermost_fullscreen_unzooms_the_whole_chain() { + let nested = NestedDepthThreeHarness::start_depth_three(TERMINAL_SIZE); + let outer_session_name = nested.outer.session_name().to_string(); + let middle_session_name = nested.middle.session_name().to_string(); + let inner_session_name = nested.inner.session_name().to_string(); + + depth_three_descend(&nested); + + enter_fullscreen_from_descended_inner(&nested); + let expected_breadcrumb = breadcrumb_for( + &[&outer_session_name, &middle_session_name], + &inner_session_name, + ); + nested.outer.wait_until( + "outer display fullscreened by the inner before restore", + |outer_grid| { + outer_grid.contains(&expected_breadcrumb) + && !host_own_name_present(outer_grid, &outer_session_name) + }, + ); + + let bubbled = nested.mark_inner_to_middle(); + let outer_told = nested.mark_outer_to_middle(); + let middle_told = nested.mark_middle_to_inner(); + nested.outer.send_stdin(&keys::ctrl('o')); + nested.inner.wait_until( + "inner entered session mode before the toggle-off bind", + session_mode_bar_settled, + ); + nested.outer.send_stdin(&keys::key('f')); + + nested.inner_to_middle_frames().wait_for_after( + bubbled, + "inner asks the middle host to exit fullscreen", + |message| { + matches!( + message, + NestedSessionMessage::ToggleHostFullscreen { fullscreen: false } + ) + }, + ); + nested.outer_to_middle_frames().wait_for_after( + outer_told, + "outer tells the middle it left host fullscreen", + |message| { + matches!( + message, + NestedSessionMessage::FullscreenState { fullscreen: false } + ) + }, + ); + nested.middle_to_inner_frames().wait_for_after( + middle_told, + "middle tells the inner it left host fullscreen", + |message| { + matches!( + message, + NestedSessionMessage::FullscreenState { fullscreen: false } + ) + }, + ); + + nested.outer.wait_until( + "outer chrome restored and breadcrumb gone across the whole chain", + |outer_grid| { + outer_grid.status_bar_appears() + && host_own_name_present(outer_grid, &outer_session_name) + && outer_grid + .lines() + .first() + .map_or(false, |first_line| !first_line.contains('▸')) + }, + ); + nested.middle.wait_until( + "middle chrome restored after chain un-zoom", + |middle_grid| { + middle_grid.status_bar_appears() + && middle_grid + .lines() + .first() + .map_or(false, |first_line| !first_line.contains('▸')) + }, + ); +} + +#[test] +fn clicking_the_ancestor_breadcrumb_segment_ascends_out_of_the_guest() { + let nested = NestedHarness::start_with_host_and_guest_config( + TERMINAL_SIZE, + "mouse_mode true", + "mouse_mode true", + ); + let host_session_name = nested.host.session_name().to_string(); + let guest_session_name = nested.guest.session_name().to_string(); + + boot_and_descend_on_first_load(&nested); + + enter_fullscreen_from_descended_guest(&nested); + let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name); + let fullscreen_grid = nested.host.wait_until( + "guest fullscreened with the breadcrumb visible before clicking", + |host_grid| { + host_grid.lines().first().map_or(false, |first_line| { + first_line.contains(&expected_breadcrumb) + }) + }, + ); + let first_line = fullscreen_grid.lines().into_iter().next().unwrap(); + let (ancestor_start, ancestor_end) = ancestor_segment_range(&first_line); + + let non_ascend_before = nested + .guest_to_host() + .count(|message| matches!(message, NestedSessionMessage::FocusHost { direction: None })); + let name_column = column_of(&first_line, &guest_session_name) + 1; + nested.host.send_stdin(&sgr_mouse_report(name_column, 1, 0)); + nested + .host + .send_stdin(&format!("\u{1b}[<0;{};1m", name_column).into_bytes()); + nested.guest.wait_until( + "guest still fullscreened after a non-ancestor click", + |guest_grid| guest_ui_settled(guest_grid), + ); + assert_eq!( + nested.guest_to_host().count(|message| matches!( + message, + NestedSessionMessage::FocusHost { direction: None } + )), + non_ascend_before, + "clicking the own-name part of the breadcrumb must not ascend" + ); + + let requested = nested.mark_guest_to_host(); + let ascended = nested.mark_host_to_guest(); + let ancestor_click_column = (ancestor_start + ancestor_end) / 2 + 1; + nested + .host + .send_stdin(&sgr_mouse_report(ancestor_click_column, 1, 0)); + nested + .host + .send_stdin(&format!("\u{1b}[<0;{};1m", ancestor_click_column).into_bytes()); + nested.guest_to_host().wait_for_after( + requested, + "clicking the ancestor breadcrumb segment asks the host to take focus back", + |message| matches!(message, NestedSessionMessage::FocusHost { direction: None }), + ); + nested.wait_for_host_to_ascend_from_guest_after(ascended); + + nested.host.wait_until( + "host regained control and restored its chrome after the breadcrumb click", + |host_grid| { + host_grid.status_bar_appears() && host_own_name_present(host_grid, &host_session_name) + }, + ); + + nested.host.send_stdin(&keys::alt('n')); + let host_new_pane = nested.host.expect_pty_spawn(); + host_new_pane.output(b"$ "); + nested.host.wait_until( + "host acts on its own key after the breadcrumb click ascended it", + |host_grid| host_grid.contains("Pane #2"), + ); +} + +#[test] +fn host_manually_breaking_fullscreen_drifts_the_guest_to_not_fullscreen() { + let nested = NestedHarness::start(TERMINAL_SIZE); + let host_session_name = nested.host.session_name().to_string(); + let guest_session_name = nested.guest.session_name().to_string(); + + boot_and_descend_on_first_load(&nested); + + let entered = nested.mark_host_to_guest(); + enter_fullscreen_from_descended_guest(&nested); + nested.host_to_guest().wait_for_after( + entered, + "the guest is told it entered host fullscreen", + |message| { + matches!( + message, + NestedSessionMessage::FullscreenState { fullscreen: true } + ) + }, + ); + let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name); + nested + .host + .wait_until("guest fullscreened over the host display", |host_grid| { + host_grid.contains(&expected_breadcrumb) + && !host_own_name_present(host_grid, &host_session_name) + }); + + let drifted = nested.mark_host_to_guest(); + let exit_code = nested + .host + .run_cli_action(CliAction::ToggleNoUiFullscreen { pane_id: None }); + assert_eq!( + exit_code, 0, + "host toggled its own no-ui fullscreen off cleanly" + ); + + nested.host_to_guest().wait_for_after( + drifted, + "the host's drift detection tells the guest it is no longer fullscreen", + |message| { + matches!( + message, + NestedSessionMessage::FullscreenState { fullscreen: false } + ) + }, + ); + nested.guest.wait_until( + "guest breadcrumb clears after the host manually broke the fullscreen", + |guest_grid| { + guest_grid + .lines() + .first() + .map_or(false, |first_line| !first_line.contains('▸')) + }, + ); + nested.host.wait_until( + "host chrome restored after manually breaking the fullscreen", + |host_grid| { + host_grid.status_bar_appears() && host_own_name_present(host_grid, &host_session_name) + }, + ); +} + +#[test] +fn guest_killed_while_fullscreened_unzooms_the_host_after_liveness_timeout() { + let nested = NestedHarness::start(TERMINAL_SIZE); + let host_session_name = nested.host.session_name().to_string(); + let guest_session_name = nested.guest.session_name().to_string(); + + boot_and_descend_on_first_load(&nested); + + let entered = nested.mark_host_to_guest(); + enter_fullscreen_from_descended_guest(&nested); + nested.host_to_guest().wait_for_after( + entered, + "the guest is told it entered host fullscreen", + |message| { + matches!( + message, + NestedSessionMessage::FullscreenState { fullscreen: true } + ) + }, + ); + let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name); + nested.host.wait_until( + "guest fullscreen composite fully settled before freezing the guest", + |host_grid| { + host_grid.contains(&expected_breadcrumb) + && !host_own_name_present(host_grid, &host_session_name) + && host_grid.cursor_is_at(col(0).row(1)) + }, + ); + + nested.freeze_guest(); + + nested.host.wait_until( + "host un-zooms and restores its chrome after the frozen guest times out", + |host_grid| { + host_grid.status_bar_appears() + && host_own_name_present(host_grid, &host_session_name) + && host_grid + .lines() + .first() + .map_or(false, |first_line| !first_line.contains('▸')) + }, + ); +} + +#[test] +fn fullscreen_breadcrumb_has_no_nested_marker_and_an_emphasized_own_name() { + let nested = NestedHarness::start(TERMINAL_SIZE); + let host_session_name = nested.host.session_name().to_string(); + let guest_session_name = nested.guest.session_name().to_string(); + + boot_and_descend_on_first_load(&nested); + enter_fullscreen_from_descended_guest(&nested); + + let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name); + let fullscreened = nested.guest.wait_until( + "guest tab-bar shows the breadcrumb while fullscreened", + |guest_grid| { + guest_grid.lines().first().map_or(false, |first_line| { + first_line.contains(&expected_breadcrumb) + }) + }, + ); + + let first_line = fullscreened.lines().into_iter().next().unwrap(); + assert!( + !first_line.contains("[NESTED]"), + "the breadcrumb no longer contains the [NESTED] marker" + ); + assert!( + first_line.contains(&format!("{})", guest_session_name)) && first_line.contains('▸'), + "the breadcrumb shows the guest's own name closed by a paren after the arrow" + ); + + let ancestor_column = column_of(&first_line, &host_session_name); + assert!( + fullscreened.char_is_italic(ancestor_column, 0), + "the ancestor breadcrumb segment is italic while descended" + ); + assert!( + !fullscreened.char_is_dim(ancestor_column, 0), + "the descended guest breadcrumb ancestor segment is not dimmed" + ); + + let name_column = column_of(&first_line, &guest_session_name); + assert!( + fullscreened.char_is_bold(name_column, 0), + "the guest's own name in the breadcrumb is bold while descended" + ); + assert!( + !fullscreened.char_is_dim(name_column, 0), + "the descended guest breadcrumb own name is not dimmed" + ); +} diff --git a/zellij-integration-tests/tests/nested_guest_modal.rs b/zellij-integration-tests/tests/nested_guest_modal.rs new file mode 100644 index 00000000..a484256e --- /dev/null +++ b/zellij-integration-tests/tests/nested_guest_modal.rs @@ -0,0 +1,280 @@ +#![cfg(unix)] + +use insta::assert_snapshot; +use zellij_integration_tests::{ + keys, normalized, GridSnapshot, NestedHarness, PROMPT, TERMINAL_SIZE, +}; + +const ARROW_DOWN: &[u8] = b"\x1b[B"; + +fn last_line_contains(grid_snapshot: &GridSnapshot, needle: &str) -> bool { + grid_snapshot + .lines() + .last() + .map_or(false, |last_line| last_line.contains(needle)) +} + +fn modal_visible(grid_snapshot: &GridSnapshot) -> bool { + grid_snapshot.contains("Nested Zellij session detected") +} + +fn grid_contains_line_starting_with(grid_snapshot: &GridSnapshot, prefix: &str) -> bool { + grid_snapshot + .lines() + .iter() + .any(|line| line.trim_start().starts_with(prefix)) +} + +fn wait_for_modal(nested: &NestedHarness) -> GridSnapshot { + nested.wait_for_guest_to_announce(); + nested.wait_for_host_to_acknowledge_guest(); + nested + .host + .wait_until("host shows the guest-detected modal", modal_visible) +} + +fn focus_guest_binding_config() -> &'static str { + r#"keybinds { + normal { + bind "Ctrl y" { FocusGuestSession; } + } +}"# +} + +fn modal_shortcut_binding_config() -> &'static str { + r#"keybinds { + normal { + bind "Ctrl y" { ToggleHostFullscreen; } + bind "Ctrl u" { FocusHostSession; } + bind "Ctrl i" { FocusGuestSession; } + } +}"# +} + +#[test] +fn modal_renders_expected_ui() { + let mut nested = + NestedHarness::start_with_host_config(TERMINAL_SIZE, modal_shortcut_binding_config()); + + let host_with_modal = wait_for_modal(&nested); + assert!(host_with_modal.contains("What would you like to do?")); + assert!(host_with_modal.contains("Zoom in and control this session")); + assert!(host_with_modal.contains("Control this session on focus")); + assert!(host_with_modal.contains("<Ctrl y>")); + assert!(host_with_modal.contains("<Ctrl u>")); + assert!(host_with_modal.contains("<↓↑> select")); + + let steady_modal = nested.host.wait_until( + "guest-detected modal fully rendered with all options and keybindings", + |host_grid| { + modal_visible(host_grid) + && host_grid.contains("What would you like to do?") + && host_grid.contains("Control this session on focus") + && host_grid.contains("<Ctrl y>") + && host_grid.contains("<Ctrl u>") + && host_grid.contains("<↓↑> select") + }, + ); + assert_snapshot!(normalized(&steady_modal)); + + nested.guest.wait_for_app_load(); + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn modal_selection_moves_with_arrow_keys() { + let mut nested = + NestedHarness::start_with_host_config(TERMINAL_SIZE, modal_shortcut_binding_config()); + + wait_for_modal(&nested); + let first_selection = nested + .host + .wait_until("first option selected on initial render", |host_grid| { + grid_contains_line_starting_with(host_grid, "> 1.") + }); + assert!(grid_contains_line_starting_with(&first_selection, "> 1.")); + assert!(!grid_contains_line_starting_with(&first_selection, "> 2.")); + + nested.host.send_stdin(ARROW_DOWN); + let second_selection = nested.host.wait_until( + "selection moved to the second option after arrow down", + |host_grid| grid_contains_line_starting_with(host_grid, "> 2."), + ); + assert!(grid_contains_line_starting_with(&second_selection, "> 2.")); + assert!(!grid_contains_line_starting_with(&second_selection, "> 1.")); + + nested.guest.wait_for_app_load(); + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn modal_appears_on_announce_and_occludes_guest_content() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + let host_with_modal = wait_for_modal(&nested); + assert!(modal_visible(&host_with_modal)); + assert!(host_with_modal.contains("Zoom in and control this session")); + assert!(host_with_modal.contains("Control this session on focus")); + assert_eq!( + nested.focus_gained_count(), + 0, + "the modal must not enter passthrough by itself" + ); + + nested.guest.wait_for_app_load(); + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn selecting_descend_enters_passthrough() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + wait_for_modal(&nested); + let descended = nested.mark_host_to_guest(); + nested.host.send_stdin(ARROW_DOWN); + nested.host.send_stdin(b"\r"); + nested.wait_for_host_to_descend_into_guest_after(descended); + nested + .host + .wait_until("modal dismissed after descend", |host_grid| { + !modal_visible(host_grid) + }); + + nested.guest.wait_for_app_load(); + nested.host.send_stdin(&keys::alt('n')); + let guest_new_pane = nested.guest.expect_pty_spawn(); + guest_new_pane.output(PROMPT); + nested.guest.wait_until( + "guest spawned a pane from the passed-through key after descend", + |guest_grid| guest_grid.contains("Pane #2"), + ); + + nested.guest.quit(); + nested.wait_for_host_to_reclaim_focus_after_guest_exit(); + nested.host.quit(); +} + +#[test] +fn digit_one_zooms_and_enters_passthrough() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + wait_for_modal(&nested); + let zoomed = nested.mark_host_to_guest(); + nested.host.send_stdin(b"1"); + nested.wait_for_host_to_descend_into_guest_after(zoomed); + nested + .host + .wait_until("modal dismissed after zoom", |host_grid| { + !modal_visible(host_grid) + }); + + nested.guest.wait_for_app_load(); + nested.host.send_stdin(&keys::alt('n')); + let guest_new_pane = nested.guest.expect_pty_spawn(); + guest_new_pane.output(PROMPT); + nested.guest.wait_until( + "guest spawned a pane from the passed-through key after zoom", + |guest_grid| guest_grid.contains("Pane #2"), + ); + + nested.guest.quit(); + nested.wait_for_host_to_reclaim_focus_after_guest_exit(); + nested.host.quit(); +} + +#[test] +fn focus_guest_session_redescends_after_ascending() { + let mut nested = + NestedHarness::start_with_host_config(TERMINAL_SIZE, focus_guest_binding_config()); + + wait_for_modal(&nested); + + let descended = nested.mark_host_to_guest(); + nested.host.send_stdin(b"2"); + nested.wait_for_host_to_descend_into_guest_after(descended); + nested + .host + .wait_until("modal answered", |host_grid| !modal_visible(host_grid)); + nested.guest.wait_for_app_load(); + + let ascended = nested.mark_host_to_guest(); + nested.host.send_stdin(&keys::ctrl('o')); + nested.guest.wait_until( + "guest entered session mode before the ascend key", + |guest_grid| last_line_contains(guest_grid, "SESSION"), + ); + nested.host.send_stdin(b"]"); + nested.wait_for_host_to_ascend_from_guest_after(ascended); + + let redescended = nested.mark_host_to_guest(); + nested.host.send_stdin(&keys::ctrl('y')); + nested.wait_for_host_to_descend_into_guest_after(redescended); + + nested.host.send_stdin(&keys::alt('n')); + let guest_new_pane = nested.guest.expect_pty_spawn(); + guest_new_pane.output(PROMPT); + nested.guest.wait_until( + "guest spawned a pane from the passed-through key after FocusGuestSession re-descended", + |guest_grid| guest_grid.contains("Pane #2"), + ); + + nested.guest.quit(); + nested.wait_for_host_to_reclaim_focus_after_guest_exit(); + nested.host.quit(); +} + +#[test] +fn two_clients_answer_their_own_modals_independently() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + let first_with_modal = wait_for_modal(&nested); + assert!(modal_visible(&first_with_modal)); + + let second = nested.host.attach_client(TERMINAL_SIZE); + let second_with_modal = + second.wait_until("the second client also sees the guest modal", modal_visible); + assert!(modal_visible(&second_with_modal)); + + let descended = nested.mark_host_to_guest(); + nested.host.send_stdin(b"2"); + nested.wait_for_host_to_descend_into_guest_after(descended); + nested.host.wait_until( + "the first client's modal is dismissed after it descends", + |host_grid| !modal_visible(host_grid), + ); + + second.wait_until( + "the second client's modal stays up while the first client descended", + modal_visible, + ); + + nested.guest.wait_for_app_load(); + second.quit(); + nested.guest.quit(); + nested.wait_for_host_to_reclaim_focus_after_guest_exit(); + nested.host.quit(); +} + +#[test] +fn host_stops_pinging_a_frozen_guest_after_answering_the_modal() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + wait_for_modal(&nested); + nested.guest.wait_for_app_load(); + + let descended = nested.mark_host_to_guest(); + nested.host.send_stdin(b"2"); + nested.wait_for_host_to_descend_into_guest_after(descended); + nested + .host + .wait_until("modal answered", |host_grid| !modal_visible(host_grid)); + + nested.freeze_guest(); + nested.assert_host_stops_pinging_frozen_guest(); + + nested.guest.quit(); + nested.host.quit(); +} diff --git a/zellij-integration-tests/tests/nested_hardening.rs b/zellij-integration-tests/tests/nested_hardening.rs new file mode 100644 index 00000000..f0c3bc0b --- /dev/null +++ b/zellij-integration-tests/tests/nested_hardening.rs @@ -0,0 +1,523 @@ +#![cfg(unix)] + +use zellij_integration_tests::{ + guest_ascended_bar_settled, keys, FakePtyHandle, GridSnapshot, NestedHarness, Size, PROMPT, + TERMINAL_SIZE, +}; +use zellij_utils::nested_session::NestedSessionMessage; + +const ASCEND_KEY: &[u8] = b"]"; +const DESCEND_KEY: &[u8] = b"["; +const ARROW_LEFT: &[u8] = b"\x1b[D"; + +fn last_line_contains(grid_snapshot: &GridSnapshot, needle: &str) -> bool { + grid_snapshot + .lines() + .last() + .map_or(false, |last_line| last_line.contains(needle)) +} + +fn has_breadcrumb(grid: &GridSnapshot) -> bool { + grid.lines().first().map_or(false, |l| l.contains('▸')) +} + +fn normal_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool { + last_line_contains(grid_snapshot, "LOCK") +} + +fn pane_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool { + last_line_contains(grid_snapshot, "Fullscreen") +} + +fn session_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool { + last_line_contains(grid_snapshot, "SESSION") && last_line_contains(grid_snapshot, "Detach") +} + +fn scroll_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool { + last_line_contains(grid_snapshot, "Scroll") + && last_line_contains(grid_snapshot, "Edit") + && last_line_contains(grid_snapshot, "Select") +} + +fn entering_search_bar_settled(grid_snapshot: &GridSnapshot) -> bool { + last_line_contains(grid_snapshot, "ENTERING SEARCH TERM") +} + +fn guest_normal_mode_bar_settled(guest_grid: &GridSnapshot) -> bool { + guest_grid.lines().last().map_or(false, |last_line| { + if last_line.contains("LOCK") { + return true; + } + let tokens: Vec<String> = last_line + .replace('\u{e0b0}', " ") + .split_whitespace() + .map(|token| token.to_owned()) + .collect(); + ["g", "p", "t", "n", "h", "s", "o", "q"] + .iter() + .all(|mode_letter| tokens.iter().any(|token| token == mode_letter)) + }) +} + +fn guest_ui_settled(guest_grid: &GridSnapshot) -> bool { + let lines = guest_grid.lines(); + lines + .first() + .map_or(false, |first_line| first_line.contains("Tab #1")) + && last_line_contains(guest_grid, "Ctrl +") + && guest_normal_mode_bar_settled(guest_grid) +} + +fn guest_ascended_ui_settled(guest_grid: &GridSnapshot) -> bool { + guest_grid + .lines() + .first() + .map_or(false, |first_line| first_line.contains("Tab #1")) + && guest_ascended_bar_settled(guest_grid) +} + +fn prompt_count(grid_snapshot: &GridSnapshot) -> usize { + grid_snapshot.text.matches('$').count() +} + +fn boot_and_descend_on_first_load(nested: &NestedHarness) { + nested.wait_for_guest_to_announce(); + nested.wait_for_host_to_acknowledge_guest(); + nested.descend_into_guest_via_modal(); + nested.guest.wait_for_app_load(); + nested.wait_for_host_to_ping_guest(); + nested.wait_for_guest_to_reply_to_ping(); + nested + .guest + .wait_until("guest settled in normal mode", guest_ui_settled); +} + +fn ascend_via_focus_host_binding(nested: &NestedHarness) { + let requested = nested.mark_guest_to_host(); + let ascended = nested.mark_host_to_guest(); + nested.guest.wait_until( + "guest settled in normal mode before the mode key is passed through", + guest_ui_settled, + ); + nested.host.send_stdin(&keys::ctrl('o')); + nested.guest.wait_until( + "guest entered session mode after the passed-through mode key", + session_mode_bar_settled, + ); + nested.host.send_stdin(ASCEND_KEY); + nested.wait_for_guest_to_request_host_focus_after(requested); + nested.wait_for_host_to_ascend_from_guest_after(ascended); +} + +fn split_host_sibling(nested: &NestedHarness) -> FakePtyHandle { + nested.host.send_stdin(&keys::ctrl('p')); + nested.host.wait_until( + "host entered pane mode before the split key", + pane_mode_bar_settled, + ); + nested.host.send_stdin(&keys::key('r')); + let sibling = nested.host.expect_pty_spawn(); + sibling.output(PROMPT); + nested.host.wait_until( + "host returned to normal mode after the split key", + |host_grid| normal_mode_bar_settled(host_grid) && prompt_count(host_grid) >= 1, + ); + sibling +} + +fn descend_into_guest_on_the_left(nested: &NestedHarness) { + let descended = nested.mark_host_to_guest(); + nested.host.send_stdin(&keys::ctrl('p')); + nested.host.wait_until( + "host entered pane mode before the focus-left key", + pane_mode_bar_settled, + ); + nested.host.send_stdin(ARROW_LEFT); + nested.host.send_stdin(&keys::ctrl('o')); + nested.host.wait_until( + "host entered session mode before the descend key", + session_mode_bar_settled, + ); + nested.host.send_stdin(DESCEND_KEY); + nested.wait_for_host_to_descend_into_guest_after(descended); +} + +fn build_descended_state_with_host_sibling(nested: &NestedHarness) -> FakePtyHandle { + boot_and_descend_on_first_load(nested); + ascend_via_focus_host_binding(nested); + let sibling = split_host_sibling(nested); + descend_into_guest_on_the_left(nested); + sibling +} + +fn quit_guest_then_host(mut nested: NestedHarness) { + nested.guest.quit(); + nested.wait_for_host_to_reclaim_focus_after_guest_exit(); + nested.host.quit(); +} + +const SMALLER_SIZE: Size = Size { cols: 90, rows: 18 }; + +const LARGER_SIZE: Size = Size { + cols: 140, + rows: 30, +}; + +#[test] +fn resizing_the_terminal_while_descended_keeps_the_guest_sized_to_its_pane() { + let nested = NestedHarness::start(TERMINAL_SIZE); + + boot_and_descend_on_first_load(&nested); + + let guest_pane_before = nested.host_pane.size().expect("guest pane sized on load"); + let guest_rows_before = nested.guest.snapshot().row_count(); + + nested.host.resize(SMALLER_SIZE); + let guest_pane_smaller = nested.host_pane.wait_for_size( + "the guest pane shrinks with the host terminal while descended", + move |cols, rows| (cols, rows) != guest_pane_before, + ); + assert!( + guest_pane_smaller.0 < guest_pane_before.0 || guest_pane_smaller.1 < guest_pane_before.1, + "the guest pane must shrink when the host terminal shrinks \ + (before={guest_pane_before:?}, after={guest_pane_smaller:?})", + ); + let guest_after_shrink = nested.guest.wait_until( + "the guest re-renders at the smaller pane size while staying descended", + |guest_grid| { + guest_grid.row_count() == guest_pane_smaller.1 as usize && guest_grid.tab_bar_appears() + }, + ); + assert!( + guest_after_shrink.row_count() < guest_rows_before, + "the descended guest must render fewer rows after the host shrinks", + ); + + nested.host.resize(LARGER_SIZE); + let guest_pane_larger = nested.host_pane.wait_for_size( + "the guest pane grows with the host terminal while descended", + move |cols, rows| (cols, rows) != guest_pane_smaller, + ); + nested.guest.wait_until( + "the guest re-renders at the larger pane size while staying descended", + |guest_grid| { + guest_grid.row_count() == guest_pane_larger.1 as usize && guest_ui_settled(guest_grid) + }, + ); + + nested.host.send_stdin(&keys::alt('n')); + let guest_new_pane = nested.guest.expect_pty_spawn(); + guest_new_pane.output(PROMPT); + nested.guest.wait_until( + "a key after the resize still reaches the descended guest", + |guest_grid| guest_grid.contains("Pane #2") && guest_grid.contains("Pane #1"), + ); + assert_eq!( + nested.focus_lost_count(), + 0, + "resizing while descended must not ascend the host out of the guest", + ); + + quit_guest_then_host(nested); +} + +#[test] +fn resizing_the_terminal_while_nested_fullscreen_keeps_the_guest_filling_the_display() { + let nested = NestedHarness::start(TERMINAL_SIZE); + + boot_and_descend_on_first_load(&nested); + + let entered = nested.mark_guest_to_host(); + nested.host.send_stdin(&keys::ctrl('o')); + nested.guest.wait_until( + "guest entered session mode before the fullscreen bind", + session_mode_bar_settled, + ); + nested.host.send_stdin(&keys::key('f')); + nested.guest_to_host().wait_for_after( + entered, + "the descended guest asks its host to fullscreen its pane", + |message| { + matches!( + message, + NestedSessionMessage::ToggleHostFullscreen { fullscreen: true } + ) + }, + ); + + let fullscreen_pane_before = nested.host.wait_until( + "the guest fills the whole host display before the resize", + |host_grid| { + has_breadcrumb(host_grid) + && host_grid.contains("Tab #1") + && host_grid.row_count() == TERMINAL_SIZE.rows + }, + ); + let host_rows_before = fullscreen_pane_before.row_count(); + + nested.host.resize(LARGER_SIZE); + let host_after_resize = nested.host.wait_until( + "the fullscreened guest still fills the whole enlarged host display", + |host_grid| { + host_grid.row_count() == LARGER_SIZE.rows + && has_breadcrumb(host_grid) + && host_grid.contains("Tab #1") + }, + ); + assert!( + host_after_resize.row_count() > host_rows_before, + "the host display must render more rows after the terminal grows", + ); + assert!( + host_after_resize.row_of_line("Pane #1").is_none(), + "no stray host pane-title line while the guest is fullscreened after resize", + ); + nested.guest.wait_until( + "the guest re-renders filling the enlarged display while fullscreened", + |guest_grid| guest_grid.row_count() == LARGER_SIZE.rows && has_breadcrumb(guest_grid), + ); + + let exited = nested.mark_guest_to_host(); + nested.host.send_stdin(&keys::ctrl('o')); + nested.guest.wait_until( + "guest entered session mode before the toggle-off bind", + session_mode_bar_settled, + ); + nested.host.send_stdin(&keys::key('f')); + nested.guest_to_host().wait_for_after( + exited, + "the guest asks its host to exit fullscreen", + |message| { + matches!( + message, + NestedSessionMessage::ToggleHostFullscreen { fullscreen: false } + ) + }, + ); + nested.host.wait_until( + "the host chrome returns at the enlarged size after exiting fullscreen", + |host_grid| { + host_grid.row_count() == LARGER_SIZE.rows + && !has_breadcrumb(host_grid) + && host_grid.tab_bar_appears() + && host_grid.status_bar_appears() + }, + ); + + quit_guest_then_host(nested); +} + +#[test] +fn host_scrollback_on_a_guest_pane_scrolls_the_host_not_the_guest() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + boot_and_descend_on_first_load(&nested); + ascend_via_focus_host_binding(&nested); + + nested.host.send_stdin(&keys::ctrl('s')); + nested.host.wait_until( + "the host enters scroll mode over the guest pane", + scroll_mode_bar_settled, + ); + + let focus_gained_before_scroll = nested.focus_gained_count(); + nested.host.send_stdin(&keys::key('k')); + nested.host.send_stdin(&keys::key('k')); + nested.host.wait_until( + "the host stays in scroll mode after scrolling the guest pane", + scroll_mode_bar_settled, + ); + assert_eq!( + nested.focus_gained_count(), + focus_gained_before_scroll, + "scrolling the host over a guest pane must not descend into the guest", + ); + + nested.host.send_stdin(&keys::ESC); + let host_back_to_normal = nested.host.wait_until( + "the host returns to normal mode after scrolling the guest pane", + |host_grid| normal_mode_bar_settled(host_grid), + ); + nested.guest.wait_until( + "the guest still shows its ascended UI undisturbed by the host scrolling its pane", + guest_ascended_ui_settled, + ); + assert!(normal_mode_bar_settled(&host_back_to_normal)); + + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn host_search_over_a_guest_pane_stays_host_side() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + boot_and_descend_on_first_load(&nested); + ascend_via_focus_host_binding(&nested); + + nested.host.send_stdin(&keys::ctrl('s')); + nested.host.wait_until( + "the host enters scroll mode over the guest pane", + scroll_mode_bar_settled, + ); + nested.host.send_stdin(&keys::key('s')); + nested.host.wait_until( + "the host enters search-input over the guest pane", + entering_search_bar_settled, + ); + + let focus_gained_before_search = nested.focus_gained_count(); + nested.host.send_stdin(b"Tab"); + nested.host.send_stdin(&keys::ESC); + nested.host.wait_until( + "the host settles back in scroll mode after the search input", + scroll_mode_bar_settled, + ); + assert_eq!( + nested.focus_gained_count(), + focus_gained_before_search, + "searching the host over a guest pane must not descend into the guest", + ); + + nested.host.send_stdin(&keys::ESC); + nested.host.wait_until( + "the host returns to normal mode after searching the guest pane", + |host_grid| normal_mode_bar_settled(host_grid), + ); + nested.guest.wait_until( + "the guest still shows its ascended UI undisturbed by the host searching its pane", + guest_ascended_ui_settled, + ); + + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn closing_a_host_sibling_pane_while_descended_keeps_the_guest_live() { + let nested = NestedHarness::start(TERMINAL_SIZE); + + let sibling = build_descended_state_with_host_sibling(&nested); + nested.host.wait_until( + "the host shows its guest pane and its sibling before the sibling closes", + |host_grid| host_grid.contains("Pane #2"), + ); + nested.guest.wait_until( + "the guest is settled while the host is descended into it", + guest_ui_settled, + ); + + sibling.exit(Some(0)); + nested.host.wait_until( + "the host closed its exited sibling, leaving only the guest pane", + |host_grid| !host_grid.contains("Pane #2"), + ); + + nested.host.send_stdin(&keys::alt('n')); + let guest_new_pane = nested.guest.expect_pty_spawn(); + guest_new_pane.output(PROMPT); + nested.guest.wait_until( + "the guest is still live and reacts to keys after the host sibling closed", + |guest_grid| guest_grid.contains("Pane #2") && guest_grid.contains("│"), + ); + + quit_guest_then_host(nested); +} + +#[test] +fn closing_an_inner_guest_pane_while_descended_keeps_the_guest_live() { + let nested = NestedHarness::start(TERMINAL_SIZE); + + boot_and_descend_on_first_load(&nested); + + nested.host.send_stdin(&keys::alt('n')); + let guest_second_pane = nested.guest.expect_pty_spawn(); + guest_second_pane.output(PROMPT); + nested.guest.wait_until( + "the guest opened a second inner pane from the passed-through key", + |guest_grid| { + guest_grid.contains("Pane #1") + && guest_grid.contains("Pane #2") + && guest_ui_settled(guest_grid) + }, + ); + + guest_second_pane.exit(Some(0)); + nested.guest.wait_until( + "the guest closed its inner pane and returned to a single pane", + |guest_grid| { + !guest_grid.contains("Pane #2") + && guest_grid.tab_bar_appears() + && guest_grid.cursor.is_some() + }, + ); + + nested.host.send_stdin(&keys::alt('n')); + let guest_new_pane = nested.guest.expect_pty_spawn(); + guest_new_pane.output(PROMPT); + nested.guest.wait_until( + "the guest is still descended-into and reacts to keys after closing an inner pane", + |guest_grid| guest_grid.contains("Pane #2") && guest_grid.contains("│"), + ); + + quit_guest_then_host(nested); +} + +#[test] +fn detaching_the_guest_client_while_descended_returns_control_to_the_host() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + boot_and_descend_on_first_load(&nested); + + nested.guest.detach_main_client(); + nested.wait_for_host_to_reclaim_focus_after_guest_exit(); + + let sibling = split_host_sibling(&nested); + sibling.output(PROMPT); + nested.host.wait_until( + "the host acts on its own keybindings again after the guest detached", + |host_grid| host_grid.contains("Pane #2") && normal_mode_bar_settled(host_grid), + ); + + nested.host.quit(); +} + +#[test] +fn opening_the_host_session_manager_with_a_guest_pane_does_not_misbehave() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + boot_and_descend_on_first_load(&nested); + ascend_via_focus_host_binding(&nested); + + nested.host.send_stdin(&keys::ctrl('o')); + nested.host.wait_until( + "the host entered session mode before launching the session manager", + session_mode_bar_settled, + ); + nested.host.send_stdin(&keys::key('w')); + nested.host.wait_until( + "the host session manager opened over its guest pane", + |host_grid| host_grid.contains("Session Manager") && host_grid.status_bar_appears(), + ); + + nested.host.send_stdin(&keys::ctrl('c')); + nested.host.wait_until( + "the host session manager closed and normal chrome returned", + |host_grid| !host_grid.contains("Session Manager") && normal_mode_bar_settled(host_grid), + ); + + nested.guest.wait_until( + "the guest is undisturbed by the host session manager", + guest_ascended_ui_settled, + ); + nested.host.send_stdin(&keys::alt('n')); + let host_new_pane = nested.host.expect_pty_spawn(); + host_new_pane.output(PROMPT); + nested.host.wait_until( + "the host still acts on its own keybindings after the session manager closed", + |host_grid| host_grid.contains("Pane #2") && normal_mode_bar_settled(host_grid), + ); + + nested.guest.quit(); + nested.host.quit(); +} diff --git a/zellij-integration-tests/tests/nested_session_handling_config.rs b/zellij-integration-tests/tests/nested_session_handling_config.rs new file mode 100644 index 00000000..7e7a8705 --- /dev/null +++ b/zellij-integration-tests/tests/nested_session_handling_config.rs @@ -0,0 +1,127 @@ +#![cfg(unix)] + +use zellij_integration_tests::{keys, GridSnapshot, NestedHarness, PROMPT, TERMINAL_SIZE}; + +fn modal_visible(grid_snapshot: &GridSnapshot) -> bool { + grid_snapshot.contains("Nested Zellij session detected") +} + +fn descend_config() -> &'static str { + r#"nested_session_handling "descend""# +} + +fn fullscreen_config() -> &'static str { + r#"nested_session_handling "fullscreen""# +} + +fn never_config() -> &'static str { + r#"nested_session_handling "never" +keybinds { + normal { + bind "Ctrl y" { FocusGuestSession; } + } +}"# +} + +#[test] +fn descend_mode_auto_descends_without_modal() { + let mut nested = NestedHarness::start_with_host_config(TERMINAL_SIZE, descend_config()); + + nested.wait_for_guest_to_announce(); + nested.wait_for_host_to_acknowledge_guest(); + nested.wait_for_host_to_descend_into_guest(); + nested + .host + .wait_until("no modal is shown in descend mode", |host_grid| { + !modal_visible(host_grid) + }); + + nested.guest.wait_for_app_load(); + nested.host.send_stdin(&keys::alt('n')); + let guest_new_pane = nested.guest.expect_pty_spawn(); + guest_new_pane.output(PROMPT); + nested.guest.wait_until( + "guest spawned a pane from the passed-through key after auto-descend", + |guest_grid| guest_grid.contains("Pane #2"), + ); + + nested.guest.quit(); + nested.wait_for_host_to_reclaim_focus_after_guest_exit(); + nested.host.quit(); +} + +#[test] +fn fullscreen_mode_auto_zooms_without_modal() { + let mut nested = NestedHarness::start_with_host_config(TERMINAL_SIZE, fullscreen_config()); + + nested.wait_for_guest_to_announce(); + nested.wait_for_host_to_acknowledge_guest(); + nested.wait_for_host_to_descend_into_guest(); + nested + .host + .wait_until("no modal is shown in fullscreen mode", |host_grid| { + !modal_visible(host_grid) + }); + + nested.guest.wait_for_app_load(); + nested.host.send_stdin(&keys::alt('n')); + let guest_new_pane = nested.guest.expect_pty_spawn(); + guest_new_pane.output(PROMPT); + nested.guest.wait_until( + "guest spawned a pane from the passed-through key after auto-zoom", + |guest_grid| guest_grid.contains("Pane #2"), + ); + + nested.guest.quit(); + nested.wait_for_host_to_reclaim_focus_after_guest_exit(); + nested.host.quit(); +} + +#[test] +fn never_mode_shows_no_modal_and_never_descends() { + let mut nested = NestedHarness::start_with_host_config(TERMINAL_SIZE, never_config()); + + nested.wait_for_guest_to_announce(); + nested.wait_for_host_to_acknowledge_guest(); + nested.guest.wait_for_app_load(); + + nested + .host + .wait_until("no modal is shown in never mode", |host_grid| { + !modal_visible(host_grid) + }); + assert_eq!( + nested.focus_gained_count(), + 0, + "never mode must not descend into the guest by itself" + ); + + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn never_mode_allows_manual_descend() { + let mut nested = NestedHarness::start_with_host_config(TERMINAL_SIZE, never_config()); + + nested.wait_for_guest_to_announce(); + nested.wait_for_host_to_acknowledge_guest(); + nested.guest.wait_for_app_load(); + assert_eq!(nested.focus_gained_count(), 0); + + let descended = nested.mark_host_to_guest(); + nested.host.send_stdin(&keys::ctrl('y')); + nested.wait_for_host_to_descend_into_guest_after(descended); + + nested.host.send_stdin(&keys::alt('n')); + let guest_new_pane = nested.guest.expect_pty_spawn(); + guest_new_pane.output(PROMPT); + nested.guest.wait_until( + "guest spawned a pane from the passed-through key after manual FocusGuestSession", + |guest_grid| guest_grid.contains("Pane #2"), + ); + + nested.guest.quit(); + nested.wait_for_host_to_reclaim_focus_after_guest_exit(); + nested.host.quit(); +} diff --git a/zellij-integration-tests/tests/nested_sessions.rs b/zellij-integration-tests/tests/nested_sessions.rs new file mode 100644 index 00000000..d2fe8c53 --- /dev/null +++ b/zellij-integration-tests/tests/nested_sessions.rs @@ -0,0 +1,1492 @@ +#![cfg(unix)] + +use insta::assert_snapshot; +use zellij_integration_tests::{ + col, composite_contains_settled_guest_grid, guest_ascended_bar_settled, + host_descended_bar_settled, host_descended_bar_with_ascend_keys_settled, keys, normalized, + wait_for_settled_composite, GridSnapshot, NestedDepthThreeHarness, NestedHarness, PROMPT, + TERMINAL_SIZE, +}; +use zellij_utils::data::Direction; +use zellij_utils::nested_session::NestedSessionMessage; + +const ARROW_UP: &[u8] = b"\x1b[A"; +const ARROW_LEFT: &[u8] = b"\x1b[D"; +const ARROW_RIGHT: &[u8] = b"\x1b[C"; +const ARROW_DOWN: &[u8] = b"\x1b[B"; +const ASCEND_KEY: &[u8] = b"]"; +const DESCEND_KEY: &[u8] = b"["; +const ALT_ARROW_RIGHT: &[u8] = b"\x1b[1;3C"; + +fn sgr_mouse_report(column: usize, line: usize, button: u8) -> Vec<u8> { + format!("\u{1b}[<{};{};{}M", button, column, line).into_bytes() +} + +fn prompt_count(grid_snapshot: &GridSnapshot) -> usize { + grid_snapshot.text.matches('$').count() +} + +fn session_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool { + last_line_contains(grid_snapshot, "SESSION") && last_line_contains(grid_snapshot, "Detach") +} + +fn last_line_contains(grid_snapshot: &GridSnapshot, needle: &str) -> bool { + grid_snapshot + .lines() + .last() + .map_or(false, |last_line| last_line.contains(needle)) +} + +fn normal_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool { + last_line_contains(grid_snapshot, "LOCK") +} + +fn pane_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool { + last_line_contains(grid_snapshot, "Fullscreen") +} + +fn guest_pane_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool { + last_line_contains(grid_snapshot, "PANE") && last_line_contains(grid_snapshot, "Move") +} + +fn guest_tab_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool { + last_line_contains(grid_snapshot, "TAB") && last_line_contains(grid_snapshot, "New") +} + +fn guest_normal_mode_bar_settled(guest_grid: &GridSnapshot) -> bool { + guest_grid.lines().last().map_or(false, |last_line| { + if last_line.contains("LOCK") { + return true; + } + let tokens: Vec<String> = last_line + .replace('\u{e0b0}', " ") + .split_whitespace() + .map(|token| token.to_owned()) + .collect(); + ["g", "p", "t", "n", "h", "s", "o", "q"] + .iter() + .all(|mode_letter| tokens.iter().any(|token| token == mode_letter)) + }) +} + +fn guest_ui_settled(guest_grid: &GridSnapshot) -> bool { + let lines = guest_grid.lines(); + lines + .first() + .map_or(false, |first_line| first_line.contains("Tab #1")) + && last_line_contains(guest_grid, "Ctrl +") + && guest_normal_mode_bar_settled(guest_grid) +} + +fn single_blank_pane_guest_settled(guest_grid: &GridSnapshot) -> bool { + guest_ui_settled(guest_grid) && guest_grid.cursor_is_at(col(0).row(1)) +} + +fn guest_ascended_ui_settled(guest_grid: &GridSnapshot) -> bool { + guest_grid + .lines() + .first() + .map_or(false, |first_line| first_line.contains("Tab #1")) + && guest_ascended_bar_settled(guest_grid) +} + +fn ascended_single_blank_pane_guest_settled(guest_grid: &GridSnapshot) -> bool { + guest_ascended_ui_settled(guest_grid) && prompt_count(guest_grid) == 0 +} + +fn ascended_two_pane_guest_settled(guest_grid: &GridSnapshot) -> bool { + guest_ascended_ui_settled(guest_grid) + && guest_grid.contains("Pane #1") + && guest_grid.contains("Pane #2") + && prompt_count(guest_grid) == 1 +} + +fn two_pane_guest_settled(guest_grid: &GridSnapshot) -> bool { + guest_ui_settled(guest_grid) + && guest_grid.contains("Pane #1") + && guest_grid.contains("Pane #2") + && prompt_count(guest_grid) == 1 + && guest_grid.cursor.is_some() +} + +fn host_own_tab_bar_settled(grid_snapshot: &GridSnapshot, guest_session_name: &str) -> bool { + let lines = grid_snapshot.lines(); + let first_row_is_host_tab_bar = lines.first().map_or(false, |first_line| { + first_line.contains("Tab #1") && !first_line.contains(guest_session_name) + }); + let guest_pane_title_row_present = lines.get(1).map_or(false, |second_line| { + second_line.contains(guest_session_name) + }); + first_row_is_host_tab_bar && guest_pane_title_row_present +} + +fn host_tab_bar_renamed_to_guest(grid_snapshot: &GridSnapshot, guest_session_name: &str) -> bool { + grid_snapshot + .lines() + .first() + .map_or(false, |first_line| first_line.contains(guest_session_name)) +} + +fn host_chrome_dimmed(host_grid: &GridSnapshot) -> bool { + host_grid.line_has_dim("Pane #2") && host_grid.char_dim_of("test-").map_or(false, |dim| dim) +} + +fn host_chrome_undimmed(host_grid: &GridSnapshot) -> bool { + !host_grid.line_has_dim("Pane #2") && host_grid.char_dim_of("test-").map_or(true, |dim| !dim) +} + +#[test] +fn a_guest_zellij_running_inside_a_host_zellij_pane_introduces_itself_and_is_kept_alive() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + nested.wait_for_guest_to_announce(); + nested.wait_for_host_to_acknowledge_guest(); + nested.descend_into_guest_via_modal(); + nested.wait_for_host_to_ping_guest(); + nested.wait_for_guest_to_reply_to_ping(); + + nested.guest.wait_for_app_load(); + nested.host.wait_for_app_load(); + + nested.guest.quit(); + nested.wait_for_host_to_release_guest_focus(); + nested.host.quit(); +} + +#[test] +fn a_host_zellij_stops_pinging_a_guest_that_freezes_inside_one_of_its_panes() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + nested.wait_for_guest_to_announce(); + nested.wait_for_host_to_acknowledge_guest(); + nested.wait_for_host_to_ping_guest(); + + nested.freeze_guest(); + nested.assert_host_stops_pinging_frozen_guest(); + + nested.guest.quit(); + nested.host.quit(); +} + +fn boot_and_descend_on_first_load(nested: &NestedHarness) { + nested.wait_for_guest_to_announce(); + nested.wait_for_host_to_acknowledge_guest(); + nested.descend_into_guest_via_modal(); + nested.guest.wait_for_app_load(); + nested.wait_for_host_to_ping_guest(); + nested.wait_for_guest_to_reply_to_ping(); +} + +fn ascend_via_focus_host_binding(nested: &NestedHarness) { + let requested = nested.mark_guest_to_host(); + let ascended = nested.mark_host_to_guest(); + nested.guest.wait_until( + "guest settled in normal mode before the mode key is passed through", + guest_ui_settled, + ); + nested.host.send_stdin(&keys::ctrl('o')); + nested.guest.wait_until( + "guest entered session mode after the passed-through mode key", + session_mode_bar_settled, + ); + nested.host.send_stdin(ASCEND_KEY); + nested.wait_for_guest_to_request_host_focus_after(requested); + nested.wait_for_host_to_ascend_from_guest_after(ascended); +} + +fn split_host_sibling(nested: &NestedHarness) { + nested.host.send_stdin(&keys::ctrl('p')); + nested.host.wait_until( + "host entered pane mode before the split key", + pane_mode_bar_settled, + ); + nested.host.send_stdin(&keys::key('r')); + let sibling = nested.host.expect_pty_spawn(); + sibling.output(PROMPT); + nested.host.wait_until( + "host returned to normal mode after the split key", + |host_grid| normal_mode_bar_settled(host_grid) && prompt_count(host_grid) >= 1, + ); +} + +fn descend_into_guest_on_the_left(nested: &NestedHarness) { + let descended = nested.mark_host_to_guest(); + nested.host.send_stdin(&keys::ctrl('p')); + nested.host.wait_until( + "host entered pane mode before the focus-left key", + pane_mode_bar_settled, + ); + nested.host.send_stdin(ARROW_LEFT); + nested.host.send_stdin(&keys::ctrl('o')); + nested.host.wait_until( + "host entered session mode before the descend key", + session_mode_bar_settled, + ); + nested.host.send_stdin(DESCEND_KEY); + nested.wait_for_host_to_descend_into_guest_after(descended); +} + +fn build_descended_state_with_host_sibling(nested: &NestedHarness) { + boot_and_descend_on_first_load(nested); + ascend_via_focus_host_binding(nested); + split_host_sibling(nested); + descend_into_guest_on_the_left(nested); +} + +fn split_host_sibling_below(nested: &NestedHarness) { + nested.host.send_stdin(&keys::ctrl('p')); + nested.host.wait_until( + "host entered pane mode before the split-down key", + pane_mode_bar_settled, + ); + nested.host.send_stdin(&keys::key('d')); + let sibling = nested.host.expect_pty_spawn(); + sibling.output(PROMPT); + nested.host.wait_until( + "host returned to normal mode after the split-down key", + |host_grid| normal_mode_bar_settled(host_grid) && prompt_count(host_grid) >= 1, + ); +} + +fn descend_into_guest_above(nested: &NestedHarness) { + let descended = nested.mark_host_to_guest(); + nested.host.send_stdin(&keys::ctrl('p')); + nested.host.wait_until( + "host entered pane mode before the focus-up key", + pane_mode_bar_settled, + ); + nested.host.send_stdin(ARROW_UP); + nested.host.send_stdin(&keys::ctrl('o')); + nested.host.wait_until( + "host entered session mode before the descend key", + session_mode_bar_settled, + ); + nested.host.send_stdin(DESCEND_KEY); + nested.wait_for_host_to_descend_into_guest_after(descended); +} + +fn build_descended_state_with_host_sibling_below(nested: &NestedHarness) { + boot_and_descend_on_first_load(nested); + ascend_via_focus_host_binding(nested); + split_host_sibling_below(nested); + descend_into_guest_above(nested); +} + +fn quit_guest_then_host(mut nested: NestedHarness) { + nested.guest.quit(); + nested.wait_for_host_to_reclaim_focus_after_guest_exit(); + nested.host.quit(); +} + +#[test] +fn host_keys_act_on_host_until_focus_enters_the_guest_then_route_to_guest() { + let nested = NestedHarness::start(TERMINAL_SIZE); + let guest_session_name = nested.guest.session_name().to_string(); + + boot_and_descend_on_first_load(&nested); + ascend_via_focus_host_binding(&nested); + + split_host_sibling(&nested); + let host_focused_on_sibling = nested.wait_until_host_composites_settled_guest( + "host acted on its own split keybind and created a sibling pane", + ascended_single_blank_pane_guest_settled, + |host_grid| { + normal_mode_bar_settled(host_grid) + && host_grid.contains("Pane #2") + && prompt_count(host_grid) == 1 + && host_own_tab_bar_settled(host_grid, &guest_session_name) + }, + ); + assert_snapshot!( + "host_keys_act_on_host_before_descend", + normalized(&host_focused_on_sibling) + ); + + descend_into_guest_on_the_left(&nested); + let host_descended = nested.wait_until_host_composites_settled_guest( + "host descended into the guest pane, showing the guest nested inside", + single_blank_pane_guest_settled, + |host_grid| { + host_descended_bar_settled(host_grid) + && host_grid.contains("Pane #2") + && prompt_count(host_grid) == 1 + && host_own_tab_bar_settled(host_grid, &guest_session_name) + }, + ); + assert_snapshot!("host_grid_while_descended", normalized(&host_descended)); + + nested.host.send_stdin(&keys::alt('n')); + let guest_new_pane = nested.guest.expect_pty_spawn(); + guest_new_pane.output(PROMPT); + let guest_after_new_pane = nested.guest.wait_until( + "guest spawned a second pane from the passed-through key", + two_pane_guest_settled, + ); + assert_snapshot!( + "guest_reacts_to_passed_through_new_pane_key", + normalized(&guest_after_new_pane) + ); + + let host_pane_count_unchanged = nested.wait_until_host_composites_settled_guest( + "host still shows exactly its own two panes while the guest gained an inner pane", + two_pane_guest_settled, + |host_grid| { + host_descended_bar_settled(host_grid) + && host_grid.contains("Pane #2") + && !host_grid.contains("Pane #3") + && host_own_tab_bar_settled(host_grid, &guest_session_name) + }, + ); + assert_snapshot!( + "host_pane_count_unchanged_while_descended", + normalized(&host_pane_count_unchanged) + ); + + quit_guest_then_host(nested); +} + +#[test] +fn focus_host_session_binding_returns_control_to_host() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + let guest_session_name = nested.guest.session_name().to_string(); + + build_descended_state_with_host_sibling(&nested); + + let requested = nested.mark_guest_to_host(); + let ascended = nested.mark_host_to_guest(); + nested.host.send_stdin(&keys::ctrl('o')); + nested.host.send_stdin(ASCEND_KEY); + nested.wait_for_guest_to_request_host_focus_after(requested); + nested.wait_for_host_to_ascend_from_guest_after(ascended); + + let host_ascended = nested.wait_until_host_composites_settled_guest( + "host took control back from the guest", + ascended_single_blank_pane_guest_settled, + |host_grid| { + normal_mode_bar_settled(host_grid) + && host_grid.contains("Pane #2") + && prompt_count(host_grid) == 1 + && host_own_tab_bar_settled(host_grid, &guest_session_name) + }, + ); + assert_snapshot!( + "host_ascended_after_focus_host_binding", + normalized(&host_ascended) + ); + + nested.host.send_stdin(&keys::alt('n')); + let host_new_pane = nested.host.expect_pty_spawn(); + host_new_pane.output(PROMPT); + let host_after_new_pane = nested.wait_until_host_composites_settled_guest( + "host acted on its own key after ascending", + ascended_single_blank_pane_guest_settled, + |host_grid| { + normal_mode_bar_settled(host_grid) + && host_grid.contains("Pane #3") + && prompt_count(host_grid) == 2 + && host_own_tab_bar_settled(host_grid, &guest_session_name) + }, + ); + assert_snapshot!( + "host_acts_on_key_after_ascend", + normalized(&host_after_new_pane) + ); + + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn clicking_a_host_pane_while_descended_refocuses_and_ascends() { + let mut nested = NestedHarness::start_with_host_config(TERMINAL_SIZE, "mouse_mode true"); + let guest_session_name = nested.guest.session_name().to_string(); + + build_descended_state_with_host_sibling(&nested); + + let ascended = nested.mark_host_to_guest(); + nested.host.send_stdin(&sgr_mouse_report(90, 8, 0)); + nested.wait_for_host_to_ascend_from_guest_after(ascended); + + let host_after_click = nested.wait_until_host_composites_settled_guest( + "host focus moved onto the clicked host pane", + ascended_single_blank_pane_guest_settled, + |host_grid| { + normal_mode_bar_settled(host_grid) + && host_grid.contains("Pane #2") + && prompt_count(host_grid) == 1 + && host_own_tab_bar_settled(host_grid, &guest_session_name) + }, + ); + assert_snapshot!( + "host_ascended_after_mouse_click", + normalized(&host_after_click) + ); + + nested.host.send_stdin(&keys::alt('n')); + let host_new_pane = nested.host.expect_pty_spawn(); + host_new_pane.output(PROMPT); + let host_after_new_pane = nested.wait_until_host_composites_settled_guest( + "host acted on its own key after the click ascended it", + ascended_single_blank_pane_guest_settled, + |host_grid| { + normal_mode_bar_settled(host_grid) + && host_grid.contains("Pane #3") + && prompt_count(host_grid) == 2 + && host_own_tab_bar_settled(host_grid, &guest_session_name) + }, + ); + assert_snapshot!( + "host_acts_on_key_after_mouse_ascend", + normalized(&host_after_new_pane) + ); + + nested.guest.quit(); + nested.host.quit(); +} + +fn sgr_left_click(column: usize, line: usize) -> Vec<u8> { + format!( + "\u{1b}[<0;{};{}M\u{1b}[<0;{};{}m", + column, line, column, line + ) + .into_bytes() +} + +fn display_column_of(line: &str, needle: &str) -> Option<usize> { + line.find(needle) + .map(|byte_offset| line[..byte_offset].chars().count()) +} + +#[test] +fn clicking_the_host_new_tab_button_while_descended_ascends_and_keeps_working() { + let mut nested = NestedHarness::start_with_host_config(TERMINAL_SIZE, "mouse_mode true"); + let guest_session_name = nested.guest.session_name().to_string(); + + boot_and_descend_on_first_load(&nested); + nested.guest.wait_for_app_load(); + + let host_before = nested.host.wait_until( + "the host tab bar shows the new tab button while descended", + |host_grid| { + host_grid + .lines() + .first() + .is_some_and(|tab_bar| tab_bar.contains('+')) + }, + ); + let tab_bar = host_before.lines().first().cloned().unwrap(); + let new_tab_button_column = + display_column_of(&tab_bar, "+").expect("new tab button is on the host tab bar") + 1; + + let ascended = nested.mark_host_to_guest(); + nested + .host + .send_stdin(&sgr_left_click(new_tab_button_column, 1)); + let host_new_tab_pane = nested.host.expect_pty_spawn(); + host_new_tab_pane.output(PROMPT); + nested.wait_for_host_to_ascend_from_guest_after(ascended); + + let host_after_new_tab = nested.host.wait_until( + "the host opened a second tab and undimmed after clicking the new tab button", + |host_grid| { + host_grid.lines().first().map_or(false, |tab_bar| { + tab_bar.contains("Tab #1") && tab_bar.contains("Tab #2") + }) && normal_mode_bar_settled(host_grid) + }, + ); + assert_snapshot!( + "host_ascended_after_clicking_new_tab_button", + normalized(&host_after_new_tab) + ); + + nested.host.send_stdin(&keys::alt('n')); + let host_new_pane = nested.host.expect_pty_spawn(); + host_new_pane.output(PROMPT); + nested.host.wait_until( + "the host acts on its own key after clicking the new tab button ascended it", + |host_grid| host_grid.contains("Pane #2") && normal_mode_bar_settled(host_grid), + ); + + let _ = guest_session_name; + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn clicking_a_host_tab_while_descended_switches_tabs_and_ascends() { + let mut nested = NestedHarness::start_with_host_config(TERMINAL_SIZE, "mouse_mode true"); + let guest_session_name = nested.guest.session_name().to_string(); + + boot_and_descend_on_first_load(&nested); + nested.guest.wait_for_app_load(); + + let host_before = nested.host.wait_until( + "the host tab bar shows the new tab button while descended", + |host_grid| { + host_grid + .lines() + .first() + .is_some_and(|tab_bar| tab_bar.contains('+')) + }, + ); + let tab_bar = host_before.lines().first().cloned().unwrap(); + let new_tab_button_column = + display_column_of(&tab_bar, "+").expect("new tab button is on the host tab bar") + 1; + + let ascended_by_new_tab = nested.mark_host_to_guest(); + nested + .host + .send_stdin(&sgr_left_click(new_tab_button_column, 1)); + let host_new_tab_pane = nested.host.expect_pty_spawn(); + host_new_tab_pane.output(PROMPT); + nested.wait_for_host_to_ascend_from_guest_after(ascended_by_new_tab); + let host_two_tabs = nested.host.wait_until( + "the host now has two tabs with the second one focused on a host pane", + |host_grid| { + host_grid.lines().first().map_or(false, |tab_bar| { + tab_bar.contains("Tab #1") && tab_bar.contains("Tab #2") + }) && normal_mode_bar_settled(host_grid) + }, + ); + let first_tab_column = host_two_tabs + .lines() + .first() + .and_then(|tab_bar| display_column_of(tab_bar, "Tab #1")) + .expect("the first host tab ribbon is on the host tab bar") + + 1; + let second_tab_column = host_two_tabs + .lines() + .first() + .and_then(|tab_bar| display_column_of(tab_bar, "Tab #2")) + .expect("the second host tab ribbon is on the host tab bar") + + 1; + + let descended_by_tab_one = nested.mark_host_to_guest(); + nested.host.send_stdin(&sgr_left_click(first_tab_column, 1)); + nested.wait_for_host_to_descend_into_guest_after(descended_by_tab_one); + + let ascended_by_tab_two = nested.mark_host_to_guest(); + nested + .host + .send_stdin(&sgr_left_click(second_tab_column, 1)); + nested.wait_for_host_to_ascend_from_guest_after(ascended_by_tab_two); + nested.host.wait_until( + "clicking the second host tab switches away from the guest tab in normal mode", + |host_grid| { + host_grid.lines().first().map_or(false, |tab_bar| { + tab_bar.contains("Tab #1") && tab_bar.contains("Tab #2") + }) && normal_mode_bar_settled(host_grid) + }, + ); + + nested.host.send_stdin(&keys::alt('n')); + let host_new_pane = nested.host.expect_pty_spawn(); + host_new_pane.output(PROMPT); + nested.host.wait_until( + "the host acts on its own key after the tab click ascended it", + |host_grid| host_grid.contains("Pane #2") && normal_mode_bar_settled(host_grid), + ); + + let _ = guest_session_name; + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn a_key_immediately_after_descend_lands_in_the_guest_and_after_ascend_lands_in_the_host() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + let guest_session_name = nested.guest.session_name().to_string(); + + boot_and_descend_on_first_load(&nested); + ascend_via_focus_host_binding(&nested); + split_host_sibling(&nested); + + descend_into_guest_on_the_left(&nested); + nested.host.send_stdin(&keys::alt('n')); + let guest_new_pane = nested.guest.expect_pty_spawn(); + guest_new_pane.output(PROMPT); + let guest_end_state = nested.guest.wait_until( + "key immediately after descend landed in the guest", + two_pane_guest_settled, + ); + assert_snapshot!( + "key_after_descend_lands_in_guest", + normalized(&guest_end_state) + ); + + let requested = nested.mark_guest_to_host(); + let ascended = nested.mark_host_to_guest(); + nested.host.send_stdin(&keys::ctrl('o')); + nested.host.send_stdin(ASCEND_KEY); + nested.wait_for_guest_to_request_host_focus_after(requested); + nested.wait_for_host_to_ascend_from_guest_after(ascended); + nested.host.send_stdin(&keys::alt('n')); + let host_new_pane = nested.host.expect_pty_spawn(); + host_new_pane.output(PROMPT); + let host_end_state = nested.wait_until_host_composites_settled_guest( + "key immediately after ascend landed in the host", + ascended_two_pane_guest_settled, + |host_grid| { + normal_mode_bar_settled(host_grid) + && host_grid.contains("Pane #3") + && prompt_count(host_grid) == 3 + && host_own_tab_bar_settled(host_grid, &guest_session_name) + }, + ); + assert_snapshot!( + "key_after_ascend_lands_in_host", + normalized(&host_end_state) + ); + + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn descend_on_first_load_when_guest_pane_is_already_focused() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + let guest_session_name = nested.guest.session_name().to_string(); + + nested.wait_for_guest_to_announce(); + nested.wait_for_host_to_acknowledge_guest(); + nested.descend_into_guest_via_modal(); + assert_eq!( + nested.focus_gained_count(), + 1, + "the host should descend exactly once after the modal is answered without any focus change" + ); + assert_eq!( + nested.focus_lost_count(), + 0, + "the host should not ascend on first load" + ); + + nested.guest.wait_for_app_load(); + let host_descended_on_load = nested.wait_until_host_composites_settled_guest( + "host descended into the already-focused guest pane at load", + single_blank_pane_guest_settled, + |host_grid| { + host_descended_bar_with_ascend_keys_settled(host_grid) + && host_tab_bar_renamed_to_guest(host_grid, &guest_session_name) + }, + ); + assert_snapshot!( + "host_descended_on_first_load", + normalized(&host_descended_on_load) + ); + + nested.host.send_stdin(&keys::alt('n')); + let guest_new_pane = nested.guest.expect_pty_spawn(); + guest_new_pane.output(PROMPT); + let guest_after_new_pane = nested.guest.wait_until( + "guest reacted to the passed-through key after a first-load descend", + two_pane_guest_settled, + ); + assert_snapshot!( + "guest_reacts_after_first_load_descend", + normalized(&guest_after_new_pane) + ); + + nested.guest.quit(); + nested.wait_for_host_to_reclaim_focus_after_guest_exit(); + nested.host.quit(); +} + +#[test] +fn guest_going_away_clears_passthrough() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + let guest_session_name = nested.guest.session_name().to_string(); + + build_descended_state_with_host_sibling(&nested); + nested.wait_until_host_composites_settled_guest( + "host settled the descended guest view before the guest froze", + single_blank_pane_guest_settled, + |host_grid| { + host_descended_bar_settled(host_grid) + && host_grid.contains("Pane #2") + && prompt_count(host_grid) == 1 + && host_own_tab_bar_settled(host_grid, &guest_session_name) + }, + ); + + nested.freeze_guest(); + nested.assert_host_stops_pinging_frozen_guest(); + nested.wait_for_host_to_reclaim_focus_after_guest_exit(); + + nested.host.send_stdin(&keys::alt('n')); + let host_new_pane = nested.host.expect_pty_spawn(); + host_new_pane.output(PROMPT); + let host_after_guest_gone = nested.host.wait_until( + "host acts on its own key after the guest went away", + |host_grid| { + normal_mode_bar_settled(host_grid) + && host_grid.contains("Pane #3") + && prompt_count(host_grid) == 2 + && host_grid.cursor.is_some() + && host_own_tab_bar_settled(host_grid, &guest_session_name) + }, + ); + assert_snapshot!( + "host_acts_after_guest_cleared_passthrough", + normalized(&host_after_guest_gone) + ); + + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn descend_and_ascend_work_at_depth_three() { + let mut nested = NestedDepthThreeHarness::start_depth_three(TERMINAL_SIZE); + let inner_session_name = nested.inner.session_name().to_string(); + + nested.boot_and_descend_depth_three(); + nested.inner.wait_for_app_load(); + + let middle_descended_into_settled_inner = |middle_grid: &GridSnapshot| { + let inner_grid = nested.inner.snapshot(); + single_blank_pane_guest_settled(&inner_grid) + && host_descended_bar_settled(middle_grid) + && middle_grid + .lines() + .first() + .map_or(false, |first_line| first_line.contains(&inner_session_name)) + && composite_contains_settled_guest_grid(middle_grid, &inner_grid) + }; + let middle_ascended_from_settled_inner = |middle_grid: &GridSnapshot| { + let inner_grid = nested.inner.snapshot(); + ascended_single_blank_pane_guest_settled(&inner_grid) + && normal_mode_bar_settled(middle_grid) + && middle_grid + .lines() + .first() + .map_or(false, |first_line| first_line.contains(&inner_session_name)) + && composite_contains_settled_guest_grid(middle_grid, &inner_grid) + }; + let settled_outer_tab_title = format!("{} | {}", inner_session_name, inner_session_name); + let outer_settled = |outer_grid: &GridSnapshot| { + host_descended_bar_settled(outer_grid) + && outer_grid.lines().first().map_or(false, |first_line| { + first_line.contains(&settled_outer_tab_title) + }) + }; + + let outer_doubly_nested = wait_for_settled_composite( + &nested.outer, + &nested.middle, + "outer host shows the doubly-nested descended UI", + &middle_descended_into_settled_inner, + &outer_settled, + ); + assert_snapshot!( + "outer_grid_doubly_nested_descended", + normalized(&outer_doubly_nested) + ); + + let requested = nested.mark_inner_to_middle(); + let ascended = nested.mark_middle_to_inner(); + nested.outer.send_stdin(&keys::ctrl('o')); + nested.inner.wait_until( + "inner entered session mode after the passed-through mode key", + session_mode_bar_settled, + ); + nested.outer.send_stdin(ASCEND_KEY); + nested.wait_for_inner_to_request_host_focus_after(requested); + nested.wait_for_middle_to_ascend_from_inner_after(ascended); + + let outer_after_inner_ascend = wait_for_settled_composite( + &nested.outer, + &nested.middle, + "middle ascended out of the inner while outer stays descended into middle", + &middle_ascended_from_settled_inner, + &outer_settled, + ); + assert_snapshot!( + "outer_grid_after_inner_ascend", + normalized(&outer_after_inner_ascend) + ); + + nested.inner.quit(); + + nested.middle.quit(); + nested.wait_for_outer_to_reclaim_focus_after_middle_exit(); + + nested.outer.quit(); +} + +fn pass_ctrl_p_to_guest(nested: &NestedHarness) { + nested.host.send_stdin(&keys::ctrl('p')); + nested.guest.wait_until( + "guest entered pane mode after the passed-through mode key", + guest_pane_mode_bar_settled, + ); +} + +fn split_second_guest_pane(nested: &NestedHarness) { + nested.host.send_stdin(&keys::alt('n')); + let guest_new_pane = nested.guest.expect_pty_spawn(); + guest_new_pane.output(PROMPT); + nested.guest.wait_until( + "guest spawned a second pane from the passed-through key", + two_pane_guest_settled, + ); +} + +#[test] +fn descended_client_sees_dimmed_host_chrome_while_second_client_does_not() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + let guest_session_name = nested.guest.session_name().to_string(); + + boot_and_descend_on_first_load(&nested); + ascend_via_focus_host_binding(&nested); + split_host_sibling(&nested); + + let second = nested.host.attach_client(TERMINAL_SIZE); + let second_client = second.wait_until( + "the second host client focuses the sibling pane undimmed on attach", + |host_grid| { + host_own_tab_bar_settled(host_grid, &guest_session_name) + && host_grid.contains("Pane #2") + && host_chrome_undimmed(host_grid) + }, + ); + + let descended = nested.mark_host_to_guest(); + nested.host.send_stdin(&keys::ctrl('p')); + nested.host.wait_until( + "first client entered pane mode before moving focus onto the guest", + pane_mode_bar_settled, + ); + nested.host.send_stdin(ARROW_LEFT); + nested.host.send_stdin(&keys::ctrl('o')); + nested.host.wait_until( + "first client entered session mode before the descend key", + session_mode_bar_settled, + ); + nested.host.send_stdin(DESCEND_KEY); + nested.wait_for_host_to_descend_into_guest_after(descended); + + let descended_client = nested.host.wait_until( + "the first host client renders dimmed host chrome while descended", + |host_grid| { + host_grid.contains("Pane #2") + && host_grid.contains("Tab #1") + && host_chrome_dimmed(host_grid) + }, + ); + + let second_still_undimmed = second.wait_until( + "the second host client stays undimmed while the first is descended", + |host_grid| host_grid.contains("Pane #2") && host_chrome_undimmed(host_grid), + ); + + assert!(host_chrome_dimmed(&descended_client)); + assert!(host_chrome_undimmed(&second_client)); + assert!(host_chrome_undimmed(&second_still_undimmed)); + + second.quit(); + quit_guest_then_host(nested); +} + +fn host_chrome_rows(host_grid: &GridSnapshot) -> (String, String) { + let lines = host_grid.lines(); + let first = lines.first().cloned().unwrap_or_default(); + let last = lines.last().cloned().unwrap_or_default(); + (first, last) +} + +#[test] +fn host_chrome_restores_exactly_after_ascend() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + let guest_session_name = nested.guest.session_name().to_string(); + + boot_and_descend_on_first_load(&nested); + ascend_via_focus_host_binding(&nested); + split_host_sibling(&nested); + + let before_descend = + nested + .host + .wait_until("host chrome is undimmed before descending", |host_grid| { + host_own_tab_bar_settled(host_grid, &guest_session_name) + && host_grid.contains("Pane #2") + && normal_mode_bar_settled(host_grid) + && host_chrome_undimmed(host_grid) + }); + let before_chrome = host_chrome_rows(&before_descend); + + descend_into_guest_on_the_left(&nested); + nested + .host + .wait_until("host chrome dims while descended", |host_grid| { + host_own_tab_bar_settled(host_grid, &guest_session_name) + && host_grid.contains("Pane #2") + && host_chrome_dimmed(host_grid) + }); + + let released = nested.mark_host_to_guest(); + nested.host.send_stdin(&keys::ctrl('o')); + nested.host.send_stdin(ASCEND_KEY); + nested.wait_for_host_to_ascend_from_guest_after(released); + nested.host.send_stdin(&keys::ESC); + let after_ascend = nested.host.wait_until( + "host chrome restores to undimmed after ascending", + |host_grid| { + host_grid.contains("Pane #2") + && normal_mode_bar_settled(host_grid) + && host_chrome_undimmed(host_grid) + && host_chrome_rows(host_grid) == before_chrome + }, + ); + + assert_eq!( + host_chrome_rows(&after_ascend), + before_chrome, + "host chrome rows restore exactly after ascend" + ); + + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn guest_edge_focus_move_bubbles_out_to_the_adjacent_host_pane() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + build_descended_state_with_host_sibling(&nested); + + let requested = nested.mark_guest_to_host(); + let ascended = nested.mark_host_to_guest(); + pass_ctrl_p_to_guest(&nested); + nested.host.send_stdin(ARROW_RIGHT); + nested.guest_to_host().wait_for_after( + requested, + "the guest at its right edge bubbles a focus-host request to the right", + |message| { + matches!( + message, + NestedSessionMessage::FocusHost { + direction: Some(Direction::Right) + } + ) + }, + ); + nested.wait_for_host_to_ascend_from_guest_after(ascended); + + let host_on_sibling = nested.host.wait_until( + "host focus landed on the sibling pane to the right of the guest", + |host_grid| { + host_grid.contains("Pane #2") + && host_grid.contains("Tab #1") + && host_chrome_undimmed(host_grid) + }, + ); + assert!(host_chrome_undimmed(&host_on_sibling)); + + nested.host.send_stdin(&keys::alt('n')); + let host_new_pane = nested.host.expect_pty_spawn(); + host_new_pane.output(PROMPT); + nested.host.wait_until( + "host acts on its own key after the guest bubbled focus out", + |host_grid| host_grid.contains("Pane #3") && host_grid.contains("Tab #1"), + ); + + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn host_focus_move_onto_guest_pane_enters_at_the_correct_edge() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + build_descended_state_with_host_sibling(&nested); + + let requested = nested.mark_guest_to_host(); + let ascended = nested.mark_host_to_guest(); + pass_ctrl_p_to_guest(&nested); + nested.host.send_stdin(ARROW_RIGHT); + nested.guest_to_host().wait_for_after( + requested, + "the guest at its right edge bubbles a focus-host request to the right", + |message| { + matches!( + message, + NestedSessionMessage::FocusHost { + direction: Some(Direction::Right) + } + ) + }, + ); + nested.wait_for_host_to_ascend_from_guest_after(ascended); + + let entered = nested.mark_host_to_guest(); + nested.host.send_stdin(&keys::ctrl('p')); + nested.host.wait_until( + "host entered pane mode before moving focus onto the guest", + pane_mode_bar_settled, + ); + nested.host.send_stdin(ARROW_LEFT); + nested.host_to_guest().wait_for_after( + entered, + "the host tells the guest it entered from the left edge when moving left onto it", + |message| { + matches!( + message, + NestedSessionMessage::FocusGained { + from_direction: Some(Direction::Left) + } + ) + }, + ); + + quit_guest_then_host(nested); +} + +#[test] +fn no_bubble_when_the_focus_move_stays_inside_the_guest() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + build_descended_state_with_host_sibling(&nested); + split_second_guest_pane(&nested); + + nested.guest.wait_until("two panes", two_pane_guest_settled); + pass_ctrl_p_to_guest(&nested); + let focus_host_before = nested + .guest_to_host() + .count(|message| matches!(message, NestedSessionMessage::FocusHost { .. })); + nested.host.send_stdin(ARROW_LEFT); + nested.guest.wait_until( + "guest focus moved to the left pane while staying in pane mode", + |guest_grid| { + guest_grid.cursor.map_or(false, |cursor| cursor.x < 15) + && guest_pane_mode_bar_settled(guest_grid) + }, + ); + assert_eq!( + nested + .guest_to_host() + .count(|message| matches!(message, NestedSessionMessage::FocusHost { .. })), + focus_host_before, + "moving focus between two guest panes must not bubble a focus-host request" + ); + + quit_guest_then_host(nested); +} + +#[test] +fn fullscreened_guest_pane_still_bubbles_at_the_edges() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + build_descended_state_with_host_sibling_below(&nested); + split_second_guest_pane(&nested); + + pass_ctrl_p_to_guest(&nested); + nested.host.send_stdin(&keys::key('f')); + nested + .guest + .wait_until("guest fullscreened its focused pane", |guest_grid| { + guest_grid.contains("FULLSCREEN") || guest_ui_settled(guest_grid) + }); + + let requested = nested.mark_guest_to_host(); + let ascended = nested.mark_host_to_guest(); + pass_ctrl_p_to_guest(&nested); + nested.host.send_stdin(ARROW_DOWN); + nested.guest_to_host().wait_for_after( + requested, + "the fullscreened guest bubbles a downward focus-host request off its edge", + |message| { + matches!( + message, + NestedSessionMessage::FocusHost { + direction: Some(Direction::Down) + } + ) + }, + ); + nested.wait_for_host_to_ascend_from_guest_after(ascended); + nested.host.wait_until( + "host focus landed on the pane below the guest", + |host_grid| { + host_grid.contains("Pane #2") + && host_grid.contains("Tab #1") + && host_chrome_undimmed(host_grid) + }, + ); + + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn move_focus_or_tab_bubbles_only_when_the_guest_has_a_single_tab() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + build_descended_state_with_host_sibling(&nested); + + let requested = nested.mark_guest_to_host(); + let ascended = nested.mark_host_to_guest(); + nested.guest.wait_until( + "guest settled in normal mode before the alt-arrow is passed through", + guest_ui_settled, + ); + nested.host.send_stdin(ALT_ARROW_RIGHT); + nested.guest_to_host().wait_for_after( + requested, + "a single-tab guest bubbles the move-or-tab off its right edge", + |message| { + matches!( + message, + NestedSessionMessage::FocusHost { + direction: Some(Direction::Right) + } + ) + }, + ); + nested.wait_for_host_to_ascend_from_guest_after(ascended); + nested.host.wait_until( + "host focus landed on the sibling after the single-tab move-or-tab bubbled", + |host_grid| { + host_grid.contains("Pane #2") + && host_grid.contains("Tab #1") + && host_chrome_undimmed(host_grid) + }, + ); + + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn move_focus_or_tab_wraps_tabs_without_bubbling_when_the_guest_has_multiple_tabs() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + build_descended_state_with_host_sibling(&nested); + + nested.host.send_stdin(&keys::ctrl('t')); + nested.guest.wait_until( + "guest entered tab mode after the passed-through key", + guest_tab_mode_bar_settled, + ); + nested.host.send_stdin(&keys::key('n')); + nested + .guest + .wait_until("guest opened a second tab and focused it", |guest_grid| { + guest_grid.contains("Tab #2") && guest_normal_mode_bar_settled(guest_grid) + }); + + let focus_host_before = nested + .guest_to_host() + .count(|message| matches!(message, NestedSessionMessage::FocusHost { .. })); + nested.host.send_stdin(ALT_ARROW_RIGHT); + nested.guest.wait_until( + "guest wrapped back to the first tab instead of bubbling", + |guest_grid| { + guest_grid.lines().first().map_or(false, |first_line| { + first_line.contains("Tab #1") && first_line.contains("Tab #2") + }) && guest_normal_mode_bar_settled(guest_grid) + }, + ); + assert_eq!( + nested + .guest_to_host() + .count(|message| matches!(message, NestedSessionMessage::FocusHost { .. })), + focus_host_before, + "a multi-tab guest wraps tabs and must not bubble a focus-host request" + ); + + quit_guest_then_host(nested); +} + +#[test] +fn nested_fullscreen_exit_keeps_hidden_host_floating_panes_hidden() { + let nested = NestedDepthThreeHarness::start_depth_three(TERMINAL_SIZE); + + nested.boot_and_descend_depth_three(); + nested.inner.wait_for_app_load(); + + nested.outer.send_stdin(&keys::alt('f')); + let inner_float = nested.inner.expect_pty_spawn(); + inner_float.output(b"INNER-FLOAT "); + nested + .inner + .wait_until("inner floating pane shown", |inner_grid| { + inner_grid.contains("INNER-FLOAT") + }); + + let requested = nested.mark_inner_to_middle(); + let ascended = nested.mark_middle_to_inner(); + nested.outer.send_stdin(&keys::ctrl('o')); + nested.inner.wait_until( + "inner entered session mode after the passed-through mode key", + session_mode_bar_settled, + ); + nested.outer.send_stdin(ASCEND_KEY); + nested.wait_for_inner_to_request_host_focus_after(requested); + nested.wait_for_middle_to_ascend_from_inner_after(ascended); + + nested.outer.send_stdin(&keys::alt('f')); + let middle_float = nested.middle.expect_pty_spawn(); + middle_float.output(b"MIDDLE-FLOAT "); + nested + .middle + .wait_until("middle floating pane shown", |middle_grid| { + middle_grid.contains("MIDDLE-FLOAT") + }); + nested.outer.send_stdin(&keys::alt('f')); + nested + .middle + .wait_until("middle floating pane hidden", |middle_grid| { + !middle_grid.contains("MIDDLE-FLOAT") + }); + + nested.outer.send_stdin(&keys::ctrl('p')); + nested.middle.wait_until( + "middle entered pane mode before the split key", + pane_mode_bar_settled, + ); + nested.outer.send_stdin(&keys::key('r')); + let middle_sibling = nested.middle.expect_pty_spawn(); + middle_sibling.output(b"MIDDLE-SIBLING "); + nested.middle.wait_until( + "middle spawned a sibling pane next to the inner guest", + |middle_grid| middle_grid.contains("MIDDLE-SIBLING"), + ); + + let ascended_outer = nested.mark_outer_to_middle(); + nested.outer.send_stdin(&keys::ctrl('o')); + nested.middle.wait_until( + "middle entered session mode before the ascend key", + session_mode_bar_settled, + ); + nested.outer.send_stdin(ASCEND_KEY); + nested.wait_for_outer_to_ascend_from_middle_after(ascended_outer); + + nested.outer.send_stdin(&keys::alt('f')); + let outer_float = nested.outer.expect_pty_spawn(); + outer_float.output(b"OUTER-FLOAT "); + nested + .outer + .wait_until("outer floating pane shown", |outer_grid| { + outer_grid.contains("OUTER-FLOAT") + }); + nested.outer.send_stdin(&keys::alt('f')); + nested + .outer + .wait_until("outer floating pane hidden", |outer_grid| { + !outer_grid.contains("OUTER-FLOAT") + }); + + nested.outer.send_stdin(&keys::ctrl('p')); + nested.outer.wait_until( + "outer entered pane mode before the split key", + pane_mode_bar_settled, + ); + nested.outer.send_stdin(&keys::key('r')); + let outer_sibling = nested.outer.expect_pty_spawn(); + outer_sibling.output(b"OUTER-SIBLING "); + nested.outer.wait_until( + "outer spawned a sibling pane next to the middle guest", + |outer_grid| outer_grid.contains("OUTER-SIBLING"), + ); + + let outer_descended = nested.mark_outer_to_middle(); + nested.outer.send_stdin(&keys::ctrl('p')); + nested.outer.wait_until( + "outer entered pane mode before the focus-left key", + pane_mode_bar_settled, + ); + nested.outer.send_stdin(ARROW_LEFT); + nested.outer.send_stdin(&keys::ctrl('o')); + nested.outer.wait_until( + "outer entered session mode before the descend key", + session_mode_bar_settled, + ); + nested.outer.send_stdin(DESCEND_KEY); + nested.wait_for_outer_to_descend_into_middle_after(outer_descended); + + let middle_descended = nested.mark_middle_to_inner(); + nested.outer.send_stdin(&keys::ctrl('p')); + nested.middle.wait_until( + "middle entered pane mode before the focus-left key", + guest_pane_mode_bar_settled, + ); + nested.outer.send_stdin(ARROW_LEFT); + nested.outer.send_stdin(&keys::ctrl('o')); + nested.middle.wait_until( + "middle entered session mode before the descend key", + session_mode_bar_settled, + ); + nested.outer.send_stdin(DESCEND_KEY); + nested.wait_for_middle_to_descend_into_inner_after(middle_descended); + + nested.outer.send_stdin(&keys::ctrl('o')); + nested.outer.send_stdin(&keys::key('f')); + nested.middle.wait_until( + "middle hid its own chrome and sibling while the inner guest is fullscreen", + |middle_grid| { + middle_grid.contains("INNER-FLOAT") + && !middle_grid.contains("MIDDLE-SIBLING") + && !middle_grid.contains("MIDDLE-FLOAT") + }, + ); + nested.outer.wait_until( + "outer hid its own chrome and sibling while the nested guest chain is fullscreen", + |outer_grid| { + outer_grid.contains("INNER-FLOAT") + && !outer_grid.contains("OUTER-SIBLING") + && !outer_grid.contains("MIDDLE-SIBLING") + }, + ); + + nested.outer.send_stdin(&keys::ctrl('o')); + nested.outer.send_stdin(&keys::key('f')); + nested.middle.wait_until( + "middle restored its sibling pane after fullscreen exit", + |middle_grid| middle_grid.contains("MIDDLE-SIBLING"), + ); + nested.outer.wait_until( + "outer restored its sibling pane after fullscreen exit", + |outer_grid| outer_grid.contains("OUTER-SIBLING"), + ); + + let middle_after_exit = nested.middle.snapshot(); + let outer_after_exit = nested.outer.snapshot(); + assert!( + !middle_after_exit.contains("MIDDLE-FLOAT") && !outer_after_exit.contains("OUTER-FLOAT"), + "hidden host floating panes must stay hidden after nested fullscreen exit\n=== middle grid ===\n{}\n=== outer grid ===\n{}\n=== log tail ===\n{}", + middle_after_exit.text, + outer_after_exit.text, + zellij_integration_tests::test_env::log_tail(400), + ); + + nested.outer.send_stdin(&keys::ctrl('o')); + nested.outer.send_stdin(&keys::key('f')); + nested.middle.wait_until( + "middle hid its sibling again during the second fullscreen", + |middle_grid| !middle_grid.contains("MIDDLE-SIBLING"), + ); + nested.outer.wait_until( + "outer hid its sibling again during the second fullscreen", + |outer_grid| !outer_grid.contains("OUTER-SIBLING"), + ); + + nested.outer.send_stdin(&keys::ctrl('o')); + nested.outer.send_stdin(&keys::key('f')); + nested.middle.wait_until( + "middle restored its sibling pane after the second fullscreen exit", + |middle_grid| middle_grid.contains("MIDDLE-SIBLING"), + ); + nested.outer.wait_until( + "outer restored its sibling pane after the second fullscreen exit", + |outer_grid| outer_grid.contains("OUTER-SIBLING"), + ); + + let middle_after_second_exit = nested.middle.snapshot(); + let outer_after_second_exit = nested.outer.snapshot(); + assert!( + !middle_after_second_exit.contains("MIDDLE-FLOAT") + && !outer_after_second_exit.contains("OUTER-FLOAT"), + "hidden host floating panes must stay hidden after a repeated nested fullscreen exit\n=== middle grid ===\n{}\n=== outer grid ===\n{}\n=== log tail ===\n{}", + middle_after_second_exit.text, + outer_after_second_exit.text, + zellij_integration_tests::test_env::log_tail(400), + ); +} + +fn column_in_last_line(grid: &GridSnapshot, needle: &str) -> Option<(usize, usize)> { + let lines = grid.lines(); + let y = lines.len().checked_sub(1)?; + let line = lines.last()?; + let byte_index = line.find(needle)?; + Some((line[..byte_index].chars().count(), y)) +} + +fn descended_hint_present(grid: &GridSnapshot) -> bool { + last_line_contains(grid, "Ascend:") +} + +#[test] +fn descended_host_tab_bar_session_name_is_dimmed() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + let host_session_name = nested.host.session_name().to_string(); + + build_descended_state_with_host_sibling(&nested); + + let descended = nested.host.wait_until( + "descended host renders its dimmed tab-bar with the status hint", + |host_grid| { + host_grid.contains("Pane #2") + && host_grid.contains(&format!("({})", host_session_name)) + && descended_hint_present(host_grid) + && host_grid + .char_dim_of(&format!("({})", host_session_name)) + .unwrap_or(false) + }, + ); + + assert!( + descended + .char_dim_of("Zellij") + .expect("host tab-bar prefix present"), + "the host tab-bar 'Zellij' prefix is dimmed while descended" + ); + assert!( + descended + .char_dim_of(&format!("({})", host_session_name)) + .expect("host session name present"), + "the host tab-bar session name is dimmed while descended" + ); + + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn descended_status_hint_is_dim_italic_with_colored_keys() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + build_descended_state_with_host_sibling(&nested); + + let descended = nested.host.wait_until( + "descended host shows the nested session status hint", + |host_grid| host_grid.contains("Pane #2") && descended_hint_present(host_grid), + ); + + let (label_column, label_row) = column_in_last_line(&descended, "Ascend:") + .expect("the ascend hint label is present on the last line"); + assert!( + descended.char_is_dim(label_column, label_row), + "the ascend hint label is dimmed" + ); + assert!( + descended.char_is_italic(label_column, label_row), + "the ascend hint label is italic" + ); + + nested.guest.quit(); + nested.host.quit(); +} + +#[test] +fn nested_pane_frames_never_render_the_guest_choice_indicator() { + let mut nested = NestedHarness::start(TERMINAL_SIZE); + + build_descended_state_with_host_sibling(&nested); + + let descended = nested.host.wait_until( + "descended host settled with its guest pane and sibling present", + |host_grid| host_grid.contains("Pane #2") && descended_hint_present(host_grid), + ); + + assert!( + !descended.contains("NESTED ZELLIJ") + && !descended.contains("NESTED AUTO FOCUS") + && !descended.contains("NESTED MANUAL FOCUS"), + "no guest-choice indicator is rendered in any pane frame while descended\n=== host grid ===\n{}", + descended.text + ); + + nested.guest.quit(); + nested.host.quit(); +} diff --git a/zellij-integration-tests/tests/nested_status_hints.rs b/zellij-integration-tests/tests/nested_status_hints.rs new file mode 100644 index 00000000..29f25989 --- /dev/null +++ b/zellij-integration-tests/tests/nested_status_hints.rs @@ -0,0 +1,28 @@ +#![cfg(unix)] +use zellij_integration_tests::nested::NestedHarness; +use zellij_utils::nested_session::NestedSessionMessage; +use zellij_utils::pane_size::Size; + +const TERMINAL_SIZE: Size = Size { + cols: 121, + rows: 30, +}; + +#[test] +fn guest_shows_the_ascended_hint_on_boot() { + let nested = NestedHarness::start(TERMINAL_SIZE); + nested.wait_for_guest_to_announce(); + nested.wait_for_host_to_acknowledge_guest(); + nested.host_to_guest().wait_for( + "the host to include its descend shortcut in the announce_ack", + |message| matches!(message, NestedSessionMessage::AnnounceAck { descend_keys, .. } if !descend_keys.is_empty()), + ); + nested.guest_to_host().wait_for( + "the guest to advertise its ascend shortcut after the handshake", + |message| matches!(message, NestedSessionMessage::ShortcutUpdate { ascend_keys, .. } if !ascend_keys.is_empty()), + ); + nested.guest.wait_until( + "the guest status bar to show the ascended hint", + |guest_grid| guest_grid.contains("Descend:"), + ); +} diff --git a/zellij-integration-tests/tests/no_ui_fullscreen.rs b/zellij-integration-tests/tests/no_ui_fullscreen.rs new file mode 100644 index 00000000..f032c68f --- /dev/null +++ b/zellij-integration-tests/tests/no_ui_fullscreen.rs @@ -0,0 +1,413 @@ +#![cfg(unix)] + +use insta::assert_snapshot; +use zellij_integration_tests::{ + assert_same_rendered_grid, claim_first_terminal_and_wait_for_prompt, col, keys, normalized, + split_right_and_wait_for_prompt, start_zellij, Size, PROMPT, TERMINAL_SIZE, +}; +use zellij_utils::cli::CliAction; +use zellij_utils::input::options::PaneFrameStyle; + +const FULL_FRAME_CORNER: &str = "┌"; + +fn toggle_no_ui_fullscreen_via_keybinding(zellij: &zellij_integration_tests::TestSession) { + zellij.send_stdin(&keys::ctrl('p')); + zellij.send_stdin(&keys::key('F')); +} + +#[test] +fn no_ui_fullscreen_hides_ui_and_covers_display() { + let mut zellij = start_zellij(); + claim_first_terminal_and_wait_for_prompt(&zellij); + split_right_and_wait_for_prompt(&zellij); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + + let grid_snapshot = zellij.wait_until( + "focused pane covers the whole display without chrome", + |grid_snapshot| { + !grid_snapshot.tab_bar_appears() + && !grid_snapshot.status_bar_appears() + && grid_snapshot.cursor_is_at(col(2).row(0)) + }, + ); + assert_snapshot!(normalized(&grid_snapshot)); + zellij.quit(); +} + +#[test] +fn no_ui_fullscreen_toggle_off_restores_ui_and_layout() { + let mut zellij = start_zellij(); + claim_first_terminal_and_wait_for_prompt(&zellij); + split_right_and_wait_for_prompt(&zellij); + + let before = zellij.wait_until("two panes settled with chrome", |grid_snapshot| { + grid_snapshot.tab_bar_appears() + && grid_snapshot.status_bar_appears() + && grid_snapshot.cursor_is_at(col(62).row(2)) + }); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + zellij.wait_until("chrome hidden while no-ui fullscreen", |grid_snapshot| { + !grid_snapshot.tab_bar_appears() && !grid_snapshot.status_bar_appears() + }); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + let after = zellij.wait_until("chrome and layout restored", |grid_snapshot| { + grid_snapshot.tab_bar_appears() + && grid_snapshot.status_bar_appears() + && !grid_snapshot.contains("(FULLSCREEN)") + && grid_snapshot.cursor_is_at(col(62).row(2)) + }); + + assert_same_rendered_grid( + &after, + &before, + "layout, UI panes and frames are restored exactly", + ); + zellij.quit(); +} + +#[test] +fn no_ui_fullscreen_works_with_single_pane() { + let mut zellij = start_zellij(); + claim_first_terminal_and_wait_for_prompt(&zellij); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + + let grid_snapshot = zellij.wait_until( + "single pane covers the whole display without chrome", + |grid_snapshot| { + !grid_snapshot.tab_bar_appears() + && !grid_snapshot.status_bar_appears() + && grid_snapshot.cursor_is_at(col(2).row(0)) + }, + ); + assert_snapshot!(normalized(&grid_snapshot)); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + zellij.wait_until("chrome restored for single pane", |grid_snapshot| { + grid_snapshot.tab_bar_appears() + && grid_snapshot.status_bar_appears() + && grid_snapshot.cursor_is_at(col(2).row(1)) + }); + zellij.quit(); +} + +#[test] +fn resize_terminal_while_no_ui_fullscreen_tracks_display() { + let mut zellij = start_zellij(); + claim_first_terminal_and_wait_for_prompt(&zellij); + let right_terminal = split_right_and_wait_for_prompt(&zellij); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + zellij.wait_until("chrome hidden while no-ui fullscreen", |grid_snapshot| { + !grid_snapshot.tab_bar_appears() + && !grid_snapshot.status_bar_appears() + && grid_snapshot.cursor_is_at(col(2).row(0)) + }); + + zellij.resize(Size { + cols: 100, + rows: 24, + }); + right_terminal.wait_for_size( + "no-ui fullscreen pane tracks the new display size", + |cols, rows| cols == 100 && rows == 24, + ); + let grid_snapshot = zellij.wait_until( + "no-ui fullscreen still covers the resized display", + |grid_snapshot| { + !grid_snapshot.tab_bar_appears() + && !grid_snapshot.status_bar_appears() + && grid_snapshot.cursor_is_at(col(2).row(0)) + }, + ); + assert_snapshot!(normalized(&grid_snapshot)); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + zellij.wait_until("chrome restored at the new size", |grid_snapshot| { + grid_snapshot.tab_bar_appears() + && grid_snapshot.contains("Ctrl +") + && grid_snapshot.cursor_is_at(col(52).row(2)) + }); + zellij.quit(); +} + +#[test] +fn no_ui_fullscreen_with_full_frames() { + let mut zellij = start_zellij(); + claim_first_terminal_and_wait_for_prompt(&zellij); + let right_terminal = split_right_and_wait_for_prompt(&zellij); + + let exit_code = zellij.run_cli_action(CliAction::SetPaneFrameStyle { + style: PaneFrameStyle::Full, + }); + assert_eq!(exit_code, 0, "set-pane-frame-style exited cleanly"); + zellij.wait_until("full frames drawn", |grid_snapshot| { + grid_snapshot.contains(FULL_FRAME_CORNER) + }); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + + right_terminal.wait_for_size( + "no-ui fullscreen pane forced frameless and sized to the exact display", + |cols, rows| cols == TERMINAL_SIZE.cols as u16 && rows == TERMINAL_SIZE.rows as u16, + ); + let grid_snapshot = zellij.wait_until( + "frameless fullscreen pane covers the whole display without chrome", + |grid_snapshot| { + !grid_snapshot.tab_bar_appears() + && !grid_snapshot.status_bar_appears() + && !grid_snapshot.contains(FULL_FRAME_CORNER) + && grid_snapshot.cursor_is_at(col(2).row(0)) + }, + ); + assert_snapshot!(normalized(&grid_snapshot)); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + zellij.wait_until("chrome and frames restored", |grid_snapshot| { + grid_snapshot.tab_bar_appears() + && grid_snapshot.status_bar_appears() + && grid_snapshot.contains(FULL_FRAME_CORNER) + }); + zellij.quit(); +} + +#[test] +fn no_ui_fullscreen_with_frameless_panes() { + let mut zellij = start_zellij(); + claim_first_terminal_and_wait_for_prompt(&zellij); + let right_terminal = split_right_and_wait_for_prompt(&zellij); + + let exit_code = zellij.run_cli_action(CliAction::SetPaneFrameStyle { + style: PaneFrameStyle::None, + }); + assert_eq!(exit_code, 0, "set-pane-frame-style exited cleanly"); + zellij.wait_until("titles removed in none mode", |grid_snapshot| { + grid_snapshot.status_bar_appears() && !grid_snapshot.contains("Pane #1") + }); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + + right_terminal.wait_for_size( + "frameless no-ui fullscreen pane sized to the exact display", + |cols, rows| cols == TERMINAL_SIZE.cols as u16 && rows == TERMINAL_SIZE.rows as u16, + ); + zellij.wait_until( + "frameless pane covers the whole display without chrome", + |grid_snapshot| { + !grid_snapshot.tab_bar_appears() + && !grid_snapshot.status_bar_appears() + && grid_snapshot.cursor_is_at(col(2).row(0)) + }, + ); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + zellij.wait_until("chrome restored in none mode", |grid_snapshot| { + grid_snapshot.tab_bar_appears() && grid_snapshot.status_bar_appears() + }); + zellij.quit(); +} + +#[test] +fn no_ui_fullscreen_on_focused_floating_pane_covers_display() { + let mut zellij = start_zellij(); + claim_first_terminal_and_wait_for_prompt(&zellij); + + zellij.send_stdin(&keys::ctrl('p')); + zellij.send_stdin(&keys::key('w')); + let floating_terminal = zellij.expect_pty_spawn(); + floating_terminal.output(PROMPT); + floating_terminal.output(b"floating_marker"); + zellij.wait_until( + "floating pane rendered and focused in normal mode", + |grid_snapshot| { + grid_snapshot.contains(FULL_FRAME_CORNER) + && grid_snapshot.contains("floating_marker") + && grid_snapshot.status_bar_appears() + && grid_snapshot.tab_bar_appears() + }, + ); + + let exit_code = zellij.run_cli_action(CliAction::ToggleNoUiFullscreen { pane_id: None }); + assert_eq!(exit_code, 0, "toggle-no-ui-fullscreen exited cleanly"); + + zellij.wait_until( + "focused floating pane goes no-ui fullscreen and hides the chrome", + |grid_snapshot| { + grid_snapshot.contains("floating_marker") + && !grid_snapshot.tab_bar_appears() + && !grid_snapshot.status_bar_appears() + && !grid_snapshot.contains(FULL_FRAME_CORNER) + }, + ); + + let exit_code = zellij.run_cli_action(CliAction::ToggleNoUiFullscreen { pane_id: None }); + assert_eq!(exit_code, 0, "toggle-no-ui-fullscreen exited cleanly"); + zellij.wait_until( + "toggling off restores the chrome and the floating frame", + |grid_snapshot| { + grid_snapshot.tab_bar_appears() + && grid_snapshot.status_bar_appears() + && grid_snapshot.contains(FULL_FRAME_CORNER) + }, + ); + zellij.quit(); +} + +#[test] +fn regular_fullscreen_switches_to_no_ui_fullscreen() { + let mut zellij = start_zellij(); + claim_first_terminal_and_wait_for_prompt(&zellij); + split_right_and_wait_for_prompt(&zellij); + + zellij.send_stdin(&keys::ctrl('p')); + zellij.send_stdin(&keys::key('f')); + zellij.wait_until("regular fullscreen keeps the chrome", |grid_snapshot| { + grid_snapshot.contains("(FULLSCREEN)") + && grid_snapshot.status_bar_appears() + && grid_snapshot.cursor_is_at(col(2).row(2)) + }); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + zellij.wait_until( + "switching kinds hides the chrome without leaving fullscreen", + |grid_snapshot| { + !grid_snapshot.tab_bar_appears() + && !grid_snapshot.status_bar_appears() + && grid_snapshot.cursor_is_at(col(2).row(0)) + }, + ); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + zellij.wait_until("fullscreen fully off, layout restored", |grid_snapshot| { + grid_snapshot.tab_bar_appears() + && grid_snapshot.status_bar_appears() + && !grid_snapshot.contains("(FULLSCREEN)") + && grid_snapshot.cursor_is_at(col(62).row(2)) + }); + zellij.quit(); +} + +#[test] +fn move_focus_while_no_ui_fullscreen_keeps_no_ui() { + let mut zellij = start_zellij(); + let first_terminal = claim_first_terminal_and_wait_for_prompt(&zellij); + split_right_and_wait_for_prompt(&zellij); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + zellij.wait_until("right pane is no-ui fullscreen", |grid_snapshot| { + !grid_snapshot.tab_bar_appears() + && !grid_snapshot.status_bar_appears() + && grid_snapshot.cursor_is_at(col(2).row(0)) + }); + + zellij.send_stdin(&keys::alt('h')); + first_terminal.output(b"left_marker"); + + let grid_snapshot = zellij.wait_until( + "focus moved to the left pane which is now no-ui fullscreen", + |grid_snapshot| grid_snapshot.contains("left_marker"), + ); + assert!( + !grid_snapshot.tab_bar_appears() && !grid_snapshot.status_bar_appears(), + "no-ui fullscreen kind survives a focus move" + ); + zellij.quit(); +} + +#[test] +fn new_pane_while_no_ui_fullscreen_breaks_out_and_restores_chrome() { + let mut zellij = start_zellij(); + claim_first_terminal_and_wait_for_prompt(&zellij); + split_right_and_wait_for_prompt(&zellij); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + zellij.wait_until("chrome hidden while no-ui fullscreen", |grid_snapshot| { + !grid_snapshot.tab_bar_appears() && !grid_snapshot.status_bar_appears() + }); + + zellij.send_stdin(&keys::alt('n')); + let new_terminal = zellij.expect_pty_spawn(); + new_terminal.output(PROMPT); + + let grid_snapshot = zellij.wait_until( + "all three panes tiled with chrome after breaking out of no-ui fullscreen", + |grid_snapshot| { + grid_snapshot.tab_bar_appears() + && grid_snapshot.status_bar_appears() + && !grid_snapshot.contains("(FULLSCREEN)") + && grid_snapshot.contains("Pane #1") + && grid_snapshot.contains("Pane #2") + && grid_snapshot.contains("Pane #3") + && grid_snapshot.cursor.is_some() + }, + ); + assert_snapshot!(normalized(&grid_snapshot)); + zellij.quit(); +} + +#[test] +fn closing_the_no_ui_fullscreen_pane_restores_the_remaining_layout() { + let mut zellij = start_zellij(); + claim_first_terminal_and_wait_for_prompt(&zellij); + split_right_and_wait_for_prompt(&zellij); + let two_pane_state = zellij.wait_until("two panes settled", |grid_snapshot| { + grid_snapshot.status_bar_appears() && grid_snapshot.cursor_is_at(col(62).row(2)) + }); + + zellij.send_stdin(&keys::ctrl('p')); + zellij.send_stdin(&keys::key('d')); + let third_terminal = zellij.expect_pty_spawn(); + third_terminal.output(PROMPT); + zellij.wait_until("third pane split below the right pane", |grid_snapshot| { + grid_snapshot.status_bar_appears() && grid_snapshot.cursor_is_at(col(62).row(13)) + }); + + toggle_no_ui_fullscreen_via_keybinding(&zellij); + zellij.wait_until("third pane is no-ui fullscreen", |grid_snapshot| { + !grid_snapshot.tab_bar_appears() && !grid_snapshot.status_bar_appears() + }); + + zellij.send_stdin(&keys::ctrl('p')); + zellij.send_stdin(&keys::key('x')); + let after_close = zellij.wait_until( + "no-ui fullscreen pane closed, two panes and chrome restored", + |grid_snapshot| { + grid_snapshot.tab_bar_appears() + && grid_snapshot.status_bar_appears() + && !grid_snapshot.contains("(FULLSCREEN)") + && grid_snapshot.cursor_is_at(col(62).row(2)) + }, + ); + + assert_same_rendered_grid( + &after_close, + &two_pane_state, + "closing the no-ui fullscreen pane restores the pre-split layout", + ); + zellij.quit(); +} + +#[test] +fn no_ui_fullscreen_through_the_cli() { + let mut zellij = start_zellij(); + claim_first_terminal_and_wait_for_prompt(&zellij); + split_right_and_wait_for_prompt(&zellij); + + let exit_code = zellij.run_cli_action(CliAction::ToggleNoUiFullscreen { pane_id: None }); + assert_eq!(exit_code, 0, "toggle-no-ui-fullscreen exited cleanly"); + zellij.wait_until("cli action hid the chrome", |grid_snapshot| { + !grid_snapshot.tab_bar_appears() && !grid_snapshot.status_bar_appears() + }); + + let exit_code = zellij.run_cli_action(CliAction::ToggleNoUiFullscreen { pane_id: None }); + assert_eq!(exit_code, 0, "toggle-no-ui-fullscreen exited cleanly"); + zellij.wait_until("cli action restored the chrome", |grid_snapshot| { + grid_snapshot.tab_bar_appears() + && grid_snapshot.status_bar_appears() + && grid_snapshot.cursor_is_at(col(62).row(2)) + }); + zellij.quit(); +} diff --git a/zellij-integration-tests/tests/panes.rs b/zellij-integration-tests/tests/panes.rs index 5ed88a1d..2f06612b 100644 --- a/zellij-integration-tests/tests/panes.rs +++ b/zellij-integration-tests/tests/panes.rs @@ -2,7 +2,7 @@ use insta::assert_snapshot; use zellij_integration_tests::{ - claim_first_terminal_and_wait_for_prompt, col, keys, normalized, + assert_same_rendered_grid, claim_first_terminal_and_wait_for_prompt, col, keys, normalized, split_down_and_wait_for_prompt, split_right_and_wait_for_prompt, start_zellij, Coord, Size, TestRunner, TestSession, PROMPT, TERMINAL_SIZE, }; @@ -76,6 +76,79 @@ fn toggle_pane_fullscreen() { } #[test] +fn new_pane_while_fullscreen_breaks_out_of_fullscreen() { + let mut zellij = start_zellij(); + claim_first_terminal_and_wait_for_prompt(&zellij); + split_right_and_wait_for_prompt(&zellij); + + zellij.send_stdin(&keys::ctrl('p')); + zellij.send_stdin(&keys::key('f')); + zellij.wait_until("focused pane is fullscreen", |grid_snapshot| { + grid_snapshot.contains("(FULLSCREEN)") + }); + + zellij.send_stdin(&keys::alt('n')); + let new_terminal = zellij.expect_pty_spawn(); + new_terminal.output(PROMPT); + + let grid_snapshot = zellij.wait_until( + "all three panes tiled after breaking out of fullscreen", + |grid_snapshot| { + !grid_snapshot.contains("(FULLSCREEN)") + && grid_snapshot.status_bar_appears() + && grid_snapshot.contains("Pane #1") + && grid_snapshot.contains("Pane #2") + && grid_snapshot.contains("Pane #3") + && grid_snapshot.cursor.is_some() + }, + ); + assert_snapshot!(normalized(&grid_snapshot)); + zellij.quit(); +} + +#[test] +fn closing_the_fullscreen_pane_restores_the_remaining_layout() { + let mut zellij = start_zellij(); + claim_first_terminal_and_wait_for_prompt(&zellij); + split_right_and_wait_for_prompt(&zellij); + let two_pane_state = zellij.wait_until("two panes settled", |grid_snapshot| { + grid_snapshot.status_bar_appears() && grid_snapshot.cursor_is_at(col(62).row(2)) + }); + + zellij.send_stdin(&keys::ctrl('p')); + zellij.send_stdin(&keys::key('d')); + let third_terminal = zellij.expect_pty_spawn(); + third_terminal.output(PROMPT); + zellij.wait_until("third pane split below the right pane", |grid_snapshot| { + grid_snapshot.status_bar_appears() && grid_snapshot.cursor_is_at(col(62).row(13)) + }); + + zellij.send_stdin(&keys::ctrl('p')); + zellij.send_stdin(&keys::key('f')); + zellij.wait_until("third pane is fullscreen", |grid_snapshot| { + grid_snapshot.contains("(FULLSCREEN)") + }); + + zellij.send_stdin(&keys::ctrl('p')); + zellij.send_stdin(&keys::key('x')); + let after_close = zellij.wait_until( + "fullscreen pane closed, two panes restored", + |grid_snapshot| { + !grid_snapshot.contains("(FULLSCREEN)") + && grid_snapshot.status_bar_appears() + && grid_snapshot.cursor_is_at(col(62).row(2)) + }, + ); + + assert_same_rendered_grid( + &after_close, + &two_pane_state, + "closing the fullscreen pane restores the pre-split layout", + ); + zellij.quit(); +} + +#[test] fn close_pane() { let mut zellij = start_zellij(); claim_first_terminal_and_wait_for_prompt(&zellij); diff --git a/zellij-integration-tests/tests/search.rs b/zellij-integration-tests/tests/search.rs index 6c750b0b..e6ac11c0 100644 --- a/zellij-integration-tests/tests/search.rs +++ b/zellij-integration-tests/tests/search.rs @@ -109,7 +109,7 @@ fn search_down_to_next_match() { zellij.send_stdin(&keys::key('p')); zellij.send_stdin(&keys::key('p')); zellij.wait_until("upper match revealed by searching up", |grid_snapshot| { - grid_snapshot.contains("NEEDLE upper") + grid_snapshot.contains("NEEDLE upper") && grid_snapshot.contains("SCROLL 15/19") }); zellij.send_stdin(&keys::key('n')); @@ -118,6 +118,7 @@ fn search_down_to_next_match() { zellij.wait_until("lower match revealed by searching down", |grid_snapshot| { grid_snapshot.contains("NEEDLE lower") && !grid_snapshot.contains("NEEDLE upper") + && grid_snapshot.contains("SCROLL 6/19") && grid_snapshot.contains("PgDn|PgUp") }); assert_snapshot!(normalized(&grid_snapshot)); diff --git a/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth2_breadcrumb_tab_bar_line.snap b/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth2_breadcrumb_tab_bar_line.snap new file mode 100644 index 00000000..2da02305 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth2_breadcrumb_tab_bar_line.snap @@ -0,0 +1,6 @@ +--- +source: zellij-integration-tests/tests/nested_fullscreen.rs +assertion_line: 352 +expression: normalized(&fullscreened).lines().next().unwrap() +--- + Zellij (test-0 ▸ test-1) Tab #1 + diff --git a/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth2_guest_fullscreen_fills_display.snap b/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth2_guest_fullscreen_fills_display.snap new file mode 100644 index 00000000..002f8384 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth2_guest_fullscreen_fills_display.snap @@ -0,0 +1,8 @@ +--- +source: zellij-integration-tests/tests/nested_fullscreen.rs +assertion_line: 162 +expression: normalized(&fullscreen_grid) +--- + Zellij (test-0 ▸ test-1) Tab #1 + +█ + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... diff --git a/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth3_inner_fullscreen_breadcrumb.snap b/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth3_inner_fullscreen_breadcrumb.snap new file mode 100644 index 00000000..e9985200 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth3_inner_fullscreen_breadcrumb.snap @@ -0,0 +1,9 @@ +--- +source: zellij-integration-tests/tests/nested_fullscreen.rs +assertion_line: 486 +expression: normalized(&fullscreen_grid) +--- + Zellij (test-0 ▸ test-1 ▸ test-2) Tab #1 + +█ + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... + diff --git a/zellij-integration-tests/tests/snapshots/nested_guest_modal__modal_renders_expected_ui.snap b/zellij-integration-tests/tests/snapshots/nested_guest_modal__modal_renders_expected_ui.snap new file mode 100644 index 00000000..18317da3 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_guest_modal__modal_renders_expected_ui.snap @@ -0,0 +1,16 @@ +--- +source: zellij-integration-tests/tests/nested_guest_modal.rs +expression: normalized(&steady_modal) +--- + Zellij (test) Tab #1 + + Nested Zellij session detected: test-1 + What would you like to do? + > 1. Zoom in and control this session + The session will take up the whole screen. Toggle + this on/off with <Ctrl y> + 2. Control this session on focus + When this pane gains focus, keybindings will be sent + to this session. Ascend to the current session with + <Ctrl u> + <↓↑> select <Enter> confirm <1-2> direct <Esc> dismiss + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__guest_reacts_after_first_load_descend.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__guest_reacts_after_first_load_descend.snap new file mode 100644 index 00000000..8b290dcd --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__guest_reacts_after_first_load_descend.snap @@ -0,0 +1,26 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&guest_after_new_pane) +--- + Zellij (test) Tab #1 + + Pane #1 │ Pane #2 +$ │█ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__guest_reacts_to_passed_through_new_pane_key.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__guest_reacts_to_passed_through_new_pane_key.snap new file mode 100644 index 00000000..3007dac2 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__guest_reacts_to_passed_through_new_pane_key.snap @@ -0,0 +1,25 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&guest_after_new_pane) +--- + Zellij (test) Tab #1 + + Pane #1 │ Pane #2 +$ │█ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + Ctrl + g p t n h s o q ... diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_after_guest_cleared_passthrough.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_after_guest_cleared_passthrough.snap new file mode 100644 index 00000000..ed9ccfb8 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_after_guest_cleared_passthrough.snap @@ -0,0 +1,28 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&host_after_guest_gone) +--- + Zellij (test) Tab #1 + + test-1 │ Pane #2 + Zellij (test) Tab #1 + │$ + │ + │ + │ + │ + │ + │ + │ + │ +───────────────────────────────────────────────────────────┤ + Pane #3 │ +$ █ │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_on_key_after_ascend.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_on_key_after_ascend.snap new file mode 100644 index 00000000..a71fe392 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_on_key_after_ascend.snap @@ -0,0 +1,28 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&host_after_new_pane) +--- + Zellij (test) Tab #1 + + test-1 │ Pane #2 + Zellij (test) Tab #1 + │$ + │ + │ + │ + │ + │ + │ + │ + Descend: Ctrl + <o> + <[> │ +───────────────────────────────────────────────────────────┤ + Pane #3 │ +$ █ │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_on_key_after_mouse_ascend.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_on_key_after_mouse_ascend.snap new file mode 100644 index 00000000..bca169f3 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_on_key_after_mouse_ascend.snap @@ -0,0 +1,28 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&host_after_new_pane) +--- + Zellij (test) Tab #1 + + test-1 │ Pane #2 + Zellij (test) Tab #1 + │$ + │ + │ + │ + │ + │ + │ + │ + │ + ├──────────────────────────────────────────────────────────── + │ Pane #3 + │$ █ + │ + │ + │ + │ + │ + │ + │ + │ + Descend: Ctrl + <o> + <[> │ + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_clicking_new_tab_button.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_clicking_new_tab_button.snap new file mode 100644 index 00000000..782526bf --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_clicking_new_tab_button.snap @@ -0,0 +1,7 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&host_after_new_tab) +--- + Zellij (test) Tab #1 Tab #2 + +$ █ + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_focus_host_binding.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_focus_host_binding.snap new file mode 100644 index 00000000..2e1c0853 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_focus_host_binding.snap @@ -0,0 +1,28 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&host_ascended) +--- + Zellij (test) Tab #1 + + test-1 │ Pane #2 + Zellij (test) Tab #1 + │$ +█ │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + Descend: Ctrl + <o> + <[> │ + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_mouse_click.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_mouse_click.snap new file mode 100644 index 00000000..0450491b --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_mouse_click.snap @@ -0,0 +1,28 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&host_after_click) +--- + Zellij (test) Tab #1 + + test-1 │ Pane #2 + Zellij (test) Tab #1 + │$ █ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + Descend: Ctrl + <o> + <[> │ + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_descended_on_first_load.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_descended_on_first_load.snap new file mode 100644 index 00000000..5ce2e107 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_descended_on_first_load.snap @@ -0,0 +1,9 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&host_descended_on_load) +--- + Zellij (test) test-1 + + Zellij (test) Tab #1 + +█ + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... + Ascend: Ctrl + <o> + <]> diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_grid_while_descended.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_grid_while_descended.snap new file mode 100644 index 00000000..1533480f --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_grid_while_descended.snap @@ -0,0 +1,28 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&host_descended) +--- + Zellij (test) Tab #1 + + test-1 │ Pane #2 + Zellij (test) Tab #1 + │$ +█ │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + Ctrl + g p t n h s o q ...│ + Ascend: Ctrl + <o> + <]> diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_keys_act_on_host_before_descend.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_keys_act_on_host_before_descend.snap new file mode 100644 index 00000000..7df03a1c --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_keys_act_on_host_before_descend.snap @@ -0,0 +1,28 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&host_focused_on_sibling) +--- + Zellij (test) Tab #1 + + test-1 │ Pane #2 + Zellij (test) Tab #1 + │$ █ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + Descend: Ctrl + <o> + <[> │ + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_pane_count_unchanged_while_descended.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_pane_count_unchanged_while_descended.snap new file mode 100644 index 00000000..5060b831 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_pane_count_unchanged_while_descended.snap @@ -0,0 +1,28 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&host_pane_count_unchanged) +--- + Zellij (test) Tab #1 + + test-1 │ Pane #2 + Zellij (test) Tab #1 + Alt <[]> VERTICAL │$ + Pane #1 │ Pane #2 │ +$ │█ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + Ctrl + g p t n h s o q ...│ + Ascend: Ctrl + <o> + <]> diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__key_after_ascend_lands_in_host.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__key_after_ascend_lands_in_host.snap new file mode 100644 index 00000000..b5ee6b38 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__key_after_ascend_lands_in_host.snap @@ -0,0 +1,28 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&host_end_state) +--- + Zellij (test) Tab #1 + + test-1 │ Pane #2 + Zellij (test) Tab #1 + Alt <[]> VERTICAL │$ + Pane #1 │ Pane #2 │ +$ │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + Descend: Ctrl + <o> + <[> │ +───────────────────────────────────────────────────────────┤ + Pane #3 │ +$ █ │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__key_after_descend_lands_in_guest.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__key_after_descend_lands_in_guest.snap new file mode 100644 index 00000000..28943cec --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__key_after_descend_lands_in_guest.snap @@ -0,0 +1,25 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&guest_end_state) +--- + Zellij (test) Tab #1 + + Pane #1 │ Pane #2 +$ │█ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + Ctrl + g p t n h s o q ... diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__outer_grid_after_inner_ascend.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__outer_grid_after_inner_ascend.snap new file mode 100644 index 00000000..cb2da9f0 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__outer_grid_after_inner_ascend.snap @@ -0,0 +1,11 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&outer_after_inner_ascend) +--- + Zellij (test) test-2 | test-2 + + Zellij (test) test-2 + + Zellij (test) Tab #1 + +█ + Descend: Ctrl + <o> + <[> + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... + Ascend: Ctrl + <o> + <]> diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__outer_grid_doubly_nested_descended.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__outer_grid_doubly_nested_descended.snap new file mode 100644 index 00000000..d2cc247d --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/nested_sessions__outer_grid_doubly_nested_descended.snap @@ -0,0 +1,11 @@ +--- +source: zellij-integration-tests/tests/nested_sessions.rs +expression: normalized(&outer_doubly_nested) +--- + Zellij (test) test-2 | test-2 + + Zellij (test) test-2 + + Zellij (test) Tab #1 + +█ + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... + Ascend: Ctrl + <o> + <]> + Ascend: Ctrl + <o> + <]> diff --git a/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__new_pane_while_no_ui_fullscreen_breaks_out_and_restores_chrome.snap b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__new_pane_while_no_ui_fullscreen_breaks_out_and_restores_chrome.snap new file mode 100644 index 00000000..227d5755 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__new_pane_while_no_ui_fullscreen_breaks_out_and_restores_chrome.snap @@ -0,0 +1,28 @@ +--- +source: zellij-integration-tests/tests/no_ui_fullscreen.rs +expression: normalized(&grid_snapshot) +--- + Zellij (test) Tab #1 + + Pane #1 │ Pane #2 +$ │$ + │ + │ + │ + │ + │ + │ + │ + │ + ├──────────────────────────────────────────────────────────── + │ Pane #3 + │$ █ + │ + │ + │ + │ + │ + │ + │ + │ + │ + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... diff --git a/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_hides_ui_and_covers_display.snap b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_hides_ui_and_covers_display.snap new file mode 100644 index 00000000..835b0803 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_hides_ui_and_covers_display.snap @@ -0,0 +1,6 @@ +--- +source: zellij-integration-tests/tests/no_ui_fullscreen.rs +expression: normalized(&grid_snapshot) +--- +$ █ + diff --git a/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_with_full_frames.snap b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_with_full_frames.snap new file mode 100644 index 00000000..835b0803 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_with_full_frames.snap @@ -0,0 +1,6 @@ +--- +source: zellij-integration-tests/tests/no_ui_fullscreen.rs +expression: normalized(&grid_snapshot) +--- +$ █ + diff --git a/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_works_with_single_pane.snap b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_works_with_single_pane.snap new file mode 100644 index 00000000..835b0803 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_works_with_single_pane.snap @@ -0,0 +1,6 @@ +--- +source: zellij-integration-tests/tests/no_ui_fullscreen.rs +expression: normalized(&grid_snapshot) +--- +$ █ + diff --git a/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__resize_terminal_while_no_ui_fullscreen_tracks_display.snap b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__resize_terminal_while_no_ui_fullscreen_tracks_display.snap new file mode 100644 index 00000000..74c6eafa --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__resize_terminal_while_no_ui_fullscreen_tracks_display.snap @@ -0,0 +1,6 @@ +--- +source: zellij-integration-tests/tests/no_ui_fullscreen.rs +expression: normalized(&grid_snapshot) +--- + █ + diff --git a/zellij-integration-tests/tests/snapshots/panes__new_pane_while_fullscreen_breaks_out_of_fullscreen.snap b/zellij-integration-tests/tests/snapshots/panes__new_pane_while_fullscreen_breaks_out_of_fullscreen.snap new file mode 100644 index 00000000..7e5ac185 --- /dev/null +++ b/zellij-integration-tests/tests/snapshots/panes__new_pane_while_fullscreen_breaks_out_of_fullscreen.snap @@ -0,0 +1,28 @@ +--- +source: zellij-integration-tests/tests/panes.rs +expression: normalized(&grid_snapshot) +--- + Zellij (test) Tab #1 + + Pane #1 │ Pane #2 +$ │$ + │ + │ + │ + │ + │ + │ + │ + │ + ├──────────────────────────────────────────────────────────── + │ Pane #3 + │$ █ + │ + │ + │ + │ + │ + │ + │ + │ + │ + Ctrl + <g> LOCK <p> PANE <t> TAB <n> RESIZE <h> MOVE <s> SEARCH <o> SESSION <q> QUIT ... diff --git a/zellij-integration-tests/tests/snapshots/search__search_down_to_next_match.snap b/zellij-integration-tests/tests/snapshots/search__search_down_to_next_match.snap index af4123ad..3a4ffdbb 100644 --- a/zellij-integration-tests/tests/snapshots/search__search_down_to_next_match.snap +++ b/zellij-integration-tests/tests/snapshots/search__search_down_to_next_match.snap @@ -2,7 +2,7 @@ source: zellij-integration-tests/tests/search.rs expression: normalized(&grid_snapshot) --- - Zellij (test) Tab #1 + [ SCROLL 15/19 ] + Zellij (test) Tab #1 + [ SCROLL 6/19 ] line14 line15 line16 @@ -25,4 +25,4 @@ line32 line33 line34 █ine35 NEEDLE lower - SEARCHING ↓↑ Scroll PgDn|PgUp Scroll d|u Scroll n Down p Up c Case w Wrap o Whole + SEARCHING ↓↑ Scroll PgDn|PgUp Scroll d|u Scroll n Down p Up c Case w Wrap o Whole diff --git a/zellij-integration-tests/tests/startup_host_query.rs b/zellij-integration-tests/tests/startup_host_query.rs index 514df24f..7c4b6648 100644 --- a/zellij-integration-tests/tests/startup_host_query.rs +++ b/zellij-integration-tests/tests/startup_host_query.rs @@ -59,6 +59,22 @@ fn fragmented_attach_reply_burst_does_not_leak_into_focused_pane() { } #[test] +fn theme_query_from_pane_is_answered_when_host_mode_is_unknown() { + let mut zellij = start_zellij(); + let terminal = claim_first_terminal_and_wait_for_prompt(&zellij); + + terminal.output(b"\x1b[?996n"); + + terminal.wait_for_stdin("theme mode reply reached the pane", |stdin_bytes| { + stdin_bytes + .windows(9) + .any(|window| window == b"\x1b[?997;1n") + }); + + zellij.quit(); +} + +#[test] fn fragmented_function_key_arrives_intact() { let mut zellij = start_zellij(); let terminal = claim_first_terminal_and_wait_for_prompt(&zellij); diff --git a/zellij-server/src/background_jobs.rs b/zellij-server/src/background_jobs.rs index c01df526..42b3a116 100644 --- a/zellij-server/src/background_jobs.rs +++ b/zellij-server/src/background_jobs.rs @@ -75,6 +75,8 @@ pub enum BackgroundJob { FlashTabBell(usize), // usize = tab_id StopFlashTabBell(usize), // usize = tab_id MobileGateTimeout(ClientId), + StartNestedGuestPing(PaneId), + StopNestedGuestPing(PaneId), Exit, } @@ -104,6 +106,8 @@ impl From<&BackgroundJob> for BackgroundJobContext { BackgroundJob::FlashTabBell(..) => BackgroundJobContext::FlashTabBell, BackgroundJob::StopFlashTabBell(..) => BackgroundJobContext::StopFlashTabBell, BackgroundJob::MobileGateTimeout(..) => BackgroundJobContext::MobileGateTimeout, + BackgroundJob::StartNestedGuestPing(..) => BackgroundJobContext::StartNestedGuestPing, + BackgroundJob::StopNestedGuestPing(..) => BackgroundJobContext::StopNestedGuestPing, BackgroundJob::Exit => BackgroundJobContext::Exit, } } @@ -161,6 +165,7 @@ pub(crate) fn background_jobs_main( Arc::new(Mutex::new(HashMap::new())); let mut flashing_pane_bells: HashMap<PaneId, Arc<AtomicBool>> = HashMap::new(); let mut flashing_tab_bells: HashMap<usize, Arc<AtomicBool>> = HashMap::new(); + let mut nested_guest_pings: HashMap<PaneId, Arc<AtomicBool>> = HashMap::new(); let http_client = HttpClient::builder() // TODO: timeout? @@ -624,6 +629,30 @@ pub(crate) fn background_jobs_main( .senders .send_to_screen(ScreenInstruction::SetTabBellFlash(tab_id, false)); }, + BackgroundJob::StartNestedGuestPing(pane_id) => { + if !nested_guest_pings.contains_key(&pane_id) { + let is_pinging = Arc::new(AtomicBool::new(true)); + nested_guest_pings.insert(pane_id, is_pinging.clone()); + runtime.spawn({ + let senders = bus.senders.clone(); + let flag = is_pinging.clone(); + let ping_interval_ms = crate::nested_guest::ping_interval_ms(); + async move { + while flag.load(Ordering::SeqCst) { + let _ = senders.send_to_screen( + ScreenInstruction::NestedGuestPingTick { pane_id }, + ); + tokio::time::sleep(Duration::from_millis(ping_interval_ms)).await; + } + } + }); + } + }, + BackgroundJob::StopNestedGuestPing(pane_id) => { + if let Some(flag) = nested_guest_pings.remove(&pane_id) { + flag.store(false, Ordering::SeqCst); + } + }, BackgroundJob::MobileGateTimeout(client_id) => { runtime.spawn({ let senders = bus.senders.clone(); @@ -641,6 +670,9 @@ pub(crate) fn background_jobs_main( for loading_plugin in loading_plugins.values() { loading_plugin.store(false, Ordering::SeqCst); } + for nested_guest_ping in nested_guest_pings.values() { + nested_guest_ping.store(false, Ordering::SeqCst); + } let cache_file_name = session_info_cache_file_name(¤t_session_name.lock().unwrap().to_owned()); diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs index cd9dcb5e..daaa938d 100644 --- a/zellij-server/src/lib.rs +++ b/zellij-server/src/lib.rs @@ -15,6 +15,7 @@ pub mod background_jobs; mod global_async_runtime; mod logging_pipe; mod mobile_mode; +pub mod nested_guest; mod pane_groups; mod plugins; mod pty; @@ -46,6 +47,7 @@ use wasmi::Engine; use crate::{ os_input_output::ServerOsApi, + panes::PaneId, plugins::{plugin_thread_main, PluginInstruction}, pty::{get_default_shell, pty_thread_main, Pty, PtyInstruction}, screen::{screen_thread_main, ScreenInstruction}, @@ -58,8 +60,8 @@ use zellij_utils::{ DEFAULT_SCROLL_BUFFER_SIZE, SCROLL_BUFFER_SIZE, ZELLIJ_SEEN_RELEASE_NOTES_CACHE_FILE, }, data::{ - ConnectToSession, InputMode, KeyWithModifier, LayoutInfo, LayoutWithError, Style, - WebSharing, + ConnectToSession, Direction, InputMode, KeyWithModifier, LayoutInfo, LayoutWithError, + Style, WebSharing, }, errors::{prelude::*, ContextType, ErrorInstruction, FatalError, ServerContext}, home::{default_layout_dir, get_default_data_dir}, @@ -138,6 +140,8 @@ pub enum ServerInstruction { /// loop. The main loop writes `ServerToClientMsg::ForwardQueryToHost` /// to any connected regular client. ForwardQueryToHost(u32, Vec<u8>), + KeyPassthroughChanged(ClientId, PaneId, PaneId, bool, Option<Direction>, bool), + EmitNestedSessionFrameToClient(ClientId, Vec<u8>), } impl From<&ServerInstruction> for ServerContext { @@ -187,6 +191,10 @@ impl From<&ServerInstruction> for ServerContext { }, ServerInstruction::ClearMouseHelpText(..) => ServerContext::ClearMouseHelpText, ServerInstruction::ForwardQueryToHost(..) => ServerContext::ForwardQueryToHost, + ServerInstruction::KeyPassthroughChanged(..) => ServerContext::KeyPassthroughChanged, + ServerInstruction::EmitNestedSessionFrameToClient(..) => { + ServerContext::EmitNestedSessionFrameToClient + }, } } } @@ -326,6 +334,7 @@ pub(crate) struct SessionMetaData { pub default_shell: Option<TerminalAction>, pub current_input_modes: HashMap<ClientId, InputMode>, pub session_configuration: SessionConfiguration, + pub key_passthrough_clients: HashMap<ClientId, PaneId>, pub web_sharing: WebSharing, // this is a special attribute explicitly set on session // initialization because we don't want it to be overridden by // configuration changes, the only way it can be overwritten is by @@ -356,6 +365,30 @@ impl SessionMetaData { _ => None, } } + pub fn remove_key_passthrough_client(&mut self, client_id: ClientId) { + self.remove_key_passthrough_client_with_notify(client_id, true); + } + pub fn remove_key_passthrough_client_with_notify( + &mut self, + client_id: ClientId, + notify_guest: bool, + ) { + if let Some(pane_id) = self.key_passthrough_clients.remove(&client_id) { + let pane_still_active = self.key_passthrough_clients.values().any(|p| *p == pane_id); + if !pane_still_active && notify_guest { + if let PaneId::Terminal(terminal_id) = pane_id { + let frame = zellij_utils::nested_session::encode_frame( + &zellij_utils::nested_session::NestedSessionMessage::FocusLost, + ); + let _ = self.senders.send_to_pty_writer(PtyWriteInstruction::Write( + frame, + terminal_id, + None, + )); + } + } + } + } pub fn change_mode_for_all_clients(&mut self, input_mode: InputMode) { let all_clients: Vec<ClientId> = self.current_input_modes.keys().copied().collect(); for client_id in all_clients { @@ -438,6 +471,10 @@ impl SessionMetaData { visual_bell: new_config.options.visual_bell.unwrap_or(true), focus_follows_mouse: new_config.options.focus_follows_mouse.unwrap_or(false), mouse_click_through: new_config.options.mouse_click_through.unwrap_or(false), + nested_session_handling: new_config + .options + .nested_session_handling + .unwrap_or_default(), }) .unwrap(); self.senders @@ -1334,6 +1371,9 @@ pub fn start_server_impl( os_input.remove_client(client_id).unwrap(); } else { + if let Some(session_data) = session_data.write().unwrap().as_mut() { + session_data.remove_key_passthrough_client(client_id); + } // Handle regular client removal remove_client!(client_id, os_input, session_state, session_data); drop(completion_tx); // prevent deadlock with route thread @@ -1398,6 +1438,9 @@ pub fn start_server_impl( os_input.remove_client(client_id).unwrap(); } else { + if let Some(session_data) = session_data.write().unwrap().as_mut() { + session_data.remove_key_passthrough_client(client_id); + } // Handle regular client removal remove_client!(client_id, os_input, session_state, session_data); session_data @@ -1450,6 +1493,9 @@ pub fn start_server_impl( .filter(|c| c != &client_id) .collect(); for client_id in client_ids { + if let Some(session_data) = session_data.write().unwrap().as_mut() { + session_data.remove_key_passthrough_client(client_id); + } let _ = os_input.send_to_client( client_id, ServerToClientMsg::Exit { @@ -1461,6 +1507,9 @@ pub fn start_server_impl( }, ServerInstruction::DetachSession(client_ids, completion_tx) => { for client_id in &client_ids { + if let Some(session_data) = session_data.write().unwrap().as_mut() { + session_data.remove_key_passthrough_client(*client_id); + } let _ = os_input.send_to_client( *client_id, ServerToClientMsg::Exit { @@ -1913,6 +1962,77 @@ pub fn start_server_impl( } } }, + ServerInstruction::KeyPassthroughChanged( + client_id, + _old_pane_id, + new_pane_id, + should_route, + entered_from_direction, + notify_guest, + ) => { + let mut session_data = session_data.write().unwrap(); + if let Some(session_data) = session_data.as_mut() { + let previous_pane = session_data + .key_passthrough_clients + .get(&client_id) + .copied(); + + if should_route { + if previous_pane != Some(new_pane_id) { + if let Some(previous_pane_id) = previous_pane { + session_data.key_passthrough_clients.remove(&client_id); + let pane_still_active = session_data + .key_passthrough_clients + .values() + .any(|p| *p == previous_pane_id); + if !pane_still_active && notify_guest { + if let crate::panes::PaneId::Terminal(terminal_id) = + previous_pane_id + { + let frame = zellij_utils::nested_session::encode_frame( + &zellij_utils::nested_session::NestedSessionMessage::FocusLost, + ); + let _ = session_data.senders.send_to_pty_writer( + PtyWriteInstruction::Write(frame, terminal_id, None), + ); + } + } + } + let pane_already_active = session_data + .key_passthrough_clients + .values() + .any(|p| *p == new_pane_id); + session_data + .key_passthrough_clients + .insert(client_id, new_pane_id); + if !pane_already_active { + if let crate::panes::PaneId::Terminal(terminal_id) = new_pane_id { + let frame = zellij_utils::nested_session::encode_frame( + &zellij_utils::nested_session::NestedSessionMessage::FocusGained { + from_direction: entered_from_direction, + }, + ); + let _ = session_data.senders.send_to_pty_writer( + PtyWriteInstruction::Write(frame, terminal_id, None), + ); + } + } + } + } else if previous_pane.is_some() { + session_data + .remove_key_passthrough_client_with_notify(client_id, notify_guest); + } + } + }, + ServerInstruction::EmitNestedSessionFrameToClient(client_id, payload_bytes) => { + send_to_client!( + client_id, + os_input, + ServerToClientMsg::EmitNestedSessionFrame { payload_bytes }, + session_state, + session_data + ); + }, } } @@ -2193,6 +2313,7 @@ fn init_session( web_sharing: config.options.web_sharing.unwrap_or(WebSharing::Off), #[cfg(not(feature = "web_server_capability"))] web_sharing: WebSharing::Disabled, + key_passthrough_clients: HashMap::new(), config_file_path: cli_assets.config_file_path, } } diff --git a/zellij-server/src/nested_guest.rs b/zellij-server/src/nested_guest.rs new file mode 100644 index 00000000..b63697ad --- /dev/null +++ b/zellij-server/src/nested_guest.rs @@ -0,0 +1,205 @@ +use crate::panes::PaneId; +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; + +pub const PING_INTERVAL_MS: u64 = 2000; +pub const PONG_TIMEOUT_MS: u64 = 5000; + +pub fn ping_interval_ms() -> u64 { + std::env::var("ZELLIJ_NESTED_PING_INTERVAL_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(PING_INTERVAL_MS) +} + +pub fn pong_timeout_ms() -> u64 { + std::env::var("ZELLIJ_NESTED_PONG_TIMEOUT_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(PONG_TIMEOUT_MS) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GuestPingTickAction { + SendPing, + Suspended, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AnnounceKind { + New, + Revived, + Refresh, +} + +#[derive(Debug, Default)] +pub struct NestedGuestTracker { + last_heard_from: HashMap<PaneId, Instant>, + dormant: HashSet<PaneId>, +} + +impl NestedGuestTracker { + pub fn on_announce(&mut self, pane_id: PaneId, now: Instant) -> AnnounceKind { + let was_tracked = self.last_heard_from.contains_key(&pane_id); + let was_dormant = self.dormant.remove(&pane_id); + self.last_heard_from.insert(pane_id, now); + if was_tracked { + AnnounceKind::Refresh + } else if was_dormant { + AnnounceKind::Revived + } else { + AnnounceKind::New + } + } + pub fn on_pong(&mut self, pane_id: PaneId, now: Instant) { + if let Some(last_heard_from) = self.last_heard_from.get_mut(&pane_id) { + *last_heard_from = now; + } + } + pub fn on_tick(&mut self, pane_id: PaneId, now: Instant) -> GuestPingTickAction { + match self.last_heard_from.get(&pane_id) { + Some(last_heard_from) + if now.duration_since(*last_heard_from) + > Duration::from_millis(pong_timeout_ms()) => + { + self.last_heard_from.remove(&pane_id); + self.dormant.insert(pane_id); + GuestPingTickAction::Suspended + }, + Some(_) => GuestPingTickAction::SendPing, + None => GuestPingTickAction::Unknown, + } + } + pub fn remove(&mut self, pane_id: PaneId) { + self.last_heard_from.remove(&pane_id); + self.dormant.remove(&pane_id); + } + pub fn is_tracked(&self, pane_id: PaneId) -> bool { + self.last_heard_from.contains_key(&pane_id) + } + pub fn is_dormant(&self, pane_id: PaneId) -> bool { + self.dormant.contains(&pane_id) + } + pub fn is_known(&self, pane_id: PaneId) -> bool { + self.is_tracked(pane_id) || self.is_dormant(pane_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn millis(ms: u64) -> Duration { + Duration::from_millis(ms) + } + + #[test] + fn announced_guest_gets_pinged_and_pong_keeps_it_alive() { + let mut tracker = NestedGuestTracker::default(); + let pane_id = PaneId::Terminal(1); + let start = Instant::now(); + tracker.on_announce(pane_id, start); + assert_eq!( + tracker.on_tick(pane_id, start + millis(PING_INTERVAL_MS)), + GuestPingTickAction::SendPing + ); + tracker.on_pong(pane_id, start + millis(4000)); + assert_eq!( + tracker.on_tick(pane_id, start + millis(8000)), + GuestPingTickAction::SendPing + ); + } + + #[test] + fn guest_that_never_pongs_suspends_after_timeout() { + let mut tracker = NestedGuestTracker::default(); + let pane_id = PaneId::Terminal(1); + let start = Instant::now(); + tracker.on_announce(pane_id, start); + assert_eq!( + tracker.on_tick(pane_id, start + millis(PONG_TIMEOUT_MS + 1)), + GuestPingTickAction::Suspended + ); + assert!(!tracker.is_tracked(pane_id)); + assert!(tracker.is_dormant(pane_id)); + assert!(tracker.is_known(pane_id)); + assert_eq!( + tracker.on_tick(pane_id, start + millis(PONG_TIMEOUT_MS + 2)), + GuestPingTickAction::Unknown + ); + } + + #[test] + fn suspended_guest_revives_on_reannounce_however_long_later() { + let mut tracker = NestedGuestTracker::default(); + let pane_id = PaneId::Terminal(1); + let start = Instant::now(); + tracker.on_announce(pane_id, start); + assert_eq!( + tracker.on_tick(pane_id, start + millis(PONG_TIMEOUT_MS + 1)), + GuestPingTickAction::Suspended + ); + let much_later = start + millis(PONG_TIMEOUT_MS + 120_000); + assert_eq!( + tracker.on_announce(pane_id, much_later), + AnnounceKind::Revived + ); + assert!(tracker.is_tracked(pane_id)); + assert!(!tracker.is_dormant(pane_id)); + assert_eq!( + tracker.on_tick(pane_id, much_later + millis(PING_INTERVAL_MS)), + GuestPingTickAction::SendPing + ); + } + + #[test] + fn reannounce_of_live_guest_is_a_refresh() { + let mut tracker = NestedGuestTracker::default(); + let pane_id = PaneId::Terminal(1); + let start = Instant::now(); + assert_eq!(tracker.on_announce(pane_id, start), AnnounceKind::New); + assert_eq!( + tracker.on_announce(pane_id, start + millis(1000)), + AnnounceKind::Refresh + ); + assert!(tracker.is_tracked(pane_id)); + } + + #[test] + fn pong_refreshes_the_expiry_deadline() { + let mut tracker = NestedGuestTracker::default(); + let pane_id = PaneId::Terminal(1); + let start = Instant::now(); + tracker.on_announce(pane_id, start); + tracker.on_pong(pane_id, start + millis(4000)); + assert_eq!( + tracker.on_tick(pane_id, start + millis(PONG_TIMEOUT_MS + 1000)), + GuestPingTickAction::SendPing + ); + assert_eq!( + tracker.on_tick(pane_id, start + millis(4000 + PONG_TIMEOUT_MS + 1)), + GuestPingTickAction::Suspended + ); + } + + #[test] + fn pong_from_unannounced_pane_is_ignored() { + let mut tracker = NestedGuestTracker::default(); + let pane_id = PaneId::Terminal(1); + let now = Instant::now(); + tracker.on_pong(pane_id, now); + assert!(!tracker.is_tracked(pane_id)); + assert_eq!(tracker.on_tick(pane_id, now), GuestPingTickAction::Unknown); + } + + #[test] + fn removed_guest_stops_being_tracked() { + let mut tracker = NestedGuestTracker::default(); + let pane_id = PaneId::Terminal(1); + let now = Instant::now(); + tracker.on_announce(pane_id, now); + tracker.remove(pane_id); + assert_eq!(tracker.on_tick(pane_id, now), GuestPingTickAction::Unknown); + } +} diff --git a/zellij-server/src/panes/floating_panes/mod.rs b/zellij-server/src/panes/floating_panes/mod.rs index 0ff972cf..896e22a2 100644 --- a/zellij-server/src/panes/floating_panes/mod.rs +++ b/zellij-server/src/panes/floating_panes/mod.rs @@ -27,6 +27,7 @@ use zellij_utils::{ errors::prelude::*, input::command::RunCommand, input::layout::{FloatingPaneLayout, Run, RunPluginOrAlias}, + input::options::PaneFrameStyle, pane_size::{Dimension, Offset, PaneGeom, Size, SizeInPixels, Viewport}, }; @@ -53,6 +54,11 @@ pub struct FloatingPanes { // last_position) senders: ThreadSenders, window_title: Option<String>, + dimmed_clients: HashSet<ClientId>, + fullscreen_covers_ui: Rc<RefCell<bool>>, + fullscreen_pane_id: Option<PaneId>, + floating_fullscreen_covers_ui: bool, + pane_frame_style: PaneFrameStyle, } #[allow(clippy::borrowed_box)] @@ -65,6 +71,8 @@ impl FloatingPanes { connected_clients_in_app: Rc<RefCell<HashMap<ClientId, bool>>>, // bool -> is_web_client mode_info: Rc<RefCell<HashMap<ClientId, ModeInfo>>>, character_cell_size: Rc<RefCell<Option<SizeInPixels>>>, + fullscreen_covers_ui: Rc<RefCell<bool>>, + pane_frame_style: PaneFrameStyle, session_is_mirrored: bool, default_mode_info: ModeInfo, style: Style, @@ -89,9 +97,39 @@ impl FloatingPanes { pane_being_moved_with_mouse: None, senders, window_title: None, + dimmed_clients: HashSet::new(), + fullscreen_covers_ui, + fullscreen_pane_id: None, + floating_fullscreen_covers_ui: false, + pane_frame_style, } } + pub fn set_pane_frame_style(&mut self, pane_frame_style: PaneFrameStyle) { + self.pane_frame_style = pane_frame_style; + if self.fullscreen_pane_id.is_some() && !self.floating_fullscreen_covers_ui { + let _ = self.set_pane_frames(); + } + } + pub fn set_client_dimmed(&mut self, client_id: ClientId, dimmed: bool) { + if dimmed { + self.dimmed_clients.insert(client_id); + } else { + self.dimmed_clients.remove(&client_id); + } + self.set_force_render(); + } pub fn stack(&self) -> Option<FloatingPanesStack> { + if *self.fullscreen_covers_ui.borrow() { + return None; + } + if let Some(fullscreen_pane_id) = self.fullscreen_pane_id { + return self + .panes + .get(&fullscreen_pane_id) + .map(|p| FloatingPanesStack { + layers: vec![p.current_geom()], + }); + } if self.panes_are_visible() { let layers: Vec<PaneGeom> = self .z_indices @@ -221,7 +259,7 @@ impl FloatingPanes { .and_then(|active_pane_id| self.panes.get_mut(active_pane_id)) } pub fn panes_are_visible(&self) -> bool { - self.show_panes + self.show_panes && !*self.fullscreen_covers_ui.borrow() } pub fn has_active_panes(&self) -> bool { !self.active_panes.is_empty() @@ -378,7 +416,43 @@ impl FloatingPanes { let err_context = |pane_id: &PaneId| format!("failed to activate frame on pane {pane_id:?}"); + let no_ui_fullscreen_pane_id = if self.floating_fullscreen_covers_ui { + self.fullscreen_pane_id + } else { + None + }; + let regular_fullscreen_pane_id = if self.floating_fullscreen_covers_ui { + None + } else { + self.fullscreen_pane_id + }; + let pane_frame_style = self.pane_frame_style; for pane in self.panes.values_mut() { + if no_ui_fullscreen_pane_id == Some(pane.pid()) { + pane.set_frame(false); + pane.set_content_offset(Offset::default()); + resize_pty!(pane, os_api, self.senders, self.character_cell_size) + .with_context(|| err_context(&pane.pid()))?; + continue; + } + if regular_fullscreen_pane_id == Some(pane.pid()) { + if pane.borderless() { + pane.set_frame(false); + pane.set_content_offset(Offset::default()); + } else if pane_frame_style.draws_full_frames() { + pane.set_frame(true); + pane.set_content_offset(Offset::frame(1)); + } else if pane_frame_style.draws_titles() { + pane.set_frame(false); + pane.set_content_offset(Offset::shift_right_top_and_bottom(0, 1, 0)); + } else { + pane.set_frame(false); + pane.set_content_offset(Offset::default()); + } + resize_pty!(pane, os_api, self.senders, self.character_cell_size) + .with_context(|| err_context(&pane.pid()))?; + continue; + } // floating panes should always have a frame unless explicitly set otherwise if !pane.borderless() { pane.set_frame(true); @@ -417,6 +491,11 @@ impl FloatingPanes { }; for (kind, pane) in &self.panes { + if let Some(fullscreen_pane_id) = self.fullscreen_pane_id { + if kind != &fullscreen_pane_id { + continue; + } + } match kind { PaneId::Terminal(_) => { output.add_pane_contents( @@ -451,7 +530,17 @@ impl FloatingPanes { } } - let mut floating_panes: Vec<_> = if self.panes_are_visible() { + let fullscreen_pane_id = self.fullscreen_pane_id; + let no_ui_fullscreen = self.floating_fullscreen_covers_ui && fullscreen_pane_id.is_some(); + let regular_fullscreen = + fullscreen_pane_id.is_some() && !self.floating_fullscreen_covers_ui; + let pane_frame_style = self.pane_frame_style; + let mut floating_panes: Vec<_> = if let Some(fullscreen_pane_id) = fullscreen_pane_id { + self.panes + .iter_mut() + .filter(|(id, _)| **id == fullscreen_pane_id) + .collect() + } else if self.panes_are_visible() { self.panes.iter_mut().collect() } else if self.has_pinned_panes() { self.panes @@ -479,6 +568,14 @@ impl FloatingPanes { 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) }); + let pane_has_guest_modal = pane.has_guest_modal_for_any_client(); + let pane_is_regular_fullscreen = + regular_fullscreen && Some(**kind) == fullscreen_pane_id; + let should_draw_pane_frames = if pane_is_regular_fullscreen { + pane_frame_style.draws_full_frames() + } else { + true + }; let mut pane_contents_and_ui = PaneContentsAndUi::new( pane, output, @@ -488,12 +585,13 @@ impl FloatingPanes { Some(z_index + 1), // +1 because 0 is reserved for non-floating panes false, false, - true, + should_draw_pane_frames, mouse_hover_pane_id, current_pane_group.clone(), show_help_text, false, mouse_scroll_resize, + self.dimmed_clients.clone(), ); for client_id in &connected_clients { let client_mode = self @@ -503,15 +601,21 @@ impl FloatingPanes { .unwrap_or(&self.default_mode_info) .mode; let is_floating = true; - pane_contents_and_ui - .render_pane_frame( - *client_id, - client_mode, - self.session_is_mirrored, - is_floating, - pane_is_selectable, - ) - .with_context(err_context)?; + let skip_frame = no_ui_fullscreen + || (pane_is_regular_fullscreen + && !pane_frame_style.draws_full_frames() + && !pane_frame_style.draws_titles()); + if !skip_frame { + pane_contents_and_ui + .render_pane_frame( + *client_id, + client_mode, + self.session_is_mirrored, + is_floating, + pane_is_selectable, + ) + .with_context(err_context)?; + } if let PaneId::Plugin(..) = kind { pane_contents_and_ui .render_pane_contents_for_client(*client_id) @@ -529,9 +633,28 @@ impl FloatingPanes { .with_context(err_context)?; } if let PaneId::Terminal(..) = kind { - pane_contents_and_ui - .render_pane_contents_to_multiple_clients(connected_clients.iter().copied()) - .with_context(err_context)?; + if pane_has_guest_modal { + let (modal_clients, plain_clients): (Vec<ClientId>, Vec<ClientId>) = + connected_clients.iter().copied().partition(|client_id| { + pane_contents_and_ui.client_has_guest_modal(*client_id) + }); + if !plain_clients.is_empty() { + pane_contents_and_ui + .render_pane_contents_to_multiple_clients(plain_clients.iter().copied()) + .with_context(err_context)?; + } else { + pane_contents_and_ui.drain_pane_render_state(); + } + for client_id in modal_clients { + pane_contents_and_ui + .render_guest_modal_for_client(client_id) + .with_context(err_context)?; + } + } else { + pane_contents_and_ui + .render_pane_contents_to_multiple_clients(connected_clients.iter().copied()) + .with_context(err_context)?; + } } } Ok(()) @@ -638,6 +761,11 @@ impl FloatingPanes { } } + fn reenter_fullscreen_on_active_pane(&mut self, client_id: ClientId, covers_ui: bool) { + if let Some(active_pane_id) = self.get_active_pane_id(client_id) { + self.set_fullscreen(active_pane_id, covers_ui); + } + } pub fn move_focus( &mut self, client_id: ClientId, @@ -648,6 +776,13 @@ impl FloatingPanes { let _err_context = || { format!("failed to move focus of floating pane {direction:?} for client {client_id}") }; + if self.fullscreen_pane_id.is_some() { + let covers_ui = self.floating_fullscreen_covers_ui; + self.unset_fullscreen(); + let ret = self.move_focus(client_id, connected_clients, direction); + self.reenter_fullscreen_on_active_pane(client_id, covers_ui); + return ret; + } let display_area = *self.display_area.borrow(); let viewport = *self.viewport.borrow(); @@ -728,6 +863,13 @@ impl FloatingPanes { Ok(false) } pub fn focus_pane_on_edge(&mut self, direction: Direction, client_id: ClientId) { + if self.fullscreen_pane_id.is_some() { + let covers_ui = self.floating_fullscreen_covers_ui; + self.unset_fullscreen(); + self.focus_pane_on_edge(direction, client_id); + self.reenter_fullscreen_on_active_pane(client_id, covers_ui); + return; + } let display_area = *self.display_area.borrow(); let viewport = *self.viewport.borrow(); let floating_pane_grid = FloatingPaneGrid::new( @@ -741,8 +883,51 @@ impl FloatingPanes { self.set_force_render(); } } + pub fn focus_pane_adjacent_to( + &mut self, + pane_id: PaneId, + direction: Direction, + client_id: ClientId, + ) -> Option<PaneId> { + if !self.panes.contains_key(&pane_id) { + return None; + } + let display_area = *self.display_area.borrow(); + let viewport = *self.viewport.borrow(); + let floating_pane_grid = FloatingPaneGrid::new( + &mut self.panes, + &mut self.desired_pane_positions, + display_area, + viewport, + ); + let next_index = match direction { + Direction::Left => floating_pane_grid.next_selectable_pane_id_to_the_left(&pane_id), + Direction::Down => floating_pane_grid.next_selectable_pane_id_below(&pane_id), + Direction::Up => floating_pane_grid.next_selectable_pane_id_above(&pane_id), + Direction::Right => floating_pane_grid.next_selectable_pane_id_to_the_right(&pane_id), + }?; + if let Some(previously_active_pane) = self.get_active_pane_mut(client_id) { + previously_active_pane.set_should_render(true); + previously_active_pane.render_full_viewport(); + } + if let Some(next_active_pane) = self.get_pane_mut(next_index) { + next_active_pane.set_should_render(true); + next_active_pane.render_full_viewport(); + } + self.focus_pane(next_index, client_id); + self.set_pane_active_at(next_index); + self.set_force_render(); + Some(next_index) + } pub fn focus_last_pane(&mut self, client_id: ClientId) { + if self.fullscreen_pane_id.is_some() { + let covers_ui = self.floating_fullscreen_covers_ui; + self.unset_fullscreen(); + self.focus_last_pane(client_id); + self.reenter_fullscreen_on_active_pane(client_id, covers_ui); + return; + } let currently_active_pane_id = self.active_panes.get(&client_id).copied(); let last_pane_id = self .active_panes @@ -777,6 +962,9 @@ impl FloatingPanes { } } pub fn move_pane_down(&mut self, pane_id: PaneId) { + if self.fullscreen_pane_id.is_some() { + return; + } let display_area = *self.display_area.borrow(); let viewport = *self.viewport.borrow(); let mut floating_pane_grid = FloatingPaneGrid::new( @@ -794,6 +982,9 @@ impl FloatingPanes { } } pub fn move_pane_up(&mut self, pane_id: PaneId) { + if self.fullscreen_pane_id.is_some() { + return; + } let display_area = *self.display_area.borrow(); let viewport = *self.viewport.borrow(); let mut floating_pane_grid = FloatingPaneGrid::new( @@ -811,6 +1002,9 @@ impl FloatingPanes { } } pub fn move_pane_left(&mut self, pane_id: PaneId) { + if self.fullscreen_pane_id.is_some() { + return; + } let display_area = *self.display_area.borrow(); let viewport = *self.viewport.borrow(); let mut floating_pane_grid = FloatingPaneGrid::new( @@ -828,6 +1022,9 @@ impl FloatingPanes { } } pub fn move_pane_right(&mut self, pane_id: PaneId) { + if self.fullscreen_pane_id.is_some() { + return; + } let display_area = *self.display_area.borrow(); let viewport = *self.viewport.borrow(); let mut floating_pane_grid = FloatingPaneGrid::new( @@ -850,6 +1047,9 @@ impl FloatingPanes { } } pub fn move_pane(&mut self, search_backwards: bool, pane_id: PaneId) { + if self.fullscreen_pane_id.is_some() { + return; + } let new_position_id = { let pane_grid = FloatingPaneGrid::new( &mut self.panes, @@ -997,6 +1197,132 @@ impl FloatingPanes { self.set_pane_active_at(pane_id); self.set_force_render(); } + pub fn fullscreen_is_active(&self) -> bool { + self.fullscreen_pane_id.is_some() + } + pub fn fullscreen_pane_id(&self) -> Option<PaneId> { + self.fullscreen_pane_id + } + pub fn fullscreen_covers_ui(&self) -> bool { + self.fullscreen_pane_id.is_some() && self.floating_fullscreen_covers_ui + } + fn expand_pane_over_area( + &mut self, + pane_id: PaneId, + x: usize, + y: usize, + rows: usize, + cols: usize, + ) { + if let Some(pane) = self.panes.get_mut(&pane_id) { + let mut expanded_geom = PaneGeom { + x, + y, + ..Default::default() + }; + expanded_geom.rows.set_inner(rows); + expanded_geom.cols.set_inner(cols); + pane.set_geom_override(expanded_geom); + } + } + fn set_fullscreen(&mut self, pane_id: PaneId, covers_ui: bool) { + if !self.panes.contains_key(&pane_id) { + log::error!("No floating pane with id: {:?} found", pane_id); + return; + } + if covers_ui { + let display_area = { *self.display_area.borrow() }; + self.expand_pane_over_area(pane_id, 0, 0, display_area.rows, display_area.cols); + } else { + let viewport = { *self.viewport.borrow() }; + self.expand_pane_over_area( + pane_id, + viewport.x, + viewport.y, + viewport.rows, + viewport.cols, + ); + } + let pane_frame_style = self.pane_frame_style; + if let Some(pane) = self.panes.get_mut(&pane_id) { + if covers_ui { + pane.set_frame(false); + pane.set_content_offset(Offset::default()); + } else if pane.borderless() { + pane.set_frame(false); + pane.set_content_offset(Offset::default()); + } else if pane_frame_style.draws_full_frames() { + pane.set_frame(true); + pane.set_content_offset(Offset::frame(1)); + } else if pane_frame_style.draws_titles() { + pane.set_frame(false); + pane.set_content_offset(Offset::shift_right_top_and_bottom(0, 1, 0)); + } else { + pane.set_frame(false); + pane.set_content_offset(Offset::default()); + } + } + let _ = resize_pty!( + self.panes.get_mut(&pane_id).unwrap(), + os_api, + self.senders, + self.character_cell_size + ); + self.fullscreen_pane_id = Some(pane_id); + self.floating_fullscreen_covers_ui = covers_ui; + self.focus_pane_for_all_clients(pane_id); + self.set_force_render(); + } + pub fn unset_fullscreen(&mut self) { + if let Some(fullscreen_pane_id) = self.fullscreen_pane_id.take() { + if let Some(pane) = self.panes.get_mut(&fullscreen_pane_id) { + pane.reset_size_and_position_override(); + if !pane.borderless() { + pane.set_frame(true); + pane.set_content_offset(Offset::frame(1)); + } else { + pane.set_content_offset(Offset::default()); + } + let _ = resize_pty!(pane, os_api, self.senders, self.character_cell_size); + } + self.floating_fullscreen_covers_ui = false; + self.set_force_render(); + } + } + pub fn toggle_pane_fullscreen(&mut self, pane_id: PaneId) { + if self.fullscreen_pane_id.is_some() { + self.unset_fullscreen(); + } else { + self.set_fullscreen(pane_id, false); + } + } + pub fn toggle_pane_no_ui_fullscreen(&mut self, pane_id: PaneId) { + if self.fullscreen_pane_id.is_some() { + let was_no_ui = self.floating_fullscreen_covers_ui; + self.unset_fullscreen(); + if !was_no_ui { + self.set_fullscreen(pane_id, true); + } + } else { + self.set_fullscreen(pane_id, true); + } + } + pub fn toggle_active_pane_fullscreen(&mut self, client_id: ClientId) { + if let Some(active_pane_id) = self + .fullscreen_pane_id + .or_else(|| self.get_active_pane_id(client_id)) + { + self.toggle_pane_fullscreen(active_pane_id); + } + } + pub fn toggle_active_pane_no_ui_fullscreen(&mut self, client_id: ClientId) { + if let Some(active_pane_id) = self + .fullscreen_pane_id + .or_else(|| self.get_active_pane_id(client_id)) + { + self.toggle_pane_no_ui_fullscreen(active_pane_id); + } + } pub fn focus_pane(&mut self, pane_id: PaneId, client_id: ClientId) { if let Some(focused_pane) = self.active_panes.get(&client_id) { if pane_id != *focused_pane { @@ -1191,6 +1517,9 @@ impl FloatingPanes { } pub fn move_pane_with_mouse(&mut self, position: Position, search_selectable: bool) -> bool { // true => handled, false => not handled (eg. no pane at this position) + if *self.fullscreen_covers_ui.borrow() || self.fullscreen_pane_id.is_some() { + return false; + } let show_panes = self.show_panes; if self.pane_being_moved_with_mouse.is_some() { if self.move_pane_to_position(&position) { @@ -1338,7 +1667,7 @@ impl FloatingPanes { pane_info_for_pane.is_floating = true; pane_info_for_pane.is_suppressed = false; pane_info_for_pane.is_focused = is_focused; - pane_info_for_pane.is_fullscreen = false; + pane_info_for_pane.is_fullscreen = self.fullscreen_pane_id == Some(*pane_id); pane_infos.push(pane_info_for_pane); } pane_infos diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs index ab87a2fe..7ccd7599 100644 --- a/zellij-server/src/panes/grid.rs +++ b/zellij-server/src/panes/grid.rs @@ -22,6 +22,7 @@ use zellij_utils::{ consts::{DEFAULT_SCROLL_BUFFER_SIZE, SCROLL_BUFFER_SIZE}, data::{Palette, PaletteColor, Styling}, input::mouse::{MouseEvent, MouseEventType}, + nested_session::{self, NestedSessionMessage}, pane_size::SizeInPixels, position::Position, }; @@ -652,7 +653,9 @@ pub struct Grid { /// Zellij should forward to the host terminal; the host's reply is /// later routed back to this pane's pty. pub pending_forwarded_queries: Vec<crate::host_query::HostQuery>, + pub pending_nested_session_messages: Vec<NestedSessionMessage>, ui_component_bytes: Option<Vec<u8>>, + nested_frame_bytes: Option<Vec<u8>>, style: Style, debug: bool, arrow_fonts: bool, @@ -971,7 +974,9 @@ impl Grid { pending_osc7_cwd: None, pending_desktop_notifications: Vec::new(), pending_forwarded_queries: Vec::new(), + pending_nested_session_messages: Vec::new(), ui_component_bytes: None, + nested_frame_bytes: None, style, debug, arrow_fonts, @@ -3508,6 +3513,12 @@ impl Perform for Grid { } else if c == 'z' { // UI-component (Zellij internal) self.ui_component_bytes = Some(vec![]); + } else if c == 'n' + && intermediates.is_empty() + && params.len() == 1 + && params.iter().next() == Some(&[nested_session::NESTED_DCS_PARAM][..]) + { + self.nested_frame_bytes = Some(vec![]); } } @@ -3519,6 +3530,8 @@ impl Perform for Grid { self.should_render = false; } else if let Some(ui_component_bytes) = self.ui_component_bytes.as_mut() { ui_component_bytes.push(byte); + } else if let Some(nested_frame_bytes) = self.nested_frame_bytes.as_mut() { + nested_frame_bytes.push(byte); } } @@ -3532,6 +3545,12 @@ impl Perform for Grid { UiComponentParser::new(self, style, arrow_fonts) .parse(component_bytes.collect()) .non_fatal(); + } else if let Some(nested_frame_bytes) = self.nested_frame_bytes.take() { + if let Some(message) = nested_session::decode_base64(&nested_frame_bytes) + .and_then(|payload_bytes| nested_session::decode_payload(&payload_bytes)) + { + self.pending_nested_session_messages.push(message); + } } self.mark_for_rerender(); } diff --git a/zellij-server/src/panes/mod.rs b/zellij-server/src/panes/mod.rs index 86dab304..21680415 100644 --- a/zellij-server/src/panes/mod.rs +++ b/zellij-server/src/panes/mod.rs @@ -2,6 +2,7 @@ pub mod alacritty_functions; pub mod grid; pub mod hyperlink_tracker; pub mod link_handler; +pub mod nested_session_modal; pub mod selection; pub mod sixel; pub mod terminal_character; diff --git a/zellij-server/src/panes/nested_session_modal.rs b/zellij-server/src/panes/nested_session_modal.rs new file mode 100644 index 00000000..7fcad47b --- /dev/null +++ b/zellij-server/src/panes/nested_session_modal.rs @@ -0,0 +1,531 @@ +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; +use zellij_utils::data::Style; + +use crate::output::CharacterChunk; +use crate::panes::terminal_character::{ + AnsiCode, RcCharacterStyles, TerminalCharacter, RESET_STYLES, +}; + +#[derive(Debug, Clone, Default)] +pub struct GuestModalShortcuts { + pub zoom: Vec<String>, + pub ascend: Vec<String>, + pub descend: Vec<String>, +} + +type GuestModalSegment = (String, RcCharacterStyles); + +fn guest_modal_row_width(segments: &[GuestModalSegment]) -> usize { + segments.iter().map(|(text, _)| text.width()).sum() +} + +fn guest_modal_empty_row( + columns: usize, + content_x: usize, + absolute_y: usize, + fill_styles: RcCharacterStyles, +) -> CharacterChunk { + guest_modal_segment_row( + &[], + 0, + 0, + columns, + content_x, + absolute_y, + fill_styles.clone(), + fill_styles, + ) +} + +#[allow(clippy::too_many_arguments)] +fn guest_modal_segment_row( + segments: &[GuestModalSegment], + left_pad: usize, + block_width: usize, + columns: usize, + content_x: usize, + absolute_y: usize, + outer_fill: RcCharacterStyles, + block_fill: RcCharacterStyles, +) -> CharacterChunk { + let mut characters: Vec<TerminalCharacter> = Vec::with_capacity(columns); + let block_end = (left_pad + block_width).min(columns); + let mut used = 0; + while used < left_pad && used < columns { + characters.push(TerminalCharacter::new_singlewidth_styled( + ' ', + outer_fill.clone(), + )); + used += 1; + } + for (text, styles) in segments { + for character in text.chars() { + let width = character.width().unwrap_or(0).max(1); + if used + width > columns { + break; + } + characters.push(TerminalCharacter::new_styled(character, styles.clone())); + used += width; + } + } + while used < block_end { + characters.push(TerminalCharacter::new_singlewidth_styled( + ' ', + block_fill.clone(), + )); + used += 1; + } + while used < columns { + characters.push(TerminalCharacter::new_singlewidth_styled( + ' ', + outer_fill.clone(), + )); + used += 1; + } + CharacterChunk::new(characters, content_x, absolute_y) +} + +fn guest_modal_hint_segments( + hint: &str, + fill_styles: RcCharacterStyles, + keycode_styles: RcCharacterStyles, +) -> Vec<GuestModalSegment> { + let mut segments: Vec<GuestModalSegment> = Vec::new(); + let mut buffer = String::new(); + let mut in_keycode = false; + for character in hint.chars() { + match character { + '<' => { + if !buffer.is_empty() { + segments.push((std::mem::take(&mut buffer), fill_styles.clone())); + } + buffer.push('<'); + in_keycode = true; + }, + '>' => { + buffer.push('>'); + segments.push((std::mem::take(&mut buffer), keycode_styles.clone())); + in_keycode = false; + }, + _ => buffer.push(character), + } + } + if !buffer.is_empty() { + let styles = if in_keycode { + keycode_styles + } else { + fill_styles + }; + segments.push((buffer, styles)); + } + segments +} + +struct GuestModalStyles { + fill: RcCharacterStyles, + title: RcCharacterStyles, + session: RcCharacterStyles, + keycode: RcCharacterStyles, + prompt: RcCharacterStyles, + option_marker: RcCharacterStyles, + option_title: RcCharacterStyles, + selected_marker: RcCharacterStyles, + selected_title: RcCharacterStyles, + selected_fill: RcCharacterStyles, + selected_keycode: RcCharacterStyles, +} + +fn guest_modal_keycode_words( + keys: &[String], + fill_styles: RcCharacterStyles, + keycode_styles: RcCharacterStyles, +) -> Vec<GuestModalSegment> { + if keys.is_empty() { + return vec![("<unbound>".to_string(), fill_styles)]; + } + let mut words: Vec<GuestModalSegment> = Vec::new(); + for (index, key) in keys.iter().enumerate() { + if index > 0 { + words.push(("+".to_string(), fill_styles.clone())); + } + words.push((format!("<{}>", key), keycode_styles.clone())); + } + words +} + +fn guest_modal_text_words(text: &str, styles: RcCharacterStyles) -> Vec<GuestModalSegment> { + text.split_whitespace() + .map(|word| (word.to_string(), styles.clone())) + .collect() +} + +fn guest_modal_wrap_words( + words: &[GuestModalSegment], + indent: usize, + indent_styles: RcCharacterStyles, + width: usize, +) -> Vec<Vec<GuestModalSegment>> { + let content_width = width.saturating_sub(indent).max(1); + let mut lines: Vec<Vec<GuestModalSegment>> = Vec::new(); + let mut current: Vec<GuestModalSegment> = Vec::new(); + let mut current_width = 0; + for (word, styles) in words { + let word_width = word.width(); + let extra = if current.is_empty() { + word_width + } else { + 1 + word_width + }; + if !current.is_empty() && current_width + extra > content_width { + lines.push(std::mem::take(&mut current)); + current_width = 0; + } + if current.is_empty() { + current.push((word.clone(), styles.clone())); + current_width = word_width; + } else { + current.push((" ".to_string(), styles.clone())); + current.push((word.clone(), styles.clone())); + current_width += 1 + word_width; + } + } + if !current.is_empty() { + lines.push(current); + } + if lines.is_empty() { + lines.push(Vec::new()); + } + for line in lines.iter_mut() { + line.insert(0, (" ".repeat(indent), indent_styles.clone())); + } + lines +} + +pub fn guest_modal_chunks( + columns: usize, + rows: usize, + content_x: usize, + content_y: usize, + style: &Style, + session_name: &str, + selected: usize, + shortcuts: &GuestModalShortcuts, +) -> Vec<CharacterChunk> { + if rows < 1 || columns < 1 { + return vec![]; + } + let styles = guest_modal_styles(style); + let fill_styles = styles.fill.clone(); + + let mut chunks: Vec<CharacterChunk> = Vec::with_capacity(rows); + for row in 0..rows { + chunks.push(guest_modal_empty_row( + columns, + content_x, + content_y + row, + fill_styles.clone(), + )); + } + + let layout = guest_modal_layout(columns, rows, &styles, session_name, selected, shortcuts); + for (offset, row) in layout.rows.iter().enumerate() { + let current_row = layout.start_row + offset; + if current_row >= rows { + break; + } + chunks[current_row] = guest_modal_segment_row( + &row.segments, + layout.left_pad, + layout.block_width, + columns, + content_x, + content_y + current_row, + fill_styles.clone(), + row.block_fill.clone(), + ); + } + + chunks +} + +fn guest_modal_styles(style: &Style) -> GuestModalStyles { + let base_declaration = style.colors.text_unselected; + let selected_declaration = style.colors.text_selected; + let fill: RcCharacterStyles = RESET_STYLES + .foreground(Some(AnsiCode::from(base_declaration.base))) + .background(Some(AnsiCode::from(base_declaration.background))) + .into(); + let title: RcCharacterStyles = RESET_STYLES + .foreground(Some(AnsiCode::from(base_declaration.emphasis_0))) + .background(Some(AnsiCode::from(base_declaration.background))) + .bold(Some(AnsiCode::On)) + .into(); + let session: RcCharacterStyles = RESET_STYLES + .foreground(Some(AnsiCode::from(base_declaration.emphasis_2))) + .background(Some(AnsiCode::from(base_declaration.background))) + .bold(Some(AnsiCode::On)) + .into(); + let keycode: RcCharacterStyles = RESET_STYLES + .foreground(Some(AnsiCode::from(base_declaration.emphasis_3))) + .background(Some(AnsiCode::from(base_declaration.background))) + .bold(Some(AnsiCode::On)) + .into(); + let prompt: RcCharacterStyles = RESET_STYLES + .foreground(Some(AnsiCode::from(base_declaration.base))) + .background(Some(AnsiCode::from(base_declaration.background))) + .bold(Some(AnsiCode::On)) + .into(); + let option_marker: RcCharacterStyles = RESET_STYLES + .foreground(Some(AnsiCode::from(base_declaration.base))) + .background(Some(AnsiCode::from(base_declaration.background))) + .bold(Some(AnsiCode::On)) + .into(); + let option_title: RcCharacterStyles = RESET_STYLES + .foreground(Some(AnsiCode::from(base_declaration.emphasis_1))) + .background(Some(AnsiCode::from(base_declaration.background))) + .bold(Some(AnsiCode::On)) + .into(); + let selected_marker: RcCharacterStyles = RESET_STYLES + .foreground(Some(AnsiCode::from(selected_declaration.base))) + .background(Some(AnsiCode::from(selected_declaration.background))) + .bold(Some(AnsiCode::On)) + .into(); + let selected_title: RcCharacterStyles = RESET_STYLES + .foreground(Some(AnsiCode::from(selected_declaration.emphasis_1))) + .background(Some(AnsiCode::from(selected_declaration.background))) + .bold(Some(AnsiCode::On)) + .into(); + let selected_fill: RcCharacterStyles = RESET_STYLES + .foreground(Some(AnsiCode::from(selected_declaration.base))) + .background(Some(AnsiCode::from(selected_declaration.background))) + .into(); + let selected_keycode: RcCharacterStyles = RESET_STYLES + .foreground(Some(AnsiCode::from(selected_declaration.emphasis_3))) + .background(Some(AnsiCode::from(selected_declaration.background))) + .bold(Some(AnsiCode::On)) + .into(); + GuestModalStyles { + fill, + title, + session, + keycode, + prompt, + option_marker, + option_title, + selected_marker, + selected_title, + selected_fill, + selected_keycode, + } +} + +struct GuestModalRow { + segments: Vec<GuestModalSegment>, + block_fill: RcCharacterStyles, +} + +struct GuestModalLayout { + rows: Vec<GuestModalRow>, + block_width: usize, + option_rows: Vec<usize>, + left_pad: usize, + start_row: usize, +} + +fn guest_modal_fallback_layout( + columns: usize, + rows: usize, + styles: &GuestModalStyles, + session_name: &str, +) -> GuestModalLayout { + let fallback: Vec<GuestModalSegment> = vec![ + ("Nested Zellij session: ".to_string(), styles.title.clone()), + (session_name.to_string(), styles.session.clone()), + (" ".to_string(), styles.fill.clone()), + ("<Esc>".to_string(), styles.keycode.clone()), + (" dismiss".to_string(), styles.fill.clone()), + ]; + let block_width = guest_modal_row_width(&fallback).min(columns); + let left_pad = center_modal_pad(block_width, columns); + GuestModalLayout { + rows: vec![GuestModalRow { + segments: fallback, + block_fill: styles.fill.clone(), + }], + block_width, + option_rows: vec![], + left_pad, + start_row: rows / 2, + } +} + +fn guest_modal_layout( + columns: usize, + rows: usize, + styles: &GuestModalStyles, + session_name: &str, + selected: usize, + shortcuts: &GuestModalShortcuts, +) -> GuestModalLayout { + if columns < 20 { + return guest_modal_fallback_layout(columns, rows, styles, session_name); + } + + let title: Vec<GuestModalSegment> = vec![ + ( + "Nested Zellij session detected: ".to_string(), + styles.title.clone(), + ), + (session_name.to_string(), styles.session.clone()), + ]; + let prompt: Vec<GuestModalSegment> = vec![( + "What would you like to do?".to_string(), + styles.prompt.clone(), + )]; + let options = [ + ("1. ", "Zoom in and control this session"), + ("2. ", "Control this session on focus"), + ]; + let explanation_texts = [ + "The session will take up the whole screen. Toggle this on/off with", + "When this pane gains focus, keybindings will be sent to this session. Ascend to the current session with", + ]; + let shortcut_keys = [&shortcuts.zoom, &shortcuts.ascend]; + let hint = "<↓↑> select <Enter> confirm <1-2> direct <Esc> dismiss"; + let hint_segments = + guest_modal_hint_segments(hint, styles.fill.clone(), styles.keycode.clone()); + + let fixed_lines: Vec<&[GuestModalSegment]> = vec![ + title.as_slice(), + prompt.as_slice(), + hint_segments.as_slice(), + ]; + let mut block_width = fixed_lines + .iter() + .map(|segments| guest_modal_row_width(segments)) + .max() + .unwrap_or(0); + for (number, option_title) in options.iter() { + block_width = block_width.max(2 + number.width() + option_title.width()); + } + let block_width = block_width.min(columns); + + let mut rows_content: Vec<GuestModalRow> = Vec::new(); + let mut option_rows: Vec<usize> = Vec::new(); + rows_content.push(GuestModalRow { + segments: title, + block_fill: styles.fill.clone(), + }); + rows_content.push(GuestModalRow { + segments: Vec::new(), + block_fill: styles.fill.clone(), + }); + rows_content.push(GuestModalRow { + segments: prompt, + block_fill: styles.fill.clone(), + }); + rows_content.push(GuestModalRow { + segments: Vec::new(), + block_fill: styles.fill.clone(), + }); + for (index, (number, option_title)) in options.iter().enumerate() { + let is_selected = index == selected; + let (marker_styles, title_styles, text_styles, keycode_styles, block_fill) = if is_selected + { + ( + styles.selected_marker.clone(), + styles.selected_title.clone(), + styles.selected_fill.clone(), + styles.selected_keycode.clone(), + styles.selected_fill.clone(), + ) + } else { + ( + styles.option_marker.clone(), + styles.option_title.clone(), + styles.fill.clone(), + styles.keycode.clone(), + styles.fill.clone(), + ) + }; + let marker = if is_selected { "> " } else { " " }; + option_rows.push(rows_content.len()); + rows_content.push(GuestModalRow { + segments: vec![ + (format!("{}{}", marker, number), marker_styles), + (option_title.to_string(), title_styles), + ], + block_fill: block_fill.clone(), + }); + let mut words = guest_modal_text_words(explanation_texts[index], text_styles.clone()); + words.extend(guest_modal_keycode_words( + shortcut_keys[index], + text_styles.clone(), + keycode_styles, + )); + for line in guest_modal_wrap_words(&words, 5, text_styles.clone(), block_width) { + rows_content.push(GuestModalRow { + segments: line, + block_fill: block_fill.clone(), + }); + } + } + rows_content.push(GuestModalRow { + segments: Vec::new(), + block_fill: styles.fill.clone(), + }); + rows_content.push(GuestModalRow { + segments: hint_segments, + block_fill: styles.fill.clone(), + }); + + let block_height = rows_content.len(); + if rows < block_height { + return guest_modal_fallback_layout(columns, rows, styles, session_name); + } + let left_pad = center_modal_pad(block_width, columns); + let start_row = rows.saturating_sub(block_height) / 2; + + GuestModalLayout { + rows: rows_content, + block_width, + option_rows, + left_pad, + start_row, + } +} + +pub fn guest_modal_option_at_content_row( + rows: usize, + columns: usize, + row: usize, + style: &Style, + session_name: &str, + selected: usize, + shortcuts: &GuestModalShortcuts, +) -> Option<usize> { + if columns < 20 { + return None; + } + let styles = guest_modal_styles(style); + let layout = guest_modal_layout(columns, rows, &styles, session_name, selected, shortcuts); + if layout.option_rows.is_empty() { + return None; + } + layout + .option_rows + .iter() + .position(|&option_row| layout.start_row + option_row == row) +} + +fn center_modal_pad(block_width: usize, columns: usize) -> usize { + if block_width >= columns { + return 0; + } + (columns - block_width) / 2 +} + +#[cfg(test)] +#[path = "./unit/nested_session_modal_tests.rs"] +mod nested_session_modal_tests; diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs index 429d025b..cdf269dd 100644 --- a/zellij-server/src/panes/terminal_pane.rs +++ b/zellij-server/src/panes/terminal_pane.rs @@ -3,11 +3,12 @@ use crate::panes::sixel::SixelImageStore; use crate::panes::LinkHandler; use crate::panes::{ grid::Grid, + nested_session_modal::GuestModalShortcuts, terminal_character::{render_first_run_banner, TerminalCharacter, EMPTY_TERMINAL_CHARACTER}, }; use crate::pty::VteBytes; use crate::route::NotificationEnd; -use crate::tab::{AdjustedInput, Pane}; +use crate::tab::{AdjustedInput, GuestChoiceIndicator, Pane}; use crate::ClientId; use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; @@ -26,6 +27,7 @@ use zellij_utils::{ }, errors::prelude::*, input::layout::Run, + nested_session::NestedSessionMessage, pane_size::PaneGeom, pane_size::SizeInPixels, position::Position, @@ -159,6 +161,11 @@ pub struct TerminalPane { /// stdin in the same stream position the original query occupied. /// Cleared by Tab when the reply (or 500 ms cache-fallback) lands. forward_paused: bool, + nested_guest: bool, + guest_modal: HashMap<ClientId, usize>, + guest_choice_indicators: HashMap<ClientId, GuestChoiceIndicator>, + guest_session_name: Option<String>, + guest_modal_shortcuts: GuestModalShortcuts, /// PTY bytes that have not yet been fed to vte. Single source of /// truth: `handle_pty_bytes` always appends here, and processing /// pops one byte at a time and advances the vte parser. Processing @@ -234,8 +241,13 @@ impl Pane for TerminalPane { } } } - fn cursor_coordinates(&self, _client_id: Option<ClientId>) -> Option<(usize, usize, bool)> { + fn cursor_coordinates(&self, client_id: Option<ClientId>) -> Option<(usize, usize, bool)> { // (x, y, is_visible) + if let Some(client_id) = client_id { + if self.guest_modal.contains_key(&client_id) { + return None; + } + } if self.get_content_rows() < 1 || self.get_content_columns() < 1 { // do not render cursor if there's no room for it return None; @@ -274,7 +286,72 @@ impl Pane for TerminalPane { } } - if self.is_held.is_some() { + if let Some(selection) = client_id.and_then(|c| self.guest_modal.get(&c).copied()) { + let client_id = client_id.expect("guest modal selection requires a client id"); + let is_up = key_with_modifier + .as_ref() + .map(|k| { + k.is_key_without_modifier(BareKey::Up) + || k.is_key_without_modifier(BareKey::Char('k')) + }) + .unwrap_or(false) + || raw_input_bytes.as_slice() == UP_ARROW; + let is_down = key_with_modifier + .as_ref() + .map(|k| { + k.is_key_without_modifier(BareKey::Down) + || k.is_key_without_modifier(BareKey::Char('j')) + }) + .unwrap_or(false) + || raw_input_bytes.as_slice() == DOWN_ARROW; + let is_enter = key_with_modifier + .as_ref() + .map(|k| k.is_key_without_modifier(BareKey::Enter)) + .unwrap_or(false) + || matches!( + raw_input_bytes.as_slice(), + ENTER_CARRIAGE_RETURN | ENTER_NEWLINE + ); + let is_esc = key_with_modifier + .as_ref() + .map(|k| k.is_key_without_modifier(BareKey::Esc)) + .unwrap_or(false) + || raw_input_bytes.as_slice() == ESC; + let digit = key_with_modifier + .as_ref() + .and_then(|k| match k.bare_key { + BareKey::Char(c @ '1'..='2') if k.key_modifiers.is_empty() => Some(c), + _ => None, + }) + .or_else(|| match raw_input_bytes.as_slice() { + b"1" => Some('1'), + b"2" => Some('2'), + _ => None, + }); + if is_up { + self.guest_modal.insert(client_id, (selection + 1) % 2); + self.set_should_render(true); + Some(AdjustedInput::GuestModalSelectionChanged) + } else if is_down { + self.guest_modal.insert(client_id, (selection + 1) % 2); + self.set_should_render(true); + Some(AdjustedInput::GuestModalSelectionChanged) + } else if let Some(digit) = digit { + match digit { + '1' => Some(AdjustedInput::GuestModalZoom), + _ => Some(AdjustedInput::GuestModalDescend), + } + } else if is_enter { + match selection { + 0 => Some(AdjustedInput::GuestModalZoom), + _ => Some(AdjustedInput::GuestModalDescend), + } + } else if is_esc { + Some(AdjustedInput::GuestModalDescend) + } else { + None + } + } else if self.is_held.is_some() { if key_with_modifier .as_ref() .map(|k| k.is_key_without_modifier(BareKey::Enter)) @@ -616,6 +693,98 @@ impl Pane for TerminalPane { self.grid.pending_forwarded_queries.drain(..).collect() } + fn drain_nested_session_messages(&mut self) -> Vec<NestedSessionMessage> { + self.grid + .pending_nested_session_messages + .drain(..) + .collect() + } + + fn is_nested_guest(&self) -> bool { + self.nested_guest + } + + fn set_is_nested_guest(&mut self, is_nested_guest: bool) { + self.nested_guest = is_nested_guest; + } + + fn set_guest_modal(&mut self, client_ids: &[ClientId]) { + for client_id in client_ids { + self.guest_modal.insert(*client_id, 0); + } + self.set_should_render(true); + } + + fn clear_guest_modal(&mut self, client_id: ClientId) { + if self.guest_modal.remove(&client_id).is_some() { + self.render_full_viewport(); + self.set_should_render(true); + } + } + + fn clear_all_guest_modals(&mut self) { + if !self.guest_modal.is_empty() { + self.guest_modal.clear(); + self.render_full_viewport(); + self.set_should_render(true); + } + } + + fn guest_modal_selection(&self, client_id: ClientId) -> Option<usize> { + self.guest_modal.get(&client_id).copied() + } + + fn has_guest_modal_for_any_client(&self) -> bool { + !self.guest_modal.is_empty() + } + + fn set_guest_choice_indicator( + &mut self, + client_id: ClientId, + indicator: Option<GuestChoiceIndicator>, + ) { + let previous = self.guest_choice_indicators.get(&client_id).copied(); + if previous == indicator { + return; + } + match indicator { + Some(indicator) => { + self.guest_choice_indicators.insert(client_id, indicator); + }, + None => { + self.guest_choice_indicators.remove(&client_id); + }, + } + self.set_should_render(true); + } + + fn guest_choice_indicator(&self, client_id: ClientId) -> Option<GuestChoiceIndicator> { + self.guest_choice_indicators.get(&client_id).copied() + } + + fn clear_all_guest_choice_indicators(&mut self) { + if !self.guest_choice_indicators.is_empty() { + self.guest_choice_indicators.clear(); + self.set_should_render(true); + } + } + + fn set_guest_session_name(&mut self, session_name: Option<String>) { + self.guest_session_name = session_name; + } + + fn guest_session_name(&self) -> Option<String> { + self.guest_session_name.clone() + } + + fn set_guest_modal_shortcuts(&mut self, shortcuts: GuestModalShortcuts) { + self.guest_modal_shortcuts = shortcuts; + } + + fn guest_modal_shortcuts(&self) -> GuestModalShortcuts { + self.guest_modal_shortcuts.clone() + } + fn arm_forward_pause(&mut self) { self.forward_paused = true; } @@ -1154,6 +1323,11 @@ impl TerminalPane { arrow_fonts, notification_end, forward_paused: false, + nested_guest: false, + guest_modal: HashMap::new(), + guest_choice_indicators: HashMap::new(), + guest_session_name: None, + guest_modal_shortcuts: GuestModalShortcuts::default(), pending_pty_input: VecDeque::new(), } } diff --git a/zellij-server/src/panes/tiled_panes/mod.rs b/zellij-server/src/panes/tiled_panes/mod.rs index 9bc67713..c8f260ee 100644 --- a/zellij-server/src/panes/tiled_panes/mod.rs +++ b/zellij-server/src/panes/tiled_panes/mod.rs @@ -80,11 +80,13 @@ pub struct TiledPanes { pane_frame_style: PaneFrameStyle, panes_to_hide: HashSet<PaneId>, fullscreen_is_active: Option<PaneId>, + fullscreen_covers_ui: Rc<RefCell<bool>>, senders: ThreadSenders, window_title: Option<String>, client_id_to_boundaries: HashMap<ClientId, Boundaries>, tombstones_before_increase: Option<(PaneId, Vec<HashMap<PaneId, PaneGeom>>)>, tombstones_before_decrease: Option<(PaneId, Vec<HashMap<PaneId, PaneGeom>>)>, + dimmed_clients: HashSet<ClientId>, } impl TiledPanes { @@ -99,6 +101,7 @@ impl TiledPanes { stacked_resize: Rc<RefCell<bool>>, stacked_pane_list: Rc<RefCell<bool>>, reserved_top_rows: Rc<RefCell<HashMap<PaneId, usize>>>, + fullscreen_covers_ui: Rc<RefCell<bool>>, session_is_mirrored: bool, pane_frame_style: PaneFrameStyle, default_mode_info: ModeInfo, @@ -124,13 +127,23 @@ impl TiledPanes { pane_frame_style, panes_to_hide: HashSet::new(), fullscreen_is_active: None, + fullscreen_covers_ui, senders, window_title: None, client_id_to_boundaries: HashMap::new(), tombstones_before_increase: None, tombstones_before_decrease: None, + dimmed_clients: HashSet::new(), } } + pub fn set_client_dimmed(&mut self, client_id: ClientId, dimmed: bool) { + if dimmed { + self.dimmed_clients.insert(client_id); + } else { + self.dimmed_clients.remove(&client_id); + } + self.set_force_render(); + } pub fn add_pane_with_existing_geom(&mut self, pane_id: PaneId, mut pane: Box<dyn Pane>) { if self.pane_frame_style.draws_full_frames() { pane.set_content_offset(Offset::frame(1)); @@ -579,6 +592,11 @@ impl TiledPanes { .count() == 1; let viewport = *self.viewport.borrow(); + let no_ui_fullscreen_pane_id = if *self.fullscreen_covers_ui.borrow() { + self.fullscreen_is_active + } else { + None + }; let position_and_sizes_of_stacks = { StackedPanes::new_from_btreemap(&mut self.panes, &self.panes_to_hide) .positions_and_sizes_of_all_stacks() @@ -590,10 +608,17 @@ impl TiledPanes { self.reserved_top_rows.borrow().clone() }; for pane in self.panes.values_mut() { + if no_ui_fullscreen_pane_id == Some(pane.pid()) { + pane.set_frame(false); + pane.set_content_offset(Offset::default()); + resize_pty!(pane, self.os_api, self.senders, self.character_cell_size).non_fatal(); + continue; + } if !pane.borderless() { pane.set_frame(draws_full_frames); } + let pane_viewport = &viewport; let reserved_rows = reserved_top_rows.get(&pane.pid()).copied().unwrap_or(0); #[allow(clippy::if_same_then_else)] @@ -608,7 +633,7 @@ impl TiledPanes { } else { let position_and_size = pane.current_geom(); let (pane_columns_offset, pane_rows_offset) = - pane_content_offset(&position_and_size, &viewport); + pane_content_offset(&position_and_size, pane_viewport); let visible_member_title_rows = if draws_full_frames { 1 } else { 0 }; pane.set_content_offset(Offset { top: visible_member_title_rows + reserved_rows, @@ -621,7 +646,7 @@ impl TiledPanes { pane.set_content_offset(Offset::frame(1)); } else if draws_full_frames && pane.borderless() { pane.set_content_offset(Offset::default()); - } else if !is_inside_viewport(&viewport, pane) { + } else if !is_inside_viewport(pane_viewport, pane) { pane.set_content_offset(Offset::default()); } else { let mut position_and_size = pane.current_geom(); @@ -635,7 +660,7 @@ impl TiledPanes { position_and_size = *position_and_size_of_stack; }; let (pane_columns_offset, pane_rows_offset) = - pane_content_offset(&position_and_size, &viewport); + pane_content_offset(&position_and_size, pane_viewport); let reserve_title_row = if draws_titles { !pane_is_borderless && is_flexible && !single_selectable_tiled_pane } else { @@ -652,7 +677,7 @@ impl TiledPanes { } } - resize_pty!(pane, self.os_api, self.senders, self.character_cell_size).unwrap(); + resize_pty!(pane, self.os_api, self.senders, self.character_cell_size).non_fatal(); } self.reset_boundaries(); } @@ -1180,7 +1205,10 @@ impl TiledPanes { stacked_pane_ids_on_top_of_stacks.contains(&pane.pid()); let pane_is_on_bottom_of_stack = stacked_pane_ids_on_bottom_of_stacks.contains(&pane.pid()); - let should_draw_pane_frames = self.pane_frame_style.draws_full_frames(); + let pane_is_no_ui_fullscreen = *self.fullscreen_covers_ui.borrow() + && self.fullscreen_is_active == Some(pane.pid()); + let should_draw_pane_frames = + self.pane_frame_style.draws_full_frames() && !pane_is_no_ui_fullscreen; let pane_is_stacked = pane.current_geom().is_stacked(); let pane_is_one_liner_in_stack = pane_is_stacked && pane.current_geom().rows.is_fixed(); @@ -1203,6 +1231,7 @@ impl TiledPanes { } else { None }; + let pane_has_guest_modal = pane.has_guest_modal_for_any_client(); let mut pane_contents_and_ui = PaneContentsAndUi::new( pane, output, @@ -1218,6 +1247,7 @@ impl TiledPanes { show_help_text, omit_pane_title && reserved_rows_for_pane == 0, mouse_scroll_resize, + self.dimmed_clients.clone(), ); pane_contents_and_ui.set_frame_geom_override(visible_member_frame_override); pane_contents_and_ui.set_blank_title(reserved_rows_for_pane > 0); @@ -1238,7 +1268,8 @@ impl TiledPanes { } } let is_floating = false; - if self.pane_frame_style.draws_full_frames() { + if pane_is_no_ui_fullscreen { + } else if self.pane_frame_style.draws_full_frames() { pane_contents_and_ui .render_pane_frame( *client_id, @@ -1297,21 +1328,45 @@ impl TiledPanes { } if let PaneId::Terminal(..) = kind { if !pane_is_one_liner_in_stack { - pane_contents_and_ui - .render_pane_contents_to_multiple_clients( - connected_clients.iter().copied(), - ) - .with_context(err_context)?; + if pane_has_guest_modal { + let (modal_clients, plain_clients): (Vec<ClientId>, Vec<ClientId>) = + connected_clients.iter().copied().partition(|client_id| { + pane_contents_and_ui.client_has_guest_modal(*client_id) + }); + if !plain_clients.is_empty() { + pane_contents_and_ui + .render_pane_contents_to_multiple_clients( + plain_clients.iter().copied(), + ) + .with_context(err_context)?; + } else { + pane_contents_and_ui.drain_pane_render_state(); + } + for client_id in modal_clients { + pane_contents_and_ui + .render_guest_modal_for_client(client_id) + .with_context(err_context)?; + } + } else { + pane_contents_and_ui + .render_pane_contents_to_multiple_clients( + connected_clients.iter().copied(), + ) + .with_context(err_context)?; + } } } } } // render boundaries if needed for (client_id, boundaries) in client_id_to_boundaries { - let boundaries_to_render = boundaries + let mut boundaries_to_render = boundaries .render(self.client_id_to_boundaries.get(&client_id)) .with_context(err_context)?; self.client_id_to_boundaries.insert(client_id, boundaries); + if self.dimmed_clients.contains(&client_id) { + crate::ui::pane_contents_and_ui::dim_character_chunks(&mut boundaries_to_render); + } output .add_character_chunks_to_client(client_id, boundaries_to_render, None) .with_context(err_context)?; @@ -1889,7 +1944,7 @@ impl TiledPanes { for pane in self.panes.values_mut() { // TODO: only for the panes whose width/height actually changed - resize_pty!(pane, self.os_api, self.senders, self.character_cell_size).unwrap(); + resize_pty!(pane, self.os_api, self.senders, self.character_cell_size).non_fatal(); } self.reset_boundaries(); Ok(pane_size_changed) @@ -1917,7 +1972,7 @@ impl TiledPanes { } for pane in self.panes.values_mut() { - resize_pty!(pane, self.os_api, self.senders, self.character_cell_size).unwrap(); + resize_pty!(pane, self.os_api, self.senders, self.character_cell_size).non_fatal(); } self.reset_boundaries(); Ok(()) @@ -2059,6 +2114,44 @@ impl TiledPanes { self.focus_pane(next_index, client_id); self.set_pane_active_at(next_index); } + pub fn focus_pane_adjacent_to( + &mut self, + pane_id: PaneId, + direction: Direction, + client_id: ClientId, + ) -> Option<PaneId> { + if !self.panes.contains_key(&pane_id) { + return None; + } + let pane_grid = TiledPaneGrid::new( + &mut self.panes, + &self.panes_to_hide, + *self.display_area.borrow(), + *self.viewport.borrow(), + ); + let next_index = match direction { + Direction::Left => pane_grid.next_selectable_pane_id_to_the_left(&pane_id), + Direction::Down => pane_grid.next_selectable_pane_id_below(&pane_id, false), + Direction::Up => pane_grid.next_selectable_pane_id_above(&pane_id, false), + Direction::Right => pane_grid.next_selectable_pane_id_to_the_right(&pane_id), + }?; + if let Some(previously_active_pane) = self + .active_panes + .get(&client_id) + .copied() + .and_then(|active_pane_id| self.panes.get_mut(&active_pane_id)) + { + previously_active_pane.set_should_render(true); + previously_active_pane.render_full_viewport(); + } + if let Some(next_active_pane) = self.panes.get_mut(&next_index) { + next_active_pane.set_should_render(true); + next_active_pane.render_full_viewport(); + } + self.focus_pane(next_index, client_id); + self.set_pane_active_at(next_index); + Some(next_index) + } pub fn move_focus_left(&mut self, client_id: ClientId) -> bool { match self.get_active_pane_id(client_id) { Some(active_pane_id) => { @@ -2674,6 +2767,9 @@ impl TiledPanes { pub fn fullscreen_pane_id(&self) -> Option<PaneId> { self.fullscreen_is_active } + pub fn fullscreen_covers_ui(&self) -> bool { + *self.fullscreen_covers_ui.borrow() + } pub fn unset_fullscreen(&mut self) { if let Some(fullscreen_pane_id) = self.fullscreen_is_active { let panes_to_hide: Vec<_> = self.panes_to_hide.iter().copied().collect(); @@ -2699,9 +2795,8 @@ impl TiledPanes { if let Some(fullscreen_pane) = self.get_pane_mut(fullscreen_pane_id) { fullscreen_pane.reset_size_and_position_override(); } - self.set_force_render(); - let display_area = *self.display_area.borrow(); - self.resize(display_area); + *self.fullscreen_covers_ui.borrow_mut() = false; + self.rerun_layout_solver_and_rerender(); self.fullscreen_is_active = None; } } @@ -2711,105 +2806,180 @@ impl TiledPanes { } } + pub fn toggle_active_pane_no_ui_fullscreen(&mut self, client_id: ClientId) { + if let Some(active_pane_id) = self.get_active_pane_id(client_id) { + self.toggle_pane_no_ui_fullscreen(active_pane_id); + } + } + pub fn toggle_pane_fullscreen(&mut self, pane_id: PaneId) { if self.fullscreen_is_active.is_some() { self.unset_fullscreen(); } else { - let pane_ids_to_hide = self.panes.iter().filter_map(|(&id, _pane)| { - if id != pane_id - && is_inside_viewport(&*self.viewport.borrow(), self.get_pane(id).unwrap()) - { - Some(id) - } else { - None - } - }); - self.panes_to_hide = pane_ids_to_hide.collect(); - if self.panes_to_hide.is_empty() { - // nothing to do, pane is already as fullscreen as it can be, let's bail - return; - } else { - // For all of the panes outside of the viewport staying on the fullscreen - // screen, switch them to using override positions as well so that the resize - // system doesn't get confused by viewport and old panes that no longer line up - let viewport_pane_ids: Vec<_> = self - .panes - .keys() - .copied() - .into_iter() - .filter(|id| { - !is_inside_viewport(&*self.viewport.borrow(), self.get_pane(*id).unwrap()) - }) - .collect(); - for pid in viewport_pane_ids { - if let Some(viewport_pane) = self.get_pane_mut(pid) { - viewport_pane.set_geom_override(viewport_pane.position_and_size()); - } - } - let viewport = { *self.viewport.borrow() }; - if let Some(active_pane) = self.get_pane_mut(pane_id) { - let full_screen_geom = PaneGeom { - x: viewport.x, - y: viewport.y, - ..Default::default() - }; - active_pane.set_geom_override(full_screen_geom); - } + self.set_fullscreen(pane_id, false); + } + } + + pub fn toggle_pane_no_ui_fullscreen(&mut self, pane_id: PaneId) { + if self.fullscreen_is_active.is_some() { + let was_no_ui = *self.fullscreen_covers_ui.borrow(); + self.unset_fullscreen(); + if !was_no_ui { + self.set_fullscreen(pane_id, true); } - let connected_client_list: Vec<ClientId> = - { self.connected_clients.borrow().iter().copied().collect() }; - for client_id in connected_client_list { - self.focus_pane(pane_id, client_id); + } else { + self.set_fullscreen(pane_id, true); + } + } + + fn set_fullscreen(&mut self, pane_id: PaneId, covers_ui: bool) { + self.panes_to_hide = self.panes_covered_by_fullscreen(pane_id, covers_ui); + if self.panes_to_hide.is_empty() && !covers_ui { + return; + } + if covers_ui { + self.expand_pane_over_whole_display(pane_id); + } else { + self.pin_panes_outside_viewport_in_place(); + self.expand_pane_over_whole_viewport(pane_id); + } + self.focus_pane_for_all_clients(pane_id); + self.fullscreen_is_active = Some(pane_id); + *self.fullscreen_covers_ui.borrow_mut() = covers_ui; + self.rerun_layout_solver_and_rerender(); + } + + fn panes_covered_by_fullscreen( + &self, + fullscreen_pane_id: PaneId, + covers_ui: bool, + ) -> HashSet<PaneId> { + self.panes + .keys() + .copied() + .filter(|&id| { + id != fullscreen_pane_id + && (covers_ui + || is_inside_viewport(&*self.viewport.borrow(), self.get_pane(id).unwrap())) + }) + .collect() + } + + fn pin_panes_outside_viewport_in_place(&mut self) { + let pane_ids_outside_viewport: Vec<_> = self + .panes + .keys() + .copied() + .filter(|id| !is_inside_viewport(&*self.viewport.borrow(), self.get_pane(*id).unwrap())) + .collect(); + for pane_id in pane_ids_outside_viewport { + if let Some(pane) = self.get_pane_mut(pane_id) { + pane.set_geom_override(pane.position_and_size()); } - self.set_force_render(); - let display_area = *self.display_area.borrow(); - self.resize(display_area); - self.fullscreen_is_active = Some(pane_id); + } + } + + fn expand_pane_over_whole_display(&mut self, pane_id: PaneId) { + let display_area = { *self.display_area.borrow() }; + self.expand_pane_over_area(pane_id, 0, 0, display_area.rows, display_area.cols); + } + + fn expand_pane_over_whole_viewport(&mut self, pane_id: PaneId) { + let viewport = { *self.viewport.borrow() }; + self.expand_pane_over_area( + pane_id, + viewport.x, + viewport.y, + viewport.rows, + viewport.cols, + ); + } + + fn expand_pane_over_area( + &mut self, + pane_id: PaneId, + x: usize, + y: usize, + rows: usize, + cols: usize, + ) { + if let Some(pane) = self.get_pane_mut(pane_id) { + let mut expanded_geom = PaneGeom { + x, + y, + ..Default::default() + }; + expanded_geom.rows.set_inner(rows); + expanded_geom.cols.set_inner(cols); + pane.set_geom_override(expanded_geom); + } + } + + fn rerun_layout_solver_and_rerender(&mut self) { + self.set_force_render(); + let display_area = *self.display_area.borrow(); + self.resize(display_area); + } + + fn reenter_active_pane_fullscreen(&mut self, client_id: ClientId, covers_ui: bool) { + if covers_ui { + self.toggle_active_pane_no_ui_fullscreen(client_id); + } else { + self.toggle_active_pane_fullscreen(client_id); } } pub fn focus_pane_left_fullscreen(&mut self, client_id: ClientId) -> bool { + let covers_ui = *self.fullscreen_covers_ui.borrow(); self.unset_fullscreen(); let ret = self.move_focus_left(client_id); - self.toggle_active_pane_fullscreen(client_id); + self.reenter_active_pane_fullscreen(client_id, covers_ui); return ret; } pub fn focus_pane_right_fullscreen(&mut self, client_id: ClientId) -> bool { + let covers_ui = *self.fullscreen_covers_ui.borrow(); self.unset_fullscreen(); let ret = self.move_focus_right(client_id); - self.toggle_active_pane_fullscreen(client_id); + self.reenter_active_pane_fullscreen(client_id, covers_ui); return ret; } - pub fn focus_pane_up_fullscreen(&mut self, client_id: ClientId) { + pub fn focus_pane_up_fullscreen(&mut self, client_id: ClientId) -> bool { + let covers_ui = *self.fullscreen_covers_ui.borrow(); self.unset_fullscreen(); - self.move_focus_up(client_id); - self.toggle_active_pane_fullscreen(client_id); + let ret = self.move_focus_up(client_id); + self.reenter_active_pane_fullscreen(client_id, covers_ui); + return ret; } - pub fn focus_pane_down_fullscreen(&mut self, client_id: ClientId) { + pub fn focus_pane_down_fullscreen(&mut self, client_id: ClientId) -> bool { + let covers_ui = *self.fullscreen_covers_ui.borrow(); self.unset_fullscreen(); - self.move_focus_down(client_id); - self.toggle_active_pane_fullscreen(client_id); + let ret = self.move_focus_down(client_id); + self.reenter_active_pane_fullscreen(client_id, covers_ui); + return ret; } pub fn switch_next_pane_fullscreen(&mut self, client_id: ClientId) { + let covers_ui = *self.fullscreen_covers_ui.borrow(); self.unset_fullscreen(); self.focus_next_pane(client_id); - self.toggle_active_pane_fullscreen(client_id); + self.reenter_active_pane_fullscreen(client_id, covers_ui); } pub fn switch_prev_pane_fullscreen(&mut self, client_id: ClientId) { + let covers_ui = *self.fullscreen_covers_ui.borrow(); self.unset_fullscreen(); self.focus_previous_pane(client_id); - self.toggle_active_pane_fullscreen(client_id); + self.reenter_active_pane_fullscreen(client_id, covers_ui); } pub fn switch_last_pane_fullscreen(&mut self, client_id: ClientId) { + let covers_ui = *self.fullscreen_covers_ui.borrow(); self.unset_fullscreen(); self.focus_last_pane(client_id); - self.toggle_active_pane_fullscreen(client_id); + self.reenter_active_pane_fullscreen(client_id, covers_ui); } pub fn panes_to_hide_count(&self) -> usize { diff --git a/zellij-server/src/panes/unit/grid_tests.rs b/zellij-server/src/panes/unit/grid_tests.rs index e21ea867..2029a4b4 100644 --- a/zellij-server/src/panes/unit/grid_tests.rs +++ b/zellij-server/src/panes/unit/grid_tests.rs @@ -6289,3 +6289,185 @@ fn csi_5n_status_query_still_handled_locally() { "DSR 5 must still produce its local 'all good' reply" ); } + +fn new_grid_for_nested_frames() -> Grid { + let sixel_image_store = Rc::new(RefCell::new(SixelImageStore::default())); + let terminal_emulator_color_codes = Rc::new(RefCell::new(HashMap::new())); + Grid::new( + 41, + 110, + Rc::new(RefCell::new(Palette::default())), + terminal_emulator_color_codes, + Rc::new(RefCell::new(LinkHandler::new())), + Rc::new(RefCell::new(None)), + sixel_image_store, + Style::default(), + false, + true, + true, + true, + false, + ) +} + +fn nested_announce_message() -> zellij_utils::nested_session::NestedSessionMessage { + zellij_utils::nested_session::NestedSessionMessage::Announce { + session_name: "guest-session".to_owned(), + capabilities: vec![zellij_utils::nested_session::NestedSessionCapability::NestedControl], + } +} + +#[test] +fn nested_session_frame_is_staged_for_dispatch() { + let mut vte_parser = vte::Parser::new(); + let mut grid = new_grid_for_nested_frames(); + let message = nested_announce_message(); + for byte in zellij_utils::nested_session::encode_frame(&message) { + vte_parser.advance(&mut grid, byte); + } + assert_eq!(grid.pending_nested_session_messages, vec![message]); +} + +#[test] +fn nested_session_frame_split_across_feeds_is_staged() { + let mut vte_parser = vte::Parser::new(); + let mut grid = new_grid_for_nested_frames(); + let message = nested_announce_message(); + let frame = zellij_utils::nested_session::encode_frame(&message); + let (first_half, second_half) = frame.split_at(frame.len() / 2); + for byte in first_half { + vte_parser.advance(&mut grid, *byte); + } + assert!(grid.pending_nested_session_messages.is_empty()); + for byte in second_half { + vte_parser.advance(&mut grid, *byte); + } + assert_eq!(grid.pending_nested_session_messages, vec![message]); +} + +#[test] +fn dcs_with_wrong_magic_param_is_not_staged() { + let mut vte_parser = vte::Parser::new(); + let mut grid = new_grid_for_nested_frames(); + for byte in b"\x1bP26662nAAAA\x1b\\" { + vte_parser.advance(&mut grid, *byte); + } + assert!(grid.pending_nested_session_messages.is_empty()); +} + +#[test] +fn dcs_with_intermediates_is_not_staged() { + let mut vte_parser = vte::Parser::new(); + let mut grid = new_grid_for_nested_frames(); + for byte in b"\x1bP26661$nAAAA\x1b\\" { + vte_parser.advance(&mut grid, *byte); + } + assert!(grid.pending_nested_session_messages.is_empty()); +} + +#[test] +fn dcs_n_without_params_is_not_staged() { + let mut vte_parser = vte::Parser::new(); + let mut grid = new_grid_for_nested_frames(); + for byte in b"\x1bPnAAAA\x1b\\" { + vte_parser.advance(&mut grid, *byte); + } + assert!(grid.pending_nested_session_messages.is_empty()); +} + +#[test] +fn nested_session_frame_with_garbage_payload_is_dropped() { + let mut vte_parser = vte::Parser::new(); + let mut grid = new_grid_for_nested_frames(); + for byte in b"\x1bP26661n!!!not-base64!!!\x1b\\" { + vte_parser.advance(&mut grid, *byte); + } + assert!(grid.pending_nested_session_messages.is_empty()); +} + +fn serialize_text_bytes(text: &str) -> String { + text.as_bytes() + .iter() + .map(|byte| byte.to_string()) + .collect::<Vec<_>>() + .join(",") +} + +fn render_ui_component(component_name: &str, serialized_params: &str) -> Grid { + let mut vte_parser = vte::Parser::new(); + let mut grid = new_grid_for_nested_frames(); + let dcs = format!("\u{1b}Pz{};{}\u{1b}\\", component_name, serialized_params); + for byte in dcs.bytes() { + vte_parser.advance(&mut grid, byte); + } + grid +} + +fn styled_cells_of( + grid: &Grid, + needle: char, +) -> Vec<crate::panes::terminal_character::CharacterStyles> { + let mut styles = vec![]; + for line in grid.as_character_lines() { + for terminal_character in line { + if terminal_character.character == needle { + styles.push(*terminal_character.styles); + } + } + } + styles +} + +#[test] +fn disabled_ribbon_renders_italic_non_bold_unselected() { + use crate::panes::terminal_character::AnsiCode; + let grid = render_ui_component("ribbon", &format!("d{}", serialize_text_bytes("RES"))); + let letter_styles = styled_cells_of(&grid, 'R'); + assert!(!letter_styles.is_empty()); + for style in letter_styles { + assert_eq!(style.italic, Some(AnsiCode::On)); + assert_ne!(style.bold, Some(AnsiCode::On)); + } +} + +#[test] +fn disabled_text_with_opaque_flag_keeps_a_background() { + use crate::panes::terminal_character::AnsiCode; + let grid = render_ui_component("text", &format!("zd{}", serialize_text_bytes("RES"))); + let letter_styles = styled_cells_of(&grid, 'R'); + assert!(!letter_styles.is_empty()); + for style in letter_styles { + assert_eq!(style.italic, Some(AnsiCode::On)); + assert_ne!(style.bold, Some(AnsiCode::On)); + assert!(style.background.is_some()); + } +} + +#[test] +fn disabled_text_renders_italic_non_bold() { + use crate::panes::terminal_character::AnsiCode; + let grid = render_ui_component("text", &format!("d{}", serialize_text_bytes("RES"))); + let letter_styles = styled_cells_of(&grid, 'R'); + assert!(!letter_styles.is_empty()); + for style in letter_styles { + assert_eq!(style.italic, Some(AnsiCode::On)); + assert_ne!(style.bold, Some(AnsiCode::On)); + } +} + +#[test] +fn ui_component_flag_prefixes_parse_order_independently() { + let baseline = render_ui_component("text", &format!("xzd{}", serialize_text_bytes("RES"))); + let baseline_styles = styled_cells_of(&baseline, 'R'); + for prefix in ["xdz", "zxd", "zdx", "dxz", "dzx"] { + let grid = render_ui_component( + "text", + &format!("{}{}", prefix, serialize_text_bytes("RES")), + ); + assert_eq!( + styled_cells_of(&grid, 'R'), + baseline_styles, + "prefix {prefix} should parse the same flags as xzd" + ); + } +} diff --git a/zellij-server/src/panes/unit/nested_session_modal_tests.rs b/zellij-server/src/panes/unit/nested_session_modal_tests.rs new file mode 100644 index 00000000..4d0a14ee --- /dev/null +++ b/zellij-server/src/panes/unit/nested_session_modal_tests.rs @@ -0,0 +1,360 @@ +use super::*; +use zellij_utils::data::{PaletteColor, Style}; + +fn style_with_distinct_selected_background() -> Style { + let mut style = Style::default(); + style.colors.text_selected.background = PaletteColor::EightBit(200); + style.colors.text_selected.base = PaletteColor::EightBit(201); + style.colors.text_selected.emphasis_1 = PaletteColor::EightBit(202); + style.colors.text_selected.emphasis_3 = PaletteColor::EightBit(203); + style +} + +fn sample_shortcuts() -> GuestModalShortcuts { + GuestModalShortcuts { + zoom: vec!["Ctrl g".to_string(), "o".to_string(), "f".to_string()], + ascend: vec!["Ctrl o".to_string()], + descend: vec!["Ctrl o".to_string(), "d".to_string()], + } +} + +fn render_lines(chunks: &[CharacterChunk]) -> Vec<String> { + let mut lines: Vec<String> = Vec::new(); + for chunk in chunks { + let text: String = chunk + .terminal_characters + .iter() + .map(|c| c.character) + .collect(); + lines.push(text); + } + lines +} + +fn rendered_text(chunks: &[CharacterChunk]) -> String { + chunks + .iter() + .flat_map(|chunk| chunk.terminal_characters.iter().map(|c| c.character)) + .collect() +} + +#[test] +fn renders_all_expected_content() { + let style = Style::default(); + let shortcuts = sample_shortcuts(); + let chunks = guest_modal_chunks(80, 30, 0, 0, &style, "my-session", 1, &shortcuts); + assert_eq!(chunks.len(), 30); + let rendered = rendered_text(&chunks); + assert!(rendered.contains("Nested Zellij session detected: my-session")); + assert!(rendered.contains("What would you like to do?")); + assert!(rendered.contains("Zoom in and control this session")); + assert!(rendered.contains("Control this session on focus")); + assert!(rendered.contains("<↓↑> select")); + assert!(rendered.contains("<Enter> confirm")); + assert!(rendered.contains("<Esc> dismiss")); +} + +#[test] +fn renders_resolved_keybindings_as_joined_tokens() { + let style = Style::default(); + let shortcuts = sample_shortcuts(); + let chunks = guest_modal_chunks(80, 30, 0, 0, &style, "s", 0, &shortcuts); + let rendered = rendered_text(&chunks); + assert!(rendered.contains("<Ctrl g> + <o> + <f>")); + assert!(rendered.contains("<Ctrl o>")); +} + +#[test] +fn renders_unbound_when_no_keys() { + let style = Style::default(); + let shortcuts = GuestModalShortcuts::default(); + let chunks = guest_modal_chunks(80, 30, 0, 0, &style, "s", 0, &shortcuts); + let rendered = rendered_text(&chunks); + assert!(rendered.contains("<unbound>")); +} + +#[test] +fn selection_marker_moves_with_selected_index() { + let style = Style::default(); + let shortcuts = sample_shortcuts(); + + let first = render_lines(&guest_modal_chunks( + 80, 30, 0, 0, &style, "s", 0, &shortcuts, + )); + assert!(first + .iter() + .any(|line| line.trim_start().starts_with("> 1."))); + assert!(first.iter().any(|line| line.trim_start().starts_with("2."))); + assert!(!first + .iter() + .any(|line| line.trim_start().starts_with("> 2."))); + + let second = render_lines(&guest_modal_chunks( + 80, 30, 0, 0, &style, "s", 1, &shortcuts, + )); + assert!(second + .iter() + .any(|line| line.trim_start().starts_with("> 2."))); + assert!(!second + .iter() + .any(|line| line.trim_start().starts_with("> 1."))); +} + +fn number_column(line: &str, number: &str) -> usize { + line.find(number).expect("option number present") +} + +#[test] +fn options_are_left_justified_within_the_centered_block() { + let style = Style::default(); + let shortcuts = sample_shortcuts(); + let chunks = guest_modal_chunks(80, 30, 0, 0, &style, "s", 0, &shortcuts); + let lines = render_lines(&chunks); + let selected_line = lines + .iter() + .find(|line| line.trim_start().starts_with("> 1.")) + .expect("selected option line present"); + let selected_number_column = number_column(selected_line, "1."); + assert!( + selected_number_column > 2, + "block must be centered with a left pad before the marker" + ); + + for (number, needle) in [("2.", "2.")] { + let line = lines + .iter() + .find(|line| line.trim_start().starts_with(needle)) + .expect("unselected option line present"); + assert_eq!( + number_column(line, number), + selected_number_column, + "the option number column is stable across selection" + ); + } +} + +#[test] +fn small_columns_render_the_fallback() { + let style = Style::default(); + let shortcuts = GuestModalShortcuts::default(); + let chunks = guest_modal_chunks(18, 20, 0, 0, &style, "s", 0, &shortcuts); + assert_eq!(chunks.len(), 20); + let rendered = rendered_text(&chunks); + assert!(rendered.contains("Nested")); + assert!(!rendered.contains("What would you like to do?")); +} + +#[test] +fn small_rows_render_the_fallback() { + let style = Style::default(); + let shortcuts = sample_shortcuts(); + let chunks = guest_modal_chunks(80, 5, 0, 0, &style, "s", 0, &shortcuts); + assert_eq!(chunks.len(), 5); + let rendered = rendered_text(&chunks); + assert!(rendered.contains("Nested Zellij session")); + assert!(!rendered.contains("What would you like to do?")); +} + +#[test] +fn tiny_geometry_does_not_panic() { + let style = Style::default(); + let shortcuts = GuestModalShortcuts::default(); + let chunks = guest_modal_chunks(3, 2, 0, 0, &style, "session", 0, &shortcuts); + assert_eq!(chunks.len(), 2); + let empty = guest_modal_chunks(0, 0, 0, 0, &style, "session", 0, &shortcuts); + assert!(empty.is_empty()); +} + +#[test] +fn every_rendered_row_spans_the_full_width() { + let style = Style::default(); + let shortcuts = sample_shortcuts(); + let columns = 80; + let chunks = guest_modal_chunks(columns, 30, 0, 0, &style, "s", 0, &shortcuts); + for chunk in &chunks { + let width: usize = chunk + .terminal_characters + .iter() + .map(|c| c.character.width().unwrap_or(0).max(1)) + .sum(); + assert_eq!(width, columns); + } +} + +#[test] +fn hit_test_round_trips_the_options() { + let style = Style::default(); + let shortcuts = sample_shortcuts(); + let rows = 40; + let columns = 80; + let mut hit_rows: Vec<usize> = Vec::new(); + for row in 0..rows { + if let Some(option) = + guest_modal_option_at_content_row(rows, columns, row, &style, "session", 0, &shortcuts) + { + assert_eq!(hit_rows.len(), option); + hit_rows.push(row); + } + } + assert_eq!(hit_rows.len(), 2); +} + +#[test] +fn hit_test_returns_none_off_option_rows_and_in_fallback() { + let style = Style::default(); + let shortcuts = sample_shortcuts(); + assert_eq!( + guest_modal_option_at_content_row(40, 10, 0, &style, "session", 0, &shortcuts), + None + ); + assert_eq!( + guest_modal_option_at_content_row(4, 10, 2, &style, "session", 0, &shortcuts), + None + ); + assert_eq!( + guest_modal_option_at_content_row(40, 80, 0, &style, "session", 0, &shortcuts), + None + ); +} + +#[test] +fn hit_test_matches_rendered_marker_rows() { + let style = Style::default(); + let shortcuts = sample_shortcuts(); + let rows = 40; + let columns = 80; + let selected = 1; + let lines = render_lines(&guest_modal_chunks( + columns, rows, 0, 0, &style, "session", selected, &shortcuts, + )); + for (row, line) in lines.iter().enumerate() { + let hit = guest_modal_option_at_content_row( + rows, columns, row, &style, "session", selected, &shortcuts, + ); + let trimmed = line.trim_start(); + let is_option_line = + trimmed.starts_with("> ") || trimmed.starts_with("1.") || trimmed.starts_with("2."); + assert_eq!(hit.is_some(), is_option_line, "row {} mismatch", row); + } +} + +#[test] +fn selected_rows_carry_the_selected_background_rectangle() { + let style = style_with_distinct_selected_background(); + let shortcuts = sample_shortcuts(); + let selected = 0; + let columns = 80; + let rows = 40; + let styles = guest_modal_styles(&style); + let layout = guest_modal_layout(columns, rows, &styles, "session", selected, &shortcuts); + let selected_option_offset = layout.option_rows[selected]; + + let chunks = guest_modal_chunks(columns, rows, 0, 0, &style, "session", selected, &shortcuts); + let selected_row_index = layout.start_row + selected_option_offset; + let selected_chunk = &chunks[selected_row_index]; + + let selected_background = styles.selected_fill.background; + let base_background = styles.fill.background; + assert_ne!( + selected_background, base_background, + "the test style must give the selected background a distinct value" + ); + + let block_start = layout.left_pad; + let block_end = layout.left_pad + layout.block_width; + let mut block_cells = 0; + for (column, character) in selected_chunk.terminal_characters.iter().enumerate() { + if column >= block_start && column < block_end { + assert_eq!( + character.styles.background, selected_background, + "block cell {} on the selected row must carry the selected background", + column + ); + block_cells += 1; + } else { + assert_eq!( + character.styles.background, base_background, + "outer padding cell {} must keep the base background", + column + ); + } + } + assert_eq!( + block_cells, layout.block_width, + "the selected rectangle spans the whole block width" + ); +} + +#[test] +fn unselected_rows_do_not_use_the_selected_background() { + let style = style_with_distinct_selected_background(); + let shortcuts = sample_shortcuts(); + let selected = 0; + let columns = 80; + let rows = 40; + let styles = guest_modal_styles(&style); + let layout = guest_modal_layout(columns, rows, &styles, "session", selected, &shortcuts); + let other_option_offset = layout.option_rows[1]; + + let selected_background = styles.selected_fill.background; + let chunks = guest_modal_chunks(columns, rows, 0, 0, &style, "session", selected, &shortcuts); + let other_row = &chunks[layout.start_row + other_option_offset]; + for character in &other_row.terminal_characters { + assert_ne!( + character.styles.background, selected_background, + "unselected option row must not carry the selected background" + ); + } +} + +#[test] +fn keycode_words_join_with_plus_separators() { + let style = Style::default(); + let styles = guest_modal_styles(&style); + let keys = vec!["Ctrl g".to_string(), "o".to_string()]; + let words = guest_modal_keycode_words(&keys, styles.fill.clone(), styles.keycode.clone()); + let joined: String = words.iter().map(|(text, _)| text.as_str()).collect(); + assert_eq!(joined, "<Ctrl g>+<o>"); + assert_eq!(words[0].1, styles.keycode); + assert_eq!(words[1].1, styles.fill); + assert_eq!(words[2].1, styles.keycode); +} + +#[test] +fn keycode_words_render_unbound_when_empty() { + let style = Style::default(); + let styles = guest_modal_styles(&style); + let words = guest_modal_keycode_words(&[], styles.fill.clone(), styles.keycode.clone()); + assert_eq!(words.len(), 1); + assert_eq!(words[0].0, "<unbound>"); + assert_eq!(words[0].1, styles.fill); +} + +#[test] +fn wrap_words_respects_width_and_indent() { + let style = Style::default(); + let styles = guest_modal_styles(&style); + let words = guest_modal_text_words("one two three four five", styles.fill.clone()); + let indent = 4; + let width = 14; + let lines = guest_modal_wrap_words(&words, indent, styles.fill.clone(), width); + assert!(lines.len() > 1, "text wider than the width must wrap"); + for line in &lines { + let indent_segment = &line[0]; + assert_eq!(indent_segment.0, " ".repeat(indent)); + let rendered_width: usize = line.iter().map(|(text, _)| text.width()).sum(); + assert!( + rendered_width <= width, + "wrapped line width {} exceeds {}", + rendered_width, + width + ); + } +} + +#[test] +fn center_pad_centers_and_clamps() { + assert_eq!(center_modal_pad(10, 30), 10); + assert_eq!(center_modal_pad(30, 30), 0); + assert_eq!(center_modal_pad(40, 30), 0); +} diff --git a/zellij-server/src/panes/unit/terminal_pane_tests.rs b/zellij-server/src/panes/unit/terminal_pane_tests.rs index 2e34b273..a6234844 100644 --- a/zellij-server/src/panes/unit/terminal_pane_tests.rs +++ b/zellij-server/src/panes/unit/terminal_pane_tests.rs @@ -873,3 +873,150 @@ pub fn frameless_pane_position_is_on_frame() { assert!(!terminal_pane.position_is_on_frame(&Position::new(30, 130))); assert!(!terminal_pane.position_is_on_frame(&Position::new(30, 131))); } + +fn create_guest_modal_pane() -> TerminalPane { + let mut fake_win_size = PaneGeom::default(); + fake_win_size.cols.set_inner(80); + fake_win_size.rows.set_inner(24); + let sixel_image_store = Rc::new(RefCell::new(SixelImageStore::default())); + let terminal_emulator_colors = Rc::new(RefCell::new(Palette::default())); + let terminal_emulator_color_codes = Rc::new(RefCell::new(HashMap::new())); + TerminalPane::new( + 1, + fake_win_size, + Style::default(), + 0, + String::new(), + Rc::new(RefCell::new(LinkHandler::new())), + Rc::new(RefCell::new(None)), + sixel_image_store, + terminal_emulator_colors, + terminal_emulator_color_codes, + None, + None, + false, + true, + true, + true, + false, + None, + ) +} + +fn press_key( + pane: &mut TerminalPane, + bare_key: zellij_utils::data::BareKey, + client_id: u16, +) -> Option<crate::tab::AdjustedInput> { + let key = zellij_utils::data::KeyWithModifier::new(bare_key); + pane.adjust_input_to_terminal(&Some(key), vec![], false, Some(client_id)) +} + +#[test] +pub fn guest_modal_navigation_wraps() { + use zellij_utils::data::BareKey; + let mut pane = create_guest_modal_pane(); + let client_id = 1; + pane.set_guest_modal(&[client_id]); + assert_eq!(pane.guest_modal_selection(client_id), Some(0)); + press_key(&mut pane, BareKey::Up, client_id); + assert_eq!(pane.guest_modal_selection(client_id), Some(1)); + press_key(&mut pane, BareKey::Down, client_id); + assert_eq!(pane.guest_modal_selection(client_id), Some(0)); + press_key(&mut pane, BareKey::Down, client_id); + assert_eq!(pane.guest_modal_selection(client_id), Some(1)); + press_key(&mut pane, BareKey::Up, client_id); + assert_eq!(pane.guest_modal_selection(client_id), Some(0)); +} + +#[test] +pub fn guest_modal_tab_is_ignored() { + use zellij_utils::data::{BareKey, KeyWithModifier}; + let mut pane = create_guest_modal_pane(); + let client_id = 1; + pane.set_guest_modal(&[client_id]); + press_key(&mut pane, BareKey::Tab, client_id); + assert_eq!(pane.guest_modal_selection(client_id), Some(0)); + let key = KeyWithModifier::new(BareKey::Tab).with_shift_modifier(); + pane.adjust_input_to_terminal(&Some(key), vec![], false, Some(client_id)); + assert_eq!(pane.guest_modal_selection(client_id), Some(0)); +} + +#[test] +pub fn guest_modal_enter_confirms_selection() { + use crate::tab::AdjustedInput; + use zellij_utils::data::BareKey; + let mut pane = create_guest_modal_pane(); + let client_id = 1; + + pane.set_guest_modal(&[client_id]); + let outcome = press_key(&mut pane, BareKey::Enter, client_id); + assert!(matches!(outcome, Some(AdjustedInput::GuestModalZoom))); + + pane.set_guest_modal(&[client_id]); + press_key(&mut pane, BareKey::Down, client_id); + let outcome = press_key(&mut pane, BareKey::Enter, client_id); + assert!(matches!(outcome, Some(AdjustedInput::GuestModalDescend))); +} + +#[test] +pub fn guest_modal_digit_shortcuts() { + use crate::tab::AdjustedInput; + use zellij_utils::data::BareKey; + let mut pane = create_guest_modal_pane(); + let client_id = 1; + + pane.set_guest_modal(&[client_id]); + let outcome = press_key(&mut pane, BareKey::Char('1'), client_id); + assert!(matches!(outcome, Some(AdjustedInput::GuestModalZoom))); + + pane.set_guest_modal(&[client_id]); + let outcome = press_key(&mut pane, BareKey::Char('2'), client_id); + assert!(matches!(outcome, Some(AdjustedInput::GuestModalDescend))); +} + +#[test] +pub fn guest_modal_esc_descends() { + use crate::tab::AdjustedInput; + use zellij_utils::data::BareKey; + let mut pane = create_guest_modal_pane(); + let client_id = 1; + pane.set_guest_modal(&[client_id]); + let outcome = press_key(&mut pane, BareKey::Esc, client_id); + assert!(matches!(outcome, Some(AdjustedInput::GuestModalDescend))); +} + +#[test] +pub fn guest_modal_swallows_other_input() { + use zellij_utils::data::BareKey; + let mut pane = create_guest_modal_pane(); + let client_id = 1; + pane.set_guest_modal(&[client_id]); + let outcome = press_key(&mut pane, BareKey::Char('x'), client_id); + assert!(outcome.is_none()); + assert_eq!(pane.guest_modal_selection(client_id), Some(0)); +} + +#[test] +pub fn guest_modal_is_per_client() { + use zellij_utils::data::BareKey; + let mut pane = create_guest_modal_pane(); + let client_a = 1; + let client_b = 2; + pane.set_guest_modal(&[client_a, client_b]); + press_key(&mut pane, BareKey::Down, client_a); + assert_eq!(pane.guest_modal_selection(client_a), Some(1)); + assert_eq!(pane.guest_modal_selection(client_b), Some(0)); +} + +#[test] +pub fn guest_modal_no_modal_passes_input_through() { + use zellij_utils::data::BareKey; + let mut pane = create_guest_modal_pane(); + let client_id = 1; + let outcome = press_key(&mut pane, BareKey::Char('x'), client_id); + assert!(matches!( + outcome, + Some(crate::tab::AdjustedInput::WriteBytesToTerminal(_)) + )); +} diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs index d850145c..318b2acc 100644 --- a/zellij-server/src/plugins/zellij_exports.rs +++ b/zellij-server/src/plugins/zellij_exports.rs @@ -337,6 +337,8 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) { PluginCommand::PageScrollUp => page_scroll_up(env), PluginCommand::PageScrollDown => page_scroll_down(env), PluginCommand::ToggleFocusFullscreen => toggle_focus_fullscreen(env), + PluginCommand::ToggleFocusNoUiFullscreen => toggle_focus_no_ui_fullscreen(env), + PluginCommand::FocusHostSession => focus_host_session(env), PluginCommand::TogglePaneFrames => toggle_pane_frames(env), PluginCommand::SetPaneFrameStyle(pane_frame_style) => { set_pane_frame_style(env, pane_frame_style) @@ -3158,6 +3160,24 @@ fn toggle_focus_fullscreen(env: &PluginEnv) { apply_action!(action, error_msg, env); } +fn toggle_focus_no_ui_fullscreen(env: &PluginEnv) { + let error_msg = || { + format!( + "failed to toggle no-ui full screen in plugin {}", + env.name() + ) + }; + let action = Action::ToggleFocusNoUiFullscreen; + apply_action!(action, error_msg, env); +} + +fn focus_host_session(env: &PluginEnv) { + env.senders + .send_to_screen(ScreenInstruction::FocusHostSession(env.client_id, None)) + .with_context(|| format!("failed to focus host session from plugin {}", env.name())) + .non_fatal(); +} + fn toggle_pane_frames(env: &PluginEnv) { let error_msg = || format!("failed to toggle full screen in plugin {}", env.name()); let action = Action::TogglePaneFrames; @@ -5535,6 +5555,8 @@ fn check_command_permission( | PluginCommand::PageScrollDown | PluginCommand::PageScrollDownInPaneId(..) | PluginCommand::ToggleFocusFullscreen + | PluginCommand::ToggleFocusNoUiFullscreen + | PluginCommand::FocusHostSession | PluginCommand::TogglePaneIdFullscreen(..) | PluginCommand::TogglePaneFrames | PluginCommand::SetPaneFrameStyle(..) diff --git a/zellij-server/src/route.rs b/zellij-server/src/route.rs index 08200abb..dfa33b09 100644 --- a/zellij-server/src/route.rs +++ b/zellij-server/src/route.rs @@ -611,6 +611,14 @@ pub(crate) fn route_action( )) .with_context(err_context)?; }, + Action::ToggleFocusNoUiFullscreen => { + senders + .send_to_screen(ScreenInstruction::ToggleActiveTerminalNoUiFullscreen( + client_id, + Some(NotificationEnd::new(completion_tx)), + )) + .with_context(err_context)?; + }, Action::TogglePaneFrames => { senders .send_to_screen(ScreenInstruction::TogglePaneFrames(Some( @@ -1579,6 +1587,30 @@ pub(crate) fn route_action( )) .with_context(err_context)?; }, + Action::FocusHostSession => { + senders + .send_to_screen(ScreenInstruction::FocusHostSession( + client_id, + Some(NotificationEnd::new(completion_tx)), + )) + .with_context(err_context)?; + }, + Action::FocusGuestSession => { + senders + .send_to_screen(ScreenInstruction::FocusGuestSession( + client_id, + Some(NotificationEnd::new(completion_tx)), + )) + .with_context(err_context)?; + }, + Action::ToggleHostFullscreen => { + senders + .send_to_screen(ScreenInstruction::ToggleHostFullscreen( + client_id, + Some(NotificationEnd::new(completion_tx)), + )) + .with_context(err_context)?; + }, Action::RenameSession { name } => { senders .send_to_screen(ScreenInstruction::RenameSession( @@ -2003,6 +2035,14 @@ pub(crate) fn route_action( )) .with_context(err_context)?; }, + Action::ToggleFocusNoUiFullscreenByPaneId { pane_id } => { + senders + .send_to_screen(ScreenInstruction::ToggleNoUiFullscreenWithPaneId( + pane_id.into(), + Some(NotificationEnd::new(completion_tx)), + )) + .with_context(err_context)?; + }, Action::TogglePaneEmbedOrFloatingByPaneId { pane_id } => { senders .send_to_screen(ScreenInstruction::TogglePaneEmbedOrFloatingWithPaneId( @@ -2274,6 +2314,21 @@ pub(crate) fn route_thread_main( // see the doc comment on `route_action` for why this matters. let dispatch_inputs = session_data.read().unwrap().as_ref().and_then(|s| { + let in_passthrough = + s.key_passthrough_clients.contains_key(&client_id); + if in_passthrough { + return Some(( + s.senders.clone(), + s.default_shell.clone(), + s.session_configuration + .get_client_default_input_mode(&client_id), + vec![Action::Write { + key_with_modifier: Some(key), + bytes: raw_bytes, + is_kitty_keyboard_protocol, + }], + )); + } let (kb, im, dim) = s.get_client_keybinds_and_mode(&client_id)?; let actions: Vec<Action> = kb @@ -2703,6 +2758,67 @@ pub(crate) fn route_thread_main( )])); } }, + ClientToServerMsg::NestedSessionFrameFromHost { ref payload_bytes } => { + match zellij_utils::nested_session::decode_payload(payload_bytes) { + Some(message @ zellij_utils::nested_session::NestedSessionMessage::AnnounceAck { .. }) => { + let _ = send_to_screen_or_retry_queue!( + senders, + ScreenInstruction::NestedSessionMessageFromHost { client_id, message }, + instruction, + retry_queue + ); + }, + Some(message @ zellij_utils::nested_session::NestedSessionMessage::FocusGained { .. }) => { + let _ = send_to_screen_or_retry_queue!( + senders, + ScreenInstruction::NestedSessionMessageFromHost { client_id, message }, + instruction, + retry_queue + ); + }, + Some(message @ zellij_utils::nested_session::NestedSessionMessage::FocusLost) => { + let _ = send_to_screen_or_retry_queue!( + senders, + ScreenInstruction::NestedSessionMessageFromHost { client_id, message }, + instruction, + retry_queue + ); + }, + Some(message @ zellij_utils::nested_session::NestedSessionMessage::ShortcutUpdate { .. }) => { + let _ = send_to_screen_or_retry_queue!( + senders, + ScreenInstruction::NestedSessionMessageFromHost { client_id, message }, + instruction, + retry_queue + ); + }, + Some(message @ zellij_utils::nested_session::NestedSessionMessage::FullscreenState { .. }) => { + let _ = send_to_screen_or_retry_queue!( + senders, + ScreenInstruction::NestedSessionMessageFromHost { client_id, message }, + instruction, + retry_queue + ); + }, + Some(message @ zellij_utils::nested_session::NestedSessionMessage::AncestryUpdate { .. }) => { + let _ = send_to_screen_or_retry_queue!( + senders, + ScreenInstruction::NestedSessionMessageFromHost { client_id, message }, + instruction, + retry_queue + ); + }, + Some(message) => { + log::debug!( + "dropping unsupported nested session frame relayed from host: {:?}", + message + ); + }, + None => { + log::debug!("dropping undecodable nested session frame relayed from host"); + }, + } + }, } Ok(should_break) }; diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs index b72e5a3b..ddf50a98 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -44,17 +44,20 @@ use zellij_utils::data::{ HostTerminalThemeMode, KeyWithModifier, LayoutInfo, LayoutWithError, ListPanesResponse, ListTabsResponse, NewPanePlacement, PaneContents, PaneInfo, PaneListEntry, PaneManifest, PaneRenderReport, PaneScrollbackResponse, PluginPermission, RegexHighlight, Resize, - ResizeStrategy, SessionInfo, Styling, TabInfo, WebSharing, + ResizeStrategy, SessionInfo, Styling, TabInfo, ThemeHue, WebSharing, }; use zellij_utils::errors::prelude::*; +use zellij_utils::input::actions::Action; use zellij_utils::input::command::RunCommand; use zellij_utils::input::config::Config; -use zellij_utils::input::keybinds::Keybinds; +use zellij_utils::input::keybinds::{shortcut_for_action, Keybinds}; use zellij_utils::input::mouse::{MouseEvent, MouseEventType}; -use zellij_utils::input::options::{Clipboard, MobileLayoutConfiguration, PaneFrameStyle}; +use zellij_utils::input::options::{ + Clipboard, MobileLayoutConfiguration, NestedSessionHandling, PaneFrameStyle, +}; use zellij_utils::ipc::{ExitReason, ServerToClientMsg}; use zellij_utils::pane_size::{PaneGeom, Size, SizeInPixels}; -use zellij_utils::shared::clean_string_from_control_and_linebreak; +use zellij_utils::shared::{clean_string_from_control_and_linebreak, detect_theme_hue}; use zellij_utils::{ consts::{session_info_folder_for_session, ZELLIJ_SOCK_DIR}, envs::set_session_name, @@ -70,18 +73,20 @@ use crate::background_jobs::BackgroundJob; use crate::os_input_output::ResizeCache; use crate::pane_groups::PaneGroups; use crate::panes::alacritty_functions::xparse_color; +use crate::panes::nested_session_modal::GuestModalShortcuts; use crate::panes::terminal_character::AnsiCode; use crate::panes::terminal_pane::{BRACKETED_PASTE_BEGIN, BRACKETED_PASTE_END}; use crate::session_layout_metadata::{PaneLayoutMetadata, SessionLayoutMetadata}; use crate::{ + nested_guest::NestedGuestTracker, output::Output, panes::sixel::SixelImageStore, panes::PaneId, plugins::{DumpSessionLayoutResponse, PluginId, PluginInstruction, PluginRenderAsset}, pty::{get_default_shell, ClientTabIndexOrPaneId, PtyInstruction, VteBytes}, pty_writer::PtyWriteInstruction, - tab::{SuppressedPanes, Tab}, + tab::{GuestChoiceIndicator, SuppressedPanes, Tab}, thread_bus::Bus, ui::loading_indication::LoadingIndication, ClientId, ServerInstruction, @@ -91,6 +96,7 @@ use zellij_utils::{ errors::{ContextType, ScreenContext}, input::get_mode_info, ipc::{ClientAttributes, PixelDimensions}, + nested_session::{self, NestedSessionCapability, NestedSessionMessage}, }; use crate::mobile_mode::{MobileRenderGate, MobileState, ShadowFocusOutcome, FIT_RESIZE_MAX_ITERS}; @@ -429,6 +435,7 @@ pub enum ScreenInstruction { ClearScroll(ClientId), CloseFocusedPane(ClientId, Option<NotificationEnd>), ToggleActiveTerminalFullscreen(ClientId, Option<NotificationEnd>), + ToggleActiveTerminalNoUiFullscreen(ClientId, Option<NotificationEnd>), TogglePaneFrames(Option<NotificationEnd>), SetPaneFrameStyle(PaneFrameStyle, Option<NotificationEnd>), SetSelectable(PaneId, bool), @@ -517,6 +524,22 @@ pub enum ScreenInstruction { pane_id: PaneId, query: crate::host_query::HostQuery, }, + NestedSessionMessageFromPane { + pane_id: PaneId, + message: NestedSessionMessage, + }, + NestedGuestPingTick { + pane_id: PaneId, + }, + NestedSessionMessageFromHost { + client_id: ClientId, + message: NestedSessionMessage, + }, + GuestModalChoice { + client_id: ClientId, + pane_id: PaneId, + outcome: GuestModalOutcome, + }, /// The client observed the host's reply to a previously forwarded /// query (closed by the Primary-DA barrier or the 500 ms timeout). /// The reply bytes are written verbatim to the originating pane. @@ -756,6 +779,7 @@ pub enum ScreenInstruction { visual_bell: bool, focus_follows_mouse: bool, mouse_click_through: bool, + nested_session_handling: NestedSessionHandling, }, RerunCommandPane(u32, Option<NotificationEnd>), // u32 - terminal pane id ResizePaneWithId(ResizeStrategy, PaneId), @@ -876,6 +900,7 @@ pub enum ScreenInstruction { ClearScreenWithPaneId(PaneId, Option<NotificationEnd>), EditScrollbackWithPaneId(PaneId, bool, Option<NotificationEnd>), ToggleFullscreenWithPaneId(PaneId, Option<NotificationEnd>), + ToggleNoUiFullscreenWithPaneId(PaneId, Option<NotificationEnd>), TogglePaneEmbedOrFloatingWithPaneId(PaneId, Option<NotificationEnd>), CloseFocusWithPaneId(PaneId, Option<NotificationEnd>), RenamePaneWithPaneId(PaneId, Vec<u8>, Option<NotificationEnd>), @@ -903,6 +928,9 @@ pub enum ScreenInstruction { on: bool, }, SetShadowFocus(ClientId, PaneId), + FocusHostSession(ClientId, Option<NotificationEnd>), + FocusGuestSession(ClientId, Option<NotificationEnd>), + ToggleHostFullscreen(ClientId, Option<NotificationEnd>), } impl From<&ScreenInstruction> for ScreenContext { @@ -992,6 +1020,9 @@ impl From<&ScreenInstruction> for ScreenContext { ScreenInstruction::ToggleActiveTerminalFullscreen(..) => { ScreenContext::ToggleActiveTerminalFullscreen }, + ScreenInstruction::ToggleActiveTerminalNoUiFullscreen(..) => { + ScreenContext::ToggleActiveTerminalNoUiFullscreen + }, ScreenInstruction::TogglePaneFrames(..) => ScreenContext::TogglePaneFrames, ScreenInstruction::SetPaneFrameStyle(..) => ScreenContext::SetPaneFrameStyle, ScreenInstruction::SetSelectable(..) => ScreenContext::SetSelectable, @@ -1028,6 +1059,14 @@ impl From<&ScreenInstruction> for ScreenContext { }, ScreenInstruction::TerminalColorRegisters(..) => ScreenContext::TerminalColorRegisters, ScreenInstruction::ForwardHostQuery { .. } => ScreenContext::ForwardHostQuery, + ScreenInstruction::NestedSessionMessageFromPane { .. } => { + ScreenContext::NestedSessionMessageFromPane + }, + ScreenInstruction::NestedGuestPingTick { .. } => ScreenContext::NestedGuestPingTick, + ScreenInstruction::NestedSessionMessageFromHost { .. } => { + ScreenContext::NestedSessionMessageFromHost + }, + ScreenInstruction::GuestModalChoice { .. } => ScreenContext::GuestModalChoice, ScreenInstruction::ForwardedReplyFromHost { .. } => { ScreenContext::ForwardedReplyFromHost }, @@ -1243,6 +1282,9 @@ impl From<&ScreenInstruction> for ScreenContext { ScreenInstruction::ToggleFullscreenWithPaneId(..) => { ScreenContext::ToggleFullscreenWithPaneId }, + ScreenInstruction::ToggleNoUiFullscreenWithPaneId(..) => { + ScreenContext::ToggleNoUiFullscreenWithPaneId + }, ScreenInstruction::TogglePaneEmbedOrFloatingWithPaneId(..) => { ScreenContext::TogglePaneEmbedOrFloatingWithPaneId }, @@ -1275,6 +1317,9 @@ impl From<&ScreenInstruction> for ScreenContext { ScreenInstruction::ReevaluateMobileMode { .. } => ScreenContext::ReevaluateMobileMode, ScreenInstruction::SetSoftKeyboard { .. } => ScreenContext::SetSoftKeyboard, ScreenInstruction::SetShadowFocus(..) => ScreenContext::SetShadowFocus, + ScreenInstruction::FocusHostSession(..) => ScreenContext::FocusHostSession, + ScreenInstruction::FocusGuestSession(..) => ScreenContext::FocusGuestSession, + ScreenInstruction::ToggleHostFullscreen(..) => ScreenContext::ToggleHostFullscreen, } } } @@ -1402,6 +1447,29 @@ struct PaneRenderSubscription { ansi: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NestedGuestChoice { + Zoom, + Descend, + Dismissed, +} + +impl NestedGuestChoice { + fn enters_passthrough(&self) -> bool { + matches!(self, NestedGuestChoice::Zoom | NestedGuestChoice::Descend) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GuestModalOutcome { + Zoom, + Descend, +} + +fn format_guest_modal_shortcut(keys: &[KeyWithModifier]) -> Vec<String> { + keys.iter().map(|key| key.to_string()).collect() +} + /// A [`Screen`] holds multiple [`Tab`]s, each one holding multiple [`panes`](crate::client::panes). /// It only directly controls which tab is active, delegating the rest to the individual `Tab`. pub(crate) struct Screen { @@ -1481,9 +1549,22 @@ pub(crate) struct Screen { pending_forwarded_queries: HashMap<u32, PendingForwardEntry>, forward_queue: VecDeque<PendingForward>, forward_in_flight_token: Option<u32>, + nested_guest_tracker: NestedGuestTracker, + nested_ancestry: Vec<String>, + nested_via_client_id: Option<ClientId>, + nested_fullscreen_panes: HashSet<PaneId>, + own_fullscreen_requested: bool, + reported_guest_fullscreen: HashMap<PaneId, bool>, + host_fullscreen: bool, + dimmed_clients: HashSet<ClientId>, + nested_guest_choices: HashMap<(ClientId, PaneId), NestedGuestChoice>, + guest_ascend_keys: HashMap<PaneId, Vec<KeyWithModifier>>, + host_descend_keys: Vec<KeyWithModifier>, + host_descended: bool, host_terminal_theme_mode: Option<HostTerminalThemeMode>, host_theme_dark_styling: Option<Styling>, host_theme_light_styling: Option<Styling>, + nested_session_handling: NestedSessionHandling, mobile_state: MobileState, mobile_render_gate: MobileRenderGate, } @@ -1569,6 +1650,7 @@ impl Screen { mouse_click_through: bool, web_server_ip: IpAddr, web_server_port: u16, + nested_session_handling: NestedSessionHandling, ) -> Self { let session_name = mode_info.session_name.clone().unwrap_or_default(); let session_info = SessionInfo::new(session_name.clone()); @@ -1642,10 +1724,23 @@ impl Screen { pending_forwarded_queries: HashMap::new(), forward_queue: VecDeque::new(), forward_in_flight_token: None, + nested_guest_tracker: NestedGuestTracker::default(), + nested_ancestry: vec![], + nested_via_client_id: None, + nested_fullscreen_panes: HashSet::new(), + own_fullscreen_requested: false, + reported_guest_fullscreen: HashMap::new(), + host_fullscreen: false, + dimmed_clients: HashSet::new(), + nested_guest_choices: HashMap::new(), + guest_ascend_keys: HashMap::new(), + host_descend_keys: vec![], + host_descended: false, host_terminal_theme_mode: None, host_theme_dark_styling: None, host_theme_light_styling: None, mobile_state: MobileState::default(), + nested_session_handling, mobile_render_gate: MobileRenderGate::default(), } } @@ -2081,6 +2176,19 @@ impl Screen { let current_tab_index = current_tab.id; let new_tab_index = new_tab.id; + let affected_client_ids: Vec<ClientId> = if self.session_is_mirrored { + self.connected_clients + .borrow() + .iter() + .map(|(c, _i)| *c) + .collect() + } else { + vec![client_id] + }; + let old_focused_panes: Vec<(ClientId, Option<PaneId>)> = affected_client_ids + .iter() + .map(|c| (*c, self.get_active_pane_id(c))) + .collect(); if self.session_is_mirrored { self.move_clients_between_tabs( current_tab_index, @@ -2154,6 +2262,12 @@ impl Screen { self.log_and_report_session_state() .with_context(err_context)?; + for (affected_client_id, old_pane_id) in old_focused_panes { + let new_pane_id = self.get_active_pane_id(&affected_client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + self.report_key_passthrough_state(affected_client_id, old, new); + } + } return self.render(None).with_context(err_context); }, Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(), @@ -2549,39 +2663,13 @@ impl Screen { token } - /// Synthesise the DSR 997 reply to a `CSI ? 996 n` query from - /// `host_terminal_theme_mode` and write it directly to the - /// originating pane's pty. Plugin panes are skipped (they receive - /// `Event::HostTerminalThemeChanged` and have no notion of - /// VT-protocol queries). When Zellij has not yet learned the host - /// mode, the pane receives no reply — matching what a host that - /// does not implement CSI 2031 would do. The Contour spec only - /// defines `;1` (dark) and `;2` (light); fabricating any other - /// code (e.g. `;0`) would be non-conformant. fn answer_color_palette_mode_query_locally(&mut self, pane_id: PaneId) { if matches!(pane_id, PaneId::Plugin(_)) { return; } - let code: u8 = match self.host_terminal_theme_mode { - Some(HostTerminalThemeMode::Dark) => 1, - Some(HostTerminalThemeMode::Light) => 2, - None => { - log::debug!( - "CSI ?996n received but host_terminal_theme_mode is unknown; \ - dropping (spec defines only 1=dark / 2=light)" - ); - // The Contour spec defines only ;1 / ;2 — silence is - // the conformant behaviour when the host's mode is - // unknown. But the pane may have been forward-paused - // on the dispatch; if so we still owe it an unblock - // cycle so any buffered bytes get replayed. Skip the - // empty resume when the pane is not paused, matching - // the "stay silent" guarantee. - if self.is_any_tab_pane_forward_paused(pane_id) { - let _ = self.resume_pane_after_forward(pane_id, Vec::new()); - } - return; - }, + let code: u8 = match self.effective_host_terminal_theme_mode() { + HostTerminalThemeMode::Dark => 1, + HostTerminalThemeMode::Light => 2, }; let reply = format!("\u{1b}[?997;{}n", code).into_bytes(); // Route via Tab so the reply lands on the pane in the correct @@ -2590,6 +2678,15 @@ impl Screen { let _ = self.resume_pane_after_forward(pane_id, reply); } + fn effective_host_terminal_theme_mode(&self) -> HostTerminalThemeMode { + self.host_terminal_theme_mode.unwrap_or_else(|| { + match detect_theme_hue(self.style.colors.text_unselected.background) { + ThemeHue::Dark => HostTerminalThemeMode::Dark, + ThemeHue::Light => HostTerminalThemeMode::Light, + } + }) + } + /// Dispatch a forward to the client and mark the slot as in-flight. /// Spawns a one-shot timeout task on the global tokio runtime that /// synthesizes an empty reply for `token` after @@ -2687,15 +2784,6 @@ impl Screen { Ok(()) } - /// Whether any tab owns `pane_id` AND the pane is currently - /// forward-paused. Used by the `ColorPaletteMode` short-circuit - /// to skip the empty-payload resume when no pane needs unblocking. - fn is_any_tab_pane_forward_paused(&self, pane_id: PaneId) -> bool { - self.tabs - .values() - .any(|tab| tab.is_pane_forward_paused(pane_id)) - } - /// Deliver a forwarded reply (or cache-fallback synthesis, or a /// locally-answered query payload) to the originating pane via /// the owning Tab. The Tab handler writes the bytes to PTY and @@ -2743,6 +2831,983 @@ impl Screen { Ok(()) } + pub fn handle_nested_session_message_from_pane( + &mut self, + pane_id: PaneId, + message: NestedSessionMessage, + ) { + match message { + NestedSessionMessage::Announce { session_name, .. } => { + self.handle_nested_guest_announce(pane_id, session_name); + }, + NestedSessionMessage::Pong => { + self.nested_guest_tracker.on_pong(pane_id, Instant::now()); + log::debug!("nested session guest in pane {:?} ponged", pane_id); + }, + NestedSessionMessage::Bye => { + log::info!( + "nested session guest in pane {:?} sent bye, clearing guest flag", + pane_id + ); + self.clear_nested_guest(pane_id); + }, + NestedSessionMessage::FocusHost { direction } => { + let clients_focused_on_pane = self.clients_with_pane_focused(pane_id); + match direction { + None => { + self.unset_nested_fullscreen_if_active(pane_id); + for client_id in clients_focused_on_pane { + self.nested_guest_choices + .insert((client_id, pane_id), NestedGuestChoice::Dismissed); + self.sync_guest_choice_indicator(client_id, pane_id); + self.set_client_dimmed(client_id, false, None); + let _ = self.bus.senders.send_to_server( + ServerInstruction::KeyPassthroughChanged( + client_id, pane_id, pane_id, false, None, true, + ), + ); + } + let _ = self.render(None); + }, + Some(direction) => { + for client_id in clients_focused_on_pane { + let new_pane_id = self + .tabs + .values_mut() + .find(|tab| tab.has_pane_with_pid(&pane_id)) + .and_then(|tab| { + tab.focus_pane_adjacent_to(pane_id, direction, client_id) + }); + match new_pane_id { + Some(new_pane_id) => { + self.report_key_passthrough_state_with_direction( + client_id, + pane_id, + new_pane_id, + Some(direction), + ); + let _ = self.render(None); + let _ = self.log_and_report_session_state(); + }, + None => { + self.bubble_focus_to_host(client_id, direction); + }, + } + } + }, + } + }, + NestedSessionMessage::ShortcutUpdate { ascend_keys, .. } => { + if !self.nested_guest_tracker.is_tracked(pane_id) { + log::debug!( + "ignoring nested shortcut update from non-live guest pane {:?}", + pane_id + ); + return; + } + self.guest_ascend_keys.insert(pane_id, ascend_keys); + self.refresh_nested_ascend_keys_for_pane(pane_id); + }, + NestedSessionMessage::ToggleHostFullscreen { fullscreen } => { + if !self.nested_guest_tracker.is_tracked(pane_id) { + log::debug!( + "ignoring nested fullscreen request from non-live guest pane {:?}", + pane_id + ); + return; + } + self.apply_nested_guest_fullscreen(pane_id, fullscreen); + if let Some(client_id) = self.nested_via_client_id { + let payload = nested_session::encode_payload( + &NestedSessionMessage::ToggleHostFullscreen { fullscreen }, + ); + let _ = self.bus.senders.send_to_server( + ServerInstruction::EmitNestedSessionFrameToClient(client_id, payload), + ); + } + let _ = self.render(None); + let _ = self.log_and_report_session_state(); + }, + other => { + log::debug!( + "dropping unsupported nested session message from pane {:?}: {:?}", + pane_id, + other + ); + }, + } + } + + fn handle_nested_guest_announce(&mut self, pane_id: PaneId, guest_session_name: String) { + use crate::nested_guest::AnnounceKind; + let terminal_id = match pane_id { + PaneId::Terminal(terminal_id) => terminal_id, + PaneId::Plugin(_) => return, + }; + let announce_kind = self + .nested_guest_tracker + .on_announce(pane_id, Instant::now()); + let is_fresh = announce_kind == AnnounceKind::New; + if is_fresh { + self.nested_guest_choices + .retain(|(_, choice_pane_id), _| *choice_pane_id != pane_id); + } + let connected_client_ids: Vec<ClientId> = + self.connected_clients.borrow().keys().copied().collect(); + let guest_modal_shortcuts = self.guest_modal_shortcuts(); + let owning_tab = self + .tabs + .values_mut() + .find(|tab| tab.has_pane_with_pid(&pane_id)); + match owning_tab { + Some(tab) => { + tab.set_pane_is_nested_guest(pane_id, true); + tab.set_guest_session_name_on_pane(pane_id, Some(guest_session_name.clone())); + tab.set_guest_modal_shortcuts_on_pane(pane_id, guest_modal_shortcuts); + if is_fresh { + tab.clear_all_guest_choice_indicators_on_pane(pane_id); + if self.nested_session_handling == NestedSessionHandling::Ask { + tab.set_guest_modal_on_pane(pane_id, &connected_client_ids); + } + } + }, + None => { + self.nested_guest_tracker.remove(pane_id); + return; + }, + } + let mut ancestry = self.nested_ancestry.clone(); + ancestry.push(self.session_name.clone()); + let announce_ack = NestedSessionMessage::AnnounceAck { + ancestry, + capabilities: vec![NestedSessionCapability::NestedControl], + descend_keys: self.own_descend_shortcut(), + }; + let _ = self + .bus + .senders + .send_to_pty_writer(PtyWriteInstruction::Write( + nested_session::encode_frame(&announce_ack), + terminal_id, + None, + )); + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::StartNestedGuestPing(pane_id)); + if is_fresh { + self.auto_apply_nested_session_handling(pane_id, &connected_client_ids); + } else { + self.revive_nested_guest(pane_id); + } + let _ = self.render(None); + log::info!( + "nested session handshake ({:?}): guest session {:?} announced in pane {:?}, sent announce_ack", + announce_kind, + guest_session_name, + pane_id + ); + } + + fn suspend_nested_guest(&mut self, pane_id: PaneId) { + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::StopNestedGuestPing(pane_id)); + self.unset_nested_fullscreen_if_active(pane_id); + self.reported_guest_fullscreen.remove(&pane_id); + let affected_clients: Vec<ClientId> = self + .clients_with_pane_focused(pane_id) + .into_iter() + .chain(self.dimmed_clients.iter().copied()) + .collect(); + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.clear_all_guest_modals_on_pane(pane_id); + break; + } + } + for client_id in affected_clients { + self.set_client_dimmed(client_id, false, None); + let _ = self + .bus + .senders + .send_to_server(ServerInstruction::KeyPassthroughChanged( + client_id, pane_id, pane_id, false, None, false, + )); + } + } + + fn revive_nested_guest(&mut self, pane_id: PaneId) { + self.broadcast_ancestry_update_to_guests(); + self.sync_nested_guest_fullscreen_state(); + let client_ids: Vec<ClientId> = self.connected_clients.borrow().keys().copied().collect(); + for client_id in client_ids { + self.sync_guest_choice_indicator(client_id, pane_id); + if self.get_active_pane_id(&client_id) == Some(pane_id) { + self.report_key_passthrough_state(client_id, pane_id, pane_id); + } + } + } + + fn handle_guest_modal_choice( + &mut self, + client_id: ClientId, + pane_id: PaneId, + outcome: GuestModalOutcome, + ) { + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.clear_guest_modal_on_pane(pane_id, client_id); + break; + } + } + match outcome { + GuestModalOutcome::Descend => { + self.nested_guest_choices + .insert((client_id, pane_id), NestedGuestChoice::Descend); + self.sync_guest_choice_indicator(client_id, pane_id); + if self.get_active_pane_id(&client_id) == Some(pane_id) { + self.report_key_passthrough_state(client_id, pane_id, pane_id); + } + let _ = self.render(None); + let _ = self.log_and_report_session_state(); + }, + GuestModalOutcome::Zoom => { + self.nested_guest_choices + .insert((client_id, pane_id), NestedGuestChoice::Zoom); + self.sync_guest_choice_indicator(client_id, pane_id); + if self.get_active_pane_id(&client_id) == Some(pane_id) { + self.report_key_passthrough_state(client_id, pane_id, pane_id); + } + self.apply_nested_guest_fullscreen(pane_id, true); + self.sync_nested_guest_fullscreen_state(); + if let Some(via_client_id) = self.nested_via_client_id { + let payload = nested_session::encode_payload( + &NestedSessionMessage::ToggleHostFullscreen { fullscreen: true }, + ); + let _ = self.bus.senders.send_to_server( + ServerInstruction::EmitNestedSessionFrameToClient(via_client_id, payload), + ); + } + let _ = self.render(None); + let _ = self.log_and_report_session_state(); + }, + } + } + + fn record_nested_guest_choice_for_client( + &mut self, + client_id: ClientId, + pane_id: PaneId, + choice: NestedGuestChoice, + ) { + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.clear_guest_modal_on_pane(pane_id, client_id); + break; + } + } + self.nested_guest_choices + .insert((client_id, pane_id), choice); + self.sync_guest_choice_indicator(client_id, pane_id); + if self.get_active_pane_id(&client_id) == Some(pane_id) { + self.report_key_passthrough_state(client_id, pane_id, pane_id); + } + } + + fn auto_apply_nested_session_handling(&mut self, pane_id: PaneId, client_ids: &[ClientId]) { + match self.nested_session_handling { + NestedSessionHandling::Descend => { + for client_id in client_ids { + self.record_nested_guest_choice_for_client( + *client_id, + pane_id, + NestedGuestChoice::Descend, + ); + } + }, + NestedSessionHandling::Fullscreen => { + for client_id in client_ids { + self.record_nested_guest_choice_for_client( + *client_id, + pane_id, + NestedGuestChoice::Zoom, + ); + } + self.apply_nested_guest_fullscreen(pane_id, true); + self.sync_nested_guest_fullscreen_state(); + if let Some(via_client_id) = self.nested_via_client_id { + let payload = nested_session::encode_payload( + &NestedSessionMessage::ToggleHostFullscreen { fullscreen: true }, + ); + let _ = self.bus.senders.send_to_server( + ServerInstruction::EmitNestedSessionFrameToClient(via_client_id, payload), + ); + } + }, + NestedSessionHandling::Ask | NestedSessionHandling::Never => {}, + } + } + + fn focus_guest_session(&mut self, client_id: ClientId) { + let focused_pane_id = match self.get_active_pane_id(&client_id) { + Some(pane_id) => pane_id, + None => return, + }; + let is_live_guest = self.tabs.values().any(|tab| { + tab.has_pane_with_pid(&focused_pane_id) && tab.is_pane_nested_guest(focused_pane_id) + }) && self.nested_guest_tracker.is_tracked(focused_pane_id); + if !is_live_guest { + return; + } + self.nested_guest_choices + .insert((client_id, focused_pane_id), NestedGuestChoice::Descend); + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&focused_pane_id) { + tab.clear_guest_modal_on_pane(focused_pane_id, client_id); + break; + } + } + self.sync_guest_choice_indicator(client_id, focused_pane_id); + self.report_key_passthrough_state(client_id, focused_pane_id, focused_pane_id); + let _ = self.render(None); + let _ = self.log_and_report_session_state(); + } + + pub fn clear_nested_guest(&mut self, pane_id: PaneId) { + self.nested_guest_tracker.remove(pane_id); + self.guest_ascend_keys.remove(&pane_id); + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::StopNestedGuestPing(pane_id)); + self.unset_nested_fullscreen_if_active(pane_id); + self.reported_guest_fullscreen.remove(&pane_id); + let mut affected_clients: Vec<ClientId> = self + .nested_guest_choices + .iter() + .filter(|((_, choice_pane_id), choice)| { + *choice_pane_id == pane_id && choice.enters_passthrough() + }) + .map(|((choice_client_id, _), _)| *choice_client_id) + .collect(); + self.nested_guest_choices + .retain(|(_, choice_pane_id), _| *choice_pane_id != pane_id); + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.set_pane_is_nested_guest(pane_id, false); + tab.clear_all_guest_modals_on_pane(pane_id); + tab.clear_all_guest_choice_indicators_on_pane(pane_id); + break; + } + } + + for client_id in self.clients_with_pane_focused(pane_id) { + if !affected_clients.contains(&client_id) { + affected_clients.push(client_id); + } + } + for client_id in affected_clients { + self.set_client_dimmed(client_id, false, None); + let _ = self + .bus + .senders + .send_to_server(ServerInstruction::KeyPassthroughChanged( + client_id, pane_id, pane_id, false, None, false, + )); + } + } + + fn show_guest_modals_for_new_client(&mut self, client_id: ClientId) { + let mut live_guest_panes = vec![]; + for tab in self.tabs.values() { + for pane_id in tab.nested_guest_pane_ids() { + if self.nested_guest_tracker.is_tracked(pane_id) { + live_guest_panes.push(pane_id); + } + } + } + for pane_id in live_guest_panes { + if self + .nested_guest_choices + .contains_key(&(client_id, pane_id)) + { + continue; + } + match self.nested_session_handling { + NestedSessionHandling::Ask => { + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.set_guest_modal_on_pane(pane_id, &[client_id]); + break; + } + } + }, + NestedSessionHandling::Descend => { + self.record_nested_guest_choice_for_client( + client_id, + pane_id, + NestedGuestChoice::Descend, + ); + }, + NestedSessionHandling::Fullscreen => { + self.record_nested_guest_choice_for_client( + client_id, + pane_id, + NestedGuestChoice::Zoom, + ); + }, + NestedSessionHandling::Never => {}, + } + } + } + + fn should_route_keys_to_pane(&self, client_id: ClientId, pane_id: PaneId) -> bool { + for tab in self.tabs.values() { + if tab.has_pane_with_pid(&pane_id) { + let is_guest = tab.is_pane_nested_guest(pane_id); + let is_tracked = self.nested_guest_tracker.is_tracked(pane_id); + let has_descended = self + .nested_guest_choices + .get(&(client_id, pane_id)) + .map(|choice| choice.enters_passthrough()) + .unwrap_or(false); + return is_guest && is_tracked && has_descended; + } + } + false + } + + fn downgrade_zoom_choice_to_descend(&mut self, pane_id: PaneId) { + let mut affected_clients = vec![]; + for ((client_id, choice_pane_id), choice) in self.nested_guest_choices.iter_mut() { + if *choice_pane_id == pane_id && *choice == NestedGuestChoice::Zoom { + *choice = NestedGuestChoice::Descend; + affected_clients.push(*client_id); + } + } + for client_id in affected_clients { + self.sync_guest_choice_indicator(client_id, pane_id); + } + } + + fn guest_choice_indicator_for( + &self, + client_id: ClientId, + pane_id: PaneId, + ) -> Option<GuestChoiceIndicator> { + match self.nested_guest_choices.get(&(client_id, pane_id)) { + Some(NestedGuestChoice::Descend) => Some(GuestChoiceIndicator::Descended), + Some(NestedGuestChoice::Dismissed) => Some(GuestChoiceIndicator::Dismissed), + Some(NestedGuestChoice::Zoom) | None => None, + } + } + + fn sync_guest_choice_indicator(&mut self, client_id: ClientId, pane_id: PaneId) { + let indicator = self.guest_choice_indicator_for(client_id, pane_id); + for tab in self.tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.set_guest_choice_indicator_on_pane(pane_id, client_id, indicator); + break; + } + } + } + + fn report_key_passthrough_state( + &mut self, + client_id: ClientId, + old_pane_id: PaneId, + new_pane_id: PaneId, + ) { + self.report_key_passthrough_state_with_direction(client_id, old_pane_id, new_pane_id, None); + } + + fn report_key_passthrough_state_with_direction( + &mut self, + client_id: ClientId, + old_pane_id: PaneId, + new_pane_id: PaneId, + entered_from_direction: Option<Direction>, + ) { + let should_route = self.should_route_keys_to_pane(client_id, new_pane_id); + let guest_pane_id = if should_route { + Some(new_pane_id) + } else { + None + }; + self.set_client_dimmed(client_id, should_route, guest_pane_id); + let _ = self + .bus + .senders + .send_to_server(ServerInstruction::KeyPassthroughChanged( + client_id, + old_pane_id, + new_pane_id, + should_route, + entered_from_direction, + true, + )); + } + + fn set_client_dimmed( + &mut self, + client_id: ClientId, + dimmed: bool, + guest_pane_id: Option<PaneId>, + ) { + let changed = if dimmed { + self.dimmed_clients.insert(client_id) + } else { + self.dimmed_clients.remove(&client_id) + }; + let ascend_keys = if dimmed { + guest_pane_id + .and_then(|pane_id| self.guest_ascend_keys.get(&pane_id).cloned()) + .unwrap_or_default() + } else { + vec![] + }; + let ascend_keys_changed = self + .mode_info + .get(&client_id) + .map(|mode_info| mode_info.nested_ascend_keys != ascend_keys) + .unwrap_or_else(|| !ascend_keys.is_empty()); + if !changed && !ascend_keys_changed { + return; + } + let mode_info = self + .mode_info + .entry(client_id) + .or_insert_with(|| self.default_mode_info.clone()); + mode_info.session_dimmed = if dimmed { Some(true) } else { None }; + mode_info.nested_ascend_keys = ascend_keys; + let mode_info = mode_info.clone(); + for tab in self.tabs.values_mut() { + tab.change_mode_info(mode_info.clone(), client_id); + tab.mark_active_pane_for_rerender(client_id); + tab.set_client_dimmed(client_id, dimmed); + } + for tab in self.tabs.values_mut() { + let _ = tab.update_input_modes(); + } + let _ = self.render(None); + } + + fn base_input_mode(&self) -> InputMode { + self.default_mode_info.base_mode.unwrap_or_default() + } + + fn own_ascend_shortcut(&self) -> Vec<KeyWithModifier> { + shortcut_for_action( + &self.default_mode_info.keybinds, + self.base_input_mode(), + |action| matches!(action, Action::FocusHostSession), + ) + .unwrap_or_default() + } + + fn own_descend_shortcut(&self) -> Vec<KeyWithModifier> { + shortcut_for_action( + &self.default_mode_info.keybinds, + self.base_input_mode(), + |action| matches!(action, Action::FocusGuestSession), + ) + .unwrap_or_default() + } + + fn own_host_zoom_shortcut(&self) -> Vec<KeyWithModifier> { + shortcut_for_action( + &self.default_mode_info.keybinds, + self.base_input_mode(), + |action| matches!(action, Action::ToggleHostFullscreen), + ) + .unwrap_or_default() + } + + fn guest_modal_shortcuts(&self) -> GuestModalShortcuts { + GuestModalShortcuts { + zoom: format_guest_modal_shortcut(&self.own_host_zoom_shortcut()), + ascend: format_guest_modal_shortcut(&self.own_ascend_shortcut()), + descend: format_guest_modal_shortcut(&self.own_descend_shortcut()), + } + } + + fn refresh_nested_ascend_keys_for_pane(&mut self, pane_id: PaneId) { + let keys = self + .guest_ascend_keys + .get(&pane_id) + .cloned() + .unwrap_or_default(); + let dimmed_client_ids: Vec<ClientId> = self.dimmed_clients.iter().copied().collect(); + let mut clients_to_update: Vec<(ClientId, ModeInfo)> = vec![]; + for client_id in dimmed_client_ids { + if self.get_active_pane_id(&client_id) != Some(pane_id) { + continue; + } + if let Some(mode_info) = self.mode_info.get_mut(&client_id) { + if mode_info.nested_ascend_keys != keys { + mode_info.nested_ascend_keys = keys.clone(); + clients_to_update.push((client_id, mode_info.clone())); + } + } + } + if clients_to_update.is_empty() { + return; + } + for (client_id, mode_info) in clients_to_update { + for tab in self.tabs.values_mut() { + tab.change_mode_info(mode_info.clone(), client_id); + } + } + for tab in self.tabs.values_mut() { + let _ = tab.update_input_modes(); + } + let _ = self.render(None); + } + + fn set_host_descended(&mut self, host_descended: bool) { + if self.host_descended != host_descended { + self.host_descended = host_descended; + self.update_all_clients_nesting_mode_info(); + } + } + + fn broadcast_nested_shortcuts(&mut self) { + if let Some(client_id) = self.nested_via_client_id { + let payload = nested_session::encode_payload(&NestedSessionMessage::ShortcutUpdate { + ascend_keys: self.own_ascend_shortcut(), + descend_keys: vec![], + }); + let _ = + self.bus + .senders + .send_to_server(ServerInstruction::EmitNestedSessionFrameToClient( + client_id, payload, + )); + } + let frame = nested_session::encode_frame(&NestedSessionMessage::ShortcutUpdate { + ascend_keys: vec![], + descend_keys: self.own_descend_shortcut(), + }); + for terminal_id in self.nested_guest_terminal_ids() { + let _ = self + .bus + .senders + .send_to_pty_writer(PtyWriteInstruction::Write(frame.clone(), terminal_id, None)); + } + } + + fn update_all_clients_nesting_mode_info(&mut self) { + let ancestry = self.nested_ancestry.clone(); + let host_fullscreen = if self.host_fullscreen { + Some(true) + } else { + None + }; + let session_ascended = if !ancestry.is_empty() && !self.host_descended { + Some(true) + } else { + None + }; + let descend_keys = self.host_descend_keys.clone(); + self.default_mode_info.session_ancestry = ancestry.clone(); + self.default_mode_info.host_fullscreen = host_fullscreen; + self.default_mode_info.session_ascended = session_ascended; + self.default_mode_info.nested_descend_keys = descend_keys.clone(); + let mut client_ids: Vec<ClientId> = self.mode_info.keys().copied().collect(); + for connected_client_id in self.connected_clients.borrow().keys() { + if !client_ids.contains(connected_client_id) { + client_ids.push(*connected_client_id); + } + } + for client_id in client_ids { + let mode_info = self + .mode_info + .entry(client_id) + .or_insert_with(|| self.default_mode_info.clone()); + mode_info.session_ancestry = ancestry.clone(); + mode_info.host_fullscreen = host_fullscreen; + mode_info.session_ascended = session_ascended; + mode_info.nested_descend_keys = descend_keys.clone(); + let mode_info = mode_info.clone(); + for tab in self.tabs.values_mut() { + tab.change_mode_info(mode_info.clone(), client_id); + } + } + for tab in self.tabs.values_mut() { + let _ = tab.update_input_modes(); + } + let _ = self.render(None); + } + + fn nested_guest_terminal_ids(&self) -> Vec<u32> { + let mut terminal_ids = vec![]; + for tab in self.tabs.values() { + for pane_id in tab.nested_guest_pane_ids() { + if let PaneId::Terminal(terminal_id) = pane_id { + if self.nested_guest_tracker.is_tracked(pane_id) { + terminal_ids.push(terminal_id); + } + } + } + } + terminal_ids + } + + fn broadcast_ancestry_update_to_guests(&self) { + let mut ancestry = self.nested_ancestry.clone(); + ancestry.push(self.session_name.clone()); + let message = NestedSessionMessage::AncestryUpdate { ancestry }; + let frame = nested_session::encode_frame(&message); + for terminal_id in self.nested_guest_terminal_ids() { + let _ = self + .bus + .senders + .send_to_pty_writer(PtyWriteInstruction::Write(frame.clone(), terminal_id, None)); + } + } + + fn apply_nested_guest_fullscreen(&mut self, pane_id: PaneId, fullscreen: bool) { + let mut actually_fullscreen = false; + let mut displaced_pane_id = None; + if let Some(tab) = self + .tabs + .values_mut() + .find(|tab| tab.has_pane_with_pid(&pane_id)) + { + let currently_fullscreen = + tab.fullscreen_pane_id() == Some(pane_id) && tab.fullscreen_covers_ui(); + if fullscreen && !currently_fullscreen { + let existing_fullscreen = tab.fullscreen_pane_id(); + if existing_fullscreen.is_some() && existing_fullscreen != Some(pane_id) { + displaced_pane_id = existing_fullscreen; + tab.toggle_pane_no_ui_fullscreen(existing_fullscreen.unwrap()); + } + tab.toggle_pane_no_ui_fullscreen(pane_id); + } else if !fullscreen && currently_fullscreen { + tab.toggle_pane_no_ui_fullscreen(pane_id); + } + actually_fullscreen = + tab.fullscreen_pane_id() == Some(pane_id) && tab.fullscreen_covers_ui(); + } + if let Some(displaced_pane_id) = displaced_pane_id { + self.nested_fullscreen_panes.remove(&displaced_pane_id); + } + if actually_fullscreen { + self.nested_fullscreen_panes.insert(pane_id); + } else { + self.nested_fullscreen_panes.remove(&pane_id); + } + } + + fn unset_nested_fullscreen_if_active(&mut self, pane_id: PaneId) { + if !self.nested_fullscreen_panes.contains(&pane_id) { + return; + } + self.apply_nested_guest_fullscreen(pane_id, false); + if let Some(client_id) = self.nested_via_client_id { + let payload = + nested_session::encode_payload(&NestedSessionMessage::ToggleHostFullscreen { + fullscreen: false, + }); + let _ = + self.bus + .senders + .send_to_server(ServerInstruction::EmitNestedSessionFrameToClient( + client_id, payload, + )); + } + } + + fn sync_nested_guest_fullscreen_state(&mut self) { + let mut actual: HashMap<PaneId, bool> = HashMap::new(); + for tab in self.tabs.values() { + let fullscreen_pane_id = tab.fullscreen_pane_id(); + let covers_ui = tab.fullscreen_covers_ui(); + for pane_id in tab.nested_guest_pane_ids() { + if !self.nested_guest_tracker.is_tracked(pane_id) { + continue; + } + let is_fullscreen = covers_ui && fullscreen_pane_id == Some(pane_id); + actual.insert(pane_id, is_fullscreen); + } + } + let mut updates: Vec<(u32, bool)> = vec![]; + for (pane_id, is_fullscreen) in actual.iter() { + let previously = self.reported_guest_fullscreen.get(pane_id).copied(); + if previously != Some(*is_fullscreen) { + if let PaneId::Terminal(terminal_id) = pane_id { + updates.push((*terminal_id, *is_fullscreen)); + } + self.reported_guest_fullscreen + .insert(*pane_id, *is_fullscreen); + } + } + self.reported_guest_fullscreen + .retain(|pane_id, _| actual.contains_key(pane_id)); + for (pane_id, is_fullscreen) in actual.iter() { + if !is_fullscreen { + self.downgrade_zoom_choice_to_descend(*pane_id); + } + } + for (terminal_id, fullscreen) in updates { + let message = NestedSessionMessage::FullscreenState { fullscreen }; + let _ = self + .bus + .senders + .send_to_pty_writer(PtyWriteInstruction::Write( + nested_session::encode_frame(&message), + terminal_id, + None, + )); + } + } + + fn clients_with_pane_focused(&self, pane_id: PaneId) -> Vec<ClientId> { + let mut clients = vec![]; + for (client_id, tab_id) in self.active_tab_ids.iter() { + if let Some(focused) = self + .tabs + .get(tab_id) + .and_then(|t| t.get_active_pane_id(*client_id)) + { + if focused == pane_id { + clients.push(*client_id); + } + } + } + clients + } + + fn bubble_focus_to_host(&self, client_id: ClientId, direction: Direction) { + let payload = nested_session::encode_payload(&NestedSessionMessage::FocusHost { + direction: Some(direction), + }); + let _ = self + .bus + .senders + .send_to_server(ServerInstruction::EmitNestedSessionFrameToClient( + client_id, payload, + )); + } + + pub fn handle_nested_session_message_from_host( + &mut self, + client_id: ClientId, + message: NestedSessionMessage, + ) { + match message { + NestedSessionMessage::AnnounceAck { + ancestry, + descend_keys, + .. + } => { + log::info!( + "nested session handshake complete: this session is nested inside ancestry {:?}", + ancestry + ); + self.nested_ancestry = ancestry; + self.nested_via_client_id = Some(client_id); + self.host_descend_keys = descend_keys; + self.update_all_clients_nesting_mode_info(); + self.broadcast_ancestry_update_to_guests(); + let payload = + nested_session::encode_payload(&NestedSessionMessage::ShortcutUpdate { + ascend_keys: self.own_ascend_shortcut(), + descend_keys: vec![], + }); + let _ = self.bus.senders.send_to_server( + ServerInstruction::EmitNestedSessionFrameToClient(client_id, payload), + ); + }, + NestedSessionMessage::ShortcutUpdate { descend_keys, .. } => { + if self.host_descend_keys != descend_keys { + self.host_descend_keys = descend_keys; + self.update_all_clients_nesting_mode_info(); + } + }, + NestedSessionMessage::FullscreenState { fullscreen } => { + self.host_fullscreen = fullscreen; + self.own_fullscreen_requested = fullscreen; + self.update_all_clients_nesting_mode_info(); + }, + NestedSessionMessage::AncestryUpdate { ancestry } => { + self.nested_ancestry = ancestry; + self.update_all_clients_nesting_mode_info(); + self.broadcast_ancestry_update_to_guests(); + }, + NestedSessionMessage::FocusGained { + from_direction: Some(direction), + } => { + self.set_host_descended(true); + let old_pane_id = self.get_active_pane_id(&client_id); + if let Ok(tab) = self.get_active_tab_mut(client_id) { + tab.focus_pane_on_edge(direction, client_id); + } + let new_pane_id = self.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + self.report_key_passthrough_state_with_direction( + client_id, + old, + new, + Some(direction), + ); + } + let _ = self.render(None); + let _ = self.log_and_report_session_state(); + }, + NestedSessionMessage::FocusGained { + from_direction: None, + } => { + self.set_host_descended(true); + let _ = self.render(None); + }, + NestedSessionMessage::FocusLost => { + self.set_host_descended(false); + let _ = self.render(None); + }, + other => { + log::debug!( + "dropping unsupported nested session message from host: {:?}", + other + ); + }, + } + } + + pub fn handle_nested_guest_ping_tick(&mut self, pane_id: PaneId) { + use crate::nested_guest::GuestPingTickAction; + match self.nested_guest_tracker.on_tick(pane_id, Instant::now()) { + GuestPingTickAction::SendPing => { + if let PaneId::Terminal(terminal_id) = pane_id { + let _ = self + .bus + .senders + .send_to_pty_writer(PtyWriteInstruction::Write( + nested_session::encode_frame(&NestedSessionMessage::Ping), + terminal_id, + None, + )); + log::debug!("pinged nested session guest in pane {:?}", pane_id); + } + }, + GuestPingTickAction::Suspended => { + log::info!( + "nested session guest in pane {:?} missed the pong timeout, suspending until it re-announces", + pane_id + ); + self.suspend_nested_guest(pane_id); + }, + GuestPingTickAction::Unknown => { + let _ = self + .bus + .senders + .send_to_background_jobs(BackgroundJob::StopNestedGuestPing(pane_id)); + }, + } + } + /// Build a reply for `query` from whatever host state Zellij has /// cached, or an empty `Vec` if nothing relevant is cached. The /// classification happened at intercept time (Grid produces a @@ -2796,14 +3861,9 @@ impl Screen { }, // Should not reach here: ColorPaletteMode short-circuits in // `forward_host_query` before any cache-fallback path runs. - // The Contour spec only defines `;1` and `;2`; if the host - // mode is unknown there is no compliant reply, so return - // empty bytes (the existing convention for "no synthesis - // possible"). - HostQuery::ColorPaletteMode => match self.host_terminal_theme_mode { - Some(HostTerminalThemeMode::Dark) => b"\x1b[?997;1n".to_vec(), - Some(HostTerminalThemeMode::Light) => b"\x1b[?997;2n".to_vec(), - None => Vec::new(), + HostQuery::ColorPaletteMode => match self.effective_host_terminal_theme_mode() { + HostTerminalThemeMode::Dark => b"\x1b[?997;1n".to_vec(), + HostTerminalThemeMode::Light => b"\x1b[?997;2n".to_vec(), }, } } @@ -2815,6 +3875,8 @@ impl Screen { // when this job decides to render, it sends back the ScreenInstruction::RenderToClients // message, triggering our render_to_clients method which does the actual rendering + self.sync_nested_guest_fullscreen_state(); + let _ = self .bus .senders @@ -3363,6 +4425,9 @@ impl Screen { for (client_id, mode_info) in &self.mode_info { tab.change_mode_info(mode_info.clone(), *client_id); } + for dimmed_client_id in &self.dimmed_clients { + tab.set_client_dimmed(*dimmed_client_id, true); + } self.tabs.insert(tab_id, tab); Ok(()) } @@ -3404,6 +4469,25 @@ impl Screen { }; let err_context = || format!("failed to apply layout for tab {tab_id:?}",); + let passthrough_affected_client_ids: Vec<ClientId> = if should_change_client_focus { + if self.session_is_mirrored { + self.connected_clients + .borrow() + .iter() + .map(|(c, _i)| *c) + .collect() + } else { + vec![client_id] + } + } else { + vec![] + }; + let passthrough_old_focused_panes: Vec<(ClientId, Option<PaneId>)> = + passthrough_affected_client_ids + .iter() + .map(|c| (*c, self.get_active_pane_id(c))) + .collect(); + // move the relevant clients out of the current tab and place them in the new one let drained_clients = if should_change_client_focus { if self.session_is_mirrored { @@ -3489,6 +4573,13 @@ impl Screen { self.recompute_tab_size(tab_id).with_context(err_context)?; + for (affected_client_id, old_pane_id) in passthrough_old_focused_panes { + let new_pane_id = self.get_active_pane_id(&affected_client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + self.report_key_passthrough_state(affected_client_id, old, new); + } + } + self.log_and_report_session_state() .and_then(|_| self.render(None)) .with_context(err_context) @@ -3535,12 +4626,39 @@ impl Screen { .with_context(|| err_context(tab_index))?; self.recompute_tab_size(tab_index) .with_context(|| err_context(tab_index))?; + if !self.nested_ancestry.is_empty() || !self.host_descend_keys.is_empty() { + self.update_all_clients_nesting_mode_info(); + } Ok(()) } pub fn remove_client(&mut self, client_id: ClientId) -> Result<()> { let err_context = || format!("failed to remove client {client_id}"); + self.set_client_dimmed(client_id, false, None); + let passthrough_panes: Vec<PaneId> = self + .nested_guest_choices + .iter() + .filter(|((choice_client_id, _), choice)| { + *choice_client_id == client_id && choice.enters_passthrough() + }) + .map(|((_, choice_pane_id), _)| *choice_pane_id) + .collect(); + for pane_id in passthrough_panes { + let _ = self + .bus + .senders + .send_to_server(ServerInstruction::KeyPassthroughChanged( + client_id, pane_id, pane_id, false, None, true, + )); + } + self.nested_guest_choices + .retain(|(choice_client_id, _), _| *choice_client_id != client_id); + for tab in self.tabs.values_mut() { + tab.clear_guest_modal_for_client_on_all_panes(client_id); + tab.clear_guest_choice_indicator_for_client_on_all_panes(client_id); + } + // If the followed client disconnected, find the next regular client if Some(client_id) == self.followed_client_id { // Try to find another regular (non-watcher) client @@ -3604,8 +4722,16 @@ impl Screen { self.close_tab_by_id(tab_id).with_context(err_context)?; } } - self.log_and_report_session_state() - .with_context(err_context) + if self.nested_via_client_id == Some(client_id) { + self.nested_via_client_id = None; + self.nested_ancestry = vec![]; + self.host_descended = false; + self.host_descend_keys = vec![]; + self.update_all_clients_nesting_mode_info(); + self.broadcast_ancestry_update_to_guests(); + } + self.log_and_report_session_state().non_fatal(); + Ok(()) } pub fn add_watcher_client(&mut self, client_id: ClientId) -> Result<()> { @@ -4404,8 +5530,13 @@ impl Screen { .move_focus_left(client_id) .and_then(|success| { if !success { - self.switch_tab_prev(Some(Direction::Left), true, client_id) - .context("failed to move focus to previous tab") + if self.tabs.len() == 1 { + self.bubble_focus_to_host(client_id, Direction::Left); + Ok(()) + } else { + self.switch_tab_prev(Some(Direction::Left), true, client_id) + .context("failed to move focus to previous tab") + } } else { Ok(()) } @@ -4440,8 +5571,13 @@ impl Screen { .move_focus_right(client_id) .and_then(|success| { if !success { - self.switch_tab_next(Some(Direction::Right), true, client_id) - .context("failed to move focus to next tab") + if self.tabs.len() == 1 { + self.bubble_focus_to_host(client_id, Direction::Right); + Ok(()) + } else { + self.switch_tab_next(Some(Direction::Right), true, client_id) + .context("failed to move focus to next tab") + } } else { Ok(()) } @@ -5052,6 +6188,7 @@ impl Screen { visual_bell: bool, focus_follows_mouse: bool, mouse_click_through: bool, + nested_session_handling: NestedSessionHandling, client_id: ClientId, ) -> Result<()> { let should_support_arrow_fonts = !simplified_ui; @@ -5078,6 +6215,7 @@ impl Screen { self.visual_bell = visual_bell; self.focus_follows_mouse = focus_follows_mouse; self.mouse_click_through = mouse_click_through; + self.nested_session_handling = nested_session_handling; self.default_mode_info .update_arrow_fonts(should_support_arrow_fonts); self.default_mode_info @@ -5137,6 +6275,7 @@ impl Screen { for tab in self.tabs.values_mut() { tab.update_input_modes()?; } + self.broadcast_nested_shortcuts(); Ok(()) } /// Apply a host-reported color-palette theme mode (CSI 2031 / DSR 997). @@ -5442,6 +6581,11 @@ impl Screen { && active_pane_id_before != active_pane_id_after { self.clear_bell_for_focused_pane(client_id); + if let (Some(old), Some(new)) = + (active_pane_id_before, active_pane_id_after) + { + self.report_key_passthrough_state(client_id, old, new); + } } should_render = true; } @@ -6241,6 +7385,7 @@ pub(crate) fn screen_thread_main( let visual_bell = config_options.visual_bell.unwrap_or(true); let focus_follows_mouse = config_options.focus_follows_mouse.unwrap_or(false); let mouse_click_through = config_options.mouse_click_through.unwrap_or(false); + let nested_session_handling = config_options.nested_session_handling.unwrap_or_default(); let thread_senders = bus.senders.clone(); let mut screen = Screen::new( @@ -6286,6 +7431,7 @@ pub(crate) fn screen_thread_main( mouse_click_through, web_server_ip, web_server_port, + nested_session_handling, ); screen.host_theme_dark_styling = host_theme_dark_styling; screen.host_theme_light_styling = host_theme_light_styling; @@ -6734,16 +7880,17 @@ pub(crate) fn screen_thread_main( screen.render(None)?; screen.log_and_report_session_state()?; }, - ScreenInstruction::SwitchFocus( - client_id, - _completion_tx, // the action ends here, dropping this will release anything - // waiting for it - ) => { + ScreenInstruction::SwitchFocus(client_id, _completion_tx) => { + let old_pane_id = screen.get_active_pane_id(&client_id); active_tab_and_connected_client_id!( screen, client_id, |tab: &mut Tab, client_id: ClientId| tab.focus_next_pane(client_id) ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + screen.report_key_passthrough_state(client_id, old, new); + } screen.render(None)?; screen.log_and_report_session_state()?; }, @@ -6755,11 +7902,16 @@ pub(crate) fn screen_thread_main( c.set_error_message("No connected clients to change focus for".to_string()); } } else { + let old_pane_id = screen.get_active_pane_id(&client_id); active_tab_and_connected_client_id!( screen, client_id, |tab: &mut Tab, client_id: ClientId| tab.focus_next_pane(client_id) ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + screen.report_key_passthrough_state(client_id, old, new); + } screen.render(None)?; } }, @@ -6771,25 +7923,31 @@ pub(crate) fn screen_thread_main( c.set_error_message("No connected clients to change focus for".to_string()); } } else { + let old_pane_id = screen.get_active_pane_id(&client_id); active_tab_and_connected_client_id!( screen, client_id, |tab: &mut Tab, client_id: ClientId| tab.focus_previous_pane(client_id) ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + screen.report_key_passthrough_state(client_id, old, new); + } screen.render(None)?; screen.log_and_report_session_state()?; } }, - ScreenInstruction::FocusLastPane( - client_id, - _completion_tx, // the action ends here, dropping this will release anything - // waiting for it - ) => { + ScreenInstruction::FocusLastPane(client_id, _completion_tx) => { + let old_pane_id = screen.get_active_pane_id(&client_id); active_tab_and_connected_client_id!( screen, client_id, |tab: &mut Tab, client_id: ClientId| tab.focus_last_pane(client_id) ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + screen.report_key_passthrough_state(client_id, old, new); + } screen.render(None)?; screen.log_and_report_session_state()?; }, @@ -6801,12 +7959,26 @@ pub(crate) fn screen_thread_main( c.set_error_message("No connected clients to move focus for".to_string()); } } else { + let old_pane_id = screen.get_active_pane_id(&client_id); active_tab_and_connected_client_id!( screen, client_id, |tab: &mut Tab, client_id: ClientId| tab.move_focus_left(client_id), ? ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + if old == new { + screen.bubble_focus_to_host(client_id, Direction::Left); + } else { + screen.report_key_passthrough_state_with_direction( + client_id, + old, + new, + Some(Direction::Left), + ); + } + } screen.clear_bell_for_focused_pane(client_id); screen.add_active_pane_to_group_if_marking(&client_id); screen.render(None)?; @@ -6821,7 +7993,19 @@ pub(crate) fn screen_thread_main( c.set_error_message("No connected clients to move focus for".to_string()); } } else { + let old_pane_id = screen.get_active_pane_id(&client_id); screen.move_focus_left_or_previous_tab(client_id)?; + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + if old != new { + screen.report_key_passthrough_state_with_direction( + client_id, + old, + new, + Some(Direction::Left), + ); + } + } screen.clear_bell_for_focused_pane(client_id); screen.add_active_pane_to_group_if_marking(&client_id); screen.render(None)?; @@ -6836,12 +8020,26 @@ pub(crate) fn screen_thread_main( c.set_error_message("No connected clients to move focus for".to_string()); } } else { + let old_pane_id = screen.get_active_pane_id(&client_id); active_tab_and_connected_client_id!( screen, client_id, |tab: &mut Tab, client_id: ClientId| tab.move_focus_down(client_id), ? ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + if old == new { + screen.bubble_focus_to_host(client_id, Direction::Down); + } else { + screen.report_key_passthrough_state_with_direction( + client_id, + old, + new, + Some(Direction::Down), + ); + } + } screen.clear_bell_for_focused_pane(client_id); screen.add_active_pane_to_group_if_marking(&client_id); screen.render(None)?; @@ -6856,12 +8054,26 @@ pub(crate) fn screen_thread_main( c.set_error_message("No connected clients to move focus for".to_string()); } } else { + let old_pane_id = screen.get_active_pane_id(&client_id); active_tab_and_connected_client_id!( screen, client_id, |tab: &mut Tab, client_id: ClientId| tab.move_focus_right(client_id), ? ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + if old == new { + screen.bubble_focus_to_host(client_id, Direction::Right); + } else { + screen.report_key_passthrough_state_with_direction( + client_id, + old, + new, + Some(Direction::Right), + ); + } + } screen.clear_bell_for_focused_pane(client_id); screen.add_active_pane_to_group_if_marking(&client_id); screen.render(None)?; @@ -6876,7 +8088,19 @@ pub(crate) fn screen_thread_main( c.set_error_message("No connected clients to move focus for".to_string()); } } else { + let old_pane_id = screen.get_active_pane_id(&client_id); screen.move_focus_right_or_next_tab(client_id)?; + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + if old != new { + screen.report_key_passthrough_state_with_direction( + client_id, + old, + new, + Some(Direction::Right), + ); + } + } screen.clear_bell_for_focused_pane(client_id); screen.add_active_pane_to_group_if_marking(&client_id); screen.render(None)?; @@ -6891,12 +8115,26 @@ pub(crate) fn screen_thread_main( c.set_error_message("No connected clients to move focus for".to_string()); } } else { + let old_pane_id = screen.get_active_pane_id(&client_id); active_tab_and_connected_client_id!( screen, client_id, |tab: &mut Tab, client_id: ClientId| tab.move_focus_up(client_id), ? ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + if old == new { + screen.bubble_focus_to_host(client_id, Direction::Up); + } else { + screen.report_key_passthrough_state_with_direction( + client_id, + old, + new, + Some(Direction::Up), + ); + } + } screen.clear_bell_for_focused_pane(client_id); screen.add_active_pane_to_group_if_marking(&client_id); screen.render(None)?; @@ -7432,11 +8670,16 @@ pub(crate) fn screen_thread_main( screen.render(None)?; }, ScreenInstruction::CloseFocusedPane(client_id, completion_tx) => { + let old_pane_id = screen.get_active_pane_id(&client_id); active_tab_and_connected_client_id!( screen, client_id, |tab: &mut Tab, client_id: ClientId| tab.close_focused_pane(client_id, completion_tx), ? ); + let new_pane_id = screen.get_active_pane_id(&client_id); + if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) { + screen.report_key_passthrough_state(client_id, old, new); + } screen.render(None)?; screen.log_and_report_session_state()?; }, @@ -7528,6 +8771,8 @@ pub(crate) fn screen_thread_main( }, } + screen.clear_nested_guest(id); + // Clean up PTY-side resources (async reader task, child PID mapping, // terminal_id_to_raw_fd entry). This is needed because the natural // child exit path (quit_cb) only sends ScreenInstruction::ClosePane @@ -7538,7 +8783,7 @@ pub(crate) fn screen_thread_main( .senders .send_to_pty(PtyInstruction::ClosePane(id, None)); - screen.log_and_report_session_state()?; + screen.log_and_report_session_state().non_fatal(); screen.retain_only_existing_panes_in_pane_groups(); }, ScreenInstruction::HoldPane(id, exit_status, run_command) => { @@ -7599,6 +8844,16 @@ pub(crate) fn screen_thread_main( screen.render(None)?; screen.log_and_report_session_state()?; }, + ScreenInstruction::ToggleActiveTerminalNoUiFullscreen(client_id, _completion_tx) => { + active_tab_and_connected_client_id!( + screen, + client_id, + |tab: &mut Tab, client_id: ClientId| tab + .toggle_active_pane_no_ui_fullscreen(client_id) + ); + screen.render(None)?; + screen.log_and_report_session_state()?; + }, ScreenInstruction::TogglePaneFrames( _completion_tx, // the action ends here, dropping this will release anything // waiting for it @@ -7970,6 +9225,22 @@ pub(crate) fn screen_thread_main( ScreenInstruction::ForwardHostQuery { pane_id, query } => { screen.forward_host_query(pane_id, query); }, + ScreenInstruction::NestedSessionMessageFromPane { pane_id, message } => { + screen.handle_nested_session_message_from_pane(pane_id, message); + }, + ScreenInstruction::NestedGuestPingTick { pane_id } => { + screen.handle_nested_guest_ping_tick(pane_id); + }, + ScreenInstruction::NestedSessionMessageFromHost { client_id, message } => { + screen.handle_nested_session_message_from_host(client_id, message); + }, + ScreenInstruction::GuestModalChoice { + client_id, + pane_id, + outcome, + } => { + screen.handle_guest_modal_choice(client_id, pane_id, outcome); + }, ScreenInstruction::ForwardedReplyFromHost { token, reply_bytes } => { screen.handle_forwarded_reply_from_host(token, reply_bytes)?; // The handler's replay of `pending_pty_input` mutates the @@ -8092,6 +9363,11 @@ pub(crate) fn screen_thread_main( } else if let Some(tab_position_to_focus) = tab_position_to_focus { screen.go_to_tab(tab_position_to_focus, client_id)?; } + screen.show_guest_modals_for_new_client(client_id); + let focused_pane_id = screen.get_active_pane_id(&client_id); + if let Some(focused) = focused_pane_id { + screen.report_key_passthrough_state(client_id, focused, focused); + } for event in pending_events_waiting_for_client.drain(..) { screen.bus.senders.send_to_screen(event).non_fatal(); } @@ -8114,9 +9390,9 @@ pub(crate) fn screen_thread_main( screen.render(None)?; }, ScreenInstruction::RemoveClient(client_id) => { - screen.remove_client(client_id)?; - screen.log_and_report_session_state()?; - screen.render(None)?; + screen.remove_client(client_id).non_fatal(); + screen.log_and_report_session_state().non_fatal(); + screen.render(None).non_fatal(); }, ScreenInstruction::SuppressRenderUntilMobile(client_id) => { screen.mobile_render_gate.gate(client_id); @@ -9474,6 +10750,7 @@ pub(crate) fn screen_thread_main( visual_bell, focus_follows_mouse, mouse_click_through, + nested_session_handling, } => { screen.host_theme_dark_styling = host_theme_dark; screen.host_theme_light_styling = host_theme_light; @@ -9500,6 +10777,7 @@ pub(crate) fn screen_thread_main( visual_bell, focus_follows_mouse, mouse_click_through, + nested_session_handling, client_id, ) .non_fatal(); @@ -10464,6 +11742,26 @@ pub(crate) fn screen_thread_main( screen.render(None)?; screen.log_and_report_session_state()?; }, + ScreenInstruction::ToggleNoUiFullscreenWithPaneId(pane_id, mut _completion_tx) => { + let all_tabs = screen.get_tabs_mut(); + let mut found = false; + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.toggle_no_ui_fullscreen_by_pane_id(pane_id); + found = true; + break; + } + } + if !found { + log::error!("Pane with id {:?} not found", pane_id); + if let Some(ref mut c) = _completion_tx { + c.set_exit_status(1); + c.set_error_message(format!("Pane with id {:?} not found", pane_id)); + } + } + screen.render(None)?; + screen.log_and_report_session_state()?; + }, ScreenInstruction::TogglePaneEmbedOrFloatingWithPaneId(pane_id, mut _completion_tx) => { let all_tabs = screen.get_tabs_mut(); let mut found = false; @@ -10688,6 +11986,28 @@ pub(crate) fn screen_thread_main( ScreenInstruction::SetShadowFocus(client_id, pane_id) => { screen.set_shadow_focus(client_id, pane_id)?; }, + ScreenInstruction::FocusHostSession(client_id, _completion_tx) => { + let payload = nested_session::encode_payload(&NestedSessionMessage::FocusHost { + direction: None, + }); + let _ = screen.bus.senders.send_to_server( + ServerInstruction::EmitNestedSessionFrameToClient(client_id, payload), + ); + }, + ScreenInstruction::FocusGuestSession(client_id, _completion_tx) => { + screen.focus_guest_session(client_id); + }, + ScreenInstruction::ToggleHostFullscreen(client_id, _completion_tx) => { + screen.own_fullscreen_requested = !screen.own_fullscreen_requested; + let fullscreen = screen.own_fullscreen_requested; + let payload = + nested_session::encode_payload(&NestedSessionMessage::ToggleHostFullscreen { + fullscreen, + }); + let _ = screen.bus.senders.send_to_server( + ServerInstruction::EmitNestedSessionFrameToClient(client_id, payload), + ); + }, } } Ok(()) diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index 26668f82..98b7054f 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -30,7 +30,7 @@ use zellij_utils::shared::clean_string_from_control_and_linebreak; use crate::background_jobs::BackgroundJob; use crate::pane_groups::PaneGroups; use crate::pty_writer::PtyWriteInstruction; -use crate::screen::{CopyOptions, ScreenInstruction}; +use crate::screen::{CopyOptions, GuestModalOutcome, ScreenInstruction}; use crate::ui::hint_text::{ held_hint_variants, hover_hint_variants, resize_hint_variants, HintExitStatus, }; @@ -48,6 +48,7 @@ use crate::{ output::{CharacterChunk, Output, SixelImageChunk}, panes::floating_panes::floating_pane_grid::half_size_middle_geom, panes::grid::namespace_notification_id, + panes::nested_session_modal::GuestModalShortcuts, panes::sixel::SixelImageStore, panes::{FloatingPanes, TiledPanes}, panes::{LinkHandler, PaneId, PluginPane, TerminalPane, EMPTY_TERMINAL_CHARACTER}, @@ -74,6 +75,7 @@ use zellij_utils::{ options::PaneFrameStyle, parse_keys, }, + nested_session::NestedSessionMessage, pane_size::{Dimension, Offset, PaneGeom, Size, SizeInPixels, Viewport}, }; @@ -243,6 +245,7 @@ pub(crate) struct Tab { web_clients_allowed: bool, web_sharing: WebSharing, mouse_hover_pane_id: HashMap<ClientId, PaneId>, + dimmed_clients: HashSet<ClientId>, plugin_hover_pane_id: HashMap<ClientId, PaneId>, mouse_last_pane_id: HashMap<ClientId, PaneId>, mouse_help_text_visible: HashMap<ClientId, bool>, @@ -459,6 +462,40 @@ pub trait Pane { // plugin panes have no such concept. vec![] } + fn drain_nested_session_messages(&mut self) -> Vec<NestedSessionMessage> { + vec![] + } + fn is_nested_guest(&self) -> bool { + false + } + fn set_is_nested_guest(&mut self, _is_nested_guest: bool) {} + fn set_guest_modal(&mut self, _client_ids: &[ClientId]) {} + fn clear_guest_modal(&mut self, _client_id: ClientId) {} + fn clear_all_guest_modals(&mut self) {} + fn guest_modal_selection(&self, _client_id: ClientId) -> Option<usize> { + None + } + fn has_guest_modal_for_any_client(&self) -> bool { + false + } + fn set_guest_choice_indicator( + &mut self, + _client_id: ClientId, + _indicator: Option<GuestChoiceIndicator>, + ) { + } + fn guest_choice_indicator(&self, _client_id: ClientId) -> Option<GuestChoiceIndicator> { + None + } + fn clear_all_guest_choice_indicators(&mut self) {} + fn set_guest_session_name(&mut self, _session_name: Option<String>) {} + fn guest_session_name(&self) -> Option<String> { + None + } + fn set_guest_modal_shortcuts(&mut self, _shortcuts: GuestModalShortcuts) {} + fn guest_modal_shortcuts(&self) -> GuestModalShortcuts { + GuestModalShortcuts::default() + } /// Mark this pane as awaiting a host-terminal reply for a forward /// it just dispatched. While paused, vte byte feeding is buffered /// so that the eventual host reply lands on the pane's stdin in @@ -740,6 +777,12 @@ pub trait Pane { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GuestChoiceIndicator { + Descended, + Dismissed, +} + #[derive(Clone, Debug)] pub enum AdjustedInput { WriteBytesToTerminal(Vec<u8>), @@ -748,6 +791,9 @@ pub enum AdjustedInput { CloseThisPane, DropToShellInThisPane { working_dir: Option<PathBuf> }, WriteKeyToPlugin(KeyWithModifier), + GuestModalSelectionChanged, + GuestModalZoom, + GuestModalDescend, } pub fn get_next_terminal_position( tiled_panes: &TiledPanes, @@ -839,6 +885,7 @@ impl Tab { let mode_info = Rc::new(RefCell::new(HashMap::new())); let reserved_top_rows: Rc<RefCell<HashMap<PaneId, usize>>> = Rc::new(RefCell::new(HashMap::new())); + let fullscreen_covers_ui = Rc::new(RefCell::new(false)); let tiled_panes = TiledPanes::new( display_area.clone(), @@ -850,6 +897,7 @@ impl Tab { stacked_resize.clone(), stacked_pane_list.clone(), reserved_top_rows.clone(), + fullscreen_covers_ui.clone(), session_is_mirrored, pane_frame_style, default_mode_info.clone(), @@ -864,6 +912,8 @@ impl Tab { connected_clients_in_app.clone(), mode_info.clone(), character_cell_size.clone(), + fullscreen_covers_ui.clone(), + pane_frame_style, session_is_mirrored, default_mode_info.clone(), style, @@ -954,9 +1004,20 @@ impl Tab { tab_has_pending_bell: false, tab_bell_flash: false, tab_bell_ring: false, + dimmed_clients: HashSet::new(), } } + pub fn set_client_dimmed(&mut self, client_id: ClientId, dimmed: bool) { + if dimmed { + self.dimmed_clients.insert(client_id); + } else { + self.dimmed_clients.remove(&client_id); + } + self.tiled_panes.set_client_dimmed(client_id, dimmed); + self.floating_panes.set_client_dimmed(client_id, dimmed); + } + pub fn stacked_pane_list_is_active(&self) -> bool { *self.stacked_pane_list.borrow() } @@ -1587,6 +1648,7 @@ impl Tab { false, false, self.mouse_scroll_resize, + self.dimmed_clients.clone(), ); pane_contents_and_ui.set_frame_geom_override(Some(header_geom)); pane_contents_and_ui.set_stack_list_entry( @@ -2179,6 +2241,7 @@ impl Tab { self.mouse_help_text_visible.remove(&client_id); self.mouse_last_pane_id.remove(&client_id); self.last_mouse_activity_time.remove(&client_id); + self.set_client_dimmed(client_id, false); self.set_force_render(); } pub fn drain_connected_clients( @@ -3693,24 +3756,6 @@ impl Tab { .values() .any(|s_p| s_p.1.pid() == PaneId::Terminal(pid)) } - /// Whether the pane with `pane_id` (if owned by this tab) is - /// currently waiting for a host-forward reply. Used by the - /// `ColorPaletteMode` short-circuit to decide whether an empty - /// "host mode unknown" answer needs to drive an unblock cycle: - /// if the pane is not paused, no work is owed. - pub fn is_pane_forward_paused(&self, pane_id: PaneId) -> bool { - self.tiled_panes - .get_pane(pane_id) - .or_else(|| self.floating_panes.get_pane(pane_id)) - .or_else(|| { - self.suppressed_panes - .values() - .find(|s_p| s_p.1.pid() == pane_id) - .map(|s_p| &s_p.1) - }) - .map(|p| p.is_forward_paused()) - .unwrap_or(false) - } pub fn has_plugin(&self, plugin_id: u32) -> bool { self.tiled_panes.panes_contain(&PaneId::Plugin(plugin_id)) || self @@ -3748,6 +3793,189 @@ 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_pane_is_nested_guest(&mut self, pane_id: PaneId, is_nested_guest: bool) { + if let Some(pane) = self + .tiled_panes + .get_pane_mut(pane_id) + .or_else(|| self.floating_panes.get_pane_mut(pane_id)) + .or_else(|| { + self.suppressed_panes + .values_mut() + .find(|s_p| s_p.1.pid() == pane_id) + .map(|s_p| &mut s_p.1) + }) + { + pane.set_is_nested_guest(is_nested_guest); + } + } + pub fn is_pane_nested_guest(&self, pane_id: PaneId) -> bool { + if let Some(pane) = self + .tiled_panes + .get_pane(pane_id) + .or_else(|| self.floating_panes.get_pane(pane_id)) + .or_else(|| { + self.suppressed_panes + .values() + .find(|s_p| s_p.1.pid() == pane_id) + .map(|s_p| &s_p.1) + }) + { + pane.is_nested_guest() + } else { + false + } + } + pub fn nested_guest_pane_ids(&self) -> Vec<PaneId> { + self.get_all_pane_ids() + .into_iter() + .filter(|pane_id| self.is_pane_nested_guest(*pane_id)) + .collect() + } + pub fn set_guest_modal_on_pane(&mut self, pane_id: PaneId, client_ids: &[ClientId]) { + if let Some(pane) = self + .tiled_panes + .get_pane_mut(pane_id) + .or_else(|| self.floating_panes.get_pane_mut(pane_id)) + .or_else(|| { + self.suppressed_panes + .values_mut() + .find(|s_p| s_p.1.pid() == pane_id) + .map(|s_p| &mut s_p.1) + }) + { + pane.set_guest_modal(client_ids); + } + } + pub fn clear_guest_modal_on_pane(&mut self, pane_id: PaneId, client_id: ClientId) { + if let Some(pane) = self + .tiled_panes + .get_pane_mut(pane_id) + .or_else(|| self.floating_panes.get_pane_mut(pane_id)) + .or_else(|| { + self.suppressed_panes + .values_mut() + .find(|s_p| s_p.1.pid() == pane_id) + .map(|s_p| &mut s_p.1) + }) + { + pane.clear_guest_modal(client_id); + } + } + pub fn clear_all_guest_modals_on_pane(&mut self, pane_id: PaneId) { + if let Some(pane) = self + .tiled_panes + .get_pane_mut(pane_id) + .or_else(|| self.floating_panes.get_pane_mut(pane_id)) + .or_else(|| { + self.suppressed_panes + .values_mut() + .find(|s_p| s_p.1.pid() == pane_id) + .map(|s_p| &mut s_p.1) + }) + { + pane.clear_all_guest_modals(); + } + } + pub fn set_guest_choice_indicator_on_pane( + &mut self, + pane_id: PaneId, + client_id: ClientId, + indicator: Option<GuestChoiceIndicator>, + ) { + if let Some(pane) = self + .tiled_panes + .get_pane_mut(pane_id) + .or_else(|| self.floating_panes.get_pane_mut(pane_id)) + .or_else(|| { + self.suppressed_panes + .values_mut() + .find(|s_p| s_p.1.pid() == pane_id) + .map(|s_p| &mut s_p.1) + }) + { + pane.set_guest_choice_indicator(client_id, indicator); + } + } + pub fn clear_all_guest_choice_indicators_on_pane(&mut self, pane_id: PaneId) { + if let Some(pane) = self + .tiled_panes + .get_pane_mut(pane_id) + .or_else(|| self.floating_panes.get_pane_mut(pane_id)) + .or_else(|| { + self.suppressed_panes + .values_mut() + .find(|s_p| s_p.1.pid() == pane_id) + .map(|s_p| &mut s_p.1) + }) + { + pane.clear_all_guest_choice_indicators(); + } + } + pub fn pane_has_guest_modal_for_client(&self, pane_id: PaneId, client_id: ClientId) -> bool { + if let Some(pane) = self + .tiled_panes + .get_pane(pane_id) + .or_else(|| self.floating_panes.get_pane(pane_id)) + .or_else(|| { + self.suppressed_panes + .values() + .find(|s_p| s_p.1.pid() == pane_id) + .map(|s_p| &s_p.1) + }) + { + pane.guest_modal_selection(client_id).is_some() + } else { + false + } + } + pub fn clear_guest_modal_for_client_on_all_panes(&mut self, client_id: ClientId) { + for pane_id in self.get_all_pane_ids() { + self.clear_guest_modal_on_pane(pane_id, client_id); + } + } + pub fn clear_guest_choice_indicator_for_client_on_all_panes(&mut self, client_id: ClientId) { + for pane_id in self.get_all_pane_ids() { + self.set_guest_choice_indicator_on_pane(pane_id, client_id, None); + } + } + pub fn set_guest_session_name_on_pane( + &mut self, + pane_id: PaneId, + session_name: Option<String>, + ) { + if let Some(pane) = self + .tiled_panes + .get_pane_mut(pane_id) + .or_else(|| self.floating_panes.get_pane_mut(pane_id)) + .or_else(|| { + self.suppressed_panes + .values_mut() + .find(|s_p| s_p.1.pid() == pane_id) + .map(|s_p| &mut s_p.1) + }) + { + pane.set_guest_session_name(session_name); + } + } + pub fn set_guest_modal_shortcuts_on_pane( + &mut self, + pane_id: PaneId, + shortcuts: GuestModalShortcuts, + ) { + if let Some(pane) = self + .tiled_panes + .get_pane_mut(pane_id) + .or_else(|| self.floating_panes.get_pane_mut(pane_id)) + .or_else(|| { + self.suppressed_panes + .values_mut() + .find(|s_p| s_p.1.pid() == pane_id) + .map(|s_p| &mut s_p.1) + }) + { + pane.set_guest_modal_shortcuts(shortcuts); + } + } 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); @@ -3924,6 +4152,7 @@ impl Tab { if !forwarded_queries.is_empty() { terminal_output.arm_forward_pause(); } + let nested_session_messages = terminal_output.drain_nested_session_messages(); let clipboard_update = terminal_output.drain_clipboard_update(); let desktop_notifications = terminal_output.drain_desktop_notifications(); let osc7_cwd = terminal_output.drain_osc7_cwd(); @@ -3939,6 +4168,14 @@ impl Tab { query, }); } + for message in nested_session_messages { + let _ = + self.senders + .send_to_screen(ScreenInstruction::NestedSessionMessageFromPane { + pane_id: PaneId::Terminal(pid), + message, + }); + } if let Some(string) = clipboard_update { self.write_selection_to_clipboard(&string) .with_context(err_context)?; @@ -4172,6 +4409,31 @@ impl Tab { .with_context(err_context)?; should_update_ui = true; }, + Some(AdjustedInput::GuestModalSelectionChanged) => { + should_update_ui = true; + }, + Some(AdjustedInput::GuestModalZoom) => { + if let Some(client_id) = client_id { + let _ = + self.senders + .send_to_screen(ScreenInstruction::GuestModalChoice { + client_id, + pane_id: PaneId::Terminal(active_terminal_id), + outcome: GuestModalOutcome::Zoom, + }); + } + }, + Some(AdjustedInput::GuestModalDescend) => { + if let Some(client_id) = client_id { + let _ = + self.senders + .send_to_screen(ScreenInstruction::GuestModalChoice { + client_id, + pane_id: PaneId::Terminal(active_terminal_id), + outcome: GuestModalOutcome::Descend, + }); + } + }, Some(_) => {}, None => {}, } @@ -4289,6 +4551,14 @@ impl Tab { ) } pub fn toggle_active_pane_fullscreen(&mut self, client_id: ClientId) { + if self.floating_panes.fullscreen_is_active() + || (self.floating_panes.panes_are_visible() && self.floating_panes.has_active_panes()) + { + self.floating_panes.toggle_active_pane_fullscreen(client_id); + self.set_force_render(); + self.set_should_clear_display_before_rendering(); + return; + } if self.floating_panes.panes_are_visible() { return; } @@ -4296,6 +4566,12 @@ impl Tab { self.tiled_panes.toggle_active_pane_fullscreen(client_id); } pub fn toggle_pane_fullscreen(&mut self, pane_id: PaneId) { + if self.floating_panes.panes_contain(&pane_id) { + self.floating_panes.toggle_pane_fullscreen(pane_id); + self.set_force_render(); + self.set_should_clear_display_before_rendering(); + return; + } if self.pane_is_hidden_stack_list_member(&pane_id) { self.make_hidden_stack_list_member_visible(pane_id); } @@ -4306,11 +4582,50 @@ impl Tab { log::error!("No tiled pane with id: {:?} found", pane_id); } } + pub fn toggle_active_pane_no_ui_fullscreen(&mut self, client_id: ClientId) { + if self.floating_panes.fullscreen_is_active() + || (self.floating_panes.panes_are_visible() && self.floating_panes.has_active_panes()) + { + self.floating_panes + .toggle_active_pane_no_ui_fullscreen(client_id); + self.set_force_render(); + self.set_should_clear_display_before_rendering(); + return; + } + if self.floating_panes.panes_are_visible() { + return; + } + self.dissolve_stack_lists_for_classic_mutation(); + self.tiled_panes + .toggle_active_pane_no_ui_fullscreen(client_id); + } + pub fn toggle_pane_no_ui_fullscreen(&mut self, pane_id: PaneId) { + if self.floating_panes.panes_contain(&pane_id) { + self.floating_panes.toggle_pane_no_ui_fullscreen(pane_id); + self.set_force_render(); + self.set_should_clear_display_before_rendering(); + return; + } + if self.pane_is_hidden_stack_list_member(&pane_id) { + self.make_hidden_stack_list_member_visible(pane_id); + } + self.dissolve_stack_lists_for_classic_mutation(); + if self.tiled_panes.panes_contain(&pane_id) { + self.tiled_panes.toggle_pane_no_ui_fullscreen(pane_id); + } else { + log::error!("No tiled pane with id: {:?} found", pane_id); + } + } pub fn is_fullscreen_active(&self) -> bool { - self.tiled_panes.fullscreen_is_active() + self.tiled_panes.fullscreen_is_active() || self.floating_panes.fullscreen_is_active() + } + pub fn fullscreen_covers_ui(&self) -> bool { + self.tiled_panes.fullscreen_covers_ui() || self.floating_panes.fullscreen_covers_ui() } pub fn fullscreen_pane_id(&self) -> Option<PaneId> { - self.tiled_panes.fullscreen_pane_id() + self.tiled_panes + .fullscreen_pane_id() + .or_else(|| self.floating_panes.fullscreen_pane_id()) } pub fn are_floating_panes_visible(&self) -> bool { self.floating_panes.panes_are_visible() @@ -4329,19 +4644,19 @@ impl Tab { return self.tiled_panes.focus_pane_right_fullscreen(client_id); } - pub fn focus_pane_up_fullscreen(&mut self, client_id: ClientId) { + pub fn focus_pane_up_fullscreen(&mut self, client_id: ClientId) -> bool { if !self.is_fullscreen_active() { - return; + return false; } - self.tiled_panes.focus_pane_up_fullscreen(client_id); + return self.tiled_panes.focus_pane_up_fullscreen(client_id); } - pub fn focus_pane_down_fullscreen(&mut self, client_id: ClientId) { + pub fn focus_pane_down_fullscreen(&mut self, client_id: ClientId) -> bool { if !self.is_fullscreen_active() { - return; + return false; } - self.tiled_panes.focus_pane_down_fullscreen(client_id); + return self.tiled_panes.focus_pane_down_fullscreen(client_id); } pub fn switch_next_pane_fullscreen(&mut self, client_id: ClientId) { if !self.is_fullscreen_active() { @@ -4453,8 +4768,10 @@ impl Tab { .with_context(err_context)?; self.render_stack_list_headers(output, client_id_override) .with_context(err_context)?; - if (self.floating_panes.panes_are_visible() && self.floating_panes.has_active_panes()) - || self.floating_panes.has_pinned_panes() + let no_ui_fullscreen_active = self.tiled_panes.fullscreen_covers_ui(); + if !no_ui_fullscreen_active + && ((self.floating_panes.panes_are_visible() && self.floating_panes.has_active_panes()) + || self.floating_panes.has_pinned_panes()) { self.floating_panes .render( @@ -4652,9 +4969,15 @@ impl Tab { // panes retain stale geometry from before the resize and the layout // solver fails when fullscreen is later toggled off. let fullscreen_pane_to_restore = self.tiled_panes.fullscreen_pane_id(); + let fullscreen_covered_ui = self.tiled_panes.fullscreen_covers_ui(); if fullscreen_pane_to_restore.is_some() { self.tiled_panes.unset_fullscreen(); } + let floating_fullscreen_pane_to_restore = self.floating_panes.fullscreen_pane_id(); + let floating_fullscreen_covered_ui = self.floating_panes.fullscreen_covers_ui(); + if floating_fullscreen_pane_to_restore.is_some() { + self.floating_panes.unset_fullscreen(); + } self.floating_panes.resize(new_screen_size); // we need to do this explicitly because floating_panes.resize does not do this self.floating_panes @@ -4686,7 +5009,20 @@ impl Tab { ); if let Some(pane_id) = fullscreen_pane_to_restore { if self.tiled_panes.panes_contain(&pane_id) { - self.tiled_panes.toggle_pane_fullscreen(pane_id); + if fullscreen_covered_ui { + self.tiled_panes.toggle_pane_no_ui_fullscreen(pane_id); + } else { + self.tiled_panes.toggle_pane_fullscreen(pane_id); + } + } + } + if let Some(pane_id) = floating_fullscreen_pane_to_restore { + if self.floating_panes.panes_contain(&pane_id) { + if floating_fullscreen_covered_ui { + self.floating_panes.toggle_pane_no_ui_fullscreen(pane_id); + } else { + self.floating_panes.toggle_pane_fullscreen(pane_id); + } } } self.resize_all_stack_list_hidden_members(); @@ -4694,6 +5030,9 @@ impl Tab { } pub fn resize(&mut self, client_id: ClientId, strategy: ResizeStrategy) -> Result<()> { let err_context = || format!("unable to resize pane"); + if self.floating_panes.fullscreen_is_active() { + return Ok(()); + } if self.floating_panes.panes_are_visible() { let successfully_resized = self .floating_panes @@ -4779,6 +5118,23 @@ impl Tab { self.tiled_panes.focus_pane_on_edge(direction, client_id); } } + pub fn focus_pane_adjacent_to( + &mut self, + pane_id: PaneId, + direction: Direction, + client_id: ClientId, + ) -> Option<PaneId> { + if self.floating_panes.panes_are_visible() { + return self + .floating_panes + .focus_pane_adjacent_to(pane_id, direction, client_id); + } + if self.tiled_panes.fullscreen_is_active() { + return None; + } + self.tiled_panes + .focus_pane_adjacent_to(pane_id, direction, client_id) + } // returns a boolean that indicates whether the focus moved pub fn move_focus_left(&mut self, client_id: ClientId) -> Result<bool> { let err_context = || format!("failed to move focus left for client {}", client_id); @@ -4817,8 +5173,7 @@ impl Tab { return Ok(false); } if self.tiled_panes.fullscreen_is_active() { - self.focus_pane_down_fullscreen(client_id); - return Ok(true); + return Ok(self.focus_pane_down_fullscreen(client_id)); } if self.stacked_pane_list_is_active() && self.move_focus_within_stack_list(client_id, true) @@ -4844,8 +5199,7 @@ impl Tab { return Ok(false); } if self.tiled_panes.fullscreen_is_active() { - self.focus_pane_up_fullscreen(client_id); - return Ok(true); + return Ok(self.focus_pane_up_fullscreen(client_id)); } if self.stacked_pane_list_is_active() && self.move_focus_within_stack_list(client_id, false) @@ -5116,7 +5470,7 @@ impl Tab { if let Some(pane) = self.floating_panes.get_pane(pane_id) { let mut info = pane_info_for_pane(&pane_id, pane, ¤t_pane_group); info.is_focused = false; - info.is_fullscreen = false; + info.is_fullscreen = self.floating_panes.fullscreen_pane_id() == Some(pane_id); info.is_floating = true; info.is_suppressed = false; return Some(info); @@ -5205,6 +5559,9 @@ impl Tab { }; } let closed_pane = if self.floating_panes.panes_contain(&id) { + if self.floating_panes.fullscreen_pane_id() == Some(id) { + self.floating_panes.unset_fullscreen(); + } let closed_pane = self.floating_panes.remove_pane(id); self.floating_panes.move_clients_out_of_pane(id); if !self.floating_panes.has_selectable_panes() { @@ -5291,6 +5648,9 @@ impl Tab { }; } if self.floating_panes.panes_contain(&id) { + if self.floating_panes.fullscreen_pane_id() == Some(id) { + self.floating_panes.unset_fullscreen(); + } let mut closed_pane = self.floating_panes.remove_pane(id); self.floating_panes.move_clients_out_of_pane(id); if !self.floating_panes.has_panes() { @@ -5911,19 +6271,12 @@ impl Tab { let err_context = || format!("failed to get id of pane at position {point:?}"); if self.tiled_panes.fullscreen_is_active() - && self - .is_position_inside_viewport(point) - .with_context(err_context)? + && (self.tiled_panes.fullscreen_covers_ui() + || self + .is_position_inside_viewport(point) + .with_context(err_context)?) { - // TODO: instead of doing this, record the pane that is in fullscreen - let first_client_id = self - .connected_clients - .borrow() - .iter() - .copied() - .next() - .with_context(err_context)?; - return Ok(self.tiled_panes.get_active_pane_id(first_client_id)); + return Ok(self.tiled_panes.fullscreen_pane_id()); } let (stacked_pane_ids_under_flexible_pane, _stacked_pane_ids_over_flexible_pane) = { @@ -6216,6 +6569,7 @@ impl Tab { pub fn set_pane_frames(&mut self, pane_frame_style: PaneFrameStyle) { self.tiled_panes.set_pane_frames(pane_frame_style); + self.floating_panes.set_pane_frame_style(pane_frame_style); self.pane_frame_style = pane_frame_style; self.set_should_clear_display_before_rendering(); self.set_force_render(); @@ -6446,6 +6800,9 @@ impl Tab { pub fn hide_floating_panes(&mut self) { // this function is to be preferred to directly invoking // floating_panes.toggle_show_panes(false) + if self.floating_panes.fullscreen_is_active() { + self.floating_panes.unset_fullscreen(); + } self.floating_panes.toggle_show_panes(false); self.tiled_panes.focus_all_panes(); self.set_force_render(); @@ -6725,6 +7082,9 @@ impl Tab { client_id: Option<ClientId>, ) -> Result<()> { let err_context = || format!("failed to add floating pane"); + if self.floating_panes.fullscreen_is_active() { + self.floating_panes.unset_fullscreen(); + } if let Some(mut new_pane_geom) = self.floating_panes.find_room_for_new_pane() { if let Some(floating_pane_coordinates) = &floating_pane_coordinates { let viewport = self.viewport.borrow(); @@ -7446,6 +7806,9 @@ impl Tab { pub fn toggle_fullscreen_by_pane_id(&mut self, pane_id: PaneId) { self.toggle_pane_fullscreen(pane_id); } + pub fn toggle_no_ui_fullscreen_by_pane_id(&mut self, pane_id: PaneId) { + self.toggle_pane_no_ui_fullscreen(pane_id); + } pub fn close_pane_by_pane_id( &mut self, pane_id: PaneId, diff --git a/zellij-server/src/tab/mouse_handler.rs b/zellij-server/src/tab/mouse_handler.rs index ca2a0804..288e58af 100644 --- a/zellij-server/src/tab/mouse_handler.rs +++ b/zellij-server/src/tab/mouse_handler.rs @@ -8,6 +8,7 @@ use zellij_utils::position::Position; use crate::background_jobs::BackgroundJob; use crate::panes::PaneId; use crate::plugins::PluginInstruction; +use crate::screen::{GuestModalOutcome, ScreenInstruction}; use crate::ClientId; use super::{Pane, Tab}; @@ -349,11 +350,72 @@ impl MouseHandler { event: &MouseEvent, client_id: ClientId, ) -> Result<MouseEffect> { + if let Some(effect) = Self::intercept_guest_modal_mouse_event(tab, event, client_id)? { + return Ok(effect); + } let context = Self::gather_mouse_event_context(tab, event, client_id)?; let action = Self::determine_mouse_action(event, &context)?; Self::execute_mouse_action(tab, action, event, client_id) } + fn intercept_guest_modal_mouse_event( + tab: &mut Tab, + event: &MouseEvent, + client_id: ClientId, + ) -> Result<Option<MouseEffect>> { + let pane_id_at_position = Self::get_pane_at(tab, &event.position, false)?.map(|p| p.pid()); + let pane_id = match pane_id_at_position { + Some(pane_id) => pane_id, + None => return Ok(None), + }; + if !tab.pane_has_guest_modal_for_client(pane_id, client_id) { + return Ok(None); + } + let hit_option = if event.event_type == MouseEventType::Release && event.left { + let style = tab.style; + if let Some(pane) = tab.get_pane_with_id(pane_id) { + let relative_position = pane.relative_position(&event.position); + let rows = pane.get_content_rows(); + let columns = pane.get_content_columns(); + let row = relative_position.line(); + if row < 0 { + None + } else { + let session_name = pane.guest_session_name().unwrap_or_default(); + let selection = pane.guest_modal_selection(client_id).unwrap_or(0); + let shortcuts = pane.guest_modal_shortcuts(); + crate::panes::nested_session_modal::guest_modal_option_at_content_row( + rows, + columns, + row as usize, + &style, + &session_name, + selection, + &shortcuts, + ) + } + } else { + None + } + } else { + None + }; + if let Some(option) = hit_option { + let outcome = match option { + 0 => GuestModalOutcome::Zoom, + _ => GuestModalOutcome::Descend, + }; + let _ = tab + .senders + .send_to_screen(ScreenInstruction::GuestModalChoice { + client_id, + pane_id, + outcome, + }); + } + Ok(Some(MouseEffect::state_changed())) + } + fn gather_mouse_event_context( tab: &mut Tab, event: &MouseEvent, diff --git a/zellij-server/src/tab/unit/layout_applier_tests.rs b/zellij-server/src/tab/unit/layout_applier_tests.rs index f67a9a17..8ebba80d 100644 --- a/zellij-server/src/tab/unit/layout_applier_tests.rs +++ b/zellij-server/src/tab/unit/layout_applier_tests.rs @@ -189,6 +189,7 @@ fn create_layout_applier_fixtures( let stacked_resize = Rc::new(RefCell::new(false)); let stacked_pane_list = Rc::new(RefCell::new(false)); let reserved_top_rows = Rc::new(RefCell::new(HashMap::new())); + let fullscreen_covers_ui = Rc::new(RefCell::new(false)); let session_is_mirrored = true; let draw_pane_frames = PaneFrameStyle::Full; let default_mode_info = ModeInfo::default(); @@ -203,6 +204,7 @@ fn create_layout_applier_fixtures( stacked_resize, stacked_pane_list, reserved_top_rows, + fullscreen_covers_ui.clone(), session_is_mirrored, draw_pane_frames, default_mode_info.clone(), @@ -219,6 +221,8 @@ fn create_layout_applier_fixtures( connected_clients.clone(), mode_info, character_cell_size.clone(), + fullscreen_covers_ui, + draw_pane_frames, session_is_mirrored, default_mode_info, style.clone(), @@ -323,6 +327,7 @@ fn create_layout_applier_fixtures_with_receivers( let stacked_resize = Rc::new(RefCell::new(false)); let stacked_pane_list = Rc::new(RefCell::new(false)); let reserved_top_rows = Rc::new(RefCell::new(HashMap::new())); + let fullscreen_covers_ui = Rc::new(RefCell::new(false)); let session_is_mirrored = true; let draw_pane_frames = PaneFrameStyle::Full; let default_mode_info = ModeInfo::default(); @@ -337,6 +342,7 @@ fn create_layout_applier_fixtures_with_receivers( stacked_resize, stacked_pane_list, reserved_top_rows, + fullscreen_covers_ui.clone(), session_is_mirrored, draw_pane_frames, default_mode_info.clone(), @@ -353,6 +359,8 @@ fn create_layout_applier_fixtures_with_receivers( connected_clients.clone(), mode_info, character_cell_size.clone(), + fullscreen_covers_ui, + draw_pane_frames, session_is_mirrored, default_mode_info, style.clone(), diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__changing_frame_style_while_floating_fullscreen_reflows_content.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__changing_frame_style_while_floating_fullscreen_reflows_content.snap new file mode 100644 index 00000000..5d2a715d --- /dev/null +++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__changing_frame_style_while_floating_fullscreen_reflows_content.snap @@ -0,0 +1,25 @@ +--- +source: zellij-server/src/tab/./unit/tab_integration_tests.rs +expression: snapshot +--- +00 (C): +01 (C): +02 (C): +03 (C): I am a floating pane +04 (C): +05 (C): +06 (C): +07 (C): +08 (C): +09 (C): +10 (C): +11 (C): +12 (C): +13 (C): +14 (C): +15 (C): +16 (C): +17 (C): +18 (C): +19 (C): + diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__exiting_floating_fullscreen_clears_background.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__exiting_floating_fullscreen_clears_background.snap new file mode 100644 index 00000000..9abbfc6b --- /dev/null +++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__exiting_floating_fullscreen_clears_background.snap @@ -0,0 +1,25 @@ +--- +source: zellij-server/src/tab/./unit/tab_integration_tests.rs +expression: snapshot +--- +00 (C): ┌ Pane #1 ──────────────────────────────────────────────────────────────────────────────────────────────── SCROLL: 0/1 ┐ +01 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +02 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +03 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +04 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +05 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE┌ Pane #2 ──────────────────────────────────────── PIN [ ] ┐EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +06 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +07 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +08 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +09 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ I am a floating pane │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +10 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +11 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +12 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +13 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +14 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE└──────────────────────────────────────────────────────────┘EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +15 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +16 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +17 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +18 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ +19 (C): └───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_full_frame_style.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_full_frame_style.snap new file mode 100644 index 00000000..4f0865a3 --- /dev/null +++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_full_frame_style.snap @@ -0,0 +1,25 @@ +--- +source: zellij-server/src/tab/./unit/tab_integration_tests.rs +expression: snapshot +--- +00 (C): ┌ Pane #2 ───────────────────────────────────────────────────────────────────────────────────────────────────── PIN [ ] ┐ +01 (C): │ │ +02 (C): │ │ +03 (C): │ │ +04 (C): │ I am a floating pane │ +05 (C): │ │ +06 (C): │ │ +07 (C): │ │ +08 (C): │ │ +09 (C): │ │ +10 (C): │ │ +11 (C): │ │ +12 (C): │ │ +13 (C): │ │ +14 (C): │ │ +15 (C): │ │ +16 (C): │ │ +17 (C): │ │ +18 (C): │ │ +19 (C): └───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_hides_other_floating_and_pinned_panes.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_hides_other_floating_and_pinned_panes.snap new file mode 100644 index 00000000..1ea8e0fa --- /dev/null +++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_hides_other_floating_and_pinned_panes.snap @@ -0,0 +1,25 @@ +--- +source: zellij-server/src/tab/./unit/tab_integration_tests.rs +expression: snapshot +--- +00 (C): ┌ Pane #2 ───────────────────────────────────────────────────────────────────────────────────────────────────── PIN [ ] ┐ +01 (C): │ │ +02 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │ +03 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │ +04 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │ +05 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │ +06 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │ +07 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │ +08 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │ +09 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │ +10 (C): │ │ +11 (C): │ │ +12 (C): │ │ +13 (C): │ │ +14 (C): │ │ +15 (C): │ │ +16 (C): │ │ +17 (C): │ │ +18 (C): │ │ +19 (C): └───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_none_frame_style.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_none_frame_style.snap new file mode 100644 index 00000000..5d2a715d --- /dev/null +++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_none_frame_style.snap @@ -0,0 +1,25 @@ +--- +source: zellij-server/src/tab/./unit/tab_integration_tests.rs +expression: snapshot +--- +00 (C): +01 (C): +02 (C): +03 (C): I am a floating pane +04 (C): +05 (C): +06 (C): +07 (C): +08 (C): +09 (C): +10 (C): +11 (C): +12 (C): +13 (C): +14 (C): +15 (C): +16 (C): +17 (C): +18 (C): +19 (C): + diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_titles_frame_style.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_titles_frame_style.snap new file mode 100644 index 00000000..c788e3b6 --- /dev/null +++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_titles_frame_style.snap @@ -0,0 +1,25 @@ +--- +source: zellij-server/src/tab/./unit/tab_integration_tests.rs +expression: snapshot +--- +00 (C): Pane #2 +01 (C): +02 (C): +03 (C): +04 (C): I am a floating pane +05 (C): +06 (C): +07 (C): +08 (C): +09 (C): +10 (C): +11 (C): +12 (C): +13 (C): +14 (C): +15 (C): +16 (C): +17 (C): +18 (C): +19 (C): + diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_no_ui_fullscreen_covers_tab_and_status_bar.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_no_ui_fullscreen_covers_tab_and_status_bar.snap new file mode 100644 index 00000000..5d2a715d --- /dev/null +++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_no_ui_fullscreen_covers_tab_and_status_bar.snap @@ -0,0 +1,25 @@ +--- +source: zellij-server/src/tab/./unit/tab_integration_tests.rs +expression: snapshot +--- +00 (C): +01 (C): +02 (C): +03 (C): I am a floating pane +04 (C): +05 (C): +06 (C): +07 (C): +08 (C): +09 (C): +10 (C): +11 (C): +12 (C): +13 (C): +14 (C): +15 (C): +16 (C): +17 (C): +18 (C): +19 (C): + diff --git a/zellij-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs index e5de0b58..3e778b16 100644 --- a/zellij-server/src/tab/unit/tab_integration_tests.rs +++ b/zellij-server/src/tab/unit/tab_integration_tests.rs @@ -3446,6 +3446,228 @@ fn float_embedded_pane_without_pane_frames() { assert_snapshot!(snapshot); } +fn setup_tab_with_focused_floating_pane( + size: Size, + client_id: ClientId, + frame_style: PaneFrameStyle, +) -> Tab { + let mut tab = create_new_tab(size, ModeInfo::default()); + tab.set_pane_frames(frame_style); + tab.toggle_floating_panes(Some(client_id), None, None) + .unwrap(); + tab.new_pane( + PaneId::Terminal(2), + None, + None, + false, + true, + NewPanePlacement::default(), + Some(client_id), + None, + ) + .unwrap(); + tab.handle_pty_bytes( + 2, + Vec::from("\n\n\n I am a floating pane".as_bytes()), + ) + .unwrap(); + tab +} + +#[test] +fn floating_fullscreen_full_frame_style() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut tab = setup_tab_with_focused_floating_pane(size, client_id, PaneFrameStyle::Full); + let mut output = Output::default(); + tab.toggle_active_pane_fullscreen(client_id); + tab.render(&mut output, None).unwrap(); + let snapshot = take_snapshot( + output.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + assert_snapshot!(snapshot); +} + +#[test] +fn floating_fullscreen_titles_frame_style() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut tab = setup_tab_with_focused_floating_pane(size, client_id, PaneFrameStyle::Titles); + let mut output = Output::default(); + tab.toggle_active_pane_fullscreen(client_id); + tab.render(&mut output, None).unwrap(); + let snapshot = take_snapshot( + output.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + assert_snapshot!(snapshot); +} + +#[test] +fn floating_fullscreen_none_frame_style() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut tab = setup_tab_with_focused_floating_pane(size, client_id, PaneFrameStyle::None); + let mut output = Output::default(); + tab.toggle_active_pane_fullscreen(client_id); + tab.render(&mut output, None).unwrap(); + let snapshot = take_snapshot( + output.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + assert_snapshot!(snapshot); +} + +#[test] +fn changing_frame_style_while_floating_fullscreen_reflows_content() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut tab = setup_tab_with_focused_floating_pane(size, client_id, PaneFrameStyle::Full); + let mut output = Output::default(); + tab.toggle_active_pane_fullscreen(client_id); + tab.set_pane_frames(PaneFrameStyle::None); + tab.render(&mut output, None).unwrap(); + let snapshot = take_snapshot( + output.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + assert_snapshot!(snapshot); +} + +#[test] +fn exiting_floating_fullscreen_clears_background() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut tab = create_new_tab(size, ModeInfo::default()); + tab.handle_pty_bytes(1, Vec::from("\u{1b}#8".as_bytes())) + .unwrap(); + tab.toggle_floating_panes(Some(client_id), None, None) + .unwrap(); + tab.new_pane( + PaneId::Terminal(2), + None, + None, + false, + true, + NewPanePlacement::default(), + Some(client_id), + None, + ) + .unwrap(); + tab.handle_pty_bytes( + 2, + Vec::from("\n\n\n I am a floating pane".as_bytes()), + ) + .unwrap(); + let mut output = Output::default(); + tab.toggle_active_pane_fullscreen(client_id); + tab.render(&mut output, None).unwrap(); + let mut output = Output::default(); + tab.toggle_active_pane_fullscreen(client_id); + tab.render(&mut output, None).unwrap(); + let snapshot = take_snapshot( + output.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + assert_snapshot!(snapshot); +} + +#[test] +fn floating_no_ui_fullscreen_covers_tab_and_status_bar() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut tab = setup_tab_with_focused_floating_pane(size, client_id, PaneFrameStyle::Full); + let mut output = Output::default(); + tab.toggle_active_pane_no_ui_fullscreen(client_id); + tab.render(&mut output, None).unwrap(); + let snapshot = take_snapshot( + output.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + assert_snapshot!(snapshot); +} + +#[test] +fn floating_fullscreen_hides_other_floating_and_pinned_panes() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut tab = create_new_tab(size, ModeInfo::default()); + tab.toggle_floating_panes(Some(client_id), None, None) + .unwrap(); + tab.new_pane( + PaneId::Terminal(2), + None, + None, + false, + true, + NewPanePlacement::default(), + Some(client_id), + None, + ) + .unwrap(); + tab.new_pane( + PaneId::Terminal(3), + None, + None, + false, + true, + NewPanePlacement::default(), + Some(client_id), + None, + ) + .unwrap(); + tab.set_floating_pane_pinned(PaneId::Terminal(3), true); + tab.handle_pty_bytes(2, Vec::from("\u{1b}#8".as_bytes())) + .unwrap(); + tab.handle_pty_bytes(3, Vec::from("\u{1b}#8".as_bytes())) + .unwrap(); + let _ = tab.focus_pane_with_id(PaneId::Terminal(2), false, false, client_id); + let mut output = Output::default(); + tab.toggle_active_pane_fullscreen(client_id); + tab.render(&mut output, None).unwrap(); + let snapshot = take_snapshot( + output.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + assert_snapshot!(snapshot); +} + #[test] fn cannot_float_only_embedded_pane() { let size = Size { diff --git a/zellij-server/src/tab/unit/tab_tests.rs b/zellij-server/src/tab/unit/tab_tests.rs index e9f8a93e..f0c2d582 100644 --- a/zellij-server/src/tab/unit/tab_tests.rs +++ b/zellij-server/src/tab/unit/tab_tests.rs @@ -12,7 +12,7 @@ use zellij_utils::errors::prelude::*; use zellij_utils::input::layout::{SplitDirection, SplitSize, TiledPaneLayout}; use zellij_utils::input::options::PaneFrameStyle; use zellij_utils::ipc::IpcReceiverWithContext; -use zellij_utils::pane_size::{Size, SizeInPixels}; +use zellij_utils::pane_size::{PaneGeom, Size, SizeInPixels}; use crate::os_input_output::AsyncReader; use std::cell::RefCell; @@ -1232,6 +1232,820 @@ pub fn resize_while_fullscreen_updates_hidden_pane_geometry() { } #[test] +pub fn toggle_no_ui_fullscreen_covers_whole_display_and_restores() { + let size = Size { + cols: 121, + rows: 20, + }; + let stacked_resize = false; + let mut tab = create_new_tab(size, stacked_resize); + for i in 2..5 { + let new_pane_id = PaneId::Terminal(i); + tab.new_pane( + new_pane_id, + None, + None, + false, + true, + NewPanePlacement::default(), + Some(1), + None, + ) + .unwrap(); + } + tab.toggle_active_pane_no_ui_fullscreen(1); + assert!(tab.is_fullscreen_active(), "NoUi fullscreen is active"); + assert!( + tab.fullscreen_covers_ui(), + "NoUi fullscreen covers the UI rows" + ); + let active_pane = tab.tiled_panes.panes.get(&PaneId::Terminal(4)).unwrap(); + assert_eq!(active_pane.x(), 0, "Pane x is on display edge"); + assert_eq!(active_pane.y(), 0, "Pane y is on display edge"); + assert_eq!(active_pane.cols(), 121, "Pane cols match display cols"); + assert_eq!(active_pane.rows(), 20, "Pane rows match display rows"); + tab.toggle_active_pane_no_ui_fullscreen(1); + assert!(!tab.is_fullscreen_active(), "NoUi fullscreen toggled off"); + assert!( + !tab.fullscreen_covers_ui(), + "NoUi flag cleared after toggle off" + ); + let active_pane = tab.tiled_panes.panes.get(&PaneId::Terminal(4)).unwrap(); + assert_eq!(active_pane.x(), 61, "Pane x restored"); + assert_eq!(active_pane.y(), 10, "Pane y restored"); + assert_eq!(active_pane.cols(), 60, "Pane cols restored"); + assert_eq!(active_pane.rows(), 10, "Pane rows restored"); +} + +#[test] +pub fn regular_fullscreen_switches_to_no_ui_fullscreen() { + let size = Size { + cols: 121, + rows: 20, + }; + let stacked_resize = false; + let mut tab = create_new_tab(size, stacked_resize); + for i in 2..5 { + let new_pane_id = PaneId::Terminal(i); + tab.new_pane( + new_pane_id, + None, + None, + false, + true, + NewPanePlacement::default(), + Some(1), + None, + ) + .unwrap(); + } + tab.toggle_active_pane_fullscreen(1); + assert!(tab.is_fullscreen_active(), "Regular fullscreen is active"); + assert!( + !tab.fullscreen_covers_ui(), + "Regular fullscreen leaves the UI rows" + ); + tab.toggle_active_pane_no_ui_fullscreen(1); + assert!( + tab.is_fullscreen_active(), + "Fullscreen stays active after switching kinds" + ); + assert!( + tab.fullscreen_covers_ui(), + "Fullscreen switched to covering the UI rows" + ); + tab.toggle_active_pane_no_ui_fullscreen(1); + assert!( + !tab.is_fullscreen_active(), + "Fullscreen toggled off entirely" + ); +} + +#[test] +pub fn resize_whole_tab_while_no_ui_fullscreen_preserves_no_ui() { + let initial_size = Size { + cols: 121, + rows: 20, + }; + let stacked_resize = false; + let mut tab = create_new_tab(initial_size, stacked_resize); + for i in 2..5 { + let new_pane_id = PaneId::Terminal(i); + tab.new_pane( + new_pane_id, + None, + None, + false, + true, + NewPanePlacement::default(), + Some(1), + None, + ) + .unwrap(); + } + tab.toggle_active_pane_no_ui_fullscreen(1); + assert!( + tab.fullscreen_covers_ui(), + "NoUi fullscreen is active before the resize" + ); + + let new_size = Size { cols: 80, rows: 30 }; + tab.resize_whole_tab(new_size).unwrap(); + + assert!( + tab.is_fullscreen_active(), + "Fullscreen is preserved across a host-terminal resize" + ); + assert!( + tab.fullscreen_covers_ui(), + "NoUi kind is preserved across a host-terminal resize" + ); + let active_pane = tab + .tiled_panes + .panes + .get(&PaneId::Terminal(4)) + .expect("Active fullscreen pane is still present"); + assert_eq!( + active_pane.cols(), + new_size.cols, + "NoUi fullscreen pane cols match the new display cols" + ); + assert_eq!( + active_pane.rows(), + new_size.rows, + "NoUi fullscreen pane rows match the new display rows" + ); + assert_eq!( + active_pane.x(), + 0, + "NoUi fullscreen pane x is at display edge" + ); + assert_eq!( + active_pane.y(), + 0, + "NoUi fullscreen pane y is at display edge" + ); +} + +fn create_tab_with_two_floating_panes() -> Tab { + let size = Size { + cols: 121, + rows: 20, + }; + let stacked_resize = false; + let mut tab = create_new_tab(size, stacked_resize); + tab.new_floating_pane(PaneId::Terminal(2), None, None, false, true, None, None) + .unwrap(); + tab.new_floating_pane(PaneId::Terminal(3), None, None, false, true, None, None) + .unwrap(); + tab +} + +fn floating_pane_geom(tab: &Tab, pane_id: PaneId) -> PaneGeom { + tab.floating_panes + .get(&pane_id) + .expect("floating pane exists") + .current_geom() +} + +#[test] +pub fn toggle_floating_pane_fullscreen_expands_over_viewport() { + let mut tab = create_tab_with_two_floating_panes(); + let viewport = *tab.viewport.borrow(); + let active_pane_id = tab + .floating_panes + .active_pane_id(1) + .expect("a floating pane is focused"); + + tab.toggle_active_pane_fullscreen(1); + + assert!(tab.is_fullscreen_active(), "Floating fullscreen is active"); + assert!( + !tab.fullscreen_covers_ui(), + "Regular floating fullscreen leaves the UI rows" + ); + assert_eq!( + tab.fullscreen_pane_id(), + Some(active_pane_id), + "Fullscreen tracks the active floating pane" + ); + let geom = floating_pane_geom(&tab, active_pane_id); + assert_eq!(geom.x, viewport.x, "Fullscreen pane x matches viewport"); + assert_eq!(geom.y, viewport.y, "Fullscreen pane y matches viewport"); + assert_eq!( + geom.cols.as_usize(), + viewport.cols, + "Fullscreen pane cols match viewport cols" + ); + assert_eq!( + geom.rows.as_usize(), + viewport.rows, + "Fullscreen pane rows match viewport rows" + ); +} + +#[test] +pub fn toggle_floating_pane_no_ui_fullscreen_covers_whole_display() { + let mut tab = create_tab_with_two_floating_panes(); + let display_area = *tab.display_area.borrow(); + let active_pane_id = tab + .floating_panes + .active_pane_id(1) + .expect("a floating pane is focused"); + + tab.toggle_active_pane_no_ui_fullscreen(1); + + assert!( + tab.is_fullscreen_active(), + "Floating no-ui fullscreen is active" + ); + assert!( + tab.fullscreen_covers_ui(), + "Floating no-ui fullscreen covers the UI rows" + ); + let geom = floating_pane_geom(&tab, active_pane_id); + assert_eq!(geom.x, 0, "No-ui fullscreen pane x is on display edge"); + assert_eq!(geom.y, 0, "No-ui fullscreen pane y is on display edge"); + assert_eq!( + geom.cols.as_usize(), + display_area.cols, + "No-ui fullscreen pane cols match display cols" + ); + assert_eq!( + geom.rows.as_usize(), + display_area.rows, + "No-ui fullscreen pane rows match display rows" + ); +} + +#[test] +pub fn toggle_floating_pane_fullscreen_off_restores_geometry() { + let mut tab = create_tab_with_two_floating_panes(); + let active_pane_id = tab + .floating_panes + .active_pane_id(1) + .expect("a floating pane is focused"); + let geom_before = floating_pane_geom(&tab, active_pane_id); + + tab.toggle_active_pane_fullscreen(1); + assert!(tab.is_fullscreen_active(), "Fullscreen active after toggle"); + tab.toggle_active_pane_fullscreen(1); + + assert!( + !tab.is_fullscreen_active(), + "Fullscreen cleared after toggling off" + ); + assert_eq!( + floating_pane_geom(&tab, active_pane_id), + geom_before, + "Floating pane geometry restored after fullscreen off" + ); +} + +#[test] +pub fn regular_floating_fullscreen_switches_to_no_ui() { + let mut tab = create_tab_with_two_floating_panes(); + tab.toggle_active_pane_fullscreen(1); + assert!( + tab.is_fullscreen_active() && !tab.fullscreen_covers_ui(), + "Regular floating fullscreen active" + ); + + tab.toggle_active_pane_no_ui_fullscreen(1); + assert!( + tab.is_fullscreen_active(), + "Fullscreen stays active when switching kinds" + ); + assert!( + tab.fullscreen_covers_ui(), + "Fullscreen switched to covering the UI rows" + ); + + tab.toggle_active_pane_no_ui_fullscreen(1); + assert!( + !tab.is_fullscreen_active(), + "Fullscreen toggled off entirely" + ); +} + +#[test] +pub fn floating_fullscreen_on_lone_floating_pane() { + let size = Size { + cols: 121, + rows: 20, + }; + let stacked_resize = false; + let mut tab = create_new_tab(size, stacked_resize); + tab.new_floating_pane(PaneId::Terminal(2), None, None, false, true, None, None) + .unwrap(); + let viewport = *tab.viewport.borrow(); + + tab.toggle_active_pane_fullscreen(1); + + assert!( + tab.is_fullscreen_active(), + "A lone floating pane still enters fullscreen" + ); + let geom = floating_pane_geom(&tab, PaneId::Terminal(2)); + assert_eq!( + geom.cols.as_usize(), + viewport.cols, + "Lone floating fullscreen pane covers the viewport cols" + ); + assert_eq!( + geom.rows.as_usize(), + viewport.rows, + "Lone floating fullscreen pane covers the viewport rows" + ); +} + +#[test] +pub fn floating_and_tiled_fullscreen_are_mutually_exclusive() { + let size = Size { + cols: 121, + rows: 20, + }; + let stacked_resize = false; + let mut tab = create_new_tab(size, stacked_resize); + tab.new_pane( + PaneId::Terminal(2), + None, + None, + false, + true, + NewPanePlacement::default(), + Some(1), + None, + ) + .unwrap(); + tab.new_floating_pane(PaneId::Terminal(3), None, None, false, true, None, None) + .unwrap(); + + tab.toggle_active_pane_fullscreen(1); + assert_eq!( + tab.fullscreen_pane_id(), + Some(PaneId::Terminal(3)), + "The focused floating pane is fullscreen" + ); + assert!( + !tab.tiled_panes.fullscreen_is_active(), + "No tiled fullscreen while a floating pane is fullscreen" + ); + + tab.hide_floating_panes(); + tab.toggle_active_pane_fullscreen(1); + assert!( + tab.tiled_panes.fullscreen_is_active(), + "A tiled pane can be fullscreen after floating is hidden" + ); + assert!( + !tab.floating_panes.fullscreen_is_active(), + "No floating fullscreen while a tiled pane is fullscreen" + ); +} + +#[test] +pub fn move_focus_while_floating_fullscreen_transfers_fullscreen() { + let mut tab = create_tab_with_two_floating_panes(); + let first = tab + .floating_panes + .active_pane_id(1) + .expect("a floating pane is focused"); + + tab.toggle_active_pane_fullscreen(1); + assert_eq!( + tab.fullscreen_pane_id(), + Some(first), + "Fullscreen starts on the focused floating pane" + ); + + let _ = tab.move_focus_left(1); + + assert!( + tab.is_fullscreen_active(), + "Fullscreen stays active after moving focus" + ); + let new_active = tab + .floating_panes + .active_pane_id(1) + .expect("a floating pane is focused after the move"); + assert_eq!( + tab.fullscreen_pane_id(), + Some(new_active), + "Fullscreen transferred to the newly focused floating pane" + ); +} + +#[test] +pub fn resize_while_floating_fullscreen_is_noop() { + let mut tab = create_tab_with_two_floating_panes(); + let active_pane_id = tab + .floating_panes + .active_pane_id(1) + .expect("a floating pane is focused"); + tab.toggle_active_pane_fullscreen(1); + let geom_before = floating_pane_geom(&tab, active_pane_id); + + tab.resize(1, ResizeStrategy::new(Resize::Increase, None)) + .unwrap(); + + assert_eq!( + floating_pane_geom(&tab, active_pane_id), + geom_before, + "Resizing a floating fullscreen pane does not change its geometry" + ); +} + +#[test] +pub fn move_pane_while_floating_fullscreen_is_noop() { + let mut tab = create_tab_with_two_floating_panes(); + let active_pane_id = tab + .floating_panes + .active_pane_id(1) + .expect("a floating pane is focused"); + tab.toggle_active_pane_fullscreen(1); + let geom_before = floating_pane_geom(&tab, active_pane_id); + + tab.move_active_pane_down(1); + tab.move_active_pane_up(1); + tab.move_active_pane_left(1); + tab.move_active_pane_right(1); + + assert_eq!( + floating_pane_geom(&tab, active_pane_id), + geom_before, + "Moving a floating fullscreen pane does not change its geometry" + ); +} + +#[test] +pub fn closing_floating_fullscreen_pane_clears_state() { + let mut tab = create_tab_with_two_floating_panes(); + let active_pane_id = tab + .floating_panes + .active_pane_id(1) + .expect("a floating pane is focused"); + tab.toggle_active_pane_fullscreen(1); + assert!(tab.is_fullscreen_active(), "Fullscreen active before close"); + + tab.close_pane(active_pane_id, false, None); + + assert!( + !tab.floating_panes.fullscreen_is_active(), + "Closing the fullscreen floating pane clears fullscreen state" + ); +} + +#[test] +pub fn adding_floating_pane_while_fullscreen_unsets_fullscreen() { + let mut tab = create_tab_with_two_floating_panes(); + tab.toggle_active_pane_fullscreen(1); + assert!(tab.is_fullscreen_active(), "Fullscreen active before add"); + + tab.new_floating_pane(PaneId::Terminal(4), None, None, false, true, None, None) + .unwrap(); + + assert!( + !tab.floating_panes.fullscreen_is_active(), + "Adding a floating pane breaks out of floating fullscreen" + ); +} + +#[test] +pub fn hiding_floating_layer_while_fullscreen_unsets_fullscreen() { + let mut tab = create_tab_with_two_floating_panes(); + tab.toggle_active_pane_fullscreen(1); + assert!(tab.is_fullscreen_active(), "Fullscreen active before hide"); + + tab.hide_floating_panes(); + + assert!( + !tab.floating_panes.fullscreen_is_active(), + "Hiding the floating layer clears floating fullscreen" + ); +} + +#[test] +pub fn embed_floating_fullscreen_pane_unsets_and_embeds() { + let mut tab = create_tab_with_two_floating_panes(); + let active_pane_id = tab + .floating_panes + .active_pane_id(1) + .expect("a floating pane is focused"); + tab.toggle_active_pane_fullscreen(1); + assert!(tab.is_fullscreen_active(), "Fullscreen active before embed"); + + tab.toggle_pane_embed_or_floating(1).unwrap(); + + assert!( + !tab.floating_panes.fullscreen_is_active(), + "Embedding clears floating fullscreen state" + ); + assert!( + tab.tiled_panes.panes_contain(&active_pane_id), + "The embedded pane is now a tiled pane" + ); +} + +#[test] +pub fn resize_whole_tab_while_floating_fullscreen_preserves_regular() { + let mut tab = create_tab_with_two_floating_panes(); + let active_pane_id = tab + .floating_panes + .active_pane_id(1) + .expect("a floating pane is focused"); + tab.toggle_active_pane_fullscreen(1); + + let new_size = Size { cols: 80, rows: 30 }; + tab.resize_whole_tab(new_size).unwrap(); + + assert!( + tab.is_fullscreen_active() && !tab.fullscreen_covers_ui(), + "Regular floating fullscreen preserved across resize" + ); + let viewport = *tab.viewport.borrow(); + let geom = floating_pane_geom(&tab, active_pane_id); + assert_eq!( + geom.cols.as_usize(), + viewport.cols, + "Floating fullscreen pane re-expands to the new viewport cols" + ); + assert_eq!( + geom.rows.as_usize(), + viewport.rows, + "Floating fullscreen pane re-expands to the new viewport rows" + ); +} + +#[test] +pub fn resize_whole_tab_while_floating_no_ui_fullscreen_preserves_no_ui() { + let mut tab = create_tab_with_two_floating_panes(); + let active_pane_id = tab + .floating_panes + .active_pane_id(1) + .expect("a floating pane is focused"); + tab.toggle_active_pane_no_ui_fullscreen(1); + + let new_size = Size { cols: 80, rows: 30 }; + tab.resize_whole_tab(new_size).unwrap(); + + assert!( + tab.fullscreen_covers_ui(), + "Floating no-ui fullscreen preserved across resize" + ); + let geom = floating_pane_geom(&tab, active_pane_id); + assert_eq!( + geom.cols.as_usize(), + new_size.cols, + "Floating no-ui fullscreen pane re-expands to the new display cols" + ); + assert_eq!( + geom.rows.as_usize(), + new_size.rows, + "Floating no-ui fullscreen pane re-expands to the new display rows" + ); +} + +#[test] +pub fn pane_info_reports_floating_fullscreen() { + let mut tab = create_tab_with_two_floating_panes(); + let active_pane_id = tab + .floating_panes + .active_pane_id(1) + .expect("a floating pane is focused"); + tab.toggle_active_pane_fullscreen(1); + + let fullscreen_info = tab + .get_pane_info(active_pane_id) + .expect("pane info for the fullscreen floating pane"); + assert!( + fullscreen_info.is_fullscreen, + "The fullscreen floating pane reports is_fullscreen" + ); + assert!( + fullscreen_info.is_floating, + "The fullscreen pane still reports is_floating" + ); + + let other_pane_id = if active_pane_id == PaneId::Terminal(2) { + PaneId::Terminal(3) + } else { + PaneId::Terminal(2) + }; + let other_info = tab + .get_pane_info(other_pane_id) + .expect("pane info for the other floating pane"); + assert!( + !other_info.is_fullscreen, + "A non-fullscreen floating pane does not report is_fullscreen" + ); +} + +#[test] +pub fn toggle_floating_fullscreen_by_pane_id() { + let mut tab = create_tab_with_two_floating_panes(); + + tab.toggle_pane_fullscreen(PaneId::Terminal(2)); + assert_eq!( + tab.fullscreen_pane_id(), + Some(PaneId::Terminal(2)), + "By-id fullscreen targets the requested floating pane" + ); + + tab.toggle_pane_fullscreen(PaneId::Terminal(2)); + assert!( + !tab.is_fullscreen_active(), + "By-id toggle off clears floating fullscreen" + ); +} + +fn create_tab_with_four_panes() -> Tab { + let size = Size { + cols: 121, + rows: 20, + }; + let stacked_resize = false; + let mut tab = create_new_tab(size, stacked_resize); + for i in 2..5 { + let new_pane_id = PaneId::Terminal(i); + tab.new_pane( + new_pane_id, + None, + None, + false, + true, + NewPanePlacement::default(), + Some(1), + None, + ) + .unwrap(); + } + tab +} + +fn tiled_pane_geoms(tab: &Tab) -> Vec<(PaneId, PaneGeom)> { + tab.tiled_panes + .panes + .iter() + .map(|(pane_id, pane)| (*pane_id, pane.position_and_size())) + .collect() +} + +fn open_pane_five(tab: &mut Tab) { + tab.new_pane( + PaneId::Terminal(5), + None, + None, + false, + true, + NewPanePlacement::default(), + Some(1), + None, + ) + .unwrap(); +} + +fn assert_panes_tile_the_whole_display(tab: &Tab, display: Size) { + let geoms = tiled_pane_geoms(tab); + let mut total_pane_area = 0; + for (pane_id, geom) in &geoms { + let right_edge = geom.x + geom.cols.as_usize(); + let bottom_edge = geom.y + geom.rows.as_usize(); + assert!( + right_edge <= display.cols && bottom_edge <= display.rows, + "{pane_id:?} fits inside the display: {geom:?}" + ); + total_pane_area += geom.rows.as_usize() * geom.cols.as_usize(); + } + for (pane_id, geom) in &geoms { + for (other_pane_id, other_geom) in &geoms { + if pane_id == other_pane_id { + continue; + } + let overlap_horizontally = geom.x < other_geom.x + other_geom.cols.as_usize() + && other_geom.x < geom.x + geom.cols.as_usize(); + let overlap_vertically = geom.y < other_geom.y + other_geom.rows.as_usize() + && other_geom.y < geom.y + geom.rows.as_usize(); + assert!( + !(overlap_horizontally && overlap_vertically), + "{pane_id:?} and {other_pane_id:?} do not overlap" + ); + } + } + assert_eq!( + total_pane_area, + display.rows * display.cols, + "Panes cover the whole display with no gaps" + ); +} + +#[test] +pub fn opening_new_pane_while_fullscreen_unsets_fullscreen() { + let mut tab = create_tab_with_four_panes(); + tab.toggle_active_pane_fullscreen(1); + assert!(tab.is_fullscreen_active(), "Fullscreen active before split"); + open_pane_five(&mut tab); + + assert!( + !tab.is_fullscreen_active(), + "Opening a new pane breaks out of fullscreen" + ); + assert_eq!( + tiled_pane_geoms(&tab).len(), + 5, + "All panes including the new one are tiled" + ); + assert_panes_tile_the_whole_display( + &tab, + Size { + cols: 121, + rows: 20, + }, + ); +} + +#[test] +pub fn opening_new_pane_while_no_ui_fullscreen_unsets_fullscreen() { + let mut tab = create_tab_with_four_panes(); + tab.toggle_active_pane_no_ui_fullscreen(1); + assert!( + tab.fullscreen_covers_ui(), + "NoUi fullscreen active before split" + ); + open_pane_five(&mut tab); + + assert!( + !tab.is_fullscreen_active(), + "Opening a new pane breaks out of no-ui fullscreen" + ); + assert!( + !tab.fullscreen_covers_ui(), + "NoUi flag cleared after breaking out" + ); + assert_eq!( + tiled_pane_geoms(&tab).len(), + 5, + "All panes including the new one are tiled" + ); + assert_panes_tile_the_whole_display( + &tab, + Size { + cols: 121, + rows: 20, + }, + ); +} + +#[test] +pub fn closing_the_fullscreen_pane_restores_remaining_layout() { + let mut tab_without_fullscreen = create_tab_with_four_panes(); + tab_without_fullscreen.close_pane(PaneId::Terminal(4), false, None); + + let mut tab = create_tab_with_four_panes(); + tab.toggle_active_pane_fullscreen(1); + assert!(tab.is_fullscreen_active(), "Fullscreen active before close"); + tab.close_pane(PaneId::Terminal(4), false, None); + + assert!( + !tab.is_fullscreen_active(), + "Closing the fullscreen pane leaves fullscreen" + ); + assert_eq!( + tiled_pane_geoms(&tab), + tiled_pane_geoms(&tab_without_fullscreen), + "Remaining panes match a close that never went through fullscreen" + ); +} + +#[test] +pub fn closing_the_no_ui_fullscreen_pane_restores_remaining_layout() { + let mut tab_without_fullscreen = create_tab_with_four_panes(); + tab_without_fullscreen.close_pane(PaneId::Terminal(4), false, None); + + let mut tab = create_tab_with_four_panes(); + tab.toggle_active_pane_no_ui_fullscreen(1); + assert!( + tab.fullscreen_covers_ui(), + "NoUi fullscreen active before close" + ); + tab.close_pane(PaneId::Terminal(4), false, None); + + assert!( + !tab.is_fullscreen_active(), + "Closing the no-ui fullscreen pane leaves fullscreen" + ); + assert!( + !tab.fullscreen_covers_ui(), + "NoUi flag cleared after the close" + ); + assert_eq!( + tiled_pane_geoms(&tab), + tiled_pane_geoms(&tab_without_fullscreen), + "Remaining panes match a close that never went through no-ui fullscreen" + ); +} + +#[test] pub fn closing_fullscreen_scrollback_editor_restores_consistent_layout() { // Replacing a pane (e.g. opening or closing a scrollback editor) swaps // the pane id occupying its tiled slot. If the replaced pane was the diff --git a/zellij-server/src/ui/components/mod.rs b/zellij-server/src/ui/components/mod.rs index bc977f1c..ad927f0e 100644 --- a/zellij-server/src/ui/components/mod.rs +++ b/zellij-server/src/ui/components/mod.rs @@ -157,22 +157,30 @@ impl<'a> UiComponentParser<'a> { } } -fn parse_selected(stringified: &mut String) -> bool { - let mut selected = false; - if stringified.chars().next() == Some('x') { - selected = true; - stringified.remove(0); +fn parse_flag(stringified: &mut String, flag: char) -> bool { + const FLAG_CHARS: [char; 3] = ['x', 'z', 'd']; + let prefix_len = stringified + .chars() + .take_while(|c| FLAG_CHARS.contains(c)) + .count(); + if let Some(position) = stringified[..prefix_len].find(flag) { + stringified.remove(position); + true + } else { + false } - selected +} + +fn parse_selected(stringified: &mut String) -> bool { + parse_flag(stringified, 'x') } fn parse_opaque(stringified: &mut String) -> bool { - let mut opaque = false; - if stringified.chars().next() == Some('z') { - opaque = true; - stringified.remove(0); - } - opaque + parse_flag(stringified, 'z') +} + +fn parse_disabled(stringified: &mut String) -> bool { + parse_flag(stringified, 'd') } fn parse_indices(stringified: &mut String) -> Vec<Vec<usize>> { diff --git a/zellij-server/src/ui/components/nested_list.rs b/zellij-server/src/ui/components/nested_list.rs index b4b76b9b..a4fcabb7 100644 --- a/zellij-server/src/ui/components/nested_list.rs +++ b/zellij-server/src/ui/components/nested_list.rs @@ -89,6 +89,7 @@ pub fn parse_nested_list_items<'a>( text, opaque, selected, + disabled: false, indices, }; Ok::<NestedListItem, String>(NestedListItem { diff --git a/zellij-server/src/ui/components/ribbon.rs b/zellij-server/src/ui/components/ribbon.rs index adfa3a79..91cc62e3 100644 --- a/zellij-server/src/ui/components/ribbon.rs +++ b/zellij-server/src/ui/components/ribbon.rs @@ -12,6 +12,46 @@ pub fn ribbon( ) -> Vec<u8> { let colors = style.colors; let background = colors.text_unselected.background; + if content.disabled { + let declaration = colors.ribbon_unselected; + let disabled_content = content.into_disabled(); + let (first_arrow_styles, text_style, last_arrow_styles) = ( + character_style(background, declaration.background), + RESET_STYLES + .foreground(Some(declaration.base.into())) + .background(Some(declaration.background.into())) + .italic(Some(AnsiCode::On)), + character_style(declaration.background, background), + ); + let (arrow, padding) = if arrow_fonts { + (ARROW_SEPARATOR, Some(4)) + } else { + ("", None) + }; + let (text, _text_width) = stringify_text( + &disabled_content, + padding, + &component_coordinates, + &declaration, + &colors, + text_style, + ); + let mut stringified = component_coordinates + .map(|c| c.to_string()) + .unwrap_or_else(|| String::new()); + stringified.push_str(&format!( + "{}{}{}{} {} {}{}{}", + RESET_STYLES, + first_arrow_styles, + arrow, + text_style, + text, + last_arrow_styles, + arrow, + RESET_STYLES + )); + return stringified.as_bytes().to_vec(); + } let declaration = if content.selected { colors.ribbon_selected } else { diff --git a/zellij-server/src/ui/components/text.rs b/zellij-server/src/ui/components/text.rs index eb8667f8..4209d433 100644 --- a/zellij-server/src/ui/components/text.rs +++ b/zellij-server/src/ui/components/text.rs @@ -1,4 +1,7 @@ -use super::{is_too_wide, parse_indices, parse_opaque, parse_selected, Coordinates}; +use super::{ + is_too_wide, parse_disabled, parse_indices, parse_opaque, parse_selected, Coordinates, +}; +use crate::panes::terminal_character::RESET_STYLES; use crate::panes::{terminal_character::CharacterStyles, AnsiCode}; use zellij_utils::{ data::{PaletteColor, Style, StyleDeclaration}, @@ -9,6 +12,32 @@ use unicode_width::UnicodeWidthChar; use zellij_utils::errors::prelude::*; pub fn text(content: Text, style: &Style, component_coordinates: Option<Coordinates>) -> Vec<u8> { + if content.disabled { + let declaration = style.colors.text_unselected; + let mut base_text_style = RESET_STYLES + .foreground(Some(declaration.base.into())) + .italic(Some(AnsiCode::On)); + if content.opaque || content.selected { + base_text_style = base_text_style.background(Some(declaration.background.into())); + } + let disabled_content = content.into_disabled(); + let (text, _text_width) = stringify_text( + &disabled_content, + None, + &component_coordinates, + &declaration, + &style.colors, + base_text_style, + ); + return match component_coordinates { + Some(component_coordinates) => { + format!("{}{}{}", component_coordinates, base_text_style, text) + .as_bytes() + .to_vec() + }, + None => format!("{}{}", base_text_style, text).as_bytes().to_vec(), + }; + } let declaration = if content.selected { style.colors.text_selected } else { @@ -138,12 +167,14 @@ pub fn parse_text_params<'a>(params_iter: impl Iterator<Item = &'a mut String>) .flat_map(|mut stringified| { let selected = parse_selected(&mut stringified); let opaque = parse_opaque(&mut stringified); + let disabled = parse_disabled(&mut stringified); let indices = parse_indices(&mut stringified); let text = parse_text(&mut stringified).map_err(|e| e.to_string())?; Ok::<Text, String>(Text { text, opaque, selected, + disabled, indices, }) }) @@ -155,10 +186,19 @@ pub struct Text { pub text: String, pub selected: bool, pub opaque: bool, + pub disabled: bool, pub indices: Vec<Vec<usize>>, } impl Text { + pub fn into_disabled(mut self) -> Self { + self.selected = false; + self.opaque = false; + self.disabled = true; + self.indices = vec![]; + self + } + pub fn pad_text(&mut self, max_column_width: usize) { for _ in ansi_len(&self.text)..max_column_width { self.text.push(' '); diff --git a/zellij-server/src/ui/pane_boundaries_frame.rs b/zellij-server/src/ui/pane_boundaries_frame.rs index 5c62bfa0..f5347f3f 100644 --- a/zellij-server/src/ui/pane_boundaries_frame.rs +++ b/zellij-server/src/ui/pane_boundaries_frame.rs @@ -2,6 +2,7 @@ use crate::output::CharacterChunk; use crate::panes::{ AnsiCode, CharacterStyles, RcCharacterStyles, TerminalCharacter, EMPTY_TERMINAL_CHARACTER, }; +use crate::tab::GuestChoiceIndicator; use crate::ui::boundaries::boundary_type; use crate::ui::hint_text::{ exit_code_segments, hover_segments, rerun_segments, resize_segments, HintExitStatus, HintLevel, @@ -96,6 +97,8 @@ pub struct FrameParams { pub stack_list_entry: Option<StackListEntry>, pub blank_title: bool, pub mouse_scroll_resize: bool, + pub dimmed: bool, + pub guest_choice_indicator: Option<GuestChoiceIndicator>, } #[derive(Default, PartialEq)] @@ -126,6 +129,8 @@ pub struct PaneFrame { stack_list_entry: Option<StackListEntry>, color_override: Option<PaletteColor>, mouse_scroll_resize: bool, + dimmed: bool, + guest_choice_indicator: Option<GuestChoiceIndicator>, } impl PaneFrame { @@ -162,6 +167,8 @@ impl PaneFrame { stack_list_entry: frame_params.stack_list_entry, color_override: None, mouse_scroll_resize: frame_params.mouse_scroll_resize, + dimmed: frame_params.dimmed, + guest_choice_indicator: frame_params.guest_choice_indicator, } } pub fn is_pinned(mut self, is_pinned: bool) -> Self { @@ -213,6 +220,12 @@ impl PaneFrame { &self, max_length: usize, ) -> Option<(Vec<TerminalCharacter>, usize)> { + self.render_title_right_side_inner(max_length) + } + fn render_title_right_side_inner( + &self, + max_length: usize, + ) -> Option<(Vec<TerminalCharacter>, usize)> { // string and length because of color let has_scroll = self.scroll_position.0 > 0 || self.scroll_position.1 > 0; if has_scroll && self.is_selectable { @@ -844,9 +857,9 @@ impl PaneFrame { let side_budget = width.saturating_sub(title_length) / 2; let focus = self.bracketed_focus_indicator(side_budget); let focus_length = focus.as_ref().map(|(_, length)| *length).unwrap_or(0); - let scroll = - self.bracketed_scroll_indicator(width.saturating_sub(focus_length + title_length)); - Ok(self.compose_bracketed_title(focus, title, scroll)) + let right_budget = width.saturating_sub(focus_length + title_length); + let right = self.bracketed_scroll_indicator(right_budget); + Ok(self.compose_bracketed_title(focus, title, right)) } fn bracketed_title_part(&self, content: &str) -> (Vec<TerminalCharacter>, usize) { let text = format!(" [ {} ] ", content); @@ -1466,6 +1479,8 @@ mod tests { stack_list_entry: None, blank_title: false, mouse_scroll_resize, + dimmed: false, + guest_choice_indicator: None, }, ) } diff --git a/zellij-server/src/ui/pane_contents_and_ui.rs b/zellij-server/src/ui/pane_contents_and_ui.rs index 668caec3..a6a401a1 100644 --- a/zellij-server/src/ui/pane_contents_and_ui.rs +++ b/zellij-server/src/ui/pane_contents_and_ui.rs @@ -1,4 +1,5 @@ -use crate::output::Output; +use crate::output::{CharacterChunk, Output}; +use crate::panes::terminal_character::AnsiCode; use crate::panes::PaneId; use crate::tab::Pane; use crate::ui::boundaries::Boundaries; @@ -8,6 +9,17 @@ use std::collections::{HashMap, HashSet}; use zellij_utils::data::{client_id_to_colors, InputMode, PaletteColor, Style}; use zellij_utils::errors::prelude::*; use zellij_utils::pane_size::PaneGeom; + +pub fn dim_character_chunks(character_chunks: &mut Vec<CharacterChunk>) { + for chunk in character_chunks.iter_mut() { + for terminal_character in chunk.terminal_characters.iter_mut() { + terminal_character.styles.update(|styles| { + styles.dim = Some(AnsiCode::On); + }); + } + } +} + pub struct PaneContentsAndUi<'a> { pane: &'a mut Box<dyn Pane>, output: &'a mut Output, @@ -28,6 +40,7 @@ pub struct PaneContentsAndUi<'a> { stack_list_entry_stack_is_focused: bool, blank_title: bool, mouse_scroll_resize: bool, + dimmed_for_clients: HashSet<ClientId>, } impl<'a> PaneContentsAndUi<'a> { @@ -46,6 +59,7 @@ impl<'a> PaneContentsAndUi<'a> { show_help_text: bool, omit_title: bool, mouse_scroll_resize: bool, + dimmed_for_clients: HashSet<ClientId>, ) -> Self { let mut focused_clients: Vec<ClientId> = active_panes .iter() @@ -83,8 +97,12 @@ impl<'a> PaneContentsAndUi<'a> { stack_list_entry_stack_is_focused: false, blank_title: false, mouse_scroll_resize, + dimmed_for_clients, } } + fn frame_is_dimmed_for_client(&self, client_id: ClientId) -> bool { + self.dimmed_for_clients.contains(&client_id) && !self.focused_clients.contains(&client_id) + } pub fn set_frame_geom_override(&mut self, frame_geom_override: Option<PaneGeom>) { self.frame_geom_override = frame_geom_override; } @@ -173,6 +191,40 @@ impl<'a> PaneContentsAndUi<'a> { } Ok(()) } + pub fn client_has_guest_modal(&self, client_id: ClientId) -> bool { + self.pane.guest_modal_selection(client_id).is_some() + } + pub fn drain_pane_render_state(&mut self) { + drop(self.pane.drain_fake_cursors()); + let _ = self.pane.render(None); + } + pub fn render_guest_modal_for_client(&mut self, client_id: ClientId) -> Result<()> { + let err_context = || format!("failed to render guest modal for client {client_id}"); + let selection = self.pane.guest_modal_selection(client_id).unwrap_or(0); + let session_name = self + .pane + .guest_session_name() + .unwrap_or_else(|| String::from("unknown")); + let columns = self.pane.get_content_columns(); + let rows = self.pane.get_content_rows(); + let content_x = self.pane.get_content_x(); + let content_y = self.pane.get_content_y(); + let shortcuts = self.pane.guest_modal_shortcuts(); + let chunks = crate::panes::nested_session_modal::guest_modal_chunks( + columns, + rows, + content_x, + content_y, + &self.style, + &session_name, + selection, + &shortcuts, + ); + self.output + .add_character_chunks_to_client(client_id, chunks, self.z_index) + .with_context(err_context)?; + Ok(()) + } pub fn render_fake_cursor_if_needed(&mut self, client_id: ClientId) -> Result<()> { let pane_focused_for_client_id = self.focused_clients.contains(&client_id); let pane_focused_for_different_client = self @@ -289,6 +341,8 @@ impl<'a> PaneContentsAndUi<'a> { .contains(&client_id) && !pane_focused_for_client_id), }); + let frame_is_dimmed = self.frame_is_dimmed_for_client(client_id); + let guest_choice_indicator = self.pane.guest_choice_indicator(client_id); let frame_params = if session_is_mirrored { FrameParams { focused_client, @@ -314,6 +368,8 @@ impl<'a> PaneContentsAndUi<'a> { stack_list_entry: stack_list_entry.clone(), blank_title: self.blank_title, mouse_scroll_resize: self.mouse_scroll_resize, + dimmed: frame_is_dimmed, + guest_choice_indicator, } } else { FrameParams { @@ -340,14 +396,19 @@ impl<'a> PaneContentsAndUi<'a> { stack_list_entry, blank_title: self.blank_title, mouse_scroll_resize: self.mouse_scroll_resize, + dimmed: frame_is_dimmed, + guest_choice_indicator, } }; - if let Some((frame_terminal_characters, vte_output)) = self + if let Some((mut frame_terminal_characters, vte_output)) = self .pane .render_frame(client_id, frame_params, client_mode) .with_context(err_context)? { + if self.frame_is_dimmed_for_client(client_id) { + dim_character_chunks(&mut frame_terminal_characters); + } self.output .add_character_chunks_to_client(client_id, frame_terminal_characters, self.z_index) .with_context(err_context)?; diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs index 03ed31de..9d839d30 100644 --- a/zellij-server/src/unit/screen_tests.rs +++ b/zellij-server/src/unit/screen_tests.rs @@ -18,7 +18,7 @@ use zellij_utils::input::layout::{ RunPlugin, RunPluginLocation, RunPluginOrAlias, SplitDirection, TiledPaneLayout, }; use zellij_utils::input::mouse::MouseEvent; -use zellij_utils::input::options::{Options, PaneFrameStyle}; +use zellij_utils::input::options::{NestedSessionHandling, Options, PaneFrameStyle}; use zellij_utils::ipc::IpcReceiverWithContext; use zellij_utils::pane_size::{Size, SizeInPixels}; use zellij_utils::position::Position; @@ -319,6 +319,7 @@ fn create_new_screen( false, // mouse_click_through web_server_ip, web_server_port, + NestedSessionHandling::default(), ); screen } @@ -329,6 +330,7 @@ struct MockScreen { pub pty_writer_receiver: Option<Receiver<(PtyWriteInstruction, ErrorContext)>>, #[allow(dead_code)] pub background_jobs_receiver: Option<Receiver<(BackgroundJob, ErrorContext)>>, + pub received_background_jobs: Arc<Mutex<Vec<BackgroundJob>>>, pub screen_receiver: Option<Receiver<(ScreenInstruction, ErrorContext)>>, pub server_receiver: Option<Receiver<(ServerInstruction, ErrorContext)>>, pub plugin_receiver: Option<Receiver<(PluginInstruction, ErrorContext)>>, @@ -639,6 +641,7 @@ impl MockScreen { current_input_modes: self.session_metadata.current_input_modes.clone(), web_sharing: WebSharing::Off, config_file_path: self.session_metadata.config_file_path.clone(), + key_passthrough_clients: self.session_metadata.key_passthrough_clients.clone(), } } } @@ -692,6 +695,7 @@ impl MockScreen { current_input_modes: HashMap::new(), web_sharing: WebSharing::Off, config_file_path: None, + key_passthrough_clients: Default::default(), }; let os_input = FakeInputOutput::default(); @@ -701,14 +705,17 @@ impl MockScreen { config.options.stacked_pane_list = Some(false); let main_client_id = 1; + let received_background_jobs = Arc::new(Mutex::new(vec![])); std::thread::Builder::new() .name("background_jobs_thread".to_string()) .spawn({ let to_screen = to_screen.clone(); + let received_background_jobs = received_background_jobs.clone(); move || loop { let (event, _err_ctx) = background_jobs_receiver .recv() .expect("failed to receive event on channel"); + received_background_jobs.lock().unwrap().push(event.clone()); match event { BackgroundJob::RenderToClients => { let _ = to_screen.send(ScreenInstruction::RenderToClients); @@ -726,6 +733,7 @@ impl MockScreen { pty_receiver: Some(pty_receiver), pty_writer_receiver: Some(pty_writer_receiver), background_jobs_receiver: None, + received_background_jobs, screen_receiver: Some(screen_receiver), server_receiver: Some(server_receiver), plugin_receiver: Some(plugin_receiver), @@ -2039,6 +2047,72 @@ fn mouse_focus_clears_bell_on_focused_pane() { } #[test] +fn nested_guest_fullscreen_moves_from_one_pane_to_another() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut screen = create_new_screen(size, true, true); + + new_tab(&mut screen, 1, 0); + let pane_one = PaneId::Terminal(1); + let pane_two = PaneId::Terminal(2); + { + let active_tab = screen.get_active_tab_mut(client_id).unwrap(); + active_tab + .horizontal_split(pane_two, None, client_id, None, None) + .unwrap(); + } + + screen.apply_nested_guest_fullscreen(pane_one, true); + { + let active_tab = screen.get_active_tab(client_id).unwrap(); + assert_eq!( + active_tab.fullscreen_pane_id(), + Some(pane_one), + "the first guest pane is fullscreen" + ); + assert!(active_tab.fullscreen_covers_ui()); + } + assert_eq!( + screen.nested_fullscreen_panes, + [pane_one].into_iter().collect(), + "only the first guest pane is tracked as fullscreen" + ); + + screen.apply_nested_guest_fullscreen(pane_two, true); + { + let active_tab = screen.get_active_tab(client_id).unwrap(); + assert_eq!( + active_tab.fullscreen_pane_id(), + Some(pane_two), + "fullscreen moved to the second guest pane" + ); + assert!(active_tab.fullscreen_covers_ui()); + } + assert_eq!( + screen.nested_fullscreen_panes, + [pane_two].into_iter().collect(), + "the first guest pane is no longer tracked as fullscreen, only the second is" + ); + + screen.apply_nested_guest_fullscreen(pane_two, false); + { + let active_tab = screen.get_active_tab(client_id).unwrap(); + assert_eq!( + active_tab.fullscreen_pane_id(), + None, + "unsetting fullscreen restores the tiled layout" + ); + } + assert!( + screen.nested_fullscreen_panes.is_empty(), + "no guest panes are tracked as fullscreen after unsetting" + ); +} + +#[test] fn group_panes_with_keyboard() { let size = Size { cols: 121, @@ -5452,6 +5526,7 @@ fn create_new_screen_with_message_capture( false, // mouse_click_through web_server_ip, web_server_port, + NestedSessionHandling::default(), ); (screen, messages) } @@ -8460,6 +8535,19 @@ impl ForwardCapture { } out } + + /// Drain every pending `ServerInstruction::KeyPassthroughChanged`, + /// returning the `notify_guest` flag for each. Other variants are + /// dropped. + fn drain_key_passthrough_notify_flags(&self) -> Vec<bool> { + let mut out = Vec::new(); + while let Ok((instr, _ctx)) = self.server_rx.try_recv() { + if let ServerInstruction::KeyPassthroughChanged(_, _, _, _, _, notify_guest) = instr { + out.push(notify_guest); + } + } + out + } } fn create_new_screen_with_forward_capture(size: Size) -> (Screen, ForwardCapture) { @@ -8535,6 +8623,7 @@ fn create_new_screen_with_forward_capture(size: Size) -> (Screen, ForwardCapture false, // mouse_click_through web_server_ip, web_server_port, + NestedSessionHandling::default(), ); ( screen, @@ -8681,6 +8770,92 @@ fn handle_reply_dispatches_next_queued_forward() { } #[test] +fn clear_nested_guest_does_not_notify_guest_focus_lost_on_teardown() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, capture) = create_new_screen_with_forward_capture(size); + let pane_id = PaneId::Terminal(7); + let client_id = 1; + screen + .nested_guest_choices + .insert((client_id, pane_id), super::NestedGuestChoice::Descend); + + screen.clear_nested_guest(pane_id); + + let notify_flags = capture.drain_key_passthrough_notify_flags(); + assert_eq!( + notify_flags, + vec![false], + "teardown clear must emit KeyPassthroughChanged with notify_guest=false so no FocusLost \ + frame is written to the exiting guest pane" + ); +} + +#[test] +fn remove_client_notifies_guest_focus_lost_on_live_ascend() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, capture) = create_new_screen_with_forward_capture(size); + let pane_id = PaneId::Terminal(7); + let client_id = 1; + screen + .nested_guest_choices + .insert((client_id, pane_id), super::NestedGuestChoice::Descend); + + screen.remove_client(client_id).expect("remove_client ok"); + + let notify_flags = capture.drain_key_passthrough_notify_flags(); + assert_eq!( + notify_flags, + vec![true], + "a live client leaving a still-alive guest must emit notify_guest=true so the guest is \ + told it lost focus" + ); +} + +#[test] +fn suspend_nested_guest_preserves_choices_for_later_revival() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, _capture) = create_new_screen_with_forward_capture(size); + let pane_id = PaneId::Terminal(7); + let client_id = 1; + screen + .nested_guest_choices + .insert((client_id, pane_id), super::NestedGuestChoice::Descend); + screen + .nested_guest_tracker + .on_announce(pane_id, std::time::Instant::now()); + + screen.suspend_nested_guest(pane_id); + + assert!( + screen + .nested_guest_choices + .contains_key(&(client_id, pane_id)), + "suspend must preserve the client's descend choice so a re-announcing guest can be revived \ + into the exact prior state" + ); +} + +#[test] +fn clear_nested_guest_discards_choices_unlike_suspend() { + let size = Size { cols: 80, rows: 20 }; + let (mut screen, _capture) = create_new_screen_with_forward_capture(size); + let pane_id = PaneId::Terminal(7); + let client_id = 1; + screen + .nested_guest_choices + .insert((client_id, pane_id), super::NestedGuestChoice::Descend); + + screen.clear_nested_guest(pane_id); + + assert!( + !screen + .nested_guest_choices + .contains_key(&(client_id, pane_id)), + "a full teardown (Bye/ClosePane) must discard choices, distinguishing it from suspend" + ); +} + +#[test] fn handle_reply_with_unknown_token_is_silent_noop() { // Token not in the map AND not the in-flight token: the handler // must not panic, must not write any bytes, and crucially must @@ -9120,6 +9295,7 @@ fn create_new_screen_with_theme_capture(size: Size) -> (Screen, ThemeCapture) { false, web_server_ip, web_server_port, + NestedSessionHandling::default(), ); ( screen, @@ -9243,7 +9419,7 @@ fn color_palette_mode_query_short_circuits_to_light_reply() { } #[test] -fn color_palette_mode_query_stays_silent_when_host_mode_unknown() { +fn color_palette_mode_query_falls_back_to_own_dark_theme_when_host_mode_unknown() { use crate::host_query::HostQuery; let size = Size { cols: 80, rows: 20 }; let (mut screen, capture) = create_new_screen_with_forward_capture(size); @@ -9251,14 +9427,60 @@ fn color_palette_mode_query_stays_silent_when_host_mode_unknown() { screen.host_terminal_theme_mode.is_none(), "precondition: no host mode learned yet" ); + screen.style.colors.text_unselected.background = + zellij_utils::data::PaletteColor::Rgb((0, 0, 0)); - let _ = screen.forward_host_query(PaneId::Terminal(1), HostQuery::ColorPaletteMode); + let token = screen.forward_host_query(PaneId::Terminal(1), HostQuery::ColorPaletteMode); + assert_eq!( + token, 0, + "ColorPaletteMode must return the sentinel token; no real forward was queued" + ); assert!( - capture.drain_pty_writes().is_empty(), - "Contour spec defines only ;1 (dark) and ;2 (light); when Zellij has \ - not learned the host's mode it must stay silent rather than fabricate \ - a non-conformant reply (e.g. ;0)" + capture.drain_forward_queries().is_empty(), + "must NOT forward to host — Zellij answers from its own effective theme" + ); + let writes = capture.drain_pty_writes(); + assert_eq!(writes.len(), 1, "exactly one pty reply expected"); + assert_eq!(writes[0], (b"\x1b[?997;1n".to_vec(), 1)); +} + +#[test] +fn color_palette_mode_query_falls_back_to_own_light_theme_when_host_mode_unknown() { + use crate::host_query::HostQuery; + let size = Size { cols: 80, rows: 20 }; + let (mut screen, capture) = create_new_screen_with_forward_capture(size); + assert!( + screen.host_terminal_theme_mode.is_none(), + "precondition: no host mode learned yet" + ); + screen.style.colors.text_unselected.background = + zellij_utils::data::PaletteColor::Rgb((255, 255, 255)); + + let _ = screen.forward_host_query(PaneId::Terminal(7), HostQuery::ColorPaletteMode); + + let writes = capture.drain_pty_writes(); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0], (b"\x1b[?997;2n".to_vec(), 7)); +} + +#[test] +fn color_palette_mode_query_prefers_known_host_mode_over_own_theme() { + use crate::host_query::HostQuery; + let size = Size { cols: 80, rows: 20 }; + let (mut screen, capture) = create_new_screen_with_forward_capture(size); + screen.host_terminal_theme_mode = Some(zellij_utils::data::HostTerminalThemeMode::Light); + screen.style.colors.text_unselected.background = + zellij_utils::data::PaletteColor::Rgb((0, 0, 0)); + + let _ = screen.forward_host_query(PaneId::Terminal(9), HostQuery::ColorPaletteMode); + + let writes = capture.drain_pty_writes(); + assert_eq!(writes.len(), 1); + assert_eq!( + writes[0], + (b"\x1b[?997;2n".to_vec(), 9), + "the host's announced mode must win over the local theme fallback" ); } @@ -9601,6 +9823,7 @@ fn create_non_mirrored_screen(size: Size) -> Screen { false, // mouse_click_through IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080, + NestedSessionHandling::default(), ) } @@ -11171,3 +11394,176 @@ fn exiting_mobile_releases_the_plugin_render() { "exiting mobile must release the plugin render; sent={sent:?}", ); } + +fn decode_nested_frame(bytes: &[u8]) -> Option<zellij_utils::nested_session::NestedSessionMessage> { + use zellij_utils::nested_session::{ + decode_base64, decode_payload, NESTED_FRAME_HEADER, NESTED_FRAME_TERMINATOR, + }; + let encoded_payload = bytes + .strip_prefix(NESTED_FRAME_HEADER)? + .strip_suffix(NESTED_FRAME_TERMINATOR)?; + decode_payload(&decode_base64(encoded_payload)?) +} + +fn guest_announce_message() -> zellij_utils::nested_session::NestedSessionMessage { + zellij_utils::nested_session::NestedSessionMessage::Announce { + session_name: "guest-session".to_owned(), + capabilities: vec![zellij_utils::nested_session::NestedSessionCapability::NestedControl], + } +} + +#[test] +pub fn nested_guest_announce_gets_announce_ack_with_ancestry() { + let size = Size { + cols: 121, + rows: 20, + }; + let mut mock_screen = MockScreen::new(size); + let pty_writer_receiver = mock_screen.pty_writer_receiver.take().unwrap(); + let received_background_jobs = mock_screen.received_background_jobs.clone(); + let screen_thread = mock_screen.run(None, vec![]); + let received_pty_instructions = Arc::new(Mutex::new(vec![])); + let pty_writer_thread = log_actions_in_thread!( + received_pty_instructions, + PtyWriteInstruction::Exit, + pty_writer_receiver + ); + let _ = mock_screen + .to_screen + .send(ScreenInstruction::NestedSessionMessageFromPane { + pane_id: PaneId::Terminal(0), + message: guest_announce_message(), + }); + std::thread::sleep(std::time::Duration::from_millis(100)); + mock_screen.teardown(vec![pty_writer_thread, screen_thread]); + let announce_ack = received_pty_instructions + .lock() + .unwrap() + .iter() + .find_map(|instruction| match instruction { + PtyWriteInstruction::Write(bytes, 0, None) => decode_nested_frame(bytes), + _ => None, + }) + .expect("an announce_ack frame written to the guest pane"); + assert_eq!( + announce_ack, + zellij_utils::nested_session::NestedSessionMessage::AnnounceAck { + ancestry: vec!["zellij-test".to_owned()], + capabilities: vec![ + zellij_utils::nested_session::NestedSessionCapability::NestedControl + ], + descend_keys: vec![], + } + ); + assert!(received_background_jobs + .lock() + .unwrap() + .iter() + .any(|job| matches!( + job, + BackgroundJob::StartNestedGuestPing(PaneId::Terminal(0)) + ))); +} + +#[test] +pub fn nested_guest_announce_in_never_mode_still_completes_handshake() { + let size = Size { + cols: 121, + rows: 20, + }; + let mut mock_screen = MockScreen::new(size); + mock_screen.config.options.nested_session_handling = Some(NestedSessionHandling::Never); + let pty_writer_receiver = mock_screen.pty_writer_receiver.take().unwrap(); + let received_background_jobs = mock_screen.received_background_jobs.clone(); + let screen_thread = mock_screen.run(None, vec![]); + let received_pty_instructions = Arc::new(Mutex::new(vec![])); + let pty_writer_thread = log_actions_in_thread!( + received_pty_instructions, + PtyWriteInstruction::Exit, + pty_writer_receiver + ); + let _ = mock_screen + .to_screen + .send(ScreenInstruction::NestedSessionMessageFromPane { + pane_id: PaneId::Terminal(0), + message: guest_announce_message(), + }); + std::thread::sleep(std::time::Duration::from_millis(100)); + mock_screen.teardown(vec![pty_writer_thread, screen_thread]); + let announce_ack = received_pty_instructions + .lock() + .unwrap() + .iter() + .find_map(|instruction| match instruction { + PtyWriteInstruction::Write(bytes, 0, None) => decode_nested_frame(bytes), + _ => None, + }) + .expect("an announce_ack frame written to the guest pane even in never mode"); + assert!(matches!( + announce_ack, + zellij_utils::nested_session::NestedSessionMessage::AnnounceAck { .. } + )); + assert!(received_background_jobs + .lock() + .unwrap() + .iter() + .any(|job| matches!( + job, + BackgroundJob::StartNestedGuestPing(PaneId::Terminal(0)) + ))); +} + +#[test] +pub fn nested_guest_bye_stops_liveness_pings() { + let size = Size { + cols: 121, + rows: 20, + }; + let mut mock_screen = MockScreen::new(size); + let pty_writer_receiver = mock_screen.pty_writer_receiver.take().unwrap(); + let received_background_jobs = mock_screen.received_background_jobs.clone(); + let screen_thread = mock_screen.run(None, vec![]); + let received_pty_instructions = Arc::new(Mutex::new(vec![])); + let pty_writer_thread = log_actions_in_thread!( + received_pty_instructions, + PtyWriteInstruction::Exit, + pty_writer_receiver + ); + let _ = mock_screen + .to_screen + .send(ScreenInstruction::NestedSessionMessageFromPane { + pane_id: PaneId::Terminal(0), + message: guest_announce_message(), + }); + let _ = mock_screen + .to_screen + .send(ScreenInstruction::NestedSessionMessageFromPane { + pane_id: PaneId::Terminal(0), + message: zellij_utils::nested_session::NestedSessionMessage::Bye, + }); + let _ = mock_screen + .to_screen + .send(ScreenInstruction::NestedGuestPingTick { + pane_id: PaneId::Terminal(0), + }); + std::thread::sleep(std::time::Duration::from_millis(100)); + mock_screen.teardown(vec![pty_writer_thread, screen_thread]); + let ping_frames_written = received_pty_instructions + .lock() + .unwrap() + .iter() + .filter(|instruction| match instruction { + PtyWriteInstruction::Write(bytes, 0, None) => matches!( + decode_nested_frame(bytes), + Some(zellij_utils::nested_session::NestedSessionMessage::Ping) + ), + _ => false, + }) + .count(); + assert_eq!(ping_frames_written, 0); + assert!(received_background_jobs + .lock() + .unwrap() + .iter() + .any(|job| matches!(job, BackgroundJob::StopNestedGuestPing(PaneId::Terminal(0))))); +} diff --git a/zellij-tile/src/shim.rs b/zellij-tile/src/shim.rs index 00be00bf..5fdbe915 100644 --- a/zellij-tile/src/shim.rs +++ b/zellij-tile/src/shim.rs @@ -1292,6 +1292,20 @@ pub fn toggle_focus_fullscreen() { unsafe { host_run_plugin_command() }; } +pub fn toggle_focus_no_ui_fullscreen() { + let plugin_command = PluginCommand::ToggleFocusNoUiFullscreen; + let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap(); + object_to_stdout(&protobuf_plugin_command.encode_to_vec()); + unsafe { host_run_plugin_command() }; +} + +pub fn focus_host_session() { + let plugin_command = PluginCommand::FocusHostSession; + let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap(); + object_to_stdout(&protobuf_plugin_command.encode_to_vec()); + unsafe { host_run_plugin_command() }; +} + /// Toggle the UI pane frames on or off pub fn toggle_pane_frames() { let plugin_command = PluginCommand::TogglePaneFrames; diff --git a/zellij-tile/src/ui_components/text.rs b/zellij-tile/src/ui_components/text.rs index ddfbc0e8..fa1533ca 100644 --- a/zellij-tile/src/ui_components/text.rs +++ b/zellij-tile/src/ui_components/text.rs @@ -7,6 +7,7 @@ pub struct Text { text: String, selected: bool, opaque: bool, + disabled: bool, indices: Vec<Vec<usize>>, } @@ -16,6 +17,7 @@ impl From<StyledText> for Text { text: styled_text.text, selected: false, opaque: false, + disabled: false, indices: styled_text.indices, } } @@ -30,6 +32,7 @@ impl Text { text: content.to_string(), selected: false, opaque: false, + disabled: false, indices: vec![], } } @@ -41,6 +44,10 @@ impl Text { self.opaque = true; self } + pub fn disabled(mut self) -> Self { + self.disabled = true; + self + } pub fn dim_indices(mut self, mut indices: Vec<usize>) -> Self { const DIM_LEVEL: usize = 4; self.pad_indices(DIM_LEVEL); @@ -406,11 +413,15 @@ impl Text { let mut prefix = "".to_owned(); if self.selected { - prefix = format!("x{}", prefix); + prefix.push('x'); } if self.opaque { - prefix = format!("z{}", prefix); + prefix.push('z'); + } + + if self.disabled { + prefix.push('d'); } format!("{}{}{}", prefix, indices, text) @@ -465,3 +476,45 @@ pub fn serialize_text_with_coordinates( text.serialize() ) } + +#[cfg(test)] +mod tests { + use super::*; + + fn text_body(text: &str) -> String { + text.as_bytes() + .iter() + .map(|byte| byte.to_string()) + .collect::<Vec<_>>() + .join(",") + } + + #[test] + fn disabled_flag_serializes_with_a_d_prefix() { + let serialized = Text::new("x").disabled().serialize(); + assert_eq!(serialized, format!("d{}", text_body("x"))); + } + + #[test] + fn opaque_and_disabled_serialize_in_push_order() { + let serialized = Text::new("x").disabled().opaque().serialize(); + assert_eq!(serialized, format!("zd{}", text_body("x"))); + } + + #[test] + fn all_flags_serialize_in_selected_opaque_disabled_order() { + let serialized = Text::new("x").disabled().opaque().selected().serialize(); + assert_eq!(serialized, format!("xzd{}", text_body("x"))); + } + + #[test] + fn flags_do_not_disturb_indices_or_text() { + let serialized = Text::new("Foo bar baz") + .disabled() + .color_indices(0, vec![0, 1, 2]) + .serialize(); + assert!(serialized.starts_with('d')); + assert!(serialized.contains("0,1,2$")); + assert!(serialized.ends_with(&text_body("Foo bar baz"))); + } +} diff --git a/zellij-utils/Cargo.toml b/zellij-utils/Cargo.toml index 24f0e8c2..4a543e08 100644 --- a/zellij-utils/Cargo.toml +++ b/zellij-utils/Cargo.toml @@ -41,6 +41,7 @@ sha2 = { workspace = true } libc = { workspace = true } [target.'cfg(not(target_family = "wasm"))'.dependencies] +base64 = { version = "0.13.0", default-features = false, features = ["std"] } tokio = { workspace = true } tokio-stream = { version = "0.1" } tokio-util = { version = "0.7", default-features = false, features = ["compat", "io"] } diff --git a/zellij-utils/assets/config/default.kdl b/zellij-utils/assets/config/default.kdl index 22ae3699..a551dc80 100644 --- a/zellij-utils/assets/config/default.kdl +++ b/zellij-utils/assets/config/default.kdl @@ -33,6 +33,7 @@ keybinds { bind "s" { NewPane "stacked"; SwitchToMode "Normal"; } bind "x" { CloseFocus; SwitchToMode "Normal"; } bind "f" { ToggleFocusFullscreen; SwitchToMode "Normal"; } + bind "Shift f" { ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; } bind "z" { TogglePaneFrames; SwitchToMode "Normal"; } bind "w" { ToggleFloatingPanes; SwitchToMode "Normal"; } bind "e" { TogglePaneEmbedOrFloating; SwitchToMode "Normal"; } @@ -115,6 +116,9 @@ keybinds { bind "Ctrl o" { SwitchToMode "Normal"; } bind "Ctrl s" { SwitchToMode "Scroll"; } bind "d" { Detach; } + bind "]" { FocusHostSession; SwitchToMode "Normal"; } + bind "[" { FocusGuestSession; SwitchToMode "Normal"; } + bind "f" { ToggleHostFullscreen; SwitchToMode "Normal"; } bind "w" { LaunchOrFocusPlugin "session-manager" { floating true @@ -261,6 +265,15 @@ load_plugins { // // on_force_close "quit" +// Choose how to handle a nested Zellij session detected running inside a pane +// Options: +// - ask (Default - prompt with a modal to zoom in or control on focus) +// - fullscreen (always zoom into the nested session) +// - descend (always control the nested session on focus) +// - never (never prompt or descend; do it manually) +// +// nested_session_handling "ask" + // Send a request for a simplified ui (without arrow fonts) to plugins // Options: // - true diff --git a/zellij-utils/assets/plugins/about.wasm b/zellij-utils/assets/plugins/about.wasm Binary files differindex 5f8c923e..927632bc 100755 --- a/zellij-utils/assets/plugins/about.wasm +++ b/zellij-utils/assets/plugins/about.wasm diff --git a/zellij-utils/assets/plugins/compact-bar.wasm b/zellij-utils/assets/plugins/compact-bar.wasm Binary files differindex 0f55fac9..bf73dadd 100755 --- a/zellij-utils/assets/plugins/compact-bar.wasm +++ b/zellij-utils/assets/plugins/compact-bar.wasm diff --git a/zellij-utils/assets/plugins/configuration.wasm b/zellij-utils/assets/plugins/configuration.wasm Binary files differindex dcd848fa..d615cdca 100755 --- a/zellij-utils/assets/plugins/configuration.wasm +++ b/zellij-utils/assets/plugins/configuration.wasm diff --git a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm Binary files differindex ccde7690..1adf8d94 100755 --- a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm +++ b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm diff --git a/zellij-utils/assets/plugins/layout-manager.wasm b/zellij-utils/assets/plugins/layout-manager.wasm Binary files differindex c5382f72..fece91ea 100755 --- a/zellij-utils/assets/plugins/layout-manager.wasm +++ b/zellij-utils/assets/plugins/layout-manager.wasm diff --git a/zellij-utils/assets/plugins/link.wasm b/zellij-utils/assets/plugins/link.wasm Binary files differindex 32d2aba3..18a96f47 100755 --- a/zellij-utils/assets/plugins/link.wasm +++ b/zellij-utils/assets/plugins/link.wasm diff --git a/zellij-utils/assets/plugins/mobile.wasm b/zellij-utils/assets/plugins/mobile.wasm Binary files differindex 05bd6516..00ec86de 100755 --- a/zellij-utils/assets/plugins/mobile.wasm +++ b/zellij-utils/assets/plugins/mobile.wasm diff --git a/zellij-utils/assets/plugins/multiple-select.wasm b/zellij-utils/assets/plugins/multiple-select.wasm Binary files differindex 036a726e..2f2767d5 100755 --- a/zellij-utils/assets/plugins/multiple-select.wasm +++ b/zellij-utils/assets/plugins/multiple-select.wasm diff --git a/zellij-utils/assets/plugins/plugin-manager.wasm b/zellij-utils/assets/plugins/plugin-manager.wasm Binary files differindex 2d4caf64..e146e20e 100755 --- a/zellij-utils/assets/plugins/plugin-manager.wasm +++ b/zellij-utils/assets/plugins/plugin-manager.wasm diff --git a/zellij-utils/assets/plugins/session-manager.wasm b/zellij-utils/assets/plugins/session-manager.wasm Binary files differindex 61e16dd8..bd2ba4b0 100755 --- a/zellij-utils/assets/plugins/session-manager.wasm +++ b/zellij-utils/assets/plugins/session-manager.wasm diff --git a/zellij-utils/assets/plugins/share.wasm b/zellij-utils/assets/plugins/share.wasm Binary files differindex fa0fb2e7..9fe98cb2 100755 --- a/zellij-utils/assets/plugins/share.wasm +++ b/zellij-utils/assets/plugins/share.wasm diff --git a/zellij-utils/assets/plugins/status-bar.wasm b/zellij-utils/assets/plugins/status-bar.wasm Binary files differindex 70a76358..d2f14b1b 100755 --- a/zellij-utils/assets/plugins/status-bar.wasm +++ b/zellij-utils/assets/plugins/status-bar.wasm diff --git a/zellij-utils/assets/plugins/strider.wasm b/zellij-utils/assets/plugins/strider.wasm Binary files differindex 9197346b..f348f0b8 100755 --- a/zellij-utils/assets/plugins/strider.wasm +++ b/zellij-utils/assets/plugins/strider.wasm diff --git a/zellij-utils/assets/plugins/tab-bar.wasm b/zellij-utils/assets/plugins/tab-bar.wasm Binary files differindex f52aa749..0bce4ec4 100755 --- a/zellij-utils/assets/plugins/tab-bar.wasm +++ b/zellij-utils/assets/plugins/tab-bar.wasm diff --git a/zellij-utils/assets/prost/api.action.rs b/zellij-utils/assets/prost/api.action.rs index a703c9f0..4860feb1 100644 --- a/zellij-utils/assets/prost/api.action.rs +++ b/zellij-utils/assets/prost/api.action.rs @@ -1134,6 +1134,10 @@ pub enum ActionName { ToggleTheme = 103, FocusLastPane = 104, SetPaneFrameStyle = 105, + ToggleFocusNoUiFullscreen = 106, + FocusHostSession = 107, + ToggleHostFullscreen = 108, + FocusGuestSession = 109, } impl ActionName { /// String value of the enum field names used in the ProtoBuf definition. @@ -1245,6 +1249,10 @@ impl ActionName { ActionName::ToggleTheme => "ToggleTheme", ActionName::FocusLastPane => "FocusLastPane", ActionName::SetPaneFrameStyle => "SetPaneFrameStyle", + ActionName::ToggleFocusNoUiFullscreen => "ToggleFocusNoUiFullscreen", + ActionName::FocusHostSession => "FocusHostSession", + ActionName::ToggleHostFullscreen => "ToggleHostFullscreen", + ActionName::FocusGuestSession => "FocusGuestSession", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -1353,6 +1361,10 @@ impl ActionName { "ToggleTheme" => Some(Self::ToggleTheme), "FocusLastPane" => Some(Self::FocusLastPane), "SetPaneFrameStyle" => Some(Self::SetPaneFrameStyle), + "ToggleFocusNoUiFullscreen" => Some(Self::ToggleFocusNoUiFullscreen), + "FocusHostSession" => Some(Self::FocusHostSession), + "ToggleHostFullscreen" => Some(Self::ToggleHostFullscreen), + "FocusGuestSession" => Some(Self::FocusGuestSession), _ => None, } } diff --git a/zellij-utils/assets/prost/api.event.rs b/zellij-utils/assets/prost/api.event.rs index 8f050da7..4230d347 100644 --- a/zellij-utils/assets/prost/api.event.rs +++ b/zellij-utils/assets/prost/api.event.rs @@ -703,6 +703,18 @@ pub struct ModeUpdatePayload { pub web_server_capability: ::core::option::Option<bool>, #[prost(enumeration="PaneFrameStyle", optional, tag="16")] pub pane_frame_style: ::core::option::Option<i32>, + #[prost(bool, optional, tag="17")] + pub session_dimmed: ::core::option::Option<bool>, + #[prost(string, repeated, tag="18")] + pub ancestry: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(bool, optional, tag="19")] + pub host_fullscreen: ::core::option::Option<bool>, + #[prost(string, repeated, tag="20")] + pub nested_ascend_keys: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(bool, optional, tag="21")] + pub session_ascended: ::core::option::Option<bool>, + #[prost(string, repeated, tag="22")] + pub nested_descend_keys: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] diff --git a/zellij-utils/assets/prost/api.plugin_command.rs b/zellij-utils/assets/prost/api.plugin_command.rs index 676361b2..9f053ae3 100644 --- a/zellij-utils/assets/prost/api.plugin_command.rs +++ b/zellij-utils/assets/prost/api.plugin_command.rs @@ -2249,6 +2249,8 @@ pub enum CommandName { SetPaneFrameStyle = 224, ToggleFloatingPanes = 225, NewPane = 226, + ToggleFocusNoUiFullscreen = 227, + FocusHostSession = 228, } impl CommandName { /// String value of the enum field names used in the ProtoBuf definition. @@ -2461,6 +2463,8 @@ impl CommandName { CommandName::SetPaneFrameStyle => "SetPaneFrameStyle", CommandName::ToggleFloatingPanes => "ToggleFloatingPanes", CommandName::NewPane => "NewPane", + CommandName::ToggleFocusNoUiFullscreen => "ToggleFocusNoUiFullscreen", + CommandName::FocusHostSession => "FocusHostSession", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -2670,6 +2674,8 @@ impl CommandName { "SetPaneFrameStyle" => Some(Self::SetPaneFrameStyle), "ToggleFloatingPanes" => Some(Self::ToggleFloatingPanes), "NewPane" => Some(Self::NewPane), + "ToggleFocusNoUiFullscreen" => Some(Self::ToggleFocusNoUiFullscreen), + "FocusHostSession" => Some(Self::FocusHostSession), _ => None, } } diff --git a/zellij-utils/assets/prost_ipc/client_server_contract.rs b/zellij-utils/assets/prost_ipc/client_server_contract.rs index 7cb4a620..11cc7937 100644 --- a/zellij-utils/assets/prost_ipc/client_server_contract.rs +++ b/zellij-utils/assets/prost_ipc/client_server_contract.rs @@ -119,7 +119,7 @@ pub struct RgbColor { #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Action { - #[prost(oneof="action::ActionType", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142")] + #[prost(oneof="action::ActionType", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147")] pub action_type: ::core::option::Option<action::ActionType>, } /// Nested message and enum types in `Action`. @@ -413,6 +413,16 @@ pub mod action { FocusLastPane(super::FocusLastPaneAction), #[prost(message, tag="142")] SetPaneFrameStyle(super::SetPaneFrameStyleAction), + #[prost(message, tag="143")] + ToggleFocusNoUiFullscreen(super::ToggleFocusNoUiFullscreenAction), + #[prost(message, tag="144")] + ToggleNoUiFullscreenByPaneId(super::ToggleNoUiFullscreenByPaneIdAction), + #[prost(message, tag="145")] + FocusHostSession(super::FocusHostSessionAction), + #[prost(message, tag="146")] + ToggleHostFullscreen(super::ToggleHostFullscreenAction), + #[prost(message, tag="147")] + FocusGuestSession(super::FocusGuestSessionAction), } } // Action message definitions (all 92 variants) @@ -436,6 +446,18 @@ pub struct FocusLastPaneAction { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct FocusHostSessionAction { +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct FocusGuestSessionAction { +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ToggleHostFullscreenAction { +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct SwitchFocusAction { } #[allow(clippy::derive_partial_eq_without_eq)] @@ -498,6 +520,10 @@ pub struct ToggleFocusFullscreenAction { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct ToggleFocusNoUiFullscreenAction { +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct TogglePaneFramesAction { } #[allow(clippy::derive_partial_eq_without_eq)] @@ -1967,7 +1993,7 @@ pub struct Options { pub show_release_notes: ::core::option::Option<bool>, #[prost(bool, optional, tag="33")] pub advanced_mouse_actions: ::core::option::Option<bool>, - #[prost(bool, optional, tag="53")] + #[prost(bool, optional, tag="54")] pub mouse_scroll_resize: ::core::option::Option<bool>, #[prost(string, optional, tag="34")] pub web_server_ip: ::core::option::Option<::prost::alloc::string::String>, @@ -2007,6 +2033,8 @@ pub struct Options { pub pane_frame_style: ::core::option::Option<::prost::alloc::string::String>, #[prost(bool, optional, tag="52")] pub stacked_pane_list: ::core::option::Option<bool>, + #[prost(enumeration="NestedSessionHandling", optional, tag="53")] + pub nested_session_handling: ::core::option::Option<i32>, } /// Pane-targeting action messages #[allow(clippy::derive_partial_eq_without_eq)] @@ -2101,6 +2129,12 @@ pub struct ToggleFullscreenByPaneIdAction { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct ToggleNoUiFullscreenByPaneIdAction { + #[prost(message, optional, tag="1")] + pub pane_id: ::core::option::Option<PaneId>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct TogglePaneEmbedOrFloatingByPaneIdAction { #[prost(message, optional, tag="1")] pub pane_id: ::core::option::Option<PaneId>, @@ -2966,10 +3000,45 @@ impl MobileLayout { } } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum NestedSessionHandling { + Unspecified = 0, + Ask = 1, + Fullscreen = 2, + Descend = 3, + Never = 4, +} +impl NestedSessionHandling { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + NestedSessionHandling::Unspecified => "NESTED_SESSION_HANDLING_UNSPECIFIED", + NestedSessionHandling::Ask => "NESTED_SESSION_HANDLING_ASK", + NestedSessionHandling::Fullscreen => "NESTED_SESSION_HANDLING_FULLSCREEN", + NestedSessionHandling::Descend => "NESTED_SESSION_HANDLING_DESCEND", + NestedSessionHandling::Never => "NESTED_SESSION_HANDLING_NEVER", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option<Self> { + match value { + "NESTED_SESSION_HANDLING_UNSPECIFIED" => Some(Self::Unspecified), + "NESTED_SESSION_HANDLING_ASK" => Some(Self::Ask), + "NESTED_SESSION_HANDLING_FULLSCREEN" => Some(Self::Fullscreen), + "NESTED_SESSION_HANDLING_DESCEND" => Some(Self::Descend), + "NESTED_SESSION_HANDLING_NEVER" => Some(Self::Never), + _ => None, + } + } +} #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ClientToServerMsg { - #[prost(oneof="client_to_server_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21")] + #[prost(oneof="client_to_server_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22")] pub message: ::core::option::Option<client_to_server_msg::Message>, } /// Nested message and enum types in `ClientToServerMsg`. @@ -3019,6 +3088,8 @@ pub mod client_to_server_msg { HostTerminalThemeChanged(super::HostTerminalThemeChangedMsg), #[prost(message, tag="21")] SoftKeyboardVisibilityChanged(super::SoftKeyboardVisibilityChangedMsg), + #[prost(message, tag="22")] + NestedSessionFrameFromHost(super::NestedSessionFrameFromHostMsg), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -3172,6 +3243,12 @@ pub struct SoftKeyboardVisibilityChangedMsg { #[prost(bool, tag="1")] pub visible: bool, } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct NestedSessionFrameFromHostMsg { + #[prost(bytes="vec", tag="1")] + pub payload_bytes: ::prost::alloc::vec::Vec<u8>, +} #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] pub enum ResizeCause { @@ -3230,7 +3307,7 @@ impl HostTerminalThemeIndication { #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ServerToClientMsg { - #[prost(oneof="server_to_client_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17")] + #[prost(oneof="server_to_client_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18")] pub message: ::core::option::Option<server_to_client_msg::Message>, } /// Nested message and enum types in `ServerToClientMsg`. @@ -3272,6 +3349,8 @@ pub mod server_to_client_msg { ForwardQueryToHost(super::ForwardQueryToHostMsg), #[prost(message, tag="17")] SetSoftKeyboard(super::SetSoftKeyboardMsg), + #[prost(message, tag="18")] + EmitNestedSessionFrame(super::EmitNestedSessionFrameMsg), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -3385,3 +3464,9 @@ pub struct SetSoftKeyboardMsg { #[prost(bool, tag="1")] pub on: bool, } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct EmitNestedSessionFrameMsg { + #[prost(bytes="vec", tag="1")] + pub payload_bytes: ::prost::alloc::vec::Vec<u8>, +} diff --git a/zellij-utils/assets/prost_nested_session/generated_nested_session_api.rs b/zellij-utils/assets/prost_nested_session/generated_nested_session_api.rs new file mode 100644 index 00000000..59f16320 --- /dev/null +++ b/zellij-utils/assets/prost_nested_session/generated_nested_session_api.rs @@ -0,0 +1,3 @@ +pub mod nested_session_contract { + include!("nested_session_contract.rs"); +} diff --git a/zellij-utils/assets/prost_nested_session/nested_session_contract.rs b/zellij-utils/assets/prost_nested_session/nested_session_contract.rs new file mode 100644 index 00000000..1a9e117a --- /dev/null +++ b/zellij-utils/assets/prost_nested_session/nested_session_contract.rs @@ -0,0 +1,170 @@ +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct NestedSessionMessage { + #[prost(oneof="nested_session_message::Payload", tags="1, 2, 3, 4, 5, 8, 9, 10, 11, 12, 13, 14")] + pub payload: ::core::option::Option<nested_session_message::Payload>, +} +/// Nested message and enum types in `NestedSessionMessage`. +pub mod nested_session_message { + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Payload { + #[prost(message, tag="1")] + Announce(super::Announce), + #[prost(message, tag="2")] + FocusHost(super::FocusHost), + #[prost(message, tag="3")] + HostFullscreen(super::ToggleHostFullscreen), + #[prost(message, tag="4")] + Pong(super::Pong), + #[prost(message, tag="5")] + Bye(super::Bye), + #[prost(message, tag="8")] + AnnounceAck(super::AnnounceAck), + #[prost(message, tag="9")] + FocusGained(super::FocusGained), + #[prost(message, tag="10")] + FocusLost(super::FocusLost), + #[prost(message, tag="11")] + FullscreenState(super::FullscreenState), + #[prost(message, tag="12")] + AncestryUpdate(super::AncestryUpdate), + #[prost(message, tag="13")] + Ping(super::Ping), + #[prost(message, tag="14")] + ShortcutUpdate(super::ShortcutUpdate), + } +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Announce { + #[prost(string, tag="1")] + pub session_name: ::prost::alloc::string::String, + #[prost(enumeration="NestedCapability", repeated, tag="2")] + pub capabilities: ::prost::alloc::vec::Vec<i32>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct FocusHost { + #[prost(enumeration="NestedDirection", tag="1")] + pub direction: i32, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ToggleHostFullscreen { + #[prost(bool, tag="1")] + pub fullscreen: bool, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Pong { +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Bye { +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AnnounceAck { + #[prost(string, repeated, tag="1")] + pub ancestry: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(enumeration="NestedCapability", repeated, tag="2")] + pub capabilities: ::prost::alloc::vec::Vec<i32>, + #[prost(string, repeated, tag="3")] + pub descend_keys: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ShortcutUpdate { + #[prost(string, repeated, tag="1")] + pub ascend_keys: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(string, repeated, tag="2")] + pub descend_keys: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct FocusGained { + #[prost(enumeration="NestedDirection", tag="1")] + pub from_direction: i32, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct FocusLost { +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct FullscreenState { + #[prost(bool, tag="1")] + pub fullscreen: bool, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AncestryUpdate { + #[prost(string, repeated, tag="1")] + pub ancestry: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Ping { +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum NestedCapability { + Unspecified = 0, + NestedControl = 1, +} +impl NestedCapability { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + NestedCapability::Unspecified => "NESTED_CAPABILITY_UNSPECIFIED", + NestedCapability::NestedControl => "NESTED_CAPABILITY_NESTED_CONTROL", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option<Self> { + match value { + "NESTED_CAPABILITY_UNSPECIFIED" => Some(Self::Unspecified), + "NESTED_CAPABILITY_NESTED_CONTROL" => Some(Self::NestedControl), + _ => None, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum NestedDirection { + Unspecified = 0, + Left = 1, + Right = 2, + Up = 3, + Down = 4, +} +impl NestedDirection { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + NestedDirection::Unspecified => "NESTED_DIRECTION_UNSPECIFIED", + NestedDirection::Left => "NESTED_DIRECTION_LEFT", + NestedDirection::Right => "NESTED_DIRECTION_RIGHT", + NestedDirection::Up => "NESTED_DIRECTION_UP", + NestedDirection::Down => "NESTED_DIRECTION_DOWN", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option<Self> { + match value { + "NESTED_DIRECTION_UNSPECIFIED" => Some(Self::Unspecified), + "NESTED_DIRECTION_LEFT" => Some(Self::Left), + "NESTED_DIRECTION_RIGHT" => Some(Self::Right), + "NESTED_DIRECTION_UP" => Some(Self::Up), + "NESTED_DIRECTION_DOWN" => Some(Self::Down), + _ => None, + } + } +} diff --git a/zellij-utils/src/cli.rs b/zellij-utils/src/cli.rs index e09755e0..6d7f0bc2 100644 --- a/zellij-utils/src/cli.rs +++ b/zellij-utils/src/cli.rs @@ -900,6 +900,18 @@ pub enum CliAction { #[clap(short, long, value_parser)] pane_id: Option<String>, }, + #[clap( + about = "Toggle between fullscreen over the entire display (including the UI bars) and normal layout" + )] + ToggleNoUiFullscreen { + #[clap( + short, + long, + value_parser, + help = "Target a specific pane by ID (eg. terminal_1, plugin_2, or 3)" + )] + pane_id: Option<String>, + }, /// Toggle frames around panes in the UI TogglePaneFrames, SetPaneFrameStyle { diff --git a/zellij-utils/src/client_server_contract/client_to_server.proto b/zellij-utils/src/client_server_contract/client_to_server.proto index bc7c0c06..d3b59483 100644 --- a/zellij-utils/src/client_server_contract/client_to_server.proto +++ b/zellij-utils/src/client_server_contract/client_to_server.proto @@ -26,6 +26,7 @@ message ClientToServerMsg { ForwardedReplyFromHostMsg forwarded_reply_from_host = 19; HostTerminalThemeChangedMsg host_terminal_theme_changed = 20; SoftKeyboardVisibilityChangedMsg soft_keyboard_visibility_changed = 21; + NestedSessionFrameFromHostMsg nested_session_frame_from_host = 22; } } @@ -137,3 +138,7 @@ enum HostTerminalThemeIndication { message SoftKeyboardVisibilityChangedMsg { bool visible = 1; } + +message NestedSessionFrameFromHostMsg { + bytes payload_bytes = 1; +} diff --git a/zellij-utils/src/client_server_contract/common_types.proto b/zellij-utils/src/client_server_contract/common_types.proto index a6debf92..1f1da83f 100644 --- a/zellij-utils/src/client_server_contract/common_types.proto +++ b/zellij-utils/src/client_server_contract/common_types.proto @@ -280,6 +280,11 @@ message Action { ToggleMobileModeAction toggle_mobile_mode = 140; FocusLastPaneAction focus_last_pane = 141; SetPaneFrameStyleAction set_pane_frame_style = 142; + ToggleFocusNoUiFullscreenAction toggle_focus_no_ui_fullscreen = 143; + ToggleNoUiFullscreenByPaneIdAction toggle_no_ui_fullscreen_by_pane_id = 144; + FocusHostSessionAction focus_host_session = 145; + ToggleHostFullscreenAction toggle_host_fullscreen = 146; + FocusGuestSessionAction focus_guest_session = 147; } } @@ -290,6 +295,9 @@ message QuitAction {} message FocusNextPaneAction {} message FocusPreviousPaneAction {} message FocusLastPaneAction {} +message FocusHostSessionAction {} +message FocusGuestSessionAction {} +message ToggleHostFullscreenAction {} message SwitchFocusAction {} message MovePaneBackwardsAction {} message ClearScreenAction {} @@ -307,6 +315,7 @@ message PageScrollDownAction {} message HalfPageScrollUpAction {} message HalfPageScrollDownAction {} message ToggleFocusFullscreenAction {} +message ToggleFocusNoUiFullscreenAction {} message TogglePaneFramesAction {} message SetPaneFrameStyleAction { string style = 1; @@ -1181,7 +1190,6 @@ message Options { optional bool show_startup_tips = 31; optional bool show_release_notes = 32; optional bool advanced_mouse_actions = 33; - optional bool mouse_scroll_resize = 53; optional string web_server_ip = 34; optional uint32 web_server_port = 35; optional string web_server_cert = 36; @@ -1201,6 +1209,8 @@ message Options { optional uint32 mobile_threshold_rows = 50; optional string pane_frame_style = 51; optional bool stacked_pane_list = 52; + optional NestedSessionHandling nested_session_handling = 53; + optional bool mouse_scroll_resize = 54; } enum OnForceClose { @@ -1235,6 +1245,14 @@ enum MobileLayout { MOBILE_LAYOUT_NEVER = 3; } +enum NestedSessionHandling { + NESTED_SESSION_HANDLING_UNSPECIFIED = 0; + NESTED_SESSION_HANDLING_ASK = 1; + NESTED_SESSION_HANDLING_FULLSCREEN = 2; + NESTED_SESSION_HANDLING_DESCEND = 3; + NESTED_SESSION_HANDLING_NEVER = 4; +} + // Pane-targeting action messages message ScrollUpByPaneIdAction { PaneId pane_id = 1; } message ScrollDownByPaneIdAction { PaneId pane_id = 1; } @@ -1253,6 +1271,7 @@ message EditScrollbackByPaneIdAction { bool ansi = 2; } message ToggleFullscreenByPaneIdAction { PaneId pane_id = 1; } +message ToggleNoUiFullscreenByPaneIdAction { PaneId pane_id = 1; } message TogglePaneEmbedOrFloatingByPaneIdAction { PaneId pane_id = 1; } message CloseFocusByPaneIdAction { PaneId pane_id = 1; } message RenamePaneByPaneIdAction { PaneId pane_id = 1; bytes name = 2; } diff --git a/zellij-utils/src/client_server_contract/server_to_client.proto b/zellij-utils/src/client_server_contract/server_to_client.proto index a8c4779d..0c7e9aec 100644 --- a/zellij-utils/src/client_server_contract/server_to_client.proto +++ b/zellij-utils/src/client_server_contract/server_to_client.proto @@ -22,6 +22,7 @@ message ServerToClientMsg { SubscribedPaneClosedMsg subscribed_pane_closed = 15; ForwardQueryToHostMsg forward_query_to_host = 16; SetSoftKeyboardMsg set_soft_keyboard = 17; + EmitNestedSessionFrameMsg emit_nested_session_frame = 18; } } @@ -99,3 +100,7 @@ message ForwardQueryToHostMsg { message SetSoftKeyboardMsg { bool on = 1; } + +message EmitNestedSessionFrameMsg { + bytes payload_bytes = 1; +} diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs index 758e2ab3..62f95a06 100644 --- a/zellij-utils/src/data.rs +++ b/zellij-utils/src/data.rs @@ -254,6 +254,7 @@ impl FromStr for BareKey { "end" => Ok(BareKey::End), "backspace" => Ok(BareKey::Backspace), "delete" => Ok(BareKey::Delete), + "del" => Ok(BareKey::Delete), "insert" => Ok(BareKey::Insert), "f1" => Ok(BareKey::F(1)), "f2" => Ok(BareKey::F(2)), @@ -1751,6 +1752,12 @@ pub struct ModeInfo { pub web_server_port: Option<u16>, pub web_server_capability: Option<bool>, pub pane_frame_style: Option<PaneFrameStyle>, + pub session_dimmed: Option<bool>, + pub session_ancestry: Vec<String>, + pub host_fullscreen: Option<bool>, + pub nested_ascend_keys: Vec<KeyWithModifier>, + pub session_ascended: Option<bool>, + pub nested_descend_keys: Vec<KeyWithModifier>, } impl ModeInfo { @@ -3416,6 +3423,7 @@ pub enum PluginCommand { PageScrollUp, PageScrollDown, ToggleFocusFullscreen, + ToggleFocusNoUiFullscreen, TogglePaneFrames, SetPaneFrameStyle(PaneFrameStyle), TogglePaneEmbedOrEject, @@ -3645,6 +3653,7 @@ pub enum PluginCommand { }, SetShadowFocus(PaneId), ExitMobileMode, + FocusHostSession, } // Response type for plugin API methods that open a pane in a new tab diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs index 8ffe0907..ae2cc239 100644 --- a/zellij-utils/src/errors.rs +++ b/zellij-utils/src/errors.rs @@ -281,6 +281,7 @@ pub enum ScreenContext { CloseFocusedPane, ToggleActiveSyncTab, ToggleActiveTerminalFullscreen, + ToggleActiveTerminalNoUiFullscreen, TogglePaneFrames, SetPaneFrameStyle, SetSelectable, @@ -314,6 +315,10 @@ pub enum ScreenContext { TerminalForegroundColor, TerminalColorRegisters, ForwardHostQuery, + NestedSessionMessageFromPane, + NestedGuestPingTick, + NestedSessionMessageFromHost, + GuestModalChoice, ForwardedReplyFromHost, ResumePaneAfterForward, HostTerminalThemeChanged, @@ -453,6 +458,7 @@ pub enum ScreenContext { ClearScreenWithPaneId, EditScrollbackWithPaneId, ToggleFullscreenWithPaneId, + ToggleNoUiFullscreenWithPaneId, TogglePaneEmbedOrFloatingWithPaneId, CloseFocusWithPaneId, RenamePaneWithPaneId, @@ -475,6 +481,9 @@ pub enum ScreenContext { ReevaluateMobileMode, SetSoftKeyboard, SetShadowFocus, + FocusHostSession, + FocusGuestSession, + ToggleHostFullscreen, } /// Stack call representations corresponding to the different types of [`PtyInstruction`]s. @@ -586,6 +595,7 @@ pub enum ClientContext { RenamedSession, ConfigFileUpdated, ForwardQueryToHost, + EmitNestedSessionFrame, } /// Stack call representations corresponding to the different types of [`ServerInstruction`]s. @@ -622,6 +632,8 @@ pub enum ServerContext { SendWebClientsForbidden, ClearMouseHelpText, ForwardQueryToHost, + KeyPassthroughChanged, + EmitNestedSessionFrameToClient, } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] @@ -653,6 +665,8 @@ pub enum BackgroundJobContext { FlashTabBell, StopFlashTabBell, MobileGateTimeout, + StartNestedGuestPing, + StopNestedGuestPing, Exit, } diff --git a/zellij-utils/src/input/actions.rs b/zellij-utils/src/input/actions.rs index c0e953b6..95409609 100644 --- a/zellij-utils/src/input/actions.rs +++ b/zellij-utils/src/input/actions.rs @@ -216,6 +216,7 @@ pub enum Action { HalfPageScrollDown, /// Toggle between fullscreen focus pane and normal layout. ToggleFocusFullscreen, + ToggleFocusNoUiFullscreen, /// Toggle frames around panes in the UI TogglePaneFrames, SetPaneFrameStyle(PaneFrameStyle), @@ -508,6 +509,9 @@ pub enum Action { BreakPane, BreakPaneRight, BreakPaneLeft, + FocusHostSession, + FocusGuestSession, + ToggleHostFullscreen, RenameSession { name: String, }, @@ -623,6 +627,9 @@ pub enum Action { ToggleFocusFullscreenByPaneId { pane_id: PaneId, }, + ToggleFocusNoUiFullscreenByPaneId { + pane_id: PaneId, + }, TogglePaneEmbedOrFloatingByPaneId { pane_id: PaneId, }, @@ -1018,6 +1025,16 @@ impl Action { }, None => Ok(vec![Action::ToggleFocusFullscreen]), }, + CliAction::ToggleNoUiFullscreen { pane_id } => match pane_id { + Some(pane_id_str) => { + let pane_id = PaneId::from_str(&pane_id_str) + .map_err(|_| format!( + "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" + ))?; + Ok(vec![Action::ToggleFocusNoUiFullscreenByPaneId { pane_id }]) + }, + None => Ok(vec![Action::ToggleFocusNoUiFullscreen]), + }, CliAction::TogglePaneFrames => Ok(vec![Action::TogglePaneFrames]), CliAction::SetPaneFrameStyle { style } => Ok(vec![Action::SetPaneFrameStyle(style)]), CliAction::ToggleActiveSyncTab { tab_id } => match tab_id { @@ -2883,6 +2900,33 @@ mod tests { assert!(matches!(actions[0], Action::ToggleFocusFullscreen)); } + #[test] + fn test_toggle_no_ui_fullscreen_with_pane_id() { + let cli_action = CliAction::ToggleNoUiFullscreen { + pane_id: Some("terminal_16".to_string()), + }; + let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); + assert!(result.is_ok()); + let actions = result.unwrap(); + assert_eq!(actions.len(), 1); + match &actions[0] { + Action::ToggleFocusNoUiFullscreenByPaneId { pane_id } => { + assert!(matches!(pane_id, PaneId::Terminal(16))); + }, + _ => panic!("Expected ToggleFocusNoUiFullscreenByPaneId action"), + } + } + + #[test] + fn test_toggle_no_ui_fullscreen_without_pane_id() { + let cli_action = CliAction::ToggleNoUiFullscreen { pane_id: None }; + let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); + assert!(result.is_ok()); + let actions = result.unwrap(); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], Action::ToggleFocusNoUiFullscreen)); + } + // 15. TogglePaneEmbedOrFloating #[test] fn test_toggle_pane_embed_or_floating_with_pane_id() { diff --git a/zellij-utils/src/input/keybinds.rs b/zellij-utils/src/input/keybinds.rs index c3943182..2cbdeee7 100644 --- a/zellij-utils/src/input/keybinds.rs +++ b/zellij-utils/src/input/keybinds.rs @@ -116,6 +116,60 @@ impl Keybinds { } } +const MAX_SHORTCUT_SEARCH_DEPTH: usize = 4; + +pub fn shortcut_for_action( + keybinds: &KeybindsVec, + base_mode: InputMode, + is_target_action: impl Fn(&Action) -> bool, +) -> Option<Vec<KeyWithModifier>> { + let mode_binds = |mode: InputMode| -> Vec<(KeyWithModifier, Vec<Action>)> { + let mut binds = keybinds + .iter() + .find(|(bind_mode, _)| *bind_mode == mode) + .map(|(_, binds)| binds.clone()) + .unwrap_or_default(); + binds.sort_by(|(a, _), (b, _)| a.cmp(b)); + binds + }; + let mut visited: Vec<InputMode> = vec![base_mode]; + let mut queue: Vec<(InputMode, Vec<KeyWithModifier>)> = vec![(base_mode, vec![])]; + for _ in 0..MAX_SHORTCUT_SEARCH_DEPTH { + let mut next_queue: Vec<(InputMode, Vec<KeyWithModifier>)> = vec![]; + for (mode, path) in &queue { + for (key, actions) in mode_binds(*mode) { + if actions.iter().any(&is_target_action) { + let mut shortcut = path.clone(); + shortcut.push(key); + return Some(shortcut); + } + } + } + for (mode, path) in &queue { + for (key, actions) in mode_binds(*mode) { + for action in &actions { + if let Action::SwitchToMode { + input_mode: next_mode, + } = action + { + if !visited.contains(next_mode) { + visited.push(*next_mode); + let mut next_path = path.clone(); + next_path.push(key.clone()); + next_queue.push((*next_mode, next_path)); + } + } + } + } + } + if next_queue.is_empty() { + return None; + } + queue = next_queue; + } + None +} + // we need to do this because [10] in standard STDIN, [10] is both Enter (without a carriage // return) and ctrl-j - so here, if ctrl-j is bound we return its bound action, and otherwise we // just write the raw bytes to the terminal and let whichever program is there decide what they are diff --git a/zellij-utils/src/input/mod.rs b/zellij-utils/src/input/mod.rs index 0934287e..3ca7508f 100644 --- a/zellij-utils/src/input/mod.rs +++ b/zellij-utils/src/input/mod.rs @@ -55,6 +55,12 @@ mod not_wasm { web_server_port: None, web_server_capability: None, pane_frame_style: None, + session_dimmed: None, + session_ancestry: vec![], + host_fullscreen: None, + nested_ascend_keys: vec![], + session_ascended: None, + nested_descend_keys: vec![], } } diff --git a/zellij-utils/src/input/options.rs b/zellij-utils/src/input/options.rs index da5f0248..194ab24c 100644 --- a/zellij-utils/src/input/options.rs +++ b/zellij-utils/src/input/options.rs @@ -75,6 +75,37 @@ impl MobileLayoutConfiguration { } } +#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ArgEnum)] +pub enum NestedSessionHandling { + #[serde(alias = "ask")] + Ask, + #[serde(alias = "fullscreen")] + Fullscreen, + #[serde(alias = "descend")] + Descend, + #[serde(alias = "never")] + Never, +} + +impl Default for NestedSessionHandling { + fn default() -> Self { + Self::Ask + } +} + +impl FromStr for NestedSessionHandling { + type Err = String; + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s { + "Ask" | "ask" => Ok(Self::Ask), + "Fullscreen" | "fullscreen" => Ok(Self::Fullscreen), + "Descend" | "descend" => Ok(Self::Descend), + "Never" | "never" => Ok(Self::Never), + _ => Err(format!("No such nested_session_handling: {}", s)), + } + } +} + impl Default for OnForceClose { fn default() -> Self { Self::Detach @@ -415,6 +446,12 @@ pub struct Options { #[clap(long, value_parser)] #[serde(default)] pub mobile_threshold_rows: Option<u16>, + + /// How to handle a nested Zellij session detected inside a pane + /// (ask, fullscreen, descend, never) + #[clap(long, arg_enum, hide_possible_values = true, value_parser)] + #[serde(default)] + pub nested_session_handling: Option<NestedSessionHandling>, } #[derive(ArgEnum, Deserialize, Serialize, Debug, Clone, Copy, PartialEq)] @@ -527,6 +564,9 @@ impl Options { let mobile_layout = other.mobile_layout.or(self.mobile_layout); let mobile_threshold_cols = other.mobile_threshold_cols.or(self.mobile_threshold_cols); let mobile_threshold_rows = other.mobile_threshold_rows.or(self.mobile_threshold_rows); + let nested_session_handling = other + .nested_session_handling + .or(self.nested_session_handling); Options { simplified_ui, @@ -582,6 +622,7 @@ impl Options { mobile_layout, mobile_threshold_cols, mobile_threshold_rows, + nested_session_handling, } } @@ -674,6 +715,9 @@ impl Options { let mobile_layout = other.mobile_layout.or(self.mobile_layout); let mobile_threshold_cols = other.mobile_threshold_cols.or(self.mobile_threshold_cols); let mobile_threshold_rows = other.mobile_threshold_rows.or(self.mobile_threshold_rows); + let nested_session_handling = other + .nested_session_handling + .or(self.nested_session_handling); Options { simplified_ui, @@ -729,6 +773,7 @@ impl Options { mobile_layout, mobile_threshold_cols, mobile_threshold_rows, + nested_session_handling, } } diff --git a/zellij-utils/src/ipc.rs b/zellij-utils/src/ipc.rs index d5edbbb4..2e36cc96 100644 --- a/zellij-utils/src/ipc.rs +++ b/zellij-utils/src/ipc.rs @@ -176,6 +176,9 @@ pub enum ClientToServerMsg { SoftKeyboardVisibilityChanged { visible: bool, }, + NestedSessionFrameFromHost { + payload_bytes: Vec<u8>, + }, } // Types of messages sent from the server to the client @@ -227,6 +230,9 @@ pub enum ServerToClientMsg { token: u32, query_bytes: Vec<u8>, }, + EmitNestedSessionFrame { + payload_bytes: Vec<u8>, + }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] diff --git a/zellij-utils/src/ipc/protobuf_conversion.rs b/zellij-utils/src/ipc/protobuf_conversion.rs index c129b6f7..6c0adc2b 100644 --- a/zellij-utils/src/ipc/protobuf_conversion.rs +++ b/zellij-utils/src/ipc/protobuf_conversion.rs @@ -5,19 +5,19 @@ use crate::{ client_to_server_msg, server_to_client_msg, ActionMsg, AttachClientMsg, AttachWatcherClientMsg, BackgroundColorMsg, CliPipeOutputMsg, ClientExitedMsg, ClientToServerMsg as ProtoClientToServerMsg, ColorRegistersMsg, ConfigFileUpdatedMsg, - ConnStatusMsg, ConnectedMsg, DesktopNotificationResponseMsg, DetachSessionMsg, ExitMsg, - ExitReason as ProtoExitReason, FailedToStartWebServerMsg, FirstClientConnectedMsg, - ForegroundColorMsg, ForwardQueryToHostMsg, ForwardedReplyFromHostMsg, - HostTerminalThemeChangedMsg, + ConnStatusMsg, ConnectedMsg, DesktopNotificationResponseMsg, DetachSessionMsg, + EmitNestedSessionFrameMsg, ExitMsg, ExitReason as ProtoExitReason, + FailedToStartWebServerMsg, FirstClientConnectedMsg, ForegroundColorMsg, + ForwardQueryToHostMsg, ForwardedReplyFromHostMsg, HostTerminalThemeChangedMsg, HostTerminalThemeIndication as ProtoHostTerminalThemeIndication, InputMode as ProtoInputMode, KeyMsg, KillSessionMsg, LayoutMetadata as ProtoLayoutMetadata, - LogErrorMsg, LogMsg, PaneMetadata as ProtoPaneMetadata, PaneRenderUpdateMsg, - QueryTerminalSizeMsg, RenamedSessionMsg, RenderMsg, ResizeCause as ProtoResizeCause, - ServerToClientMsg as ProtoServerToClientMsg, SetSoftKeyboardMsg, - SoftKeyboardVisibilityChangedMsg, StartWebServerMsg, SubscribeToPaneRendersMsg, - SubscribedPaneClosedMsg, SwitchSessionMsg, TabMetadata as ProtoTabMetadata, - TerminalPixelDimensionsMsg, TerminalResizeMsg, UnblockCliPipeInputMsg, - UnblockInputThreadMsg, WebServerStartedMsg, + LogErrorMsg, LogMsg, NestedSessionFrameFromHostMsg, PaneMetadata as ProtoPaneMetadata, + PaneRenderUpdateMsg, QueryTerminalSizeMsg, RenamedSessionMsg, RenderMsg, + ResizeCause as ProtoResizeCause, ServerToClientMsg as ProtoServerToClientMsg, + SetSoftKeyboardMsg, SoftKeyboardVisibilityChangedMsg, StartWebServerMsg, + SubscribeToPaneRendersMsg, SubscribedPaneClosedMsg, SwitchSessionMsg, + TabMetadata as ProtoTabMetadata, TerminalPixelDimensionsMsg, TerminalResizeMsg, + UnblockCliPipeInputMsg, UnblockInputThreadMsg, WebServerStartedMsg, }, data::{HostTerminalThemeMode, InputMode, PaneId}, errors::prelude::*, @@ -156,6 +156,11 @@ impl From<ClientToServerMsg> for ProtoClientToServerMsg { SoftKeyboardVisibilityChangedMsg { visible }, ) }, + ClientToServerMsg::NestedSessionFrameFromHost { payload_bytes } => { + client_to_server_msg::Message::NestedSessionFrameFromHost( + NestedSessionFrameFromHostMsg { payload_bytes }, + ) + }, }; ProtoClientToServerMsg { @@ -306,6 +311,11 @@ impl TryFrom<ProtoClientToServerMsg> for ClientToServerMsg { visible: msg.visible, }) }, + Some(client_to_server_msg::Message::NestedSessionFrameFromHost(msg)) => { + Ok(ClientToServerMsg::NestedSessionFrameFromHost { + payload_bytes: msg.payload_bytes, + }) + }, None => Err(anyhow!("Empty ClientToServerMsg message")), } } @@ -394,6 +404,11 @@ impl From<ServerToClientMsg> for ProtoServerToClientMsg { ServerToClientMsg::SetSoftKeyboard { on } => { server_to_client_msg::Message::SetSoftKeyboard(SetSoftKeyboardMsg { on }) }, + ServerToClientMsg::EmitNestedSessionFrame { payload_bytes } => { + server_to_client_msg::Message::EmitNestedSessionFrame(EmitNestedSessionFrameMsg { + payload_bytes, + }) + }, }; ProtoServerToClientMsg { @@ -509,6 +524,11 @@ impl TryFrom<ProtoServerToClientMsg> for ServerToClientMsg { Some(server_to_client_msg::Message::SetSoftKeyboard(msg)) => { Ok(ServerToClientMsg::SetSoftKeyboard { on: msg.on }) }, + Some(server_to_client_msg::Message::EmitNestedSessionFrame(msg)) => { + Ok(ServerToClientMsg::EmitNestedSessionFrame { + payload_bytes: msg.payload_bytes, + }) + }, None => Err(anyhow!("Empty ServerToClientMsg message")), } } @@ -775,6 +795,18 @@ impl From<crate::input::options::Options> crate::input::options::PaneFrameStyle::Titles => "titles".to_owned(), crate::input::options::PaneFrameStyle::None => "none".to_owned(), }), + nested_session_handling: options.nested_session_handling.map(|n| { + use crate::client_server_contract::client_server_contract::NestedSessionHandling as ProtoNestedSessionHandling; + use crate::input::options::NestedSessionHandling; + match n { + NestedSessionHandling::Ask => ProtoNestedSessionHandling::Ask as i32, + NestedSessionHandling::Fullscreen => { + ProtoNestedSessionHandling::Fullscreen as i32 + }, + NestedSessionHandling::Descend => ProtoNestedSessionHandling::Descend as i32, + NestedSessionHandling::Never => ProtoNestedSessionHandling::Never as i32, + } + }), } } } @@ -788,7 +820,8 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Options> ) -> Result<Self> { use crate::client_server_contract::client_server_contract::{ Clipboard as ProtoClipboard, MobileLayout as ProtoMobileLayout, - OnForceClose as ProtoOnForceClose, WebSharing as ProtoWebSharing, + NestedSessionHandling as ProtoNestedSessionHandling, OnForceClose as ProtoOnForceClose, + WebSharing as ProtoWebSharing, }; Ok(Self { @@ -897,6 +930,24 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Options> .transpose()?, mobile_threshold_cols: options.mobile_threshold_cols.map(|v| v as u16), mobile_threshold_rows: options.mobile_threshold_rows.map(|v| v as u16), + nested_session_handling: options + .nested_session_handling + .map(|n| match ProtoNestedSessionHandling::from_i32(n) { + Some(ProtoNestedSessionHandling::Ask) => { + Ok(crate::input::options::NestedSessionHandling::Ask) + }, + Some(ProtoNestedSessionHandling::Fullscreen) => { + Ok(crate::input::options::NestedSessionHandling::Fullscreen) + }, + Some(ProtoNestedSessionHandling::Descend) => { + Ok(crate::input::options::NestedSessionHandling::Descend) + }, + Some(ProtoNestedSessionHandling::Never) => { + Ok(crate::input::options::NestedSessionHandling::Never) + }, + _ => Err(anyhow!("Invalid NestedSessionHandling value: {}", n)), + }) + .transpose()?, }) } } @@ -932,6 +983,8 @@ impl From<crate::input::actions::Action> EditFileAction, EditScrollbackAction, EditScrollbackByPaneIdAction, + FocusGuestSessionAction, + FocusHostSessionAction, FocusLastPaneAction, FocusNextPaneAction, FocusPaneByPaneIdAction, @@ -1031,9 +1084,12 @@ impl From<crate::input::actions::Action> ToggleFloatingPanesAction, ToggleFloatingPanesByTabIdAction, ToggleFocusFullscreenAction, + ToggleFocusNoUiFullscreenAction, ToggleFullscreenByPaneIdAction, ToggleGroupMarkingAction, + ToggleHostFullscreenAction, ToggleMouseModeAction, + ToggleNoUiFullscreenByPaneIdAction, TogglePaneBorderlessAction, TogglePaneEmbedOrFloatingAction, TogglePaneEmbedOrFloatingByPaneIdAction, @@ -1112,6 +1168,15 @@ impl From<crate::input::actions::Action> crate::input::actions::Action::FocusLastPane => { ActionType::FocusLastPane(FocusLastPaneAction {}) }, + crate::input::actions::Action::FocusHostSession => { + ActionType::FocusHostSession(FocusHostSessionAction {}) + }, + crate::input::actions::Action::FocusGuestSession => { + ActionType::FocusGuestSession(FocusGuestSessionAction {}) + }, + crate::input::actions::Action::ToggleHostFullscreen => { + ActionType::ToggleHostFullscreen(ToggleHostFullscreenAction {}) + }, crate::input::actions::Action::SwitchFocus => { ActionType::SwitchFocus(SwitchFocusAction {}) }, @@ -1192,6 +1257,9 @@ impl From<crate::input::actions::Action> crate::input::actions::Action::ToggleFocusFullscreen => { ActionType::ToggleFocusFullscreen(ToggleFocusFullscreenAction {}) }, + crate::input::actions::Action::ToggleFocusNoUiFullscreen => { + ActionType::ToggleFocusNoUiFullscreen(ToggleFocusNoUiFullscreenAction {}) + }, crate::input::actions::Action::TogglePaneFrames => { ActionType::TogglePaneFrames(TogglePaneFramesAction {}) }, @@ -1877,6 +1945,11 @@ impl From<crate::input::actions::Action> pane_id: Some(pane_id.into()), }) }, + crate::input::actions::Action::ToggleFocusNoUiFullscreenByPaneId { pane_id } => { + ActionType::ToggleNoUiFullscreenByPaneId(ToggleNoUiFullscreenByPaneIdAction { + pane_id: Some(pane_id.into()), + }) + }, crate::input::actions::Action::TogglePaneEmbedOrFloatingByPaneId { pane_id } => { ActionType::TogglePaneEmbedOrFloatingByPaneId( TogglePaneEmbedOrFloatingByPaneIdAction { @@ -2086,6 +2159,9 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Action> ActionType::ToggleFocusFullscreen(_) => { Ok(crate::input::actions::Action::ToggleFocusFullscreen) }, + ActionType::ToggleFocusNoUiFullscreen(_) => { + Ok(crate::input::actions::Action::ToggleFocusNoUiFullscreen) + }, ActionType::TogglePaneFrames(_) => Ok(crate::input::actions::Action::TogglePaneFrames), ActionType::SetPaneFrameStyle(set_pane_frame_style_action) => { let style = crate::input::options::PaneFrameStyle::from_str( @@ -2552,6 +2628,13 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Action> ActionType::BreakPane(_) => Ok(crate::input::actions::Action::BreakPane), ActionType::BreakPaneRight(_) => Ok(crate::input::actions::Action::BreakPaneRight), ActionType::BreakPaneLeft(_) => Ok(crate::input::actions::Action::BreakPaneLeft), + ActionType::FocusHostSession(_) => Ok(crate::input::actions::Action::FocusHostSession), + ActionType::FocusGuestSession(_) => { + Ok(crate::input::actions::Action::FocusGuestSession) + }, + ActionType::ToggleHostFullscreen(_) => { + Ok(crate::input::actions::Action::ToggleHostFullscreen) + }, ActionType::RenameSession(rename_session_action) => { Ok(crate::input::actions::Action::RenameSession { name: rename_session_action.name, @@ -2810,6 +2893,14 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Action> .try_into()?, }, ), + ActionType::ToggleNoUiFullscreenByPaneId(a) => Ok( + crate::input::actions::Action::ToggleFocusNoUiFullscreenByPaneId { + pane_id: a + .pane_id + .ok_or_else(|| anyhow!("ToggleNoUiFullscreenByPaneId missing pane_id"))? + .try_into()?, + }, + ), ActionType::TogglePaneEmbedOrFloatingByPaneId(a) => Ok( crate::input::actions::Action::TogglePaneEmbedOrFloatingByPaneId { pane_id: a diff --git a/zellij-utils/src/ipc/tests/roundtrip_tests.rs b/zellij-utils/src/ipc/tests/roundtrip_tests.rs index 37214782..f8e6dc91 100644 --- a/zellij-utils/src/ipc/tests/roundtrip_tests.rs +++ b/zellij-utils/src/ipc/tests/roundtrip_tests.rs @@ -14,7 +14,8 @@ use crate::input::layout::{ }; use crate::input::mouse::{MouseEvent, MouseEventType}; use crate::input::options::{ - Clipboard, MobileLayoutConfiguration, OnForceClose, Options, PaneFrameStyle, + Clipboard, MobileLayoutConfiguration, NestedSessionHandling, OnForceClose, Options, + PaneFrameStyle, }; use crate::ipc::{ ClientToServerMsg, ColorRegister, ExitReason, PaneReference, PixelDimensions, ResizeCause, @@ -487,6 +488,7 @@ fn test_client_messages() { mobile_layout: Some(MobileLayoutConfiguration::Always), mobile_threshold_cols: Some(72), mobile_threshold_rows: Some(40), + nested_session_handling: Some(NestedSessionHandling::Fullscreen), }), layout: None, terminal_window_size: Size { rows: 80, cols: 42 }, @@ -678,6 +680,46 @@ fn test_client_messages() { }, is_web_client: true, }); + test_client_roundtrip!(ClientToServerMsg::FirstClientConnected { + cli_assets: CliAssets { + configuration_options: Some(Options { + nested_session_handling: Some(NestedSessionHandling::Ask), + ..Default::default() + }), + ..Default::default() + }, + is_web_client: true, + }); + test_client_roundtrip!(ClientToServerMsg::FirstClientConnected { + cli_assets: CliAssets { + configuration_options: Some(Options { + nested_session_handling: Some(NestedSessionHandling::Fullscreen), + ..Default::default() + }), + ..Default::default() + }, + is_web_client: true, + }); + test_client_roundtrip!(ClientToServerMsg::FirstClientConnected { + cli_assets: CliAssets { + configuration_options: Some(Options { + nested_session_handling: Some(NestedSessionHandling::Descend), + ..Default::default() + }), + ..Default::default() + }, + is_web_client: true, + }); + test_client_roundtrip!(ClientToServerMsg::FirstClientConnected { + cli_assets: CliAssets { + configuration_options: Some(Options { + nested_session_handling: Some(NestedSessionHandling::Never), + ..Default::default() + }), + ..Default::default() + }, + is_web_client: true, + }); test_client_roundtrip!(ClientToServerMsg::AttachClient { cli_assets: CliAssets::default(), tab_position_to_focus: None, @@ -708,6 +750,18 @@ fn test_client_messages() { }), is_web_client: true, }); + test_client_roundtrip!(ClientToServerMsg::AttachClient { + cli_assets: CliAssets { + configuration_options: Some(Options { + nested_session_handling: Some(NestedSessionHandling::Descend), + ..Default::default() + }), + ..Default::default() + }, + tab_position_to_focus: None, + pane_to_focus: None, + is_web_client: false, + }); // TODO: Action test_client_roundtrip!(ClientToServerMsg::Action { action: Action::Quit, @@ -1014,6 +1068,12 @@ fn test_client_messages() { is_cli_client: true, }); test_client_roundtrip!(ClientToServerMsg::Action { + action: Action::ToggleFocusNoUiFullscreen, + terminal_id: Some(1), + client_id: Some(100), + is_cli_client: true, + }); + test_client_roundtrip!(ClientToServerMsg::Action { action: Action::TogglePaneFrames, terminal_id: Some(1), client_id: Some(100), @@ -3506,6 +3566,14 @@ fn test_client_messages() { is_cli_client: true, }); test_client_roundtrip!(ClientToServerMsg::Action { + action: Action::ToggleFocusNoUiFullscreenByPaneId { + pane_id: PaneId::Terminal(1), + }, + terminal_id: Some(1), + client_id: Some(100), + is_cli_client: true, + }); + test_client_roundtrip!(ClientToServerMsg::Action { action: Action::TogglePaneEmbedOrFloatingByPaneId { pane_id: PaneId::Terminal(1), }, @@ -3640,6 +3708,12 @@ fn test_client_messages() { test_client_roundtrip!(ClientToServerMsg::HostTerminalThemeChanged { mode: HostTerminalThemeMode::Light, }); + test_client_roundtrip!(ClientToServerMsg::NestedSessionFrameFromHost { + payload_bytes: vec![], + }); + test_client_roundtrip!(ClientToServerMsg::NestedSessionFrameFromHost { + payload_bytes: (0u8..=255u8).collect(), + }); } fn test_server_messages() { @@ -3787,6 +3861,12 @@ fn test_server_messages() { token: u32::MAX, query_bytes: (0u8..=255u8).collect(), }); + test_server_roundtrip!(ServerToClientMsg::EmitNestedSessionFrame { + payload_bytes: vec![], + }); + test_server_roundtrip!(ServerToClientMsg::EmitNestedSessionFrame { + payload_bytes: (0u8..=255u8).collect(), + }); } #[test] diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs index b16e7f12..4e7b3735 100644 --- a/zellij-utils/src/kdl/mod.rs +++ b/zellij-utils/src/kdl/mod.rs @@ -49,6 +49,9 @@ macro_rules! parse_kdl_action_arguments { "FocusNextPane" => Ok(Action::FocusNextPane), "FocusPreviousPane" => Ok(Action::FocusPreviousPane), "FocusLastPane" => Ok(Action::FocusLastPane), + "FocusHostSession" => Ok(Action::FocusHostSession), + "FocusGuestSession" => Ok(Action::FocusGuestSession), + "ToggleHostFullscreen" => Ok(Action::ToggleHostFullscreen), "SwitchFocus" => Ok(Action::SwitchFocus), "EditScrollback" => Ok(Action::EditScrollback { ansi: false }), "ScrollUp" => Ok(Action::ScrollUp), @@ -60,6 +63,7 @@ macro_rules! parse_kdl_action_arguments { "HalfPageScrollUp" => Ok(Action::HalfPageScrollUp), "HalfPageScrollDown" => Ok(Action::HalfPageScrollDown), "ToggleFocusFullscreen" => Ok(Action::ToggleFocusFullscreen), + "ToggleFocusNoUiFullscreen" => Ok(Action::ToggleFocusNoUiFullscreen), "TogglePaneFrames" => Ok(Action::TogglePaneFrames), "ToggleActiveSyncTab" => Ok(Action::ToggleActiveSyncTab), "TogglePaneEmbedOrFloating" => Ok(Action::TogglePaneEmbedOrFloating), @@ -744,6 +748,7 @@ impl Action { Action::HalfPageScrollUp => Some(KdlNode::new("HalfPageScrollUp")), Action::HalfPageScrollDown => Some(KdlNode::new("HalfPageScrollDown")), Action::ToggleFocusFullscreen => Some(KdlNode::new("ToggleFocusFullscreen")), + Action::ToggleFocusNoUiFullscreen => Some(KdlNode::new("ToggleFocusNoUiFullscreen")), Action::TogglePaneFrames => Some(KdlNode::new("TogglePaneFrames")), Action::SetPaneFrameStyle(style) => { let mut node = KdlNode::new("SetPaneFrameStyle"); @@ -1350,6 +1355,9 @@ impl Action { Action::SetDarkTheme => Some(KdlNode::new("SetDarkTheme")), Action::SetLightTheme => Some(KdlNode::new("SetLightTheme")), Action::ToggleTheme => Some(KdlNode::new("ToggleTheme")), + Action::FocusHostSession => Some(KdlNode::new("FocusHostSession")), + Action::FocusGuestSession => Some(KdlNode::new("FocusGuestSession")), + Action::ToggleHostFullscreen => Some(KdlNode::new("ToggleHostFullscreen")), _ => None, } } @@ -1541,6 +1549,15 @@ impl TryFrom<(&KdlNode, &Options)> for Action { "FocusLastPane" => { parse_kdl_action_arguments!(action_name, action_arguments, kdl_action) }, + "FocusHostSession" => { + parse_kdl_action_arguments!(action_name, action_arguments, kdl_action) + }, + "FocusGuestSession" => { + parse_kdl_action_arguments!(action_name, action_arguments, kdl_action) + }, + "ToggleHostFullscreen" => { + parse_kdl_action_arguments!(action_name, action_arguments, kdl_action) + }, "SwitchFocus" => parse_kdl_action_arguments!(action_name, action_arguments, kdl_action), "EditScrollback" => { let ansi = crate::kdl_get_bool_property_or_child_value!(kdl_action, "ansi") @@ -1570,6 +1587,9 @@ impl TryFrom<(&KdlNode, &Options)> for Action { "ToggleFocusFullscreen" => { parse_kdl_action_arguments!(action_name, action_arguments, kdl_action) }, + "ToggleFocusNoUiFullscreen" => { + parse_kdl_action_arguments!(action_name, action_arguments, kdl_action) + }, "TogglePaneFrames" => { parse_kdl_action_arguments!(action_name, action_arguments, kdl_action) }, @@ -2926,6 +2946,19 @@ impl Options { }, None => None, }; + let nested_session_handling = match kdl_property_first_arg_as_string_or_error!( + kdl_options, + "nested_session_handling" + ) { + Some((value, entry)) => { + use crate::input::options::NestedSessionHandling; + match value.parse::<NestedSessionHandling>() { + Ok(v) => Some(v), + Err(e) => return Err(kdl_parsing_error!(e, entry)), + } + }, + None => None, + }; Ok(Options { simplified_ui, @@ -2981,6 +3014,7 @@ impl Options { mobile_layout, mobile_threshold_cols, mobile_threshold_rows, + nested_session_handling, }) } pub fn from_string(stringified_keybindings: &String) -> Result<Self, ConfigError> { @@ -4505,6 +4539,44 @@ impl Options { None } } + fn nested_session_handling_to_kdl(&self, add_comments: bool) -> Option<KdlNode> { + use crate::input::options::NestedSessionHandling; + let comment_text = format!( + "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}", + " ", + "// How to handle a nested Zellij session detected inside a pane.", + "// Options:", + "// - \"ask\" (Default — prompt with a modal)", + "// - \"fullscreen\" (always zoom into the nested session)", + "// - \"descend\" (always control the nested session on focus)", + "// - \"never\" (never prompt or descend; do it manually)", + "// ", + ); + let create_node = |value: NestedSessionHandling| -> KdlNode { + let mut node = KdlNode::new("nested_session_handling"); + let s = match value { + NestedSessionHandling::Ask => "ask", + NestedSessionHandling::Fullscreen => "fullscreen", + NestedSessionHandling::Descend => "descend", + NestedSessionHandling::Never => "never", + }; + node.push(KdlValue::String(s.to_string())); + node + }; + if let Some(value) = self.nested_session_handling { + let mut node = create_node(value); + if add_comments { + node.set_leading(format!("{}\n", comment_text)); + } + Some(node) + } else if add_comments { + let mut node = create_node(NestedSessionHandling::Ask); + node.set_leading(format!("{}\n// ", comment_text)); + Some(node) + } else { + None + } + } fn client_async_worker_tasks_to_kdl(&self, add_comments: bool) -> Option<KdlNode> { let comment_text = r#" // Number of async worker tasks to spawn per active client. @@ -4701,6 +4773,9 @@ impl Options { if let Some(mobile_threshold_rows) = self.mobile_threshold_rows_to_kdl(add_comments) { nodes.push(mobile_threshold_rows); } + if let Some(nested_session_handling) = self.nested_session_handling_to_kdl(add_comments) { + nodes.push(nested_session_handling); + } nodes } } @@ -7586,6 +7661,38 @@ fn mobile_layout_kdl_round_trip_for_every_variant() { } #[test] +fn nested_session_handling_kdl_round_trip_for_every_variant() { + use crate::input::options::NestedSessionHandling; + let cases = [ + ("ask", NestedSessionHandling::Ask), + ("fullscreen", NestedSessionHandling::Fullscreen), + ("descend", NestedSessionHandling::Descend), + ("never", NestedSessionHandling::Never), + ]; + for (value, expected) in cases { + let fake_config = format!( + r##" + nested_session_handling "{value}" + "## + ); + let document: KdlDocument = fake_config.parse().unwrap(); + let parsed = Options::from_kdl(&document).unwrap(); + assert_eq!( + parsed.nested_session_handling, + Some(expected), + "case: {value}" + ); + + let mut serialized = Options::to_kdl(&parsed, false); + let mut fake_document = KdlDocument::new(); + fake_document.nodes_mut().append(&mut serialized); + let reparsed = + Options::from_kdl(&fake_document.to_string().parse::<KdlDocument>().unwrap()).unwrap(); + assert_eq!(parsed, reparsed, "round-trip mismatch for {value}"); + } +} + +#[test] fn config_options_to_string_with_some_options() { let fake_config = r##" default_layout "compact" diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap index 1ab39935..2d7dec36 100644 --- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap +++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap @@ -15,6 +15,7 @@ keybinds clear-defaults=true { bind "d" { NewPane "down"; SwitchToMode "normal"; } bind "e" { TogglePaneEmbedOrFloating; SwitchToMode "normal"; } bind "f" { ToggleFocusFullscreen; SwitchToMode "normal"; } + bind "Shift f" { ToggleFocusNoUiFullscreen; SwitchToMode "normal"; } bind "h" { MoveFocus "left"; } bind "i" { TogglePanePinned; SwitchToMode "normal"; } bind "j" { MoveFocus "down"; } @@ -100,6 +101,8 @@ keybinds clear-defaults=true { bind "w" { SearchToggleOption "Wrap"; } } session { + bind "[" { FocusGuestSession; SwitchToMode "normal"; } + bind "]" { FocusHostSession; SwitchToMode "normal"; } bind "a" { LaunchOrFocusPlugin "zellij:about" { floating true @@ -114,6 +117,7 @@ keybinds clear-defaults=true { } SwitchToMode "normal" } + bind "f" { ToggleHostFullscreen; SwitchToMode "normal"; } bind "l" { LaunchOrFocusPlugin "zellij:layout-manager" { floating true diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap index bf816932..bbbb1248 100644 --- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap +++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap @@ -15,6 +15,7 @@ keybinds clear-defaults=true { bind "d" { NewPane "down"; SwitchToMode "normal"; } bind "e" { TogglePaneEmbedOrFloating; SwitchToMode "normal"; } bind "f" { ToggleFocusFullscreen; SwitchToMode "normal"; } + bind "Shift f" { ToggleFocusNoUiFullscreen; SwitchToMode "normal"; } bind "h" { MoveFocus "left"; } bind "i" { TogglePanePinned; SwitchToMode "normal"; } bind "j" { MoveFocus "down"; } @@ -100,6 +101,8 @@ keybinds clear-defaults=true { bind "w" { SearchToggleOption "Wrap"; } } session { + bind "[" { FocusGuestSession; SwitchToMode "normal"; } + bind "]" { FocusHostSession; SwitchToMode "normal"; } bind "a" { LaunchOrFocusPlugin "zellij:about" { floating true @@ -114,6 +117,7 @@ keybinds clear-defaults=true { } SwitchToMode "normal" } + bind "f" { ToggleHostFullscreen; SwitchToMode "normal"; } bind "l" { LaunchOrFocusPlugin "zellij:layout-manager" { floating true @@ -615,3 +619,12 @@ web_client { // Row breakpoint for mobile_layout (web/always). 0 = always match. // Default: 30 // mobile_threshold_rows 30 + +// How to handle a nested Zellij session detected inside a pane. +// Options: +// - "ask" (Default — prompt with a modal) +// - "fullscreen" (always zoom into the nested session) +// - "descend" (always control the nested session on focus) +// - "never" (never prompt or descend; do it manually) +// +// nested_session_handling "ask" diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap index 8dcbaadd..fe42491f 100644 --- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap +++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap @@ -333,3 +333,12 @@ mobile_threshold_cols 72 // Row breakpoint for mobile_layout (web/always). 0 = always match. // Default: 30 mobile_threshold_rows 0 + +// How to handle a nested Zellij session detected inside a pane. +// Options: +// - "ask" (Default — prompt with a modal) +// - "fullscreen" (always zoom into the nested session) +// - "descend" (always control the nested session on focus) +// - "never" (never prompt or descend; do it manually) +// +// nested_session_handling "ask" diff --git a/zellij-utils/src/lib.rs b/zellij-utils/src/lib.rs index ba02ce96..e7a2d797 100644 --- a/zellij-utils/src/lib.rs +++ b/zellij-utils/src/lib.rs @@ -11,6 +11,7 @@ mod home_unix; mod home_windows; pub mod input; pub mod kdl; +pub mod nested_session_contract; pub mod pane_size; pub mod plugin_api; pub mod position; @@ -29,6 +30,8 @@ pub mod downloader; // Requires tokio pub mod ipc; // Requires interprocess #[cfg(not(target_family = "wasm"))] pub mod logging; // Requires log4rs +#[cfg(not(target_family = "wasm"))] +pub mod nested_session; #[cfg(all(not(target_family = "wasm"), feature = "web_server_capability"))] pub mod remote_session_tokens; #[cfg(not(target_family = "wasm"))] diff --git a/zellij-utils/src/nested_session.rs b/zellij-utils/src/nested_session.rs new file mode 100644 index 00000000..4144defc --- /dev/null +++ b/zellij-utils/src/nested_session.rs @@ -0,0 +1,470 @@ +use crate::data::{Direction, KeyWithModifier}; +use crate::nested_session_contract::nested_session_contract as proto; +use prost::Message; +use std::str::FromStr; + +pub const NESTED_DCS_PARAM: u16 = 26661; +pub const NESTED_FRAME_HEADER: &[u8] = b"\x1bP26661n"; +pub const NESTED_FRAME_TERMINATOR: &[u8] = b"\x1b\\"; + +pub const REANNOUNCE_SILENCE_MS: u64 = 3000; +pub const REANNOUNCE_CHECK_INTERVAL_MS: u64 = 1000; + +pub fn reannounce_silence_ms() -> u64 { + std::env::var("ZELLIJ_NESTED_REANNOUNCE_SILENCE_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(REANNOUNCE_SILENCE_MS) +} + +pub fn reannounce_check_interval_ms() -> u64 { + std::env::var("ZELLIJ_NESTED_REANNOUNCE_CHECK_INTERVAL_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(REANNOUNCE_CHECK_INTERVAL_MS) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NestedSessionCapability { + NestedControl, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum NestedSessionMessage { + Announce { + session_name: String, + capabilities: Vec<NestedSessionCapability>, + }, + FocusHost { + direction: Option<Direction>, + }, + ToggleHostFullscreen { + fullscreen: bool, + }, + Pong, + Bye, + AnnounceAck { + ancestry: Vec<String>, + capabilities: Vec<NestedSessionCapability>, + descend_keys: Vec<KeyWithModifier>, + }, + FocusGained { + from_direction: Option<Direction>, + }, + FocusLost, + FullscreenState { + fullscreen: bool, + }, + AncestryUpdate { + ancestry: Vec<String>, + }, + Ping, + ShortcutUpdate { + ascend_keys: Vec<KeyWithModifier>, + descend_keys: Vec<KeyWithModifier>, + }, +} + +fn keys_to_proto(keys: &[KeyWithModifier]) -> Vec<String> { + keys.iter().map(|key| key.to_kdl()).collect() +} + +fn keys_from_proto(keys: &[String]) -> Vec<KeyWithModifier> { + let parsed: Vec<KeyWithModifier> = keys + .iter() + .filter_map(|key| KeyWithModifier::from_str(key).ok()) + .collect(); + if parsed.len() == keys.len() { + parsed + } else { + vec![] + } +} + +fn capabilities_to_proto(capabilities: &[NestedSessionCapability]) -> Vec<i32> { + capabilities + .iter() + .map(|capability| match capability { + NestedSessionCapability::NestedControl => proto::NestedCapability::NestedControl as i32, + }) + .collect() +} + +fn capabilities_from_proto(capabilities: &[i32]) -> Vec<NestedSessionCapability> { + capabilities + .iter() + .filter_map( + |capability| match proto::NestedCapability::from_i32(*capability) { + Some(proto::NestedCapability::NestedControl) => { + Some(NestedSessionCapability::NestedControl) + }, + _ => None, + }, + ) + .collect() +} + +fn direction_to_proto(direction: Option<Direction>) -> i32 { + match direction { + Some(Direction::Left) => proto::NestedDirection::Left as i32, + Some(Direction::Right) => proto::NestedDirection::Right as i32, + Some(Direction::Up) => proto::NestedDirection::Up as i32, + Some(Direction::Down) => proto::NestedDirection::Down as i32, + None => proto::NestedDirection::Unspecified as i32, + } +} + +fn direction_from_proto(direction: i32) -> Option<Direction> { + match proto::NestedDirection::from_i32(direction) { + Some(proto::NestedDirection::Left) => Some(Direction::Left), + Some(proto::NestedDirection::Right) => Some(Direction::Right), + Some(proto::NestedDirection::Up) => Some(Direction::Up), + Some(proto::NestedDirection::Down) => Some(Direction::Down), + _ => None, + } +} + +impl From<NestedSessionMessage> for proto::NestedSessionMessage { + fn from(message: NestedSessionMessage) -> Self { + use proto::nested_session_message::Payload; + let payload = match message { + NestedSessionMessage::Announce { + session_name, + capabilities, + } => Payload::Announce(proto::Announce { + session_name, + capabilities: capabilities_to_proto(&capabilities), + }), + NestedSessionMessage::FocusHost { direction } => Payload::FocusHost(proto::FocusHost { + direction: direction_to_proto(direction), + }), + NestedSessionMessage::ToggleHostFullscreen { fullscreen } => { + Payload::HostFullscreen(proto::ToggleHostFullscreen { fullscreen }) + }, + NestedSessionMessage::Pong => Payload::Pong(proto::Pong {}), + NestedSessionMessage::Bye => Payload::Bye(proto::Bye {}), + NestedSessionMessage::AnnounceAck { + ancestry, + capabilities, + descend_keys, + } => Payload::AnnounceAck(proto::AnnounceAck { + ancestry, + capabilities: capabilities_to_proto(&capabilities), + descend_keys: keys_to_proto(&descend_keys), + }), + NestedSessionMessage::FocusGained { from_direction } => { + Payload::FocusGained(proto::FocusGained { + from_direction: direction_to_proto(from_direction), + }) + }, + NestedSessionMessage::FocusLost => Payload::FocusLost(proto::FocusLost {}), + NestedSessionMessage::FullscreenState { fullscreen } => { + Payload::FullscreenState(proto::FullscreenState { fullscreen }) + }, + NestedSessionMessage::AncestryUpdate { ancestry } => { + Payload::AncestryUpdate(proto::AncestryUpdate { ancestry }) + }, + NestedSessionMessage::Ping => Payload::Ping(proto::Ping {}), + NestedSessionMessage::ShortcutUpdate { + ascend_keys, + descend_keys, + } => Payload::ShortcutUpdate(proto::ShortcutUpdate { + ascend_keys: keys_to_proto(&ascend_keys), + descend_keys: keys_to_proto(&descend_keys), + }), + }; + proto::NestedSessionMessage { + payload: Some(payload), + } + } +} + +impl TryFrom<proto::NestedSessionMessage> for NestedSessionMessage { + type Error = (); + fn try_from(message: proto::NestedSessionMessage) -> Result<Self, Self::Error> { + use proto::nested_session_message::Payload; + match message.payload { + Some(Payload::Announce(announce)) => Ok(NestedSessionMessage::Announce { + session_name: announce.session_name, + capabilities: capabilities_from_proto(&announce.capabilities), + }), + Some(Payload::FocusHost(focus_host)) => Ok(NestedSessionMessage::FocusHost { + direction: direction_from_proto(focus_host.direction), + }), + Some(Payload::HostFullscreen(host_fullscreen)) => { + Ok(NestedSessionMessage::ToggleHostFullscreen { + fullscreen: host_fullscreen.fullscreen, + }) + }, + Some(Payload::Pong(_)) => Ok(NestedSessionMessage::Pong), + Some(Payload::Bye(_)) => Ok(NestedSessionMessage::Bye), + Some(Payload::AnnounceAck(announce_ack)) => Ok(NestedSessionMessage::AnnounceAck { + ancestry: announce_ack.ancestry, + capabilities: capabilities_from_proto(&announce_ack.capabilities), + descend_keys: keys_from_proto(&announce_ack.descend_keys), + }), + Some(Payload::FocusGained(focus_gained)) => Ok(NestedSessionMessage::FocusGained { + from_direction: direction_from_proto(focus_gained.from_direction), + }), + Some(Payload::FocusLost(_)) => Ok(NestedSessionMessage::FocusLost), + Some(Payload::FullscreenState(fullscreen_state)) => { + Ok(NestedSessionMessage::FullscreenState { + fullscreen: fullscreen_state.fullscreen, + }) + }, + Some(Payload::AncestryUpdate(ancestry_update)) => { + Ok(NestedSessionMessage::AncestryUpdate { + ancestry: ancestry_update.ancestry, + }) + }, + Some(Payload::Ping(_)) => Ok(NestedSessionMessage::Ping), + Some(Payload::ShortcutUpdate(shortcut_update)) => { + Ok(NestedSessionMessage::ShortcutUpdate { + ascend_keys: keys_from_proto(&shortcut_update.ascend_keys), + descend_keys: keys_from_proto(&shortcut_update.descend_keys), + }) + }, + None => Err(()), + } + } +} + +pub fn encode_payload(message: &NestedSessionMessage) -> Vec<u8> { + let proto_message: proto::NestedSessionMessage = message.clone().into(); + proto_message.encode_to_vec() +} + +pub fn decode_payload(payload_bytes: &[u8]) -> Option<NestedSessionMessage> { + let proto_message = proto::NestedSessionMessage::decode(payload_bytes).ok()?; + NestedSessionMessage::try_from(proto_message).ok() +} + +pub fn encode_frame(message: &NestedSessionMessage) -> Vec<u8> { + encode_frame_from_payload(&encode_payload(message)) +} + +pub fn encode_frame_from_payload(payload_bytes: &[u8]) -> Vec<u8> { + let encoded = base64::encode(payload_bytes); + let mut frame = Vec::with_capacity( + NESTED_FRAME_HEADER.len() + encoded.len() + NESTED_FRAME_TERMINATOR.len(), + ); + frame.extend_from_slice(NESTED_FRAME_HEADER); + frame.extend_from_slice(encoded.as_bytes()); + frame.extend_from_slice(NESTED_FRAME_TERMINATOR); + frame +} + +pub fn decode_base64(encoded: &[u8]) -> Option<Vec<u8>> { + base64::decode(encoded).ok() +} + +const MAX_PARTIAL_FRAME_BYTES: usize = 1024 * 1024; + +enum FrameScanStatus { + Complete(usize), + NeedMore, + Diverged, +} + +fn frame_scan_status(buf: &[u8]) -> FrameScanStatus { + if buf.len() < NESTED_FRAME_HEADER.len() { + return if NESTED_FRAME_HEADER.starts_with(buf) { + FrameScanStatus::NeedMore + } else { + FrameScanStatus::Diverged + }; + } + if &buf[..NESTED_FRAME_HEADER.len()] != NESTED_FRAME_HEADER { + return FrameScanStatus::Diverged; + } + let mut i = NESTED_FRAME_HEADER.len(); + while i < buf.len() { + match buf[i] { + 0x1b => match buf.get(i + 1) { + Some(&b'\\') => return FrameScanStatus::Complete(i + 2), + Some(_) => return FrameScanStatus::Diverged, + None => return FrameScanStatus::NeedMore, + }, + _ => i += 1, + } + } + FrameScanStatus::NeedMore +} + +#[derive(Debug, Default)] +pub struct NestedFrameExtractor { + partial_frame: Vec<u8>, +} + +impl NestedFrameExtractor { + pub fn new() -> Self { + Self::default() + } + + pub fn extract(&mut self, bytes: &[u8]) -> (Vec<u8>, Vec<Vec<u8>>) { + let mut decoded_payloads = Vec::new(); + if self.partial_frame.is_empty() && !bytes.contains(&0x1b) { + return (bytes.to_vec(), decoded_payloads); + } + let mut working = std::mem::take(&mut self.partial_frame); + working.extend_from_slice(bytes); + let mut cleaned = Vec::with_capacity(working.len()); + let mut i = 0; + while i < working.len() { + let rest = &working[i..]; + if rest[0] == 0x1b { + match frame_scan_status(rest) { + FrameScanStatus::Complete(len) => { + let encoded_payload = + &rest[NESTED_FRAME_HEADER.len()..len - NESTED_FRAME_TERMINATOR.len()]; + if let Some(payload) = decode_base64(encoded_payload) { + decoded_payloads.push(payload); + } + i += len; + continue; + }, + FrameScanStatus::NeedMore => { + let tail = rest.to_vec(); + if tail.len() > MAX_PARTIAL_FRAME_BYTES { + cleaned.extend_from_slice(&tail); + } else { + self.partial_frame = tail; + } + return (cleaned, decoded_payloads); + }, + FrameScanStatus::Diverged => {}, + } + } + cleaned.push(working[i]); + i += 1; + } + (cleaned, decoded_payloads) + } + + pub fn partial_bytes(&self) -> &[u8] { + &self.partial_frame + } + + pub fn take_partial(&mut self) -> Vec<u8> { + std::mem::take(&mut self.partial_frame) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::BareKey; + + fn all_message_arms() -> Vec<NestedSessionMessage> { + vec![ + NestedSessionMessage::Announce { + session_name: "guest".to_owned(), + capabilities: vec![NestedSessionCapability::NestedControl], + }, + NestedSessionMessage::FocusHost { + direction: Some(Direction::Left), + }, + NestedSessionMessage::FocusHost { direction: None }, + NestedSessionMessage::ToggleHostFullscreen { fullscreen: true }, + NestedSessionMessage::Pong, + NestedSessionMessage::Bye, + NestedSessionMessage::AnnounceAck { + ancestry: vec!["outer".to_owned(), "middle".to_owned()], + capabilities: vec![NestedSessionCapability::NestedControl], + descend_keys: vec![ + KeyWithModifier::new(BareKey::Char('o')).with_ctrl_modifier(), + KeyWithModifier::new(BareKey::Down), + ], + }, + NestedSessionMessage::FocusGained { + from_direction: Some(Direction::Right), + }, + NestedSessionMessage::FocusLost, + NestedSessionMessage::FullscreenState { fullscreen: true }, + NestedSessionMessage::AncestryUpdate { + ancestry: vec!["outer".to_owned()], + }, + NestedSessionMessage::Ping, + NestedSessionMessage::ShortcutUpdate { + ascend_keys: vec![ + KeyWithModifier::new(BareKey::Char('o')).with_ctrl_modifier(), + KeyWithModifier::new(BareKey::Up), + ], + descend_keys: vec![], + }, + ] + } + + #[test] + fn payload_roundtrip_preserves_every_arm() { + for message in all_message_arms() { + let decoded = decode_payload(&encode_payload(&message)); + assert_eq!(decoded, Some(message)); + } + } + + #[test] + fn focus_direction_roundtrip_preserves_every_direction() { + let directions = [ + None, + Some(Direction::Left), + Some(Direction::Right), + Some(Direction::Up), + Some(Direction::Down), + ]; + for direction in directions { + let focus_host = NestedSessionMessage::FocusHost { direction }; + assert_eq!( + decode_payload(&encode_payload(&focus_host)), + Some(focus_host) + ); + let focus_gained = NestedSessionMessage::FocusGained { + from_direction: direction, + }; + assert_eq!( + decode_payload(&encode_payload(&focus_gained)), + Some(focus_gained) + ); + } + } + + #[test] + fn frame_roundtrip_preserves_message() { + let message = NestedSessionMessage::Announce { + session_name: "guest".to_owned(), + capabilities: vec![NestedSessionCapability::NestedControl], + }; + let frame = encode_frame(&message); + assert!(frame.starts_with(NESTED_FRAME_HEADER)); + assert!(frame.ends_with(NESTED_FRAME_TERMINATOR)); + let encoded_payload = + &frame[NESTED_FRAME_HEADER.len()..frame.len() - NESTED_FRAME_TERMINATOR.len()]; + let payload = decode_base64(encoded_payload).unwrap(); + assert_eq!(decode_payload(&payload), Some(message)); + } + + #[test] + fn garbage_base64_is_rejected() { + assert_eq!(decode_base64(b"!!!not-base64!!!"), None); + } + + #[test] + fn truncated_payload_is_rejected() { + let payload = encode_payload(&NestedSessionMessage::Announce { + session_name: "a-long-session-name-to-truncate".to_owned(), + capabilities: vec![], + }); + assert_eq!(decode_payload(&payload[..payload.len() - 5]), None); + } + + #[test] + fn unknown_oneof_arm_is_rejected() { + let unknown_field_bytes = [0xE2, 0x03, 0x00]; + assert_eq!(decode_payload(&unknown_field_bytes), None); + } + + #[test] + fn empty_payload_is_rejected() { + assert_eq!(decode_payload(&[]), None); + } +} diff --git a/zellij-utils/src/nested_session_contract/mod.rs b/zellij-utils/src/nested_session_contract/mod.rs new file mode 100644 index 00000000..5ec943c2 --- /dev/null +++ b/zellij-utils/src/nested_session_contract/mod.rs @@ -0,0 +1,4 @@ +include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/prost_nested_session/generated_nested_session_api.rs" +)); diff --git a/zellij-utils/src/nested_session_contract/nested_session_message.proto b/zellij-utils/src/nested_session_contract/nested_session_message.proto new file mode 100644 index 00000000..dfe2f62a --- /dev/null +++ b/zellij-utils/src/nested_session_contract/nested_session_message.proto @@ -0,0 +1,80 @@ +syntax = "proto3"; +package nested_session_contract; + +message NestedSessionMessage { + oneof payload { + Announce announce = 1; + FocusHost focus_host = 2; + ToggleHostFullscreen host_fullscreen = 3; + Pong pong = 4; + Bye bye = 5; + AnnounceAck announce_ack = 8; + FocusGained focus_gained = 9; + FocusLost focus_lost = 10; + FullscreenState fullscreen_state = 11; + AncestryUpdate ancestry_update = 12; + Ping ping = 13; + ShortcutUpdate shortcut_update = 14; + } +} + +enum NestedCapability { + NESTED_CAPABILITY_UNSPECIFIED = 0; + NESTED_CAPABILITY_NESTED_CONTROL = 1; +} + +enum NestedDirection { + NESTED_DIRECTION_UNSPECIFIED = 0; + NESTED_DIRECTION_LEFT = 1; + NESTED_DIRECTION_RIGHT = 2; + NESTED_DIRECTION_UP = 3; + NESTED_DIRECTION_DOWN = 4; +} + +message Announce { + string session_name = 1; + repeated NestedCapability capabilities = 2; +} + +message FocusHost { + NestedDirection direction = 1; +} + +message ToggleHostFullscreen { + bool fullscreen = 1; +} + +message Pong { +} + +message Bye { +} + +message AnnounceAck { + repeated string ancestry = 1; + repeated NestedCapability capabilities = 2; + repeated string descend_keys = 3; +} + +message ShortcutUpdate { + repeated string ascend_keys = 1; + repeated string descend_keys = 2; +} + +message FocusGained { + NestedDirection from_direction = 1; +} + +message FocusLost { +} + +message FullscreenState { + bool fullscreen = 1; +} + +message AncestryUpdate { + repeated string ancestry = 1; +} + +message Ping { +} diff --git a/zellij-utils/src/plugin_api/action.proto b/zellij-utils/src/plugin_api/action.proto index f9370961..1d896cef 100644 --- a/zellij-utils/src/plugin_api/action.proto +++ b/zellij-utils/src/plugin_api/action.proto @@ -501,6 +501,10 @@ enum ActionName { ToggleTheme = 103; FocusLastPane = 104; SetPaneFrameStyle = 105; + ToggleFocusNoUiFullscreen = 106; + FocusHostSession = 107; + ToggleHostFullscreen = 108; + FocusGuestSession = 109; } message Position { diff --git a/zellij-utils/src/plugin_api/action.rs b/zellij-utils/src/plugin_api/action.rs index f1d71222..c11b56f1 100644 --- a/zellij-utils/src/plugin_api/action.rs +++ b/zellij-utils/src/plugin_api/action.rs @@ -298,6 +298,12 @@ impl TryFrom<ProtobufAction> for Action { None => Ok(Action::ToggleFocusFullscreen), } }, + Some(ProtobufActionName::ToggleFocusNoUiFullscreen) => { + match protobuf_action.optional_payload { + Some(_) => Err("ToggleFocusNoUiFullscreen should not have a payload"), + None => Ok(Action::ToggleFocusNoUiFullscreen), + } + }, Some(ProtobufActionName::TogglePaneFrames) => match protobuf_action.optional_payload { Some(_) => Err("TogglePaneFrames should not have a payload"), None => Ok(Action::TogglePaneFrames), @@ -1092,6 +1098,20 @@ impl TryFrom<ProtobufAction> for Action { }, _ => Err("Wrong payload for Action::NewInPlacePane"), }, + Some(ProtobufActionName::FocusHostSession) => match protobuf_action.optional_payload { + Some(_) => Err("FocusHostSession should not have a payload"), + None => Ok(Action::FocusHostSession), + }, + Some(ProtobufActionName::FocusGuestSession) => match protobuf_action.optional_payload { + Some(_) => Err("FocusGuestSession should not have a payload"), + None => Ok(Action::FocusGuestSession), + }, + Some(ProtobufActionName::ToggleHostFullscreen) => { + match protobuf_action.optional_payload { + Some(_) => Err("ToggleHostFullscreen should not have a payload"), + None => Ok(Action::ToggleHostFullscreen), + } + }, _ => Err("Unknown Action"), } } @@ -1148,6 +1168,7 @@ impl TryFrom<Action> for ProtobufAction { | Action::ClearScreenByPaneId { .. } | Action::EditScrollbackByPaneId { .. } | Action::ToggleFocusFullscreenByPaneId { .. } + | Action::ToggleFocusNoUiFullscreenByPaneId { .. } | Action::TogglePaneEmbedOrFloatingByPaneId { .. } | Action::CloseFocusByPaneId { .. } | Action::RenamePaneByPaneId { .. } @@ -1323,6 +1344,10 @@ impl TryFrom<Action> for ProtobufAction { name: ProtobufActionName::ToggleFocusFullscreen as i32, optional_payload: None, }), + Action::ToggleFocusNoUiFullscreen => Ok(ProtobufAction { + name: ProtobufActionName::ToggleFocusNoUiFullscreen as i32, + optional_payload: None, + }), Action::TogglePaneFrames => Ok(ProtobufAction { name: ProtobufActionName::TogglePaneFrames as i32, optional_payload: None, @@ -1960,6 +1985,18 @@ impl TryFrom<Action> for ProtobufAction { )), }) }, + Action::FocusHostSession => Ok(ProtobufAction { + name: ProtobufActionName::FocusHostSession as i32, + optional_payload: None, + }), + Action::FocusGuestSession => Ok(ProtobufAction { + name: ProtobufActionName::FocusGuestSession as i32, + optional_payload: None, + }), + Action::ToggleHostFullscreen => Ok(ProtobufAction { + name: ProtobufActionName::ToggleHostFullscreen as i32, + optional_payload: None, + }), Action::NoOp | Action::Confirm | Action::NewInPlacePluginPane { diff --git a/zellij-utils/src/plugin_api/event.proto b/zellij-utils/src/plugin_api/event.proto index d3148edf..6814e8d8 100644 --- a/zellij-utils/src/plugin_api/event.proto +++ b/zellij-utils/src/plugin_api/event.proto @@ -511,6 +511,12 @@ message ModeUpdatePayload { optional uint32 web_server_port = 14; optional bool web_server_capability = 15; optional PaneFrameStyle pane_frame_style = 16; + optional bool session_dimmed = 17; + repeated string ancestry = 18; + optional bool host_fullscreen = 19; + repeated string nested_ascend_keys = 20; + optional bool session_ascended = 21; + repeated string nested_descend_keys = 22; } enum WebSharing { diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs index 190552d4..970f5e2c 100644 --- a/zellij-utils/src/plugin_api/event.rs +++ b/zellij-utils/src/plugin_api/event.rs @@ -2020,6 +2020,26 @@ impl TryFrom<ProtobufModeUpdatePayload> for ModeInfo { .and_then(|p| ProtobufPaneFrameStyle::from_i32(p)) .map(|p| p.into()); + let session_dimmed = protobuf_mode_update_payload.session_dimmed; + + let session_ancestry = protobuf_mode_update_payload.ancestry; + + let host_fullscreen = protobuf_mode_update_payload.host_fullscreen; + + let nested_ascend_keys = protobuf_mode_update_payload + .nested_ascend_keys + .iter() + .filter_map(|key| KeyWithModifier::from_str(key).ok()) + .collect(); + + let session_ascended = protobuf_mode_update_payload.session_ascended; + + let nested_descend_keys = protobuf_mode_update_payload + .nested_descend_keys + .iter() + .filter_map(|key| KeyWithModifier::from_str(key).ok()) + .collect(); + let mode_info = ModeInfo { mode: current_mode, keybinds, @@ -2037,6 +2057,12 @@ impl TryFrom<ProtobufModeUpdatePayload> for ModeInfo { web_server_port, web_server_capability, pane_frame_style, + session_dimmed, + session_ancestry, + host_fullscreen, + nested_ascend_keys, + session_ascended, + nested_descend_keys, }; Ok(mode_info) } @@ -2065,6 +2091,20 @@ impl TryFrom<ModeInfo> for ProtobufModeUpdatePayload { let protobuf_pane_frame_style: ProtobufPaneFrameStyle = p.into(); protobuf_pane_frame_style as i32 }); + let session_dimmed = mode_info.session_dimmed; + let session_ancestry = mode_info.session_ancestry; + let host_fullscreen = mode_info.host_fullscreen; + let nested_ascend_keys = mode_info + .nested_ascend_keys + .iter() + .map(|key| key.to_kdl()) + .collect(); + let session_ascended = mode_info.session_ascended; + let nested_descend_keys = mode_info + .nested_descend_keys + .iter() + .map(|key| key.to_kdl()) + .collect(); let mut protobuf_input_mode_keybinds: Vec<ProtobufInputModeKeybinds> = vec![]; for (input_mode, input_mode_keybinds) in mode_info.keybinds { let mode: ProtobufInputMode = input_mode.try_into()?; @@ -2106,6 +2146,12 @@ impl TryFrom<ModeInfo> for ProtobufModeUpdatePayload { web_server_port, web_server_capability, pane_frame_style, + session_dimmed, + ancestry: session_ancestry, + host_fullscreen, + nested_ascend_keys, + session_ascended, + nested_descend_keys, }) } } @@ -2460,6 +2506,18 @@ fn serialize_mode_update_event_with_non_default_values() { web_server_port: Some(8082), web_server_capability: Some(true), pane_frame_style: Some(crate::input::options::PaneFrameStyle::Titles), + session_dimmed: Some(true), + session_ancestry: vec!["work".to_owned(), "prod".to_owned()], + host_fullscreen: Some(true), + nested_ascend_keys: vec![ + KeyWithModifier::new(BareKey::Char('o')).with_ctrl_modifier(), + KeyWithModifier::new(BareKey::Up), + ], + session_ascended: Some(true), + nested_descend_keys: vec![ + KeyWithModifier::new(BareKey::Char('o')).with_ctrl_modifier(), + KeyWithModifier::new(BareKey::Down), + ], }); let protobuf_event: ProtobufEvent = mode_update_event.clone().try_into().unwrap(); let serialized_protobuf_event = protobuf_event.encode_to_vec(); diff --git a/zellij-utils/src/plugin_api/plugin_command.proto b/zellij-utils/src/plugin_api/plugin_command.proto index 8d31727d..e9dff3c6 100644 --- a/zellij-utils/src/plugin_api/plugin_command.proto +++ b/zellij-utils/src/plugin_api/plugin_command.proto @@ -218,6 +218,8 @@ enum CommandName { SetPaneFrameStyle = 224; ToggleFloatingPanes = 225; NewPane = 226; + ToggleFocusNoUiFullscreen = 227; + FocusHostSession = 228; } message PluginCommand { diff --git a/zellij-utils/src/plugin_api/plugin_command.rs b/zellij-utils/src/plugin_api/plugin_command.rs index 38c42565..3d86bc20 100644 --- a/zellij-utils/src/plugin_api/plugin_command.rs +++ b/zellij-utils/src/plugin_api/plugin_command.rs @@ -1110,6 +1110,18 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand { } Ok(PluginCommand::ToggleFocusFullscreen) }, + Some(CommandName::ToggleFocusNoUiFullscreen) => { + if protobuf_plugin_command.payload.is_some() { + return Err("ToggleFocusNoUiFullscreen should not have a payload"); + } + Ok(PluginCommand::ToggleFocusNoUiFullscreen) + }, + Some(CommandName::FocusHostSession) => { + if protobuf_plugin_command.payload.is_some() { + return Err("FocusHostSession should not have a payload"); + } + Ok(PluginCommand::FocusHostSession) + }, Some(CommandName::TogglePaneFrames) => { if protobuf_plugin_command.payload.is_some() { return Err("TogglePaneFrames should not have a payload"); @@ -3127,6 +3139,14 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand { name: CommandName::ToggleFocusFullscreen as i32, payload: None, }), + PluginCommand::ToggleFocusNoUiFullscreen => Ok(ProtobufPluginCommand { + name: CommandName::ToggleFocusNoUiFullscreen as i32, + payload: None, + }), + PluginCommand::FocusHostSession => Ok(ProtobufPluginCommand { + name: CommandName::FocusHostSession as i32, + payload: None, + }), PluginCommand::TogglePaneFrames => Ok(ProtobufPluginCommand { name: CommandName::TogglePaneFrames as i32, payload: None, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap index 1bef8e12..cd08644a 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap @@ -58,4 +58,5 @@ Options { mobile_layout: None, mobile_threshold_cols: None, mobile_threshold_rows: None, + nested_session_handling: None, } diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap index 71d15cbe..26de6b99 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap @@ -58,4 +58,5 @@ Options { mobile_layout: None, mobile_threshold_cols: None, mobile_threshold_rows: None, + nested_session_handling: None, } diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap index 85885291..6242e4d9 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap @@ -56,4 +56,5 @@ Options { mobile_layout: None, mobile_threshold_cols: None, mobile_threshold_rows: None, + nested_session_handling: None, } diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap index 54404196..4f2869c9 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap @@ -1073,6 +1073,19 @@ Config { ], KeyWithModifier { bare_key: Char( + 'f', + ), + key_modifiers: { + Shift, + }, + }: [ + ToggleFocusNoUiFullscreen, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( 'g', ), key_modifiers: { @@ -4150,6 +4163,17 @@ Config { bare_key: Char( '[', ), + key_modifiers: {}, + }: [ + FocusGuestSession, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + '[', + ), key_modifiers: { Alt, }, @@ -4160,6 +4184,17 @@ Config { bare_key: Char( ']', ), + key_modifiers: {}, + }: [ + FocusHostSession, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + ']', + ), key_modifiers: { Alt, }, @@ -4252,6 +4287,17 @@ Config { bare_key: Char( 'f', ), + key_modifiers: {}, + }: [ + ToggleHostFullscreen, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + 'f', + ), key_modifiers: { Alt, }, @@ -6035,6 +6081,7 @@ Config { mobile_layout: None, mobile_threshold_cols: None, mobile_threshold_rows: None, + nested_session_handling: None, }, themes: {}, plugins: PluginAliases { diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap index 6758d313..5353631b 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap @@ -1073,6 +1073,19 @@ Config { ], KeyWithModifier { bare_key: Char( + 'f', + ), + key_modifiers: { + Shift, + }, + }: [ + ToggleFocusNoUiFullscreen, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( 'g', ), key_modifiers: { @@ -4150,6 +4163,17 @@ Config { bare_key: Char( '[', ), + key_modifiers: {}, + }: [ + FocusGuestSession, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + '[', + ), key_modifiers: { Alt, }, @@ -4160,6 +4184,17 @@ Config { bare_key: Char( ']', ), + key_modifiers: {}, + }: [ + FocusHostSession, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + ']', + ), key_modifiers: { Alt, }, @@ -4252,6 +4287,17 @@ Config { bare_key: Char( 'f', ), + key_modifiers: {}, + }: [ + ToggleHostFullscreen, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + 'f', + ), key_modifiers: { Alt, }, @@ -6035,6 +6081,7 @@ Config { mobile_layout: None, mobile_threshold_cols: None, mobile_threshold_rows: None, + nested_session_handling: None, }, themes: {}, plugins: PluginAliases { diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap index e38dc3bc..d907d5b0 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap @@ -143,6 +143,7 @@ Config { mobile_layout: None, mobile_threshold_cols: None, mobile_threshold_rows: None, + nested_session_handling: None, }, themes: {}, plugins: PluginAliases { diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap index 58a3028b..ff54b105 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap @@ -58,4 +58,5 @@ Options { mobile_layout: None, mobile_threshold_cols: None, mobile_threshold_rows: None, + nested_session_handling: None, } diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap index c68392b2..624cf0b0 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap @@ -1073,6 +1073,19 @@ Config { ], KeyWithModifier { bare_key: Char( + 'f', + ), + key_modifiers: { + Shift, + }, + }: [ + ToggleFocusNoUiFullscreen, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( 'g', ), key_modifiers: { @@ -4150,6 +4163,17 @@ Config { bare_key: Char( '[', ), + key_modifiers: {}, + }: [ + FocusGuestSession, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + '[', + ), key_modifiers: { Alt, }, @@ -4160,6 +4184,17 @@ Config { bare_key: Char( ']', ), + key_modifiers: {}, + }: [ + FocusHostSession, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + ']', + ), key_modifiers: { Alt, }, @@ -4252,6 +4287,17 @@ Config { bare_key: Char( 'f', ), + key_modifiers: {}, + }: [ + ToggleHostFullscreen, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + 'f', + ), key_modifiers: { Alt, }, @@ -6035,6 +6081,7 @@ Config { mobile_layout: None, mobile_threshold_cols: None, mobile_threshold_rows: None, + nested_session_handling: None, }, themes: { "other-theme-from-config": Theme { diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap index 0609b74b..0cfece6e 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap @@ -1073,6 +1073,19 @@ Config { ], KeyWithModifier { bare_key: Char( + 'f', + ), + key_modifiers: { + Shift, + }, + }: [ + ToggleFocusNoUiFullscreen, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( 'g', ), key_modifiers: { @@ -4150,6 +4163,17 @@ Config { bare_key: Char( '[', ), + key_modifiers: {}, + }: [ + FocusGuestSession, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + '[', + ), key_modifiers: { Alt, }, @@ -4160,6 +4184,17 @@ Config { bare_key: Char( ']', ), + key_modifiers: {}, + }: [ + FocusHostSession, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + ']', + ), key_modifiers: { Alt, }, @@ -4252,6 +4287,17 @@ Config { bare_key: Char( 'f', ), + key_modifiers: {}, + }: [ + ToggleHostFullscreen, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + 'f', + ), key_modifiers: { Alt, }, @@ -6035,6 +6081,7 @@ Config { mobile_layout: None, mobile_threshold_cols: None, mobile_threshold_rows: None, + nested_session_handling: None, }, themes: {}, plugins: PluginAliases { |
