From ab4f7ecfc9b10c0bae77d040384344333946ea29 Mon Sep 17 00:00:00 2001 From: kipavy Date: Fri, 24 Jul 2026 06:30:20 +0000 Subject: [PATCH 1/2] fix(terminal): scroll alt-screen apps with the wheel/touchpad; respect Select-to-Copy (#50) Two terminal fixes reported in #50: - Wheel/touchpad scroll did nothing in full-screen apps (nano/less/vim). These run in the alternate screen buffer, where xterm has no scrollback to move; its built-in translation also dampens sub-50px touchpad deltas to zero, so scrolling was effectively a no-op. Add a custom wheel handler that translates the delta into Up/Down arrow presses, carrying the sub-row remainder so touchpads (many tiny pixel deltas) scroll smoothly, and clamping a fling to one page. It stays out of the way when the app tracks the mouse itself (e.g. vim `set mouse=a`) and in the normal buffer (native scrollback unchanged). Synthesized arrows route through the same broadcast path as typed input, and the row math lives in a pure, unit-tested core (terminalWheelCore). - Copy-on-select ignored the "Select to Copy" toggle: mouseup copied the selection unconditionally while only the feedback badge honored the setting. Gate the clipboard write on the toggle; explicit Ctrl+Shift+C still copies regardless. --- src/components/terminal/terminalClipboard.ts | 4 + .../terminal/terminalWheelCore.test.ts | 41 ++++++++++ src/components/terminal/terminalWheelCore.ts | 45 +++++++++++ src/hooks/useTerminal.ts | 77 +++++++++++++++---- 4 files changed, 151 insertions(+), 16 deletions(-) create mode 100644 src/components/terminal/terminalWheelCore.test.ts create mode 100644 src/components/terminal/terminalWheelCore.ts diff --git a/src/components/terminal/terminalClipboard.ts b/src/components/terminal/terminalClipboard.ts index 799dddbfc..9bc427ef1 100644 --- a/src/components/terminal/terminalClipboard.ts +++ b/src/components/terminal/terminalClipboard.ts @@ -88,6 +88,10 @@ export function attachTerminalClipboard( }; const handleMouseUp = (e: MouseEvent) => { + // Copy-on-select is opt-out: when the "Select to Copy" toggle is off, a + // selection must NOT touch the clipboard (#50). Explicit Ctrl+Shift+C still + // copies regardless. + if (!getToggle("select-to-copy")) return; setTimeout(() => { const sel = term.getSelection(); if (sel) { diff --git a/src/components/terminal/terminalWheelCore.test.ts b/src/components/terminal/terminalWheelCore.test.ts new file mode 100644 index 000000000..e775baf9c --- /dev/null +++ b/src/components/terminal/terminalWheelCore.test.ts @@ -0,0 +1,41 @@ +import { wheelToRows } from "./terminalWheelCore.ts"; +import { test } from "vitest"; + +test("terminalWheelCore", async () => { +function assertEqual(actual: T, expected: T, msg: string): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { console.error(`FAIL ${msg}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); throw new Error(msg); } +} + +// Pixel mode (touchpads / most browsers): one cell-height down → one row down, no carry. +assertEqual(wheelToRows({ deltaY: 17, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 0 }), { rows: 1, carry: 0 }, "one cell down"); +assertEqual(wheelToRows({ deltaY: -17, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 0 }), { rows: -1, carry: 0 }, "one cell up"); + +// Touchpad: many sub-cell pixel deltas accumulate across calls until they cross a row. +const a = wheelToRows({ deltaY: 6, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 0 }); +assertEqual(a, { rows: 0, carry: 6 }, "sub-row 1 accumulates"); +const b = wheelToRows({ deltaY: 6, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: a.carry }); +assertEqual(b, { rows: 0, carry: 12 }, "sub-row 2 accumulates"); +const c = wheelToRows({ deltaY: 6, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: b.carry }); +assertEqual(c, { rows: 1, carry: 1 }, "sub-row 3 crosses one row, remainder carried"); + +// Reversing direction nets against the carried remainder rather than double-counting. +assertEqual(wheelToRows({ deltaY: -7, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 10 }), { rows: 0, carry: 3 }, "reversal nets carry"); + +// Line mode (deltaMode 1): deltaY is already in rows, scaled via cellHeight. +assertEqual(wheelToRows({ deltaY: 3, deltaMode: 1, cellHeight: 17, viewportRows: 24, carry: 0 }), { rows: 3, carry: 0 }, "line mode 3 rows"); + +// Page mode (deltaMode 2): a page is viewportRows rows. +assertEqual(wheelToRows({ deltaY: 1, deltaMode: 2, cellHeight: 17, viewportRows: 24, carry: 0 }), { rows: 24, carry: 0 }, "page mode one page down"); + +// Degenerate cell height must not divide-by-zero or emit movement. +assertEqual(wheelToRows({ deltaY: 100, deltaMode: 0, cellHeight: 0, viewportRows: 24, carry: 0 }), { rows: 0, carry: 0 }, "zero cell height is inert"); + +// Zero delta is inert but preserves any existing carry. +assertEqual(wheelToRows({ deltaY: 0, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 5 }), { rows: 0, carry: 5 }, "zero delta preserves carry"); + +// maxRows clamps a fling to a page and drops the excess (carry reset) so the PTY +// isn't flooded with hundreds of arrows from one gesture. +assertEqual(wheelToRows({ deltaY: 5000, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 0, maxRows: 24 }), { rows: 24, carry: 0 }, "clamp down to a page"); +assertEqual(wheelToRows({ deltaY: -5000, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 0, maxRows: 24 }), { rows: -24, carry: 0 }, "clamp up to a page"); +assertEqual(wheelToRows({ deltaY: 34, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 0, maxRows: 24 }), { rows: 2, carry: 0 }, "within clamp unaffected"); +}); diff --git a/src/components/terminal/terminalWheelCore.ts b/src/components/terminal/terminalWheelCore.ts new file mode 100644 index 000000000..fe32dcb04 --- /dev/null +++ b/src/components/terminal/terminalWheelCore.ts @@ -0,0 +1,45 @@ +/** Pure wheel-delta → terminal-row math for alternate-screen scroll translation. + * No DOM/xterm; node-testable. Full-screen apps (nano/less/vim) run in the + * alternate buffer where xterm has no scrollback to move, so a wheel/touchpad + * scroll is translated into Up/Down arrow presses. This converts a WheelEvent's + * delta into a signed row count, carrying the sub-row remainder across events so + * touchpads (which emit many tiny pixel deltas) still scroll smoothly. */ +export interface WheelToRowsInput { + /** WheelEvent.deltaY. */ + deltaY: number; + /** WheelEvent.deltaMode: 0 = pixels, 1 = lines, 2 = pages. */ + deltaMode: number; + /** Rendered height of one terminal row, in CSS pixels. */ + cellHeight: number; + /** Rows in the viewport (a "page" in deltaMode 2). */ + viewportRows: number; + /** Sub-row pixel remainder carried from the previous call. */ + carry: number; + /** Cap on rows emitted from a single event (a fling shouldn't flood the PTY). + * When the movement exceeds it, the result is clamped and the remainder dropped. */ + maxRows?: number; +} + +export interface WheelToRowsResult { + /** Rows to move: negative = up, positive = down. */ + rows: number; + /** Sub-row pixel remainder to feed into the next call. */ + carry: number; +} + +export function wheelToRows({ deltaY, deltaMode, cellHeight, viewportRows, carry, maxRows }: WheelToRowsInput): WheelToRowsResult { + if (cellHeight <= 0) return { rows: 0, carry: 0 }; + + // Normalize the delta to pixels regardless of the browser's reporting mode. + let pixels: number; + if (deltaMode === 1) pixels = deltaY * cellHeight; // lines + else if (deltaMode === 2) pixels = deltaY * viewportRows * cellHeight; // pages + else pixels = deltaY; // pixels + + const total = carry + pixels; + const rows = Math.trunc(total / cellHeight); + if (maxRows !== undefined && Math.abs(rows) > maxRows) { + return { rows: rows < 0 ? -maxRows : maxRows, carry: 0 }; + } + return { rows, carry: total - rows * cellHeight }; +} diff --git a/src/hooks/useTerminal.ts b/src/hooks/useTerminal.ts index a151423b5..10321d948 100644 --- a/src/hooks/useTerminal.ts +++ b/src/hooks/useTerminal.ts @@ -21,6 +21,8 @@ import { useTeamSessionStore } from "@/stores/teamSessionStore"; import { useCommandHistoryStore } from "@/stores/commandHistoryStore"; import { consumeLatchForChar } from "@/stores/modifierLatchStore"; import { sampleLineDensities, scrollDeltaForRatio, type TerminalMinimapCell, type TerminalMinimapSample } from "@/components/terminal/minimapMath"; +import { wheelToRows } from "@/components/terminal/terminalWheelCore"; +import { keyToBytes } from "@/stores/terminalKeyCore"; import type { TerminalTheme } from "@/themes/types"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { withFlagEmojiFallback } from "@/utils/emojiFont"; @@ -821,6 +823,64 @@ export function useTerminal({ sessionId, sessionType, onClosed, inputGate, encod return true; }); + // Route already-encoded input bytes to the PTY, fanning out to every pane + // when split-pane broadcast is active. Shared by typed input (onData) and + // synthesized alt-screen scroll arrows so both honor broadcast identically. + const routeInputBytes = (bytes: Uint8Array) => { + const layout = useLayoutStore.getState(); + const paneSessionIds = getPaneSessionIds(layout.root); + if (layout.broadcastActive && layout.splitTabActive && paneSessionIds.includes(sessionId)) { + const sessions = useSessionStore.getState().sessions; + const mpConnections = useTeamSessionStore.getState().connections; + for (const targetId of paneSessionIds) { + const target = sessions.find((s) => s.id === targetId); + if (!target || target.status !== "connected" || target.type === "multiplayer") continue; + const mpState = mpConnections[target.id]; + if (mpState && mpState.controlHolder !== "" && mpState.controlHolder !== mpState.myUserId) continue; + sendSessionInput(target.id, target.type === "serial" ? "serial" : target.type as "ssh" | "local", bytes); + } + return; + } + sendSessionInput(sessionId, sessionType, bytes); + }; + + // Alternate-screen scroll: full-screen apps (nano/less/vim) run in the + // alternate buffer, where xterm has no scrollback to move — so a wheel or + // touchpad scroll would otherwise do nothing (#50). Translate it into Up/Down + // arrow presses, unless the app is tracking the mouse itself (e.g. vim + // `set mouse=a`), in which case xterm forwards the wheel as mouse events. + let wheelCarry = 0; + term.attachCustomWheelEventHandler((e: WheelEvent) => { + if (term.buffer.active.type !== "alternate") return true; + if (term.modes.mouseTrackingMode !== "none") return true; + // We own this event now: stop the page/ancestor from also scrolling, and + // return false so xterm skips its own (sub-pixel-dampened) translation. + e.preventDefault(); + if (inputGate && !inputGate.current?.()) return false; + if (!entry.connectedRef.current) return false; + + const screen = term.element?.querySelector(".xterm-screen"); + const cellHeight = screen && term.rows ? screen.clientHeight / term.rows : 0; + const { rows, carry } = wheelToRows({ + deltaY: e.deltaY, + deltaMode: e.deltaMode, + cellHeight, + viewportRows: term.rows, + carry: wheelCarry, + maxRows: term.rows, // one page per event caps runaway flings + }); + wheelCarry = carry; + if (rows !== 0) { + const seq = keyToBytes(rows < 0 ? "Up" : "Down", { + ctrl: false, + alt: false, + appCursor: term.modes.applicationCursorKeysMode, + }).repeat(Math.abs(rows)); + routeInputBytes(encoder.encode(seq)); + } + return false; + }); + term.open(container); // Android: stop xterm's hidden textarea from summoning the WebView's own (broken) IME — @@ -893,22 +953,7 @@ export function useTerminal({ sessionId, sessionType, onClosed, inputGate, encod .addInput(sessionId, sess.connectionName, sess.connectionId, data); } - const bytes = encoder.encode(data); - const layout = useLayoutStore.getState(); - const paneSessionIds = getPaneSessionIds(layout.root); - if (layout.broadcastActive && layout.splitTabActive && paneSessionIds.includes(sessionId)) { - const sessions = useSessionStore.getState().sessions; - const mpConnections = useTeamSessionStore.getState().connections; - for (const targetId of paneSessionIds) { - const target = sessions.find((s) => s.id === targetId); - if (!target || target.status !== "connected" || target.type === "multiplayer") continue; - const mpState = mpConnections[target.id]; - if (mpState && mpState.controlHolder !== "" && mpState.controlHolder !== mpState.myUserId) continue; - sendSessionInput(target.id, target.type === "serial" ? "serial" : target.type as "ssh" | "local", bytes); - } - return; - } - sendSessionInput(sessionId, sessionType, bytes); + routeInputBytes(encoder.encode(data)); }); const unlistenPromises: Promise[] = []; From 2e766c15c245497b98c35530f375e3aafe9da18f Mon Sep 17 00:00:00 2001 From: kipavy Date: Fri, 24 Jul 2026 07:24:36 +0000 Subject: [PATCH 2/2] fix(sync): drop redundant `files` rebinding (clippy redundant_locals) `files` is already declared `mut` at the top of the function, so the `let mut files = files;` rebinding is a no-op that fails CI's `cargo clippy -- -D warnings` (redundant_locals). Pre-existing on dev (introduced in #43); removing it unblocks the build check for this PR. --- src-tauri/src/commands/sync.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src-tauri/src/commands/sync.rs b/src-tauri/src/commands/sync.rs index 10d8ac75d..7b06e0d0b 100644 --- a/src-tauri/src/commands/sync.rs +++ b/src-tauri/src/commands/sync.rs @@ -208,7 +208,6 @@ pub fn backup_export( } } let data = state.export_all()?; - let mut files = files; let mut secrets = data.secrets; let mut clocks = data.clocks; let excluded: HashSet = excluded_ids.unwrap_or_default().into_iter().collect();