From 1f595df2d7e12a1efdd9d1a709250df8c32d3819 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 19 Jun 2026 21:58:16 +0900 Subject: [PATCH 1/2] feat!: remove wheel-scroll tab/pane navigation (#103) zellij delivers ScrollUp/ScrollDown with no device identity and no physical detent, so a notched wheel and a stepless device (Magic Mouse, trackpad) are indistinguishable at the event level. A stepless device emits a burst of scroll events per flick, and no single rate-limiter setting reconciles both classes: a short cooldown still races through many tabs/panes, while a long one locks out deliberate input and lets momentum tails add stray steps. The successive attempts -- leading-edge cooldown (#83), debounce-to-last-event (#96), reopen-on-timer throttle (#100) -- each only traded one failure mode for another. Roll the feature back entirely: delete src/scroll.rs, the scroll / scroll_cooldown_ms config keys, the ScrollUp/ScrollDown/Timer handlers and the EventType::Timer subscription, the cooldown state, the scroll-only pane traversal helper (projection::pane_ids_in_reading_order), and the README docs. The wheel over the bar returns to inert (pre-#80). Closes #103. BREAKING CHANGE: the `scroll` and `scroll_cooldown_ms` config keys are removed and the mouse wheel no longer navigates tabs/panes. Existing layouts keep loading -- config parsing is total, so the now-unknown keys are simply ignored -- but they have no effect. No permission change. --- README.md | 6 - src/config.rs | 83 ------------ src/lib.rs | 335 ---------------------------------------------- src/projection.rs | 63 +-------- src/scroll.rs | 216 ------------------------------ 5 files changed, 2 insertions(+), 701 deletions(-) delete mode 100644 src/scroll.rs diff --git a/README.md b/README.md index 2c0ee24..8af5618 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,6 @@ default_tab_template { reorder "false" // drag a tab to reorder; "true" also needs RunActionsAsUser close_button "true" // stamps a clickable close button on each tab; "false" to hide close_button_color "theme" // glyph color: "theme" (alert red) / "fg" (white) / "red" / "#rrggbb" - scroll "tab" // mouse wheel: "tab" (default) switch tabs / "pane" walk panes across tabs / "off" - scroll_cooldown_ms "40" // ms between wheel steps; higher = less sensitive, "0" = one step per event tab_gap "2" // cleared columns between tab blocks; "0" packs them flush gradient "sheen" // pane fill sweep: "sheen" (default) / "weave" (alternating rows) / "off" (flat) gradient_shape "linear" // sweep geometry: "linear" (default) / "radial" (circular, from each block's center) @@ -115,10 +113,6 @@ Contributors hacking on the plugin [build from source](#build-from-source) and p > > **`tab_gap` — space between tabs.** Leaves the given number of cleared columns between adjacent tab blocks so the boundary between screens reads clearly (default `2`). Set `0` to pack the blocks flush. > -> **`scroll` — mouse wheel navigation.** Selects what the wheel does over the bar (zellij scroll events carry no position, so the gesture is bar-wide, not tied to a specific tab). `tab` (default) switches tabs — scroll up = next, scroll down = previous, following zellij's stock tab-bar direction but **wrapping** at the ends (first ↔ last) instead of clamping. `pane` instead walks the **focused pane** forward / backward in reading order (top→bottom, then left→right), crossing tab boundaries — stepping past a tab's last pane jumps to the next tab's first pane, and back — wrapping globally; the focus is absolute, so the bar's highlight follows correctly. `off` leaves the wheel inert. The wheel is rate-limited by `scroll_cooldown_ms` (below) so a stepless device does not race through tabs; the reported line count is otherwise ignored (each event is one notch). No extra permission is needed beyond the default set, so existing installs gain this on update without a re-grant. -> -> **`scroll_cooldown_ms` — wheel sensitivity.** The cooldown window, in milliseconds, between wheel navigation steps (default `40`). A stepless pointing device — an Apple Magic Mouse, a trackpad — reports a single flick as a *stream* of scroll events, so the original one-event-per-step behavior raced through several tabs (or panes) at once. zellij hands the plugin only `ScrollUp`/`ScrollDown` with no device identity, so the two kinds of mouse can't be told apart by hardware — instead the wheel is rate-limited by *timing*: the first event navigates immediately and opens the window, and any event arriving within `scroll_cooldown_ms` of it is dropped. So a fast flick collapses to about one step per window, while a deliberate, well-spaced notch (its window long since elapsed) always steps at once — responsive on a notched wheel mouse, damped on a trackpad, with no added latency on that first event. Raise it to damp the wheel further; set `0` to disable the limiter and restore the original one-step-per-event feel. Ignored when `scroll "off"`. No extra permission needed. -> > **`gradient` — per-pane fill sweep.** `sheen` (default) sweeps each pane block's fill from its base color toward a luminance-shifted shade (lighter for dark themes, darker for light ones); `weave` alternates the sweep direction on each half-block pixel row for a woven texture. The focus ring, labels, and the `⌘N` badge stay solid on top, so readability is unchanged. Set `off` for flat fills. > > **`gradient_shape` / `gradient_angle` / `gradient_radial` — sweep direction.** These steer the `sheen`/`weave` sweep (they have no effect when `gradient "off"`). `gradient_shape` is `linear` (default, a straight sweep) or `radial` (a circular sweep from each pane block's center). For `linear`, `gradient_angle` sets the **perceived on-screen** direction in whole degrees over `[0, 360)`: `0` left→right (the v0.5 look), `90` top→bottom, `180` right→left, `270` bottom→top, and any angle in between for a diagonal — out-of-range or non-integer values fall back to `0`. For `radial`, `gradient_radial` chooses `outward` (default, base fill at the center easing to the stop at the edge) or `inward` (the reverse). Because each half-block pixel is already ≈ square, angles read as the true on-screen angle — `45` is a real 45°, not skewed by the terminal cell's 1:2 aspect. diff --git a/src/config.rs b/src/config.rs index 124f15c..337bcd1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,7 +12,6 @@ use std::collections::BTreeMap; use crate::color::Rgb; use crate::line::Alignment; use crate::minimap::{GradientMode, GradientShape, GradientSpec, RadialDirection}; -use crate::scroll::ScrollMode; /// Parsed plugin configuration. #[derive(Clone, Debug, PartialEq, Eq)] @@ -75,12 +74,6 @@ pub struct Config { /// new permission prompt appears on auto-update. `false` hides the button and /// reclaims its columns for the tab strip. pub new_tab_button: bool, - /// How the mouse wheel navigates over the bar (#80). `tab` (default) switches - /// tabs, `pane` walks the focused pane across tab boundaries, `off` makes the - /// wheel inert. zellij delivers scroll events without a position, so the - /// gesture acts on the whole bar. Needs no permission beyond the default set - /// (`ChangeApplicationState`). See [`ScrollMode`]. - pub scroll: ScrollMode, /// Whether each tab block draws a clickable close button near its top-right /// corner, closing that tab on click (#86). On by default (#94): the close /// glyph rides on the already-granted `ChangeApplicationState` permission @@ -102,17 +95,6 @@ pub struct Config { /// red independent of the theme), or a `#rrggbb` hex to override it. See /// [`CloseColor`]. pub close_button_color: CloseColor, - /// Cooldown window in **milliseconds** between wheel navigation steps (#83). A - /// stepless device (Magic Mouse, trackpad) fires a *burst* of scroll events for - /// one flick, so the pre-#83 "one event = one step" raced through tabs/panes. - /// This is a leading-edge rate limiter: the first event navigates at once and - /// opens the window, and events arriving within `scroll_cooldown_ms` of it are - /// dropped — so a flick advances about one step per window while a deliberate, - /// well-spaced notch always steps immediately. `40` (default) suits both a - /// notched wheel and a trackpad; raise it to damp the wheel further. `0` - /// disables the limiter (every event steps, the pre-#83 feel). Ignored when - /// `scroll` is `off`. See [`crate::scroll::gate`]. - pub scroll_cooldown_ms: usize, } impl Config { @@ -156,9 +138,6 @@ impl Config { /// box (#76). It rides on the already-granted `ChangeApplicationState` /// permission, so enabling it by default costs existing users no new prompt. pub const DEFAULT_NEW_TAB_BUTTON: bool = true; - /// Default wheel behaviour — `Tab`, matching zellij's stock tab-bar (scroll - /// switches tabs). Set `pane` to walk panes, `off` to disable (#80). - pub const DEFAULT_SCROLL: ScrollMode = ScrollMode::Tab; /// Default close-button state — on (#94), so the close affordance is present /// out of the box. It rides on the already-granted `ChangeApplicationState` /// permission (#86), so enabling it by default costs existing users no new @@ -169,10 +148,6 @@ impl Config { /// `red`, or a `#rrggbb` hex when a theme's dark error color makes the glyph /// hard to read (#94 follow-up). pub const DEFAULT_CLOSE_BUTTON_COLOR: CloseColor = CloseColor::Theme; - /// Default wheel cooldown — `40` ms between steps, taming a stepless device's - /// flick burst out of the box while still letting a deliberate notch step at - /// once. Set `0` to disable the limiter (the pre-#83 one-step-per-event feel) (#83). - pub const DEFAULT_SCROLL_COOLDOWN_MS: usize = 40; /// Parse the configuration map, falling back to a default for any missing or /// malformed value. Total: never panics on bad input. @@ -231,10 +206,6 @@ impl Config { .get("new_tab_button") .and_then(|raw| raw.parse().ok()) .unwrap_or(Self::DEFAULT_NEW_TAB_BUTTON), - scroll: configuration - .get("scroll") - .and_then(|raw| raw.parse().ok()) - .unwrap_or(Self::DEFAULT_SCROLL), close_button: configuration .get("close_button") .and_then(|raw| raw.parse().ok()) @@ -243,10 +214,6 @@ impl Config { .get("close_button_color") .and_then(|raw| raw.parse().ok()) .unwrap_or(Self::DEFAULT_CLOSE_BUTTON_COLOR), - scroll_cooldown_ms: configuration - .get("scroll_cooldown_ms") - .and_then(|raw| raw.parse().ok()) - .unwrap_or(Self::DEFAULT_SCROLL_COOLDOWN_MS), } } @@ -363,9 +330,7 @@ mod tests { assert!(config.inactive_dim); assert!(config.perspective); assert!(config.new_tab_button); - assert_eq!(config.scroll, ScrollMode::Tab); assert!(config.close_button); - assert_eq!(config.scroll_cooldown_ms, 40); } #[test] @@ -559,21 +524,6 @@ mod tests { assert!(!config_from(&[("perspective", "false")]).perspective); } - #[test] - fn parses_scroll_modes() { - assert_eq!(config_from(&[("scroll", "tab")]).scroll, ScrollMode::Tab); - assert_eq!(config_from(&[("scroll", "pane")]).scroll, ScrollMode::Pane); - assert_eq!(config_from(&[("scroll", "off")]).scroll, ScrollMode::Off); - } - - #[test] - fn malformed_scroll_falls_back_to_tab() { - // Unknown / wrong-case / empty values keep the zellij-stock default. - assert_eq!(config_from(&[("scroll", "wheel")]).scroll, ScrollMode::Tab); - assert_eq!(config_from(&[("scroll", "Tab")]).scroll, ScrollMode::Tab); - assert_eq!(config_from(&[("scroll", "")]).scroll, ScrollMode::Tab); - } - #[test] fn parses_explicit_close_button_off() { // The close glyph is on by default (#94); an explicit `false` opts out. @@ -654,39 +604,6 @@ mod tests { ); } - #[test] - fn parses_scroll_cooldown_ms() { - // A valid millisecond window is preserved verbatim; `0` is the documented - // value for disabling the limiter (the pre-#83 one-step-per-event feel). - assert_eq!( - config_from(&[("scroll_cooldown_ms", "80")]).scroll_cooldown_ms, - 80 - ); - assert_eq!( - config_from(&[("scroll_cooldown_ms", "0")]).scroll_cooldown_ms, - 0 - ); - } - - #[test] - fn malformed_scroll_cooldown_ms_falls_back() { - // Non-numeric / negative / empty values keep the taming default. An - // explicit `0` parses fine here (it disables the limiter at the use site, - // where `gate` short-circuits to `Navigate`). - assert_eq!( - config_from(&[("scroll_cooldown_ms", "fast")]).scroll_cooldown_ms, - 40 - ); - assert_eq!( - config_from(&[("scroll_cooldown_ms", "-1")]).scroll_cooldown_ms, - 40 - ); - assert_eq!( - config_from(&[("scroll_cooldown_ms", "")]).scroll_cooldown_ms, - 40 - ); - } - #[test] fn malformed_perspective_falls_back() { // A malformed or empty value keeps the on-by-default cue. diff --git a/src/lib.rs b/src/lib.rs index 15edb2b..3f603ae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,6 @@ pub mod line; pub mod minimap; pub mod paint; pub mod projection; -pub mod scroll; pub mod tab_block; pub mod title; @@ -74,12 +73,6 @@ pub struct State { /// "close tab N" against the live frame. Empty whenever the close button is /// disabled, only one tab is open, or no frame has drawn yet. close_layout: Vec, - /// Whether the wheel cooldown window is currently open (#83). The first scroll - /// event navigates and sets this, arming a `config.scroll_cooldown_ms` timer; - /// events arriving while it's set are dropped (see [`scroll::gate`]). The `Timer` - /// reopens the wheel, so a continuous scroll paces at one step per window — - /// a leading-edge throttle. Starts `false`. - scroll_cooling: bool, } /// One visible tab's drawn pane geometry, captured each render so a later click @@ -182,9 +175,6 @@ impl ZellijPlugin for State { EventType::PaneUpdate, EventType::ModeUpdate, EventType::Mouse, - // Drives the wheel cooldown window (#83): the `set_timeout` armed on a - // scroll step delivers back a `Timer` that reopens the wheel. - EventType::Timer, ]); } @@ -281,29 +271,6 @@ impl ZellijPlugin for State { self.drag = None; moved } - Event::Mouse(Mouse::ScrollUp(_)) => { - // Wheel up = forward (next tab / next pane), matching zellij's - // stock tab-bar direction. The line count is ignored — each event - // is one notch, rate-limited by the cooldown window (#83). The - // navigation arrives back as a Tab/Pane update that drives the - // repaint, so request none (#80). - self.scroll(scroll::ScrollDir::Forward); - false - } - Event::Mouse(Mouse::ScrollDown(_)) => { - // Wheel down = backward (previous tab / previous pane). See above. - self.scroll(scroll::ScrollDir::Backward); - false - } - Event::Timer(_) => { - // The wheel cooldown window (#83) elapsed: reopen the wheel so the - // next event navigates. A continuous scroll therefore paces at one - // step per window — a leading-edge throttle — rather than racing - // through tabs or locking out until the stream goes silent. No - // repaint of our own. - self.scroll_cooling = false; - false - } // Remaining events need no repaint. _ => false, } @@ -585,103 +552,6 @@ impl State { .map(|hit| hit.position) } - /// Dispatch a wheel event to the configured navigation: switch tabs, walk - /// panes, or do nothing (#80). zellij scroll events carry no position, so the - /// gesture is bar-wide — it acts on the live tab/pane set, not a clicked spot. - /// - /// A leading-edge cooldown rate-limits the wheel (#83): the first event - /// navigates at once and opens a `scroll_cooldown_ms` window; events inside it - /// are dropped, so a stepless device's burst no longer races through - /// tabs/panes. `off` mode short-circuits before the limiter, leaving the wheel - /// at rest. - fn scroll(&mut self, dir: scroll::ScrollDir) { - // Resolve the per-mode handler up front so `off` short-circuits before - // the limiter ever sees the event — and so every arm here is live (an - // `off` arm after the gate would be unreachable dead code). - let step: fn(&Self, scroll::ScrollDir) = match self.config.scroll { - scroll::ScrollMode::Off => return, - scroll::ScrollMode::Tab => Self::scroll_tabs, - scroll::ScrollMode::Pane => Self::scroll_panes, - }; - match scroll::gate(self.scroll_cooling, self.config.scroll_cooldown_ms) { - scroll::Gate::Ignore => return, // inside the window: drop the step. - scroll::Gate::NavigateThenCool => { - // Leading edge: open a fresh window and arm its timer. - self.scroll_cooling = true; - self.arm_scroll_cooldown(); - } - scroll::Gate::Navigate => {} - } - step(self, dir); - } - - /// Arm the wheel cooldown timer for `scroll_cooldown_ms` (#83). `set_timeout` - /// takes seconds; the config is milliseconds, hence the `/ 1000.0`. - fn arm_scroll_cooldown(&self) { - set_timeout(self.config.scroll_cooldown_ms as f64 / 1000.0); - } - - /// Switch one tab in `dir` from the active tab, wrapping at the ends. No-op - /// when there is no active tab (a mid-transition snapshot). - fn scroll_tabs(&self, dir: scroll::ScrollDir) { - let Some(active) = projection::active_tab(&self.tabs).map(|tab| tab.position) else { - return; - }; - let Some(target) = scroll::next_tab(active, self.tabs.len(), dir) else { - return; - }; - // `next_tab` works in 0-based positions; `switch_tab_to` is 1-based. - switch_tab_to((target + 1) as u32); - } - - /// Move the focused pane one step in `dir` along the reading-order traversal - /// of every tab's panes, crossing tab boundaries and wrapping globally (#80). - /// Focusing is absolute (`focus_terminal_pane`), which both switches to the - /// pane's tab and emits a session-state report — so the highlight follows, - /// sidestepping the next-direction freeze of #37 that `focus_next_pane` hits. - /// No-op when nothing tiled is focused. - fn scroll_panes(&self, dir: scroll::ScrollDir) { - let Some(current) = self.focused_pane_id() else { - return; - }; - let Some(target) = scroll::next_pane(&self.pane_focus_order(), current, dir) else { - return; - }; - // A visible tiled terminal pane is never hidden, so both float/in-place - // flags are moot — pass `false`, exactly as the click-to-focus path (#74). - focus_terminal_pane(target, false, false); - } - - /// The id of the focused tiled terminal pane in the active tab, if any — the - /// anchor the wheel steps from in `pane` mode. - fn focused_pane_id(&self) -> Option { - let active = projection::active_tab(&self.tabs)?; - self.panes - .panes - .get(&active.position)? - .iter() - .filter(|pane| projection::is_tiled_terminal(pane)) - .find(|pane| pane.is_focused) - .map(|pane| pane.id) - } - - /// Every tab's tiled terminal panes flattened into one wheel-traversal order: - /// tabs in ascending position, panes in reading order within each tab (#80). - /// - /// Tab order comes from the authoritative `self.tabs`, not the `PaneManifest` - /// keys: `TabUpdate` and `PaneUpdate` arrive as separate events, so a just- - /// closed tab can still linger as a stale position in the manifest. Walking - /// `self.tabs` drops those, so the wheel never steps into a pane of a tab that - /// no longer exists. - fn pane_focus_order(&self) -> Vec { - let mut tabs: Vec<&TabInfo> = self.tabs.iter().collect(); - tabs.sort_by_key(|tab| tab.position); - tabs.into_iter() - .filter_map(|tab| self.panes.panes.get(&tab.position)) - .flat_map(|panes| projection::pane_ids_in_reading_order(panes)) - .collect() - } - /// Record a potential drag for the tab drawn at `column`. `None` when the /// press landed on no tab (overflow marker, gap, padding) — nothing to drag. /// The tab is captured by its stable `tab_id` (resolved from the current @@ -1462,211 +1332,6 @@ mod tests { state.focus_or_switch_at(1, 5); // off every block → switch fallback } - /// A tiled terminal pane `id` at `(x, y=1)`, `is_focused` set — the shape the - /// wheel's `pane`-mode anchor and traversal read (#80). - fn focusable_pane(id: u32, x: usize, focused: bool) -> PaneInfo { - PaneInfo { - id, - pane_x: x, - pane_y: 1, - pane_columns: 40, - pane_rows: 24, - is_focused: focused, - title: "sh".to_string(), - ..Default::default() - } - } - - /// A two-tab session for the wheel tests: tab 0 (active) holds panes 10 (x=0) - /// and 20 (x=40); tab 1 holds pane 30. `focused` marks which id, if any, is the - /// active tab's focused pane; `mode` selects the wheel behaviour. - fn scroll_state(mode: scroll::ScrollMode, focused: Option) -> State { - let mut manifest = PaneManifest::default(); - manifest.panes.insert( - 0, - vec![ - focusable_pane(10, 0, focused == Some(10)), - focusable_pane(20, 40, focused == Some(20)), - ], - ); - manifest - .panes - .insert(1, vec![focusable_pane(30, 0, focused == Some(30))]); - - let mut state = State::default(); - state.config = Config { - scroll: mode, - ..Default::default() - }; - state.tabs = vec![ - TabInfo { - active: true, - ..tab(0, 1) - }, - TabInfo { - active: false, - ..tab(1, 2) - }, - ]; - state.panes = manifest; - state - } - - #[test] - fn focused_pane_id_resolves_the_active_tabs_focused_pane() { - // The anchor is the focused pane of the *active* tab only — tab 1's pane - // is never the answer even though it sits earlier in the manifest map. - let state = scroll_state(scroll::ScrollMode::Pane, Some(20)); - assert_eq!(state.focused_pane_id(), Some(20)); - } - - #[test] - fn focused_pane_id_is_none_without_a_focus() { - // No focused pane in the active tab → no anchor, so `pane` mode leaves - // focus untouched rather than guessing a target. - let state = scroll_state(scroll::ScrollMode::Pane, None); - assert_eq!(state.focused_pane_id(), None); - } - - #[test] - fn pane_focus_order_flattens_tabs_then_reading_order() { - // Tabs in ascending position, panes in reading order within each: tab 0's - // 10 (x=0) then 20 (x=40), then tab 1's 30 — the global wheel traversal. - let state = scroll_state(scroll::ScrollMode::Pane, Some(10)); - assert_eq!(state.pane_focus_order(), vec![10, 20, 30]); - } - - #[test] - fn pane_focus_order_ignores_manifest_positions_not_in_the_tabs() { - // `TabUpdate` and `PaneUpdate` arrive separately, so a just-closed tab can - // linger as a stale position in the manifest. The traversal walks the - // authoritative `self.tabs`, so that stale pane (99 at position 5) is never - // visited — the wheel can't step into a tab that no longer exists. - let mut state = scroll_state(scroll::ScrollMode::Pane, Some(10)); - state - .panes - .panes - .insert(5, vec![focusable_pane(99, 0, false)]); - assert_eq!(state.pane_focus_order(), vec![10, 20, 30]); - } - - #[test] - fn scroll_dispatches_each_mode_without_panicking() { - // Host effects (`switch_tab_to` / `focus_terminal_pane`) are no-op stubs - // off-wasm, so the contract is that every mode dispatches both directions - // without panicking over the live tab/pane set. Disable the cooldown - // (`scroll_cooldown_ms = 0`) so every event reaches the dispatch rather - // than being dropped inside the window. - for mode in [ - scroll::ScrollMode::Tab, - scroll::ScrollMode::Pane, - scroll::ScrollMode::Off, - ] { - let mut state = scroll_state(mode, Some(20)); - state.config.scroll_cooldown_ms = 0; - state.scroll(scroll::ScrollDir::Forward); - state.scroll(scroll::ScrollDir::Backward); - } - } - - #[test] - fn scroll_opens_a_cooldown_on_the_first_event_and_drops_the_rest() { - // Leading edge (#83): the first wheel event navigates and opens the - // cooldown window; further events arriving inside it are dropped, so the - // flag stays set rather than racing through tabs. - let mut state = scroll_state(scroll::ScrollMode::Tab, Some(10)); - assert!(!state.scroll_cooling); - state.scroll(scroll::ScrollDir::Forward); - assert!( - state.scroll_cooling, - "first event opens the cooldown window" - ); - state.scroll(scroll::ScrollDir::Forward); - assert!( - state.scroll_cooling, - "events inside the window are dropped, leaving it open" - ); - } - - #[test] - fn scroll_timer_reopens_the_wheel() { - // The `Timer` the cooldown armed clears the window, so the next wheel - // event navigates again. - let mut state = scroll_state(scroll::ScrollMode::Tab, Some(10)); - state.scroll(scroll::ScrollDir::Forward); - assert!(state.scroll_cooling); - assert!(!state.update(Event::Timer(0.04))); - assert!( - !state.scroll_cooling, - "the cooldown timer reopens the wheel" - ); - } - - #[test] - fn scroll_paces_continuous_input_one_step_per_window() { - // Leading-edge throttle (#100, regression fix for #96): while a continuous - // stream flows, each Timer reopens the wheel so the next event steps again — - // pacing at one step per window. The wheel must NOT lock out until silence - // (the #96 bug, where a mid-stream Timer re-armed and stayed closed forever). - let mut state = scroll_state(scroll::ScrollMode::Tab, Some(10)); - - // Leading edge: step now and open the window. - state.scroll(scroll::ScrollDir::Forward); - assert!(state.scroll_cooling); - - // An event lands inside the window — dropped, window stays open. - state.scroll(scroll::ScrollDir::Forward); - assert!(state.scroll_cooling); - - // The timer fires *while the stream is still flowing*: it reopens the wheel - // regardless of that activity (this is the fix — #96 stayed closed here). - assert!(!state.update(Event::Timer(0.04))); - assert!( - !state.scroll_cooling, - "a timer reopens the wheel even mid-stream — no lock-out" - ); - - // So the very next event steps again and re-opens: continuous input paces, - // it is never rejected until you stop. - state.scroll(scroll::ScrollDir::Forward); - assert!( - state.scroll_cooling, - "the next event after a reopen navigates and re-arms (paced stepping)" - ); - } - - #[test] - fn scroll_zero_cooldown_never_cools() { - // `scroll_cooldown_ms = 0` disables the limiter: every event navigates and - // no window opens (the pre-#83 one-step-per-event feel). - let mut state = scroll_state(scroll::ScrollMode::Tab, Some(10)); - state.config.scroll_cooldown_ms = 0; - state.scroll(scroll::ScrollDir::Forward); - assert!(!state.scroll_cooling); - state.scroll(scroll::ScrollDir::Forward); - assert!(!state.scroll_cooling); - } - - #[test] - fn scroll_off_mode_never_cools() { - // `off` short-circuits before the limiter, so it never opens a cooldown - // window — toggling back to tab/pane later starts from a clean state. - let mut state = scroll_state(scroll::ScrollMode::Off, Some(10)); - state.scroll(scroll::ScrollDir::Forward); - state.scroll(scroll::ScrollDir::Forward); - assert!(!state.scroll_cooling); - } - - #[test] - fn scroll_events_request_no_immediate_repaint() { - // A wheel step navigates via the host; the redraw arrives as the resulting - // TabUpdate / PaneUpdate, so the Mouse arms themselves request no repaint - // (return false) — mirroring the click path (#8/#74). - let mut state = scroll_state(scroll::ScrollMode::Tab, Some(10)); - assert!(!state.update(Event::Mouse(Mouse::ScrollUp(1)))); - assert!(!state.update(Event::Mouse(Mouse::ScrollDown(1)))); - } - #[test] fn render_omits_narrow_tabs_from_the_click_geometry() { // Squeezed into 80 columns, many tabs collapse to L3/L4 rungs that draw diff --git a/src/projection.rs b/src/projection.rs index 182393f..784627a 100644 --- a/src/projection.rs +++ b/src/projection.rs @@ -22,26 +22,15 @@ pub fn active_tab(tabs: &[TabInfo]) -> Option<&TabInfo> { } /// Whether a pane belongs to the user's tiled terminal layout — the set the -/// minimap depicts and the wheel navigates (#80). +/// minimap depicts. /// /// Excludes plugin panes (the tab-bar / status-bar / attention overlays), /// floating panes, and suppressed (background) panes. The single source of this -/// filter, so [`project`] and the scroll traversal can never drift apart. +/// filter, so every consumer of the tiled set stays in sync. pub fn is_tiled_terminal(pane: &PaneInfo) -> bool { !(pane.is_plugin || pane.is_floating || pane.is_suppressed) } -/// The ids of a tab's tiled terminal panes in **reading order** — top to bottom, -/// then left to right — the per-tab order the wheel walks in `pane` mode (#80). -pub fn pane_ids_in_reading_order(panes: &[PaneInfo]) -> Vec { - let mut tiled: Vec<&PaneInfo> = panes - .iter() - .filter(|pane| is_tiled_terminal(pane)) - .collect(); - tiled.sort_by_key(|pane| (pane.pane_y, pane.pane_x)); - tiled.into_iter().map(|pane| pane.id).collect() -} - /// Project a tab's panes into renderer rectangles, dropping everything that is /// not part of the user's tiled layout. /// @@ -195,52 +184,4 @@ mod tests { assert!(project(&panes).is_empty()); assert!(project(&[]).is_empty()); } - - fn pane_at(id: u32, x: usize, y: usize) -> PaneInfo { - PaneInfo { - id, - pane_x: x, - pane_y: y, - pane_columns: 40, - pane_rows: 12, - title: "sh".to_string(), - ..Default::default() - } - } - - #[test] - fn reading_order_sorts_top_to_bottom_then_left_to_right() { - // A 2x2 grid given out of order: reading order is top-left, top-right, - // bottom-left, bottom-right (y first, then x) — the wheel's per-tab walk. - let panes = [ - pane_at(30, 40, 13), // bottom-right - pane_at(10, 0, 1), // top-left - pane_at(20, 40, 1), // top-right - pane_at(25, 0, 13), // bottom-left - ]; - assert_eq!(pane_ids_in_reading_order(&panes), vec![10, 20, 25, 30]); - } - - #[test] - fn reading_order_drops_chrome_and_overlays() { - // Only tiled terminal panes are walked — the bars, floats, and suppressed - // panes never appear in the traversal. - let panes = [ - PaneInfo { - is_plugin: true, - ..pane_at(99, 0, 1) - }, - pane_at(10, 0, 1), - PaneInfo { - is_floating: true, - ..pane_at(98, 0, 13) - }, - PaneInfo { - is_suppressed: true, - ..pane_at(97, 40, 1) - }, - ]; - assert_eq!(pane_ids_in_reading_order(&panes), vec![10]); - assert!(pane_ids_in_reading_order(&[]).is_empty()); - } } diff --git a/src/scroll.rs b/src/scroll.rs deleted file mode 100644 index 6af66f5..0000000 --- a/src/scroll.rs +++ /dev/null @@ -1,216 +0,0 @@ -//! Pure traversal math for wheel-driven navigation on the tab bar (#80). -//! -//! zellij delivers `Mouse::ScrollUp` / `Mouse::ScrollDown` with only a line -//! count — **no position** — so the wheel acts on the bar as a whole. These -//! helpers turn one wheel step into the next tab index ([`next_tab`], `tab` -//! mode) or the next pane id ([`next_pane`], `pane` mode), wrapping at the ends. -//! They take plain numbers / an id slice and return plain numbers, so they -//! unit-test off-wasm with no zellij types — the same dependency-free discipline -//! the renderer follows. - -/// The direction one wheel notch maps to. zellij's stock tab-bar maps -/// `ScrollUp → Forward` (next) and `ScrollDown → Backward` (previous); we follow -/// that direction so the wheel feels native. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ScrollDir { - Forward, - Backward, -} - -/// How the mouse wheel navigates over the tab bar (config key `scroll`, #80). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum ScrollMode { - /// Scroll switches tabs (forward = next, backward = previous), wrapping — - /// matching zellij's stock tab-bar direction. The default. - #[default] - Tab, - /// Scroll walks the focused pane forward / backward in reading order, - /// crossing tab boundaries (the last pane of a tab steps to the first pane of - /// the next, and back), wrapping globally. - Pane, - /// Scroll does nothing. - Off, -} - -impl std::str::FromStr for ScrollMode { - type Err = (); - - /// `"tab"` / `"pane"` / `"off"` (exact match); any other value errors so the - /// config parser falls back to the documented default rather than panicking. - fn from_str(value: &str) -> Result { - match value { - "tab" => Ok(Self::Tab), - "pane" => Ok(Self::Pane), - "off" => Ok(Self::Off), - _ => Err(()), - } - } -} - -/// The 0-based tab index one wheel step from `active` among `count` tabs, -/// wrapping at both ends. `None` when there are no tabs (nothing to switch to). -/// The caller adds 1 for `switch_tab_to`, which is 1-based. -pub fn next_tab(active: usize, count: usize, dir: ScrollDir) -> Option { - if count == 0 { - return None; - } - Some(step(active.min(count - 1), count, dir)) -} - -/// The pane id one wheel step from `current` in `order` — a flattened traversal -/// of every tab's panes (tabs in position order, panes in reading order) — with -/// a global wrap (last pane of the last tab ↔ first pane of the first tab). -/// `None` when `order` is empty or `current` is not in it (nothing to move -/// from), so the caller leaves focus untouched. -pub fn next_pane(order: &[u32], current: u32, dir: ScrollDir) -> Option { - let here = order.iter().position(|&id| id == current)?; - Some(order[step(here, order.len(), dir)]) -} - -/// One wrapping step over `0..len`. Both callers guarantee `len > 0` (a guarded -/// `count`, or a `position` match that proves the slice non-empty). -fn step(here: usize, len: usize, dir: ScrollDir) -> usize { - match dir { - ScrollDir::Forward => (here + 1) % len, - ScrollDir::Backward => (here + len - 1) % len, - } -} - -/// What a wheel event does under the leading-edge cooldown rate-limiter (#83). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Gate { - /// Navigate now; the limiter is off (`cooldown_ms == 0`), so nothing is armed. - Navigate, - /// Navigate now *and* open the cooldown window — the caller arms a - /// `cooldown_ms` timer and ignores further events until it fires. - NavigateThenCool, - /// Drop this event: a cooldown window is already open. - Ignore, -} - -/// Decide a wheel event's fate under the cooldown limiter (#83). zellij emits one -/// `Mouse::ScrollUp` / `ScrollDown` per sub-notch, and a stepless device (Magic -/// Mouse, trackpad) fires a *burst* of them for a single flick — so "one event = -/// one nav step" (#80) races through several tabs/panes. We can't tell the -/// devices apart (the `Mouse` event carries no device identity), so instead of a -/// physical detent we rate-limit by *timing*: the first event navigates at once -/// and opens a `cooldown_ms` window; events arriving inside the window are -/// dropped. A fast burst collapses to one step per window, while a deliberate, -/// well-spaced notch (its window long since elapsed) always navigates -/// immediately — responsive on a detented wheel, damped on a flick. -/// -/// `cooling` is whether a cooldown window is currently open; `cooldown_ms == 0` -/// disables the limiter so every event navigates (the pre-#83 one-step-per-event -/// feel). The leading edge is never delayed — unlike a trailing-edge debounce the -/// step lands *on* the event, not after the window closes. -pub fn gate(cooling: bool, cooldown_ms: usize) -> Gate { - if cooldown_ms == 0 { - return Gate::Navigate; - } - if cooling { - return Gate::Ignore; - } - Gate::NavigateThenCool -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_scroll_modes() { - assert_eq!("tab".parse(), Ok(ScrollMode::Tab)); - assert_eq!("pane".parse(), Ok(ScrollMode::Pane)); - assert_eq!("off".parse(), Ok(ScrollMode::Off)); - } - - #[test] - fn malformed_scroll_mode_errors() { - // Case-sensitive, exact-match only — the config parser turns the error - // into the documented default. - assert_eq!("Tab".parse::(), Err(())); - assert_eq!("wheel".parse::(), Err(())); - assert_eq!("".parse::(), Err(())); - } - - #[test] - fn next_tab_steps_forward_and_backward() { - // Three tabs, active in the middle (index 1). - assert_eq!(next_tab(1, 3, ScrollDir::Forward), Some(2)); - assert_eq!(next_tab(1, 3, ScrollDir::Backward), Some(0)); - } - - #[test] - fn next_tab_wraps_at_both_ends() { - // Forward off the last tab wraps to the first; backward off the first - // wraps to the last (zellij stock clamps here; we wrap by design, #80). - assert_eq!(next_tab(2, 3, ScrollDir::Forward), Some(0)); - assert_eq!(next_tab(0, 3, ScrollDir::Backward), Some(2)); - } - - #[test] - fn next_tab_handles_degenerate_counts() { - // A single tab steps to itself; no tabs yields nothing. - assert_eq!(next_tab(0, 1, ScrollDir::Forward), Some(0)); - assert_eq!(next_tab(0, 1, ScrollDir::Backward), Some(0)); - assert_eq!(next_tab(0, 0, ScrollDir::Forward), None); - // An out-of-range active index is clamped into the tab set rather than - // indexing past the end. - assert_eq!(next_tab(9, 3, ScrollDir::Forward), Some(0)); - } - - #[test] - fn next_pane_walks_the_flattened_order() { - // Ids are arbitrary (not positions): [10, 20, 30] is the traversal. - let order = [10u32, 20, 30]; - assert_eq!(next_pane(&order, 10, ScrollDir::Forward), Some(20)); - assert_eq!(next_pane(&order, 20, ScrollDir::Forward), Some(30)); - assert_eq!(next_pane(&order, 20, ScrollDir::Backward), Some(10)); - } - - #[test] - fn next_pane_wraps_globally() { - // Forward off the last id wraps to the first; backward off the first - // wraps to the last — the cross-tab hand-off at the very ends (#80). - let order = [10u32, 20, 30]; - assert_eq!(next_pane(&order, 30, ScrollDir::Forward), Some(10)); - assert_eq!(next_pane(&order, 10, ScrollDir::Backward), Some(30)); - } - - #[test] - fn next_pane_is_none_when_unanchored() { - // No panes, or a focused id absent from the order, leaves focus - // untouched rather than guessing. - assert_eq!(next_pane(&[], 10, ScrollDir::Forward), None); - assert_eq!(next_pane(&[10, 20], 99, ScrollDir::Forward), None); - } - - #[test] - fn next_pane_wraps_around_a_single_pane() { - // A lone pane (e.g. a single tab with one pane) steps to itself. - assert_eq!(next_pane(&[7], 7, ScrollDir::Forward), Some(7)); - assert_eq!(next_pane(&[7], 7, ScrollDir::Backward), Some(7)); - } - - #[test] - fn gate_navigates_and_opens_the_window_on_the_leading_edge() { - // First event (not yet cooling): navigate now and open the cooldown - // window — the immediate, no-latency response a detented wheel wants. - assert_eq!(gate(false, 40), Gate::NavigateThenCool); - } - - #[test] - fn gate_ignores_events_inside_the_window() { - // While cooling, a flick's burst of follow-up events is dropped so it - // can't race through tabs. - assert_eq!(gate(true, 40), Gate::Ignore); - } - - #[test] - fn gate_zero_cooldown_disables_the_limiter() { - // `0` = off: every event navigates and nothing is armed — the pre-#83 - // one-step-per-event feel — regardless of the cooling flag. - assert_eq!(gate(false, 0), Gate::Navigate); - assert_eq!(gate(true, 0), Gate::Navigate); - } -} From 8c216fdbc59383c2c529b36ce07acbff935efec2 Mon Sep 17 00:00:00 2001 From: YUMENOSUKE Date: Fri, 19 Jun 2026 23:31:02 +0900 Subject: [PATCH 2/2] feat!: remove drag-to-reorder (#102) (#105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zellij never delivers the tab-drag Hold/Release events to a non-selectable `default_tab_template` bar, so drag-to-reorder was inert in normal use (#102) — the same "mouse gesture on a pinned bar" dead-end that sank wheel-scroll (#103). Rather than keep dead code and a dormant permission behind a config flag, remove the feature entirely. - config: drop the `reorder` key (parsing is total — existing layouts keep loading, the key is simply ignored). - permissions: the bar now always requests exactly two — `ReadApplicationState` + `ChangeApplicationState`; `RunActionsAsUser` is no longer requested. Dropping a permission is freeze-safe (zellij#4982 only bites when *adding* one). - remove `DragState`, the press/Hold/Release drag handling, and the `line::Shift` / `drag_steps` / drop-resolution layout math + tests. - a left click on a tab is now purely switch / focus / close / new-tab. `docs/design.md` keeps its original v2 D&D design notes as a historical record (internal Japanese planning doc). BREAKING CHANGE: the `reorder` config key is removed and the bar no longer requests the `RunActionsAsUser` permission. Layouts that set `reorder "true"` keep working (the key is ignored) and may drop it. --- README.md | 3 - cliff.toml | 2 - src/config.rs | 24 +-- src/lib.rs | 500 ++++++-------------------------------------------- src/line.rs | 295 +---------------------------- 5 files changed, 61 insertions(+), 763 deletions(-) diff --git a/README.md b/README.md index 8af5618..af5e5e7 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,6 @@ default_tab_template { shortcut_prefix "⌘" active_width "24" align "center" // "center" slides to keep the active tab centered; "left" anchors the row (all-fit only) - reorder "false" // drag a tab to reorder; "true" also needs RunActionsAsUser close_button "true" // stamps a clickable close button on each tab; "false" to hide close_button_color "theme" // glyph color: "theme" (alert red) / "fg" (white) / "red" / "#rrggbb" tab_gap "2" // cleared columns between tab blocks; "0" packs them flush @@ -121,8 +120,6 @@ Contributors hacking on the plugin [build from source](#build-from-source) and p > > **`perspective` — lift the active tab with depth.** When `true` (default) **and** the bar is at least **4 rows tall**, every inactive tab recedes by one row — a half-row of terminal background inset at its top and bottom — while the active tab fills the full height, so the selected tab appears to float forward. The height comes from the layout's `pane size=N`, which the plugin can only read, not set: bump the tab-bar pane to `size=4` (or more) to see the effect. Below 4 rows the option is a no-op (every tab fills the bar), and `false` always renders every tab at full height. Pairs naturally with `inactive_dim` — color recede plus depth recede. The bar renders nothing if it is given fewer than 3 rows (the minimap needs that floor to stay legible). > -> **Enabling `reorder`** requests a third permission, `RunActionsAsUser` (for the `MoveTabByTabId` action a tab drag performs). Granting is all-or-nothing for tab-template plugins, so when you set `reorder "true"` you must **re-run step 2** (the grant prompt then lists all three permissions) and restart — otherwise the bar freezes with no prompt. Left at the default (`false`), the plugin requests only the two permissions above, so an existing install keeps working unchanged across updates. -> > **`close_button` — click to close a tab.** When `true` (the default), a tab stamps a small close glyph (the Nerd Font *close-circle*, or a plain `×` where your terminal runs zellij's simplified UI without a Nerd Font) in its **top-right corner**; left-clicking exactly that cell closes the tab (via `close_tab_with_index`, which falls under the existing `ChangeApplicationState` grant — no re-grant needed). The glyph appears on the **active tab** — and, when the `perspective` depth cue is off, on **every** tab; under perspective the inactive tabs recede, where a corner glyph reads unbalanced, so they carry none. It only appears on blocks wide enough to draw a per-pane minimap, and never on the **last** remaining tab, so you can't close the bar out of existence. A click anywhere else on the block keeps its usual behavior (switch tab / focus the clicked pane), since the close target is that single cell, not the whole column. Set `false` to hide the glyph for keyboard-driven users. > > **`close_button_color` — close glyph color.** The color of the `close_button` glyph (default `theme`). `theme` uses zellij's own alert red — your theme's `exit_code_error` color, falling back to a built-in red when the theme leaves it unset. Some themes derive that alert color from a near-black or near-white `red` (e.g. sobrio's `red "#121212"`), which renders the `theme` glyph almost invisible; override it with `fg` (the active label's white — always legible on a colored tab), `red` (the plugin's built-in alert red, independent of the theme), or any `#rrggbb` hex (e.g. `"#ff5555"`). Affects only the close glyph and is ignored when `close_button "false"`. diff --git a/cliff.toml b/cliff.toml index def4c15..862c355 100644 --- a/cliff.toml +++ b/cliff.toml @@ -24,8 +24,6 @@ If you pin to a version URL, paste this block into your zellij `permissions.kdl` } ``` -If `reorder "true"` is set, also add `RunActionsAsUser` to that block. - > Using `releases/latest/download/` in your layout? The permission is keyed to that URL and was granted once — no action needed. """ trim = true diff --git a/src/config.rs b/src/config.rs index 337bcd1..a21abb9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,12 +34,6 @@ pub struct Config { pub tab_gap: usize, /// Whether to draw a 1px dark separator between adjacent panes. pub gutter: bool, - /// Whether drag-to-reorder is enabled. Off by default: the plugin then - /// requests only the v0.1.0 permission set (`ReadApplicationState` + - /// `ChangeApplicationState`), so existing users do not hit a - /// `RunActionsAsUser` cache miss on auto-update (zellij#4982). On → the - /// third permission is requested and a tab drag reorders. - pub reorder: bool, /// Gradient sweep applied to each pane block's fill. Defaults to `sheen` /// — the polished out-of-the-box look; `off` restores the flat /// v0.1.0-style fills. See [`GradientMode`]. @@ -106,7 +100,7 @@ impl Config { pub const DEFAULT_ACTIVE_WIDTH: usize = 24; /// Default alignment — centered, preserving the v0.1.0 sliding behavior so /// existing layouts render identically on auto-update (opt into `left` to - /// anchor the row). Same default-preserve rationale as [`Self::DEFAULT_REORDER`]. + /// anchor the row). pub const DEFAULT_ALIGN: Alignment = Alignment::Center; /// Default gap between tab blocks — `2` cleared columns, so adjacent /// screens read as separate blocks out of the box. Set `0` to pack the @@ -114,8 +108,6 @@ impl Config { pub const DEFAULT_TAB_GAP: usize = 2; /// Default gutter state — no separator. pub const DEFAULT_GUTTER: bool = false; - /// Default reorder state — off, preserving the v0.1.0 permission posture. - pub const DEFAULT_REORDER: bool = false; /// Default gradient mode — `Sheen`, the polished out-of-the-box look. /// Set `off` to restore the flat v0.1.0-style fills. pub const DEFAULT_GRADIENT: GradientMode = GradientMode::Sheen; @@ -173,10 +165,6 @@ impl Config { .get("gutter") .and_then(|raw| raw.parse().ok()) .unwrap_or(Self::DEFAULT_GUTTER), - reorder: configuration - .get("reorder") - .and_then(|raw| raw.parse().ok()) - .unwrap_or(Self::DEFAULT_REORDER), gradient: configuration .get("gradient") .and_then(|raw| raw.parse().ok()) @@ -322,7 +310,6 @@ mod tests { assert_eq!(config.align, Alignment::Center); assert_eq!(config.tab_gap, 2); assert!(!config.gutter); - assert!(!config.reorder); assert_eq!(config.gradient, GradientMode::Sheen); assert_eq!(config.gradient_shape, GradientShape::Linear); assert_eq!(config.gradient_angle, 0); @@ -341,7 +328,6 @@ mod tests { ("align", "left"), ("tab_gap", "1"), ("gutter", "true"), - ("reorder", "true"), ("gradient", "sheen"), ]); assert_eq!(config.shortcut_prefix, "C-"); @@ -349,7 +335,6 @@ mod tests { assert_eq!(config.align, Alignment::Left); assert_eq!(config.tab_gap, 1); assert!(config.gutter); - assert!(config.reorder); assert_eq!(config.gradient, GradientMode::Sheen); } @@ -488,12 +473,6 @@ mod tests { assert!(!config_from(&[("gutter", "maybe")]).gutter); } - #[test] - fn malformed_reorder_falls_back() { - assert!(!config_from(&[("reorder", "yes")]).reorder); - assert!(!config_from(&[("reorder", "")]).reorder); - } - #[test] fn malformed_tab_gap_falls_back() { assert_eq!(config_from(&[("tab_gap", "wide")]).tab_gap, 2); @@ -632,6 +611,5 @@ mod tests { assert_eq!(config.align, Alignment::Center); assert_eq!(config.tab_gap, 2); assert!(!config.gutter); - assert!(!config.reorder); } } diff --git a/src/lib.rs b/src/lib.rs index 3f603ae..3d1dff2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,10 +57,6 @@ pub struct State { /// click always tests against the live frame. `None` whenever the button is /// disabled, did not fit, or no frame has drawn yet. button_layout: Option, - /// The in-progress tab drag, if any. Set when a press lands on a tab, - /// resolved (and cleared) on release. `None` whenever no drag is underway. - /// v2 drag-to-reorder (#10). - drag: Option, /// Per-tab pane geometry for click-to-focus (#74), keyed by tab position and /// rebuilt every render alongside `tab_layout`. Only tabs drawn as a minimap /// (the L0–L2 grid rungs) get an entry — narrow tabs (L3 glyph / L4 hint) @@ -88,18 +84,6 @@ struct TabPaneGeom { panes: Vec, } -/// An in-progress tab drag. Recorded from the `LeftClick` that begins a press -/// and resolved on `Release`; `dragging` flips only once a `Hold` arrives, so a -/// plain click (press + release, no motion) never reorders. The grabbed tab is -/// tracked by its **stable** `tab_id`, never its position — focus (the same -/// click also switches) and every reorder hop reshuffle positions, but the id -/// is invariant, so on release the tab's current slot is re-derived from it. -#[derive(Clone, Copy)] -struct DragState { - grabbed_tab_id: usize, - dragging: bool, -} - /// Convert a zellij theme color to the renderer's [`color::Rgb`]. fn rgb(c: PaletteColor) -> color::Rgb { match c { @@ -160,15 +144,14 @@ impl ZellijPlugin for State { // after load, and an ad-hoc load with no grant stays focusable until // the user answers the prompt — which then delivers the result. self.config = Config::from_configuration(&configuration); - // Request exactly the permissions the active config needs — see - // [`Self::permissions`]. A plugin started from `default_tab_template` - // cannot show the interactive permission dialog (zellij#4982), so users - // pre-grant the set in the plugin permission cache and reload; granting - // is all-or-nothing (event delivery freezes until every requested - // permission is cached), which is exactly why the set must stay minimal - // by default (#23): an existing v0.1.0 user who cached only Read+Change - // must not hit a third-permission cache miss on auto-update. - request_permission(&Self::permissions(&self.config)); + // Request the bar's fixed two-permission set — see [`Self::permissions`]. + // A plugin started from `default_tab_template` cannot show the + // interactive permission dialog (zellij#4982), so users pre-grant the set + // in the plugin permission cache and reload; granting is all-or-nothing + // (event delivery freezes until every requested permission is cached), + // which is why the set stays minimal: an existing v0.1.0 user who cached + // only Read+Change must not hit a new-permission cache miss on auto-update. + request_permission(&Self::permissions()); subscribe(&[ EventType::PermissionRequestResult, EventType::TabUpdate, @@ -213,12 +196,10 @@ impl ZellijPlugin for State { Event::Mouse(Mouse::LeftClick(row, column)) => { // A click in the "+" button's span opens (and focuses) a new tab // and consumes the gesture — it never falls through to a pane - // focus, tab switch, or drag arm, and it clears any stale drag - // first so a later Hold/Release can't reorder against it. zellij - // focuses the new tab; the resulting TabUpdate drives the repaint, - // so this requests none. The button span is only ever recorded - // when `config.new_tab_button` is on (see `render`), so a disabled - // button leaves this guard inert (#76). + // focus or tab switch. zellij focuses the new tab; the resulting + // TabUpdate drives the repaint, so this requests none. The button + // span is only ever recorded when `config.new_tab_button` is on + // (see `render`), so a disabled button leaves this guard inert (#76). // // Otherwise the click resolves as finely as the drawn frame // allows: when it lands on a pane cell of a tab's minimap, focus @@ -226,26 +207,20 @@ impl ZellijPlugin for State { // clicked tab (#8). Focusing a pane also switches to its tab, so a // click on a non-active tab's pane both switches and focuses in one // step. The change arrives back as a Tab/Pane update that drives - // the repaint, so this arm requests none. It also records the press - // as a *potential* drag (#10): if the pointer then holds and - // releases elsewhere, the tab is reordered; a plain click never - // sets `dragging`, so it stays a pure focus/switch. Press on no tab - // clears any stale drag. + // the repaint, so this arm requests none. if self.clicked_new_tab_button(column) { - self.drag = None; let _opened = new_tab::<&str>(None, None); return false; } // A click on a tab's top-right "×" cell closes that tab and // consumes the gesture — checked before the focus/switch fallback - // so the corner cell closes rather than switches, and before any - // drag is armed. `close_tab_with_index` closes by position without - // focusing first, and rides the already-granted - // `ChangeApplicationState` (#86). The span is recorded only when - // the feature is on and >1 tab is open, so this guard is inert - // otherwise — and the last tab is never closeable. + // so the corner cell closes rather than switches. + // `close_tab_with_index` closes by position without focusing + // first, and rides the already-granted `ChangeApplicationState` + // (#86). The span is recorded only when the feature is on and >1 + // tab is open, so this guard is inert otherwise — and the last tab + // is never closeable. if let Some(position) = self.clicked_close_button(row, column) { - self.drag = None; // Consume the close target so a duplicate click on the same // cell can't re-dispatch off stale geometry before the next // render rebuilds `close_layout` (the close shifts positions). @@ -254,23 +229,8 @@ impl ZellijPlugin for State { return false; } self.focus_or_switch_at(row, column); - self.drag = self.grab_at(column); false } - Event::Mouse(Mouse::Hold(..)) => { - // The pointer moved while pressed → the press is a real drag. - // Only the fact matters here, not the column; the drop column is - // read from the Release. (#10) - self.mark_dragging() - } - Event::Mouse(Mouse::Release(_row, column)) => { - // Release ends the gesture: reorder the grabbed tab to the drop - // column (no-op unless it was actually dragging), then clear the - // drag regardless. (#10) - let moved = self.commit_drag_at(column); - self.drag = None; - moved - } // Remaining events need no repaint. _ => false, } @@ -460,23 +420,19 @@ impl ZellijPlugin for State { } impl State { - /// The permission set this config requires. `ReadApplicationState` (Tab/ - /// Pane/Mode updates) and `ChangeApplicationState` (`switch_tab_to` for - /// click-to-switch, #8) are always needed. `RunActionsAsUser` (the - /// `MoveTab` run_action behind drag-to-reorder, #10) is added **only - /// when `reorder` is enabled** (#23) — so the default request matches the - /// v0.1.0 two-permission set and existing auto-updaters never freeze on a - /// cache miss (zellij#4982). Pure, so the gating is unit-tested directly - /// (host imports are stubbed off-wasm, so what `load` requests is otherwise - /// unobservable). - fn permissions(config: &Config) -> Vec { - [ + /// The bar's fixed permission set — always exactly two: `ReadApplicationState` + /// (Tab/Pane/Mode updates) and `ChangeApplicationState` (`switch_tab_to`, + /// `focus_terminal_pane`, `close_tab_with_index`, and `new_tab` — behind + /// click-to-switch #8, click-to-focus #74, close #86, and new-tab #76). Kept + /// minimal so an existing v0.1.0 user who cached only these two never hits a + /// permission cache miss on auto-update (zellij#4982). Pure and arg-free, so + /// it is unit-tested directly (host imports are stubbed off-wasm, so what + /// `load` requests is otherwise unobservable). + fn permissions() -> Vec { + vec![ PermissionType::ReadApplicationState, PermissionType::ChangeApplicationState, ] - .into_iter() - .chain(config.reorder.then_some(PermissionType::RunActionsAsUser)) - .collect() } /// Resolve a left click at (`row`, `column`) as finely as the drawn frame @@ -530,8 +486,7 @@ impl State { /// decision behind a new-tab click (#76). Tests the last frame's recorded /// button geometry: `None` (button disabled, didn't fit, or no frame yet) is /// always a miss. Split from the `new_tab` host effect so the decision is - /// unit-tested without a zellij host, mirroring the - /// [`reorder_plan`](Self::reorder_plan)/[`reorder`](Self::reorder) seam. + /// unit-tested without a zellij host. fn clicked_new_tab_button(&self, column: usize) -> bool { self.button_layout.is_some_and(|hit| hit.contains(column)) } @@ -551,95 +506,6 @@ impl State { .find(|hit| hit.contains(row, column)) .map(|hit| hit.position) } - - /// Record a potential drag for the tab drawn at `column`. `None` when the - /// press landed on no tab (overflow marker, gap, padding) — nothing to drag. - /// The tab is captured by its stable `tab_id` (resolved from the current - /// layout's position) so the release can find it after any position shift. - /// - /// Short-circuits to `None` when `reorder` is disabled (#23): without it the - /// plugin lacks `RunActionsAsUser`, so arming a drag would only leave the - /// gesture "armed but inert" — its `MoveTab` silently dropped at the - /// host boundary. Refusing to arm keeps a press a clean switch-only no-op. - fn grab_at(&self, column: usize) -> Option { - if !self.config.reorder { - return None; - } - let position = line::position_at_column(&self.tab_layout, column)?; - let grabbed_tab_id = self - .tabs - .iter() - .find(|tab| tab.position == position)? - .tab_id; - Some(DragState { - grabbed_tab_id, - dragging: false, - }) - } - - /// Promote the in-progress drag to actually dragging (a `Hold` arrived). - /// No-op when nothing was grabbed. Returns `false`: the drag has no visual - /// yet, so no repaint is warranted (a drop indicator is deferred to a - /// follow-up — see the PR notes). - fn mark_dragging(&mut self) -> bool { - let Some(drag) = self.drag.as_mut() else { - return false; - }; - drag.dragging = true; - false - } - - /// On release, reorder the grabbed tab to the drop `column`: resolve the - /// pure [`reorder_plan`](Self::reorder_plan), then emit it. No-op (returns - /// `false`) when the plan is `None`. Keeping the decision in `reorder_plan` - /// leaves this method a thin resolve→effect seam and makes the move math - /// testable without a zellij host. - fn commit_drag_at(&self, column: usize) -> bool { - let Some((_, shift, steps)) = self.reorder_plan(column) else { - return false; - }; - self.reorder(shift, steps); - true - } - - /// The reorder decision for a release at `column`: the grabbed tab's stable - /// id, which way to shift it, and how many neighbour hops — or `None` when - /// nothing should move (no drag in motion, the grabbed tab is gone, or the - /// drop lands on its own slot). Pure: reads state and derives the plan but - /// emits no action. The grabbed tab's *current* position is re-derived from - /// its stable id (focus and every prior hop reshuffle positions, the id is - /// invariant); [`line::drag_steps`] then gives the direction and neighbour - /// count. That re-derivation is the invariant that keeps a focus/hop repack - /// from corrupting the move, so it is pinned by unit tests. - fn reorder_plan(&self, column: usize) -> Option<(usize, line::Shift, usize)> { - let drag = self.drag.filter(|drag| drag.dragging)?; - let from = self - .tabs - .iter() - .find(|tab| tab.tab_id == drag.grabbed_tab_id) - .map(|tab| tab.position)?; - let (shift, steps) = line::drag_steps(&self.tab_layout, from, column)?; - Some((drag.grabbed_tab_id, shift, steps)) - } - - /// Emit one `MoveTab` per step. `MoveTab` shifts the **focused** tab a - /// single neighbour per call, and the press that armed this drag already - /// focused the grabbed tab (`switch_to_tab_at`), so emitting it `steps` - /// times walks that tab into the drop slot. The by-id variant - /// (`MoveTabByTabId`) cannot be used here: zellij-utils classifies it as - /// CLI-only and the `run_action` shim `unwrap`s the failed protobuf - /// conversion — a guaranteed panic on every drag commit (pinned by the - /// release tests). Needs the `RunActionsAsUser` permission; without it - /// the host drops the action and reorder is inert. - fn reorder(&self, shift: line::Shift, steps: usize) { - let direction = match shift { - line::Shift::Left => Direction::Left, - line::Shift::Right => Direction::Right, - }; - (0..steps).for_each(|_| { - run_action(actions::Action::MoveTab { direction }, BTreeMap::new()); - }); - } } // Native test builds link the whole lib, which references zellij-tile's host @@ -668,7 +534,7 @@ extern "C" fn host_run_plugin_command() { #[cfg(test)] mod tests { use super::*; - use crate::line::{Shift, TabHit}; + use crate::line::TabHit; /// The number of host commands `body` emits, as a delta of this thread's /// stub counter — robust whether the harness gives each test its own @@ -679,7 +545,7 @@ mod tests { HOST_COMMAND_CALLS.with(std::cell::Cell::get) - before } - /// A `TabInfo` carrying only the two fields the reorder math reads. + /// A `TabInfo` carrying only the position and stable id the tests read. fn tab(position: usize, tab_id: usize) -> TabInfo { TabInfo { position, @@ -688,7 +554,7 @@ mod tests { } } - /// A drawn span; `active` is irrelevant to drag resolution, so it is `false`. + /// A drawn span; `active` is irrelevant to hit-testing here, so it is `false`. fn hit(position: usize, start: usize, width: usize) -> TabHit { TabHit { position, @@ -704,89 +570,6 @@ mod tests { (0..5).map(|p| hit(p, p * 4, 4)).collect() } - #[test] - fn reorder_plan_resolves_from_by_the_grabbed_tabs_current_position() { - // The drag stores only the *stable* tab_id. Here the grabbed tab (id - // 100) currently sits at position 1 — deliberately not position 0, the - // slot a grab-time position snapshot might have frozen — so the plan - // MUST look up its live slot by id. Release lands in position 4's span - // (columns 16..20), making the move 3 hops right (1 → 4). If the code - // ever resolved `from` from a cached position instead of the current - // tabs, this count would change and the test would fail. - let mut state = State::default(); - state.tabs = vec![tab(0, 7), tab(1, 100), tab(2, 8), tab(3, 9), tab(4, 10)]; - state.tab_layout = five_block_layout(); - state.drag = Some(DragState { - grabbed_tab_id: 100, - dragging: true, - }); - - assert_eq!( - state.reorder_plan(18), - Some((100, Shift::Right, 3)), - "from is the grabbed tab's current position (1), resolved by stable id" - ); - } - - #[test] - fn reorder_plan_is_none_when_the_press_never_became_a_drag() { - // `dragging` stays false for a plain click (press + release, no motion). - // The plan must be `None` so a click is a pure switch, never a reorder — - // even though the release column would otherwise resolve to a move. - let mut state = State::default(); - state.tabs = vec![tab(0, 7), tab(1, 100), tab(2, 8), tab(3, 9), tab(4, 10)]; - state.tab_layout = five_block_layout(); - state.drag = Some(DragState { - grabbed_tab_id: 100, - dragging: false, - }); - - assert_eq!(state.reorder_plan(18), None); - } - - #[test] - fn reorder_plan_is_none_when_the_grabbed_tab_vanished() { - // A tab closed mid-drag: the grabbed id is no longer among the tabs, so - // there is no current position to move from. The plan must be `None` - // rather than panicking or moving the wrong tab. - let mut state = State::default(); - state.tabs = vec![tab(0, 7), tab(1, 8), tab(2, 9)]; - state.tab_layout = five_block_layout(); - state.drag = Some(DragState { - grabbed_tab_id: 999, - dragging: true, - }); - - assert_eq!(state.reorder_plan(18), None); - } - - #[test] - fn reorder_plan_is_none_when_dropped_on_its_own_slot() { - // The grabbed tab (id 100, position 2) is released within its own block - // (columns 8..12). `drag_steps` yields zero hops, so the plan is `None` - // — a drag that ends where it started moves nothing. - let mut state = State::default(); - state.tabs = vec![tab(0, 7), tab(1, 8), tab(2, 100), tab(3, 9), tab(4, 10)]; - state.tab_layout = five_block_layout(); - state.drag = Some(DragState { - grabbed_tab_id: 100, - dragging: true, - }); - - assert_eq!(state.reorder_plan(9), None); - } - - #[test] - fn reorder_plan_is_none_without_a_drag() { - // No press was recorded (e.g. release with no prior grab). `None` drag - // → `None` plan, no panic. - let mut state = State::default(); - state.tabs = vec![tab(0, 7)]; - state.tab_layout = five_block_layout(); - - assert_eq!(state.reorder_plan(18), None); - } - #[test] fn render_clears_tab_layout_when_it_cannot_draw() { // The bar only draws once permitted, and only repopulates `tab_layout` @@ -842,17 +625,14 @@ mod tests { } #[test] - fn permissions_exclude_run_actions_when_reorder_off() { - // The default (reorder off) requests exactly the v0.1.0 two-permission - // set, so an existing user who cached only Read+Change keeps working on - // auto-update — no `RunActionsAsUser` cache miss, no frozen bar - // (zellij#4982). - let config = Config { - reorder: false, - ..Default::default() - }; + fn permissions_are_the_two_base_grants() { + // The bar requests exactly the v0.1.0 two-permission set — + // `ReadApplicationState` (event subscription) and `ChangeApplicationState` + // (click-to-switch / focus / close / new-tab). No `RunActionsAsUser`, so + // an existing user who cached only Read+Change keeps working on + // auto-update with no cache-miss freeze (zellij#4982). assert_eq!( - State::permissions(&config), + State::permissions(), vec![ PermissionType::ReadApplicationState, PermissionType::ChangeApplicationState, @@ -860,61 +640,6 @@ mod tests { ); } - #[test] - fn permissions_include_run_actions_when_reorder_on() { - // Opting into reorder adds the third permission `MoveTab` needs. - let config = Config { - reorder: true, - ..Default::default() - }; - assert_eq!( - State::permissions(&config), - vec![ - PermissionType::ReadApplicationState, - PermissionType::ChangeApplicationState, - PermissionType::RunActionsAsUser, - ] - ); - } - - #[test] - fn grab_is_inert_when_reorder_off() { - // With reorder off the plugin never even arms a drag: a press over a tab - // records no `DragState`, so the gesture is a clean no-op rather than an - // "armed but inert" drag whose `MoveTab` is silently dropped at - // the host boundary for lack of `RunActionsAsUser`. - let mut state = State::default(); - state.config = Config { - reorder: false, - ..Default::default() - }; - state.tabs = vec![tab(0, 7), tab(1, 8), tab(2, 100), tab(3, 9), tab(4, 10)]; - state.tab_layout = five_block_layout(); - - assert!(state.grab_at(9).is_none()); - } - - #[test] - fn grab_arms_a_drag_when_reorder_on() { - // With reorder on, a press over position 2's block (columns 8..12) arms a - // drag on that tab's stable id (100), not yet dragging. - let mut state = State::default(); - state.config = Config { - reorder: true, - ..Default::default() - }; - state.tabs = vec![tab(0, 7), tab(1, 8), tab(2, 100), tab(3, 9), tab(4, 10)]; - state.tab_layout = five_block_layout(); - - assert!(matches!( - state.grab_at(9), - Some(DragState { - grabbed_tab_id: 100, - dragging: false - }) - )); - } - /// A configuration map as zellij would deliver it from the KDL block. fn config_map(pairs: &[(&str, &str)]) -> BTreeMap { pairs @@ -937,14 +662,13 @@ mod tests { #[test] fn load_parses_the_configuration_before_requesting_permissions() { - // `load` must parse the delivered map first — the permission request - // depends on `reorder` (#23). Host imports are stubbed natively, so the - // observable contract is the parsed config; what gets *requested* per - // config is pinned separately by the `permissions_*` tests. + // `load` must parse the delivered map before it drives the rest of the + // plugin. Host imports are stubbed natively, so the observable contract + // is the parsed config; what gets *requested* is pinned separately by the + // `permissions_are_the_two_base_grants` test. let mut state = State::default(); - state.load(config_map(&[("reorder", "true"), ("active_width", "20")])); + state.load(config_map(&[("active_width", "20")])); - assert!(state.config.reorder); assert_eq!(state.config.active_width, 20); assert!(!state.permitted, "permission arrives later as an event"); } @@ -1051,125 +775,15 @@ mod tests { } #[test] - fn left_click_on_a_tab_arms_a_drag_and_defers_the_repaint() { - // The click switches tabs via the host (stubbed here) and records the - // press as a potential drag. No repaint is requested — the switch - // arrives back as a TabUpdate, which drives the redraw. + fn left_click_on_a_tab_switches_and_defers_the_repaint() { + // The click switches tabs (or focuses a pane) via the host (stubbed + // here) and requests no repaint — the switch arrives back as a TabUpdate, + // which drives the redraw. let mut state = State::default(); - state.config = Config { - reorder: true, - ..Default::default() - }; state.tabs = vec![tab(0, 7), tab(1, 8), tab(2, 100), tab(3, 9), tab(4, 10)]; state.tab_layout = five_block_layout(); assert!(!state.update(Event::Mouse(Mouse::LeftClick(0, 9)))); - assert!(matches!( - state.drag, - Some(DragState { - grabbed_tab_id: 100, - dragging: false - }) - )); - } - - #[test] - fn left_click_off_any_tab_clears_a_stale_drag() { - // A press past the drawn blocks (columns 0..20) resolves to no tab: - // nothing to switch to, and any stale drag is dropped rather than left - // armed against geometry the press never touched. - let mut state = State::default(); - state.config = Config { - reorder: true, - ..Default::default() - }; - state.tabs = vec![tab(0, 7)]; - state.tab_layout = five_block_layout(); - state.drag = Some(DragState { - grabbed_tab_id: 7, - dragging: false, - }); - - assert!(!state.update(Event::Mouse(Mouse::LeftClick(0, 30)))); - assert!(state.drag.is_none()); - } - - #[test] - fn hold_promotes_the_press_to_a_real_drag() { - // Motion while pressed is what separates a drag from a plain click; - // only the fact matters, the drop column is read from the Release. - let mut state = State::default(); - state.drag = Some(DragState { - grabbed_tab_id: 100, - dragging: false, - }); - - assert!(!state.update(Event::Mouse(Mouse::Hold(0, 12)))); - assert!(matches!(state.drag, Some(DragState { dragging: true, .. }))); - } - - #[test] - fn hold_without_a_grab_is_a_no_op() { - // A Hold with no recorded press (e.g. the press landed off any tab) - // must not conjure a drag out of nothing. - let mut state = State::default(); - - assert!(!state.update(Event::Mouse(Mouse::Hold(0, 12)))); - assert!(state.drag.is_none()); - } - - #[test] - fn release_commits_a_rightward_drag_and_requests_a_repaint() { - // The full gesture: a dragging grab on id 100 (currently position 1) - // released over position 4's block resolves a rightward plan and emits - // `MoveTab` once per hop (the press already focused the grabbed tab, - // so the focused-tab move walks exactly that tab). The choice of - // action is load-bearing: the by-id variant (`MoveTabByTabId`) is - // CLI-only in zellij-utils and `run_action`'s shim panics on its - // failed protobuf conversion — this test running update() through the - // real emit pins that the emitted action converts cleanly. - let mut state = State::default(); - state.tabs = vec![tab(0, 7), tab(1, 100), tab(2, 8), tab(3, 9), tab(4, 10)]; - state.tab_layout = five_block_layout(); - state.drag = Some(DragState { - grabbed_tab_id: 100, - dragging: true, - }); - - assert!( - state.update(Event::Mouse(Mouse::Release(0, 18))), - "a committed drag must request a repaint" - ); - assert!(state.drag.is_none(), "release always clears the drag"); - } - - #[test] - fn release_commits_a_leftward_drag() { - // Same gesture mirrored: id 100 (position 3) dropped on position 0's - // block resolves a leftward plan — covering the `Shift::Left` - // direction arm — and the emit passes the same shim boundary. - let mut state = State::default(); - state.tabs = vec![tab(0, 7), tab(1, 8), tab(2, 9), tab(3, 100), tab(4, 10)]; - state.tab_layout = five_block_layout(); - state.drag = Some(DragState { - grabbed_tab_id: 100, - dragging: true, - }); - - assert_eq!(state.reorder_plan(1), Some((100, Shift::Left, 3))); - assert!(state.update(Event::Mouse(Mouse::Release(0, 1)))); - assert!(state.drag.is_none()); - } - - #[test] - fn release_without_a_drag_requests_no_repaint() { - // A Release with nothing grabbed (or after a plain click) moves nothing - // and must not waste a repaint. - let mut state = State::default(); - state.tab_layout = five_block_layout(); - - assert!(!state.update(Event::Mouse(Mouse::Release(0, 18)))); - assert!(state.drag.is_none()); } #[test] @@ -1712,15 +1326,11 @@ mod tests { #[test] fn left_click_on_the_close_cell_closes_the_tab_and_consumes_the_gesture() { // A press on a recorded "×" cell closes that tab via the host (stubbed - // here), drops any armed drag, and requests no repaint — the close - // arrives back as a TabUpdate, which drives the redraw. Checked before - // the focus/switch fallback so the corner closes rather than switches, - // and before any drag is armed (#86). + // here) and requests no repaint — the close arrives back as a TabUpdate, + // which drives the redraw. Checked before the focus/switch fallback so + // the corner closes rather than switches (#86), and the close target is + // consumed so a duplicate click can't re-dispatch off stale geometry. let mut state = State::default(); - state.drag = Some(DragState { - grabbed_tab_id: 7, - dragging: false, - }); state.close_layout = vec![line::CloseHit { position: 2, row: 0, @@ -1729,8 +1339,8 @@ mod tests { assert!(!state.update(Event::Mouse(Mouse::LeftClick(0, 9)))); assert!( - state.drag.is_none(), - "closing consumes the gesture and drops any stale drag" + state.close_layout.is_empty(), + "closing consumes the close target" ); } diff --git a/src/line.rs b/src/line.rs index 681f8bf..ea897da 100644 --- a/src/line.rs +++ b/src/line.rs @@ -5,9 +5,9 @@ //! This is layout math only — no `zellij_tile` calls and no rendering — so it //! runs and is unit-tested on the native host like the rest of the renderer //! (`minimap` / `paint` / `projection`). The [`TabHit`] spans it produces are -//! the input for click-to-switch (#8) and, later, drag-and-drop reordering -//! (#10), so each span reflects exactly where a block is drawn, measured in -//! display columns (see [`display_width`]) rather than `char` count. +//! the input for click-to-switch (#8), so each span reflects exactly where a +//! block is drawn, measured in display columns (see [`display_width`]) rather +//! than `char` count. use unicode_width::UnicodeWidthStr; @@ -101,16 +101,6 @@ pub struct LineLayout { pub button: Option, } -/// Which way a dragged tab travels, in `zellij`-free terms. Drag-and-drop -/// reorder (#10) maps this to `Direction::Left` / `Direction::Right` at the -/// host-calling site, so this layout module stays free of `zellij_tile` types -/// (like the rest of `line` / `minimap` / `paint`, it is native-testable). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Shift { - Left, - Right, -} - /// How the all-fit tab row is anchored horizontally (config key `align`). /// /// Governs **only** the branch where every tab fits: `Center` re-centers the @@ -155,9 +145,8 @@ pub fn display_width(text: &str) -> usize { /// The 0-based `position` of the visible tab whose drawn span `[start, start + /// width)` contains `column`, or `None` when the column misses every block (an /// overflow marker, an inter-block gap, or trailing padding). This is the exact -/// hit-test that both *grabs* a tab for drag-and-drop (#10) and underlies -/// click-to-switch (#8); keeping it column-precise means a stray click or grab -/// is a no-op, never a wrong tab. +/// hit-test underlying click-to-switch (#8) and click-to-focus (#74); keeping it +/// column-precise means a stray click is a no-op, never a wrong tab. pub fn position_at_column(tabs: &[TabHit], column: usize) -> Option { tabs.iter() .find(|tab| (tab.start..tab.start + tab.width).contains(&column)) @@ -177,75 +166,6 @@ pub fn switch_target_at_column(tabs: &[TabHit], column: usize) -> Option { position_at_column(tabs, column).map(|position| position as u32 + 1) } -/// Resolve a drag that grabbed the tab at 0-based `from` and released at -/// `release_col`: how many single-position steps, and which way, to move the -/// grabbed tab so it lands where it was dropped — or `None` when the gesture is -/// a no-op (released back on its own slot, or nothing is drawn to drop onto). -/// -/// The drop column is clamped into the drawn strip by [`drop_position_at_column`] -/// (left of every block → first visible tab, right of every block → last, -/// otherwise the block under the column), so a drop never silently vanishes off -/// an edge. `zellij`'s `MoveTabByTabId` shifts a tab one neighbour per call, and -/// packed positions are contiguous, so the step count is exactly the absolute -/// distance between `from` and the drop position. Direction is `zellij`-free -/// (see [`Shift`]); the caller maps it to `Direction` and emits one move per -/// step. -/// -/// `from` must be the position of a currently drawn tab. A `from` that is not -/// among `tabs` — a stale grab whose tab scrolled into the overflow, or the -/// layout repacked mid-drag — resolves to `None` rather than a delta against a -/// position no longer on screen, keeping the conservative "uncertain ⇒ no-op" -/// stance of the click hit-test (a stray gesture never moves the wrong tab). -pub fn drag_steps(tabs: &[TabHit], from: usize, release_col: usize) -> Option<(Shift, usize)> { - tabs.iter().find(|tab| tab.position == from)?; - let to = drop_position_at_column(tabs, release_col)?; - let steps = to.abs_diff(from); - let shift = if to > from { Shift::Right } else { Shift::Left }; - (steps > 0).then_some((shift, steps)) -} - -/// The 0-based `position` a release at `release_col` drops onto. Unlike -/// [`position_at_column`] (an exact grab hit that may miss), a drop is clamped -/// into the drawn strip: a column left of the first block targets the first -/// visible tab, one at or past the last block targets the last, one inside the -/// run targets the block under it, and one on an inter-block gap column (a -/// real miss once `tab_gap` > 0, #33) targets the nearest block. `None` only -/// when nothing is drawn — so an in-strip drop always resolves to a sensible -/// neighbour rather than discarding the gesture. -fn drop_position_at_column(tabs: &[TabHit], release_col: usize) -> Option { - let first = tabs.first()?; - let last = tabs.last()?; - if release_col < first.start { - return Some(first.position); - } - if release_col >= last.start + last.width { - return Some(last.position); - } - // With `tab_gap = 0` blocks pack flush, so the exact hit always lands; a - // positive gap opens real miss columns *between* blocks. Falling back to - // the last tab there (as this once did) teleported a gap drop to the end - // of the strip (#34) — resolve it to the nearest block instead. - position_at_column(tabs, release_col).or_else(|| nearest_position_to_column(tabs, release_col)) -} - -/// The 0-based `position` of the visible tab whose drawn span lies nearest to -/// `column` — the gap-column resolver behind [`drop_position_at_column`]. -/// Distance is measured to the nearer span edge (zero inside a span; at most -/// one of the two saturating terms is non-zero, so their max is the distance). -/// A column equidistant from both neighbours resolves to the **left** block — -/// `min_by_key` keeps the first minimum and `tabs` is ordered left → right — -/// a deterministic tie rule the user can learn. `None` only when nothing is -/// drawn. -fn nearest_position_to_column(tabs: &[TabHit], column: usize) -> Option { - tabs.iter() - .min_by_key(|tab| { - tab.start - .saturating_sub(column) - .max(column.saturating_sub(tab.start + tab.width - 1)) - }) - .map(|tab| tab.position) -} - fn left_marker(hidden: usize) -> String { format!("← +{hidden} ") } @@ -1455,209 +1375,4 @@ mod tests { ); assert_eq!(position_at_column(&[], 0), None, "empty layout"); } - - // ---- drag_steps (drop resolution + delta, #10) ----------------------- - - #[test] - fn drag_to_a_later_block_steps_right_by_the_position_delta() { - // positions 0..5, each width 2, contiguous: p1 spans [2,4), p3 [6,8). - let tabs: Vec = (0..5).map(|p| hit(p, p * 2, 2, p == 2)).collect(); - // grabbed position 1, released inside position 3's span → move right 2. - assert_eq!(drag_steps(&tabs, 1, 6), Some((Shift::Right, 2))); - assert_eq!( - drag_steps(&tabs, 1, 7), - Some((Shift::Right, 2)), - "either column of p3" - ); - } - - #[test] - fn drag_to_an_earlier_block_steps_left_by_the_position_delta() { - let tabs: Vec = (0..5).map(|p| hit(p, p * 2, 2, p == 2)).collect(); - // grabbed position 3, released inside position 1's span → move left 2. - assert_eq!(drag_steps(&tabs, 3, 2), Some((Shift::Left, 2))); - } - - #[test] - fn drag_released_on_its_own_slot_is_a_no_op() { - // A click-without-move (grab and release on the same block) must never - // emit a move action — both columns of position 2's span resolve to None. - let tabs: Vec = (0..5).map(|p| hit(p, p * 2, 2, p == 2)).collect(); - assert_eq!( - drag_steps(&tabs, 2, 4), - None, - "first column of the grabbed slot" - ); - assert_eq!( - drag_steps(&tabs, 2, 5), - None, - "last column of the grabbed slot" - ); - } - - #[test] - fn drop_past_the_last_block_clamps_to_the_last_visible_tab() { - // positions 0..5; last block (p4) spans [8,10). A release at or beyond - // column 10 (slack / right overflow marker) lands the tab at the end. - let tabs: Vec = (0..5).map(|p| hit(p, p * 2, 2, p == 2)).collect(); - assert_eq!( - drag_steps(&tabs, 1, 10), - Some((Shift::Right, 3)), - "at the end boundary" - ); - assert_eq!( - drag_steps(&tabs, 1, 99), - Some((Shift::Right, 3)), - "far past the end" - ); - } - - #[test] - fn drop_before_the_first_block_clamps_to_the_first_visible_tab() { - // First block does not start at column 0 (a left overflow marker / row - // offset occupies [0,5)); a release in that region targets the first - // visible tab, position 2. - let tabs = vec![hit(2, 5, 2, true), hit(3, 7, 2, false)]; - assert_eq!(drag_steps(&tabs, 3, 0), Some((Shift::Left, 1)), "far left"); - assert_eq!( - drag_steps(&tabs, 3, 4), - Some((Shift::Left, 1)), - "just left of the first block" - ); - } - - #[test] - fn drop_in_a_gap_column_lands_on_the_nearest_block_not_the_last_tab() { - // A `tab_gap = 3` strip: p0 spans [0,2), p1 [5,7), p2 [10,12), so - // columns 2..5 and 7..10 are real hit-test misses. Before #34 every - // gap drop fell through to the LAST tab; now it resolves to the block - // whose span edge is nearest. - let tabs = vec![ - hit(0, 0, 2, true), - hit(1, 5, 2, false), - hit(2, 10, 2, false), - ]; - assert_eq!( - drag_steps(&tabs, 2, 2), - Some((Shift::Left, 2)), - "column 2 is 1 from p0, 3 from p1" - ); - assert_eq!( - drag_steps(&tabs, 2, 4), - Some((Shift::Left, 1)), - "column 4 is 1 from p1, 3 from p0" - ); - assert_eq!( - drag_steps(&tabs, 0, 9), - Some((Shift::Right, 2)), - "column 9 is 1 from p2, 3 from p1" - ); - } - - #[test] - fn drop_on_the_centre_of_an_odd_gap_ties_to_the_left_block() { - // Column 3 sits exactly 2 from p0's right edge (column 1) and 2 from - // p1's left edge (column 5); the tie breaks toward the LEFT block so - // the rule stays deterministic across symmetric gaps. - let tabs = vec![ - hit(0, 0, 2, true), - hit(1, 5, 2, false), - hit(2, 10, 2, false), - ]; - assert_eq!(drag_steps(&tabs, 2, 3), Some((Shift::Left, 2)), "tie → p0"); - assert_eq!( - drag_steps(&tabs, 0, 8), - Some((Shift::Right, 1)), - "tie → p1, not p2" - ); - } - - #[test] - fn drag_steps_matches_the_nearest_block_across_every_gapped_release() { - // Positions 0..4 separated by 2-column gaps: p spans [4p, 4p + 2), - // last block ends at 14. Independently of the implementation, each - // block owns its span plus the nearer half of each neighbouring gap, - // so the drop position of any release is `(release_col + 1) / 4`, - // clamped to the last position past the end — a genuine cross-check, - // not a restatement. - let spans: Vec = (0..4).map(|p| hit(p, p * 4, 2, p == 1)).collect(); - for from in 0usize..4 { - for release_col in 0usize..20 { - let to = ((release_col + 1) / 4).min(3); - let expected = (to != from).then(|| { - let shift = if to > from { Shift::Right } else { Shift::Left }; - (shift, to.abs_diff(from)) - }); - assert_eq!( - drag_steps(&spans, from, release_col), - expected, - "from {from}, release_col {release_col} (drop position {to})" - ); - } - } - } - - #[test] - fn drag_on_an_empty_or_single_tab_layout_never_moves() { - assert_eq!(drag_steps(&[], 0, 5), None, "nothing drawn"); - let one = vec![hit(0, 4, 2, true)]; - // A lone tab has only one slot, so every drop resolves back onto it. - assert_eq!(drag_steps(&one, 0, 4), None, "onto itself"); - assert_eq!(drag_steps(&one, 0, 0), None, "clamped left to itself"); - assert_eq!(drag_steps(&one, 0, 99), None, "clamped right to itself"); - } - - #[test] - fn drag_with_a_grabbed_position_not_currently_drawn_is_a_no_op() { - // Only positions 5,6,7 are visible (the rest collapsed into overflow). A - // grab whose recorded position is no longer on screen — a stale drag, or - // the grabbed tab scrolled into the overflow before release — resolves to - // no move, never a delta against an off-screen position. - let tabs = vec![hit(5, 0, 2, false), hit(6, 2, 2, true), hit(7, 4, 2, false)]; - assert_eq!( - drag_steps(&tabs, 2, 4), - None, - "grabbed position 2 is hidden" - ); - assert_eq!( - drag_steps(&tabs, 9, 0), - None, - "grabbed position 9 is hidden" - ); - // A grab on a position that IS drawn still resolves normally. - assert_eq!( - drag_steps(&tabs, 5, 4), - Some((Shift::Right, 2)), - "visible grab works" - ); - } - - #[test] - fn drag_steps_matches_the_clamped_drop_across_every_grab_and_release() { - // A deterministic sweep over a hand-built contiguous strip: positions - // 0..5, each width 3, drawn from column 0 (p spans [3p, 3p+3)), last - // block ends at 18. The expected drop position is derived independently - // of `drop_position_at_column` — `release_col / 3`, clamped to the last - // position past the end — so the assertion genuinely cross-checks the - // implementation rather than restating it. - let spans: Vec = (0..6).map(|p| hit(p, p * 3, 3, p == 2)).collect(); - for from in 0usize..6 { - for release_col in 0usize..24 { - let to = if release_col >= 18 { - 5 - } else { - release_col / 3 - }; - let expected = (to != from).then(|| { - let shift = if to > from { Shift::Right } else { Shift::Left }; - (shift, to.abs_diff(from)) - }); - assert_eq!( - drag_steps(&spans, from, release_col), - expected, - "from {from}, release_col {release_col} (drop position {to})" - ); - } - } - } }