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})" - ); - } - } - } }