diff options
| author | user <user@clank> | 2026-08-13 14:04:23 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-13 14:04:23 +0200 |
| commit | 8e93e00e2c8f5692b6fbc200d989646bd088ddad (patch) | |
| tree | 9bc363dabc2cbd6207900d2eaecf65f4d3b6ae73 | |
| parent | docs: record the responsive fix and how to re-verify it (diff) | |
| download | sl0ppi-8e93e00e2c8f5692b6fbc200d989646bd088ddad.tar.gz sl0ppi-8e93e00e2c8f5692b6fbc200d989646bd088ddad.tar.xz sl0ppi-8e93e00e2c8f5692b6fbc200d989646bd088ddad.zip | |
sl0p: a mouse affordance for splitting a pane
Switching panes, switching tabs, renaming, reordering and resizing all have a
mouse path in this fork. Spawning a pane did not: it was keyboard-only.
A `+` now appears at the right end of a pane's title line while the pointer is
over that pane, and clicking it splits the pane. Hover-driven rather than always
drawn, for the same reason the resize hint and the status bar button highlight
are: a permanent `+` on every frame is noise, and the pointer being there is
exactly when the button is useful.
render ui/pane_boundaries_frame.rs::render_split_button
hit tab/mouse_handler.rs::intercept_split_button_click
shared split_button_columns(), so what is clickable is what is visible
The button sits immediately left of the top-right corner and the existing scroll
and pin indications get the room that is left. That ordering is deliberate: a
fixed position relative to the pane's right edge is one the mouse handler can
hit-test directly, instead of re-deriving the whole title layout the way
`status_bar_button_at` has to.
The clicked pane is focused first, then the split is requested exactly as a
keybinding would (`PtyInstruction::SpawnTerminal` with `NoPreference`), so mouse
and keyboard produce the same arrangement and the split lands on the pane whose
button was clicked rather than whichever pane happened to be focused.
Not drawn on floating panes: their PIN control owns that corner and is
hit-tested by its drawn position, so anything to its right silently moves it out
from under the pointer. Caught by
test_ctrl_click_on_floating_pin_button_toggles_pin, which stopped pinning.
Verified on the test box by injecting real SGR mouse events into an attached
client's pty at 160x45:
click on the button 1 -> 2 panes
click on the title 1 -> 1
click mid-frame 1 -> 1
Two hover snapshots updated, and only those two: the `+` on the hovered pane
(including an unfocused one -- splitting a pane you are not in is useful).
zellij-server 1502 pass, zellij-utils 473 pass.
4 files changed, 148 insertions, 3 deletions
diff --git a/sl0ppij/zellij-server/src/tab/mouse_handler.rs b/sl0ppij/zellij-server/src/tab/mouse_handler.rs index cb1b79f..6961fc7 100644 --- a/sl0ppij/zellij-server/src/tab/mouse_handler.rs +++ b/sl0ppij/zellij-server/src/tab/mouse_handler.rs @@ -9,6 +9,8 @@ use crate::background_jobs::BackgroundJob; use crate::panes::grid::Click; use crate::panes::PaneId; use crate::plugins::PluginInstruction; +use crate::pty::{ClientTabIndexOrPaneId, PtyInstruction}; +use crate::ui::pane_boundaries_frame::split_button_columns; use crate::screen::{GuestModalOutcome, ScreenInstruction}; use crate::ServerInstruction; use crate::ClientId; @@ -407,6 +409,9 @@ impl MouseHandler { if let Some(effect) = Self::intercept_stack_list_indicator_click(tab, event)? { return Ok(effect); } + if let Some(effect) = Self::intercept_split_button_click(tab, event, client_id)? { + return Ok(effect); + } if let Some(effect) = Self::intercept_status_bar_button_click(tab, event)? { return Ok(effect); } @@ -468,6 +473,77 @@ impl MouseHandler { /// untrusted -- `cat` of a hostile file emits it just as readily -- so if a /// click could send arbitrary bytes, viewing a file would be able to inject /// into the stdin of whatever is running, a shell included. + /// sl0p fork: clicking the `+` on a pane's title line splits that pane. + /// + /// Spawning a pane was the one common operation with no mouse path at all -- + /// switching, renaming, reordering and resizing all have one. The button is + /// only drawn while the pointer is over the pane (see `render_split_button`), + /// and both sides derive its columns from `split_button_columns`, so what is + /// clickable is exactly what is visible. + /// + /// Intercepted before the OSC-5577 status bar buttons: those are drawn by + /// the program inside the pane on the row BELOW the content, so the two + /// cannot collide, but ordering it first keeps the frame's own affordances + /// owned by the terminal rather than by whatever is running in the pane. + fn intercept_split_button_click( + tab: &mut Tab, + event: &MouseEvent, + client_id: ClientId, + ) -> Result<Option<MouseEffect>> { + if !(event.left && event.event_type == MouseEventType::Press) { + return Ok(None); + } + // No frame, no button to click. + if !tab.pane_frame_style.draws_full_frames() { + return Ok(None); + } + // Floating panes are excluded to match the renderer: their PIN control + // owns this corner (see `render_split_button`). + if tab.floating_panes.panes_are_visible() { + return Ok(None); + } + let Some(pane) = Self::get_pane_at(tab, &event.position, false)? else { + return Ok(None); + }; + if !pane.selectable() { + return Ok(None); + } + let geom = pane.position_and_size(); + let pane_id = pane.pid(); + // the title line is the pane's top row + if event.position.line() != geom.y as isize { + return Ok(None); + } + let Some(columns) = split_button_columns(geom.x, geom.cols.as_usize()) else { + return Ok(None); + }; + if !columns.contains(&event.position.column()) { + return Ok(None); + } + + // Focus first: a split goes next to the focused pane, and clicking a + // pane's own button should split THAT pane, not whichever one happened + // to be focused. + tab.focus_pane_with_id(pane_id, false, false, client_id)?; + + // Split direction is left to the same logic a keybinding uses + // (NoPreference splits along the longer axis), so mouse and keyboard + // produce the same arrangement. + tab.senders + .send_to_pty(PtyInstruction::SpawnTerminal( + None, // default shell + None, // no explicit name + Default::default(), + false, // not suppressed + ClientTabIndexOrPaneId::ClientId(client_id), + None, // nothing is waiting on this + false, // not blocking + None, // no workspace-hint purpose: this is a plain user split + )) + .with_context(|| format!("failed to split pane {pane_id:?} from its frame button"))?; + Ok(Some(MouseEffect::state_changed())) + } + fn intercept_status_bar_button_click( tab: &mut Tab, event: &MouseEvent, diff --git a/sl0ppij/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_on_unfocused_pane_no_highlight.snap b/sl0ppij/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_on_unfocused_pane_no_highlight.snap index 8070d41..fc7ddd7 100644 --- a/sl0ppij/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_on_unfocused_pane_no_highlight.snap +++ b/sl0ppij/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_on_unfocused_pane_no_highlight.snap @@ -2,7 +2,7 @@ source: zellij-server/src/tab/./unit/tab_integration_tests.rs expression: snapshot --- -00 (C): ┌ Pane #1 ──────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐ +00 (C): ┌ Pane #1 ─────────────────────────────────────────────── + ┐┌ Pane #2 ─────────────────────────────────────────────────┐ 01 (C): │link_text ││right pane │ 02 (C): │ ││ │ 03 (C): │ ││ │ diff --git a/sl0ppij/zellij-server/src/ui/pane_boundaries_frame.rs b/sl0ppij/zellij-server/src/ui/pane_boundaries_frame.rs index 7076d34..cb22912 100644 --- a/sl0ppij/zellij-server/src/ui/pane_boundaries_frame.rs +++ b/sl0ppij/zellij-server/src/ui/pane_boundaries_frame.rs @@ -11,6 +11,30 @@ use crate::ui::hint_text::{ use crate::ClientId; use zellij_utils::data::{client_id_to_colors, PaletteColor, PaneTitleAlignment, Style}; use zellij_utils::input::layout::{layout_status_bar, StatusBarButton}; + +/// sl0p fork: the mouse "split this pane" button drawn at the right end of a +/// hovered pane's title line, and the geometry both the renderer and the mouse +/// handler derive it from. +/// +/// Kept here, next to the code that draws it, so the two cannot drift: a button +/// you can see but not click (or click but not see) is worse than no button. +pub const SPLIT_BUTTON_TEXT: &str = " + "; +pub const SPLIT_BUTTON_WIDTH: usize = 3; +/// Narrower than this and the title line has nothing to spare; the button is +/// not drawn and clicking there does nothing special. +pub const SPLIT_BUTTON_MIN_PANE_COLS: usize = 20; + +/// The columns a pane's split button occupies, or None when it is not drawn. +/// +/// Sits immediately left of the top-right corner, so it does not move when the +/// scroll or pin indications appear. +pub fn split_button_columns(pane_x: usize, pane_cols: usize) -> Option<std::ops::Range<usize>> { + if pane_cols < SPLIT_BUTTON_MIN_PANE_COLS { + return None; + } + let corner = pane_x + pane_cols.saturating_sub(1); + Some(corner.saturating_sub(SPLIT_BUTTON_WIDTH)..corner) +} use zellij_utils::input::theme::StackListStyle; use zellij_utils::errors::prelude::*; use zellij_utils::pane_size::{Offset, PaneGeom, Viewport}; @@ -319,7 +343,52 @@ impl PaneFrame { &self, max_length: usize, ) -> Option<(Vec<TerminalCharacter>, usize)> { - self.render_title_right_side_inner(max_length) + // sl0p fork: the split button is drawn RIGHTMOST, and the indications + // that were already here get whatever room is left. Its position is + // therefore fixed relative to the pane's right edge, which is what lets + // the mouse handler hit-test it with `split_button_columns` instead of + // re-deriving this whole layout. + let button = self.render_split_button(max_length); + let button_len = button.as_ref().map(|(_, len)| *len).unwrap_or(0); + let inner = self.render_title_right_side_inner(max_length.saturating_sub(button_len)); + match (inner, button) { + (Some((mut chars, len)), Some((mut button, button_len))) => { + chars.append(&mut button); + Some((chars, len + button_len)) + }, + (Some(inner), None) => Some(inner), + (None, Some(button)) => Some(button), + (None, None) => None, + } + } + /// sl0p fork: a "split this pane" affordance for the mouse. + /// + /// Only while the pointer is over the pane: a permanent `+` on every frame + /// is noise, and the pointer being here is exactly the moment the button is + /// useful. The same reason the resize hint and the status bar button + /// highlight are hover-driven. + fn render_split_button(&self, max_length: usize) -> Option<(Vec<TerminalCharacter>, usize)> { + if !self.mouse_is_hovering_over_pane || !self.is_selectable { + return None; + } + // Not on floating panes: the PIN control already lives in this corner, + // and it is hit-tested by its drawn position -- adding anything to its + // right silently moves it out from under the pointer. Caught by + // test_ctrl_click_on_floating_pin_button_toggles_pin, which stopped + // pinning. + if self.is_floating { + return None; + } + if max_length < SPLIT_BUTTON_WIDTH { + return None; + } + // Uses the pane's own geometry, not `max_length`, so that rendering and + // hit-testing agree on when the button exists at all. + split_button_columns(self.geom.x, self.geom.cols)?; + Some(( + foreground_color(SPLIT_BUTTON_TEXT, self.color), + SPLIT_BUTTON_WIDTH, + )) } fn render_title_right_side_inner( &self, diff --git a/sl0ppij/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__mouse_hover_effect-2.snap b/sl0ppij/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__mouse_hover_effect-2.snap index 0b636b2..e89c78d 100644 --- a/sl0ppij/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__mouse_hover_effect-2.snap +++ b/sl0ppij/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__mouse_hover_effect-2.snap @@ -2,7 +2,7 @@ source: zellij-server/src/./unit/screen_tests.rs expression: "format!(\"{}\", snapshot)" --- -00 (C): ┌ Pane #1 ──────────────────────────────────────────────────────┐┌ Pane #2 ──────────────────────────────────────────────────────┐ +00 (C): ┌ Pane #1 ──────────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────── + ┐ 01 (C): │ ││ │ 02 (C): │ ││ │ 03 (C): │ ││ │ |
