From fc1c3978dfcd2549bf23d702819858ae1f3373e9 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 25 Aug 2026 18:10:10 -0400 Subject: [PATCH] feat(config): make wheel scrolling configurable --- .changeset/faster-wheels-scroll.md | 5 ++ README.md | 2 + scripts/generate-docs.ts | 3 +- src/app/cli.test.ts | 17 ++++++ src/app/cli.ts | 23 +++++++- src/core/bootstrap.ts | 2 + src/core/changeset/loaders.ts | 2 + src/core/run/commandInputs.ts | 2 + src/core/run/config.test.ts | 39 ++++++++++++++ src/core/run/config.ts | 53 ++++++++++++++++--- src/core/run/wheelScrollLines.test.ts | 22 ++++++++ src/core/run/wheelScrollLines.ts | 33 ++++++++++++ src/ui/App.tsx | 1 + src/ui/components/panes/DiffPane.tsx | 10 +++- src/ui/lib/scrollAcceleration.test.ts | 19 +++++++ src/ui/lib/scrollAcceleration.ts | 18 +++++-- test/pty/harness.ts | 29 ++++++++++ test/pty/scroll.test.ts | 28 +++++++++- .../src/content/docs/docs/reference/cli.md | 49 ++++++++--------- .../src/content/docs/docs/reference/config.md | 11 ++++ 20 files changed, 329 insertions(+), 39 deletions(-) create mode 100644 .changeset/faster-wheels-scroll.md create mode 100644 src/core/run/wheelScrollLines.test.ts create mode 100644 src/core/run/wheelScrollLines.ts create mode 100644 src/ui/lib/scrollAcceleration.test.ts diff --git a/.changeset/faster-wheels-scroll.md b/.changeset/faster-wheels-scroll.md new file mode 100644 index 000000000..1f1d90448 --- /dev/null +++ b/.changeset/faster-wheels-scroll.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Let reviewers configure a fixed number of rows per mouse-wheel event for faster large-diff scrolling. diff --git a/README.md b/README.md index e83db1698..dcd9ad6e9 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ watch = false exclude_untracked = false line_numbers = true tab_width = 4 # tab stops, 1-16 +wheel_scroll_lines = "auto" # auto acceleration, or a fixed 1-10 rows per event wrap_lines = false menu_bar = true sidebar = "auto" # "auto", true, false @@ -165,6 +166,7 @@ syntax scopes, and legacy syntax-table migration. `exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only. `tab_width` controls source-code tab stops and can be overridden with `-x4` or `--tab-width 4`. +`wheel_scroll_lines` is a user-only preference and can be overridden with `--wheel-scroll-lines 3`. `prompt_save_view_preferences = false` disables the quit prompt for saving changed view preferences. `transparent_background` can also be written as `transparentBackground`. diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 8832d4ae8..3777177bb 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -250,7 +250,8 @@ export function renderConfigReference() { option.runtimeDefault !== undefined ? `\`${String(option.runtimeDefault)}\`` : (option.defaultValue ?? "—"); - return `**\`${option.key}\`**\n\n${option.description}\n\n- **Type:** ${option.type}\n- **Accepted:** ${option.accepted}\n- **Built-in default:** ${defaultValue}${aliasDetails}`; + const scopeDetails = option.userOnly ? "\n- **Scope:** user config only" : ""; + return `**\`${option.key}\`**\n\n${option.description}\n\n- **Type:** ${option.type}\n- **Accepted:** ${option.accepted}\n- **Built-in default:** ${defaultValue}${scopeDetails}${aliasDetails}`; }); const sectionRows = Object.entries(CONFIG_COMMAND_SECTIONS).map( ([section, description]) => `| \`[${section}]\` | ${description} |`, diff --git a/src/app/cli.test.ts b/src/app/cli.test.ts index c3f3dcf53..21bba7dc0 100644 --- a/src/app/cli.test.ts +++ b/src/app/cli.test.ts @@ -124,6 +124,8 @@ describe("parseCli", () => { "notes.json", "--no-line-numbers", "-x4", + "--wheel-scroll-lines", + "3", "--wrap", "--no-hunk-headers", "--agent-notes", @@ -144,6 +146,7 @@ describe("parseCli", () => { experimental: true, lineNumbers: false, tabWidth: 4, + wheelScrollLines: 3, wrapLines: true, hunkHeaders: false, agentNotes: true, @@ -152,6 +155,20 @@ describe("parseCli", () => { }); }); + test("parses wheel scroll lines and rejects invalid values", async () => { + const automatic = await parseCli(["bun", "hunk", "diff", "--wheel-scroll-lines", "auto"]); + const fixed = await parseCli(["bun", "hunk", "diff", "--wheel-scroll-lines", "5"]); + + expect(automatic).toMatchObject({ kind: "vcs", options: { wheelScrollLines: "auto" } }); + expect(fixed).toMatchObject({ kind: "vcs", options: { wheelScrollLines: 5 } }); + + for (const invalid of ["0", "11", "fast"]) { + await expect( + parseCli(["bun", "hunk", "diff", "--wheel-scroll-lines", invalid]), + ).rejects.toThrow(/wheel scroll lines/); + } + }); + test("parses the current-line style and rejects an unknown one", async () => { const parsed = await parseCli(["bun", "hunk", "diff", "--cursor-line", "number"]); diff --git a/src/app/cli.ts b/src/app/cli.ts index f761b7e97..2a24908b3 100644 --- a/src/app/cli.ts +++ b/src/app/cli.ts @@ -50,12 +50,23 @@ import { } from "../session/agent/errors"; import { DEFAULT_TAB_WIDTH, parseTabWidth } from "../core/run/tabWidth"; import { resolveCliVersion } from "../core/run/version"; +import { + DEFAULT_WHEEL_SCROLL_LINES, + parseWheelScrollLines, + type WheelScrollLines, +} from "../core/run/wheelScrollLines"; /** Structured option metadata shared by Commander registration and generated CLI docs. */ export interface CliReferenceOption { readonly flag: string; readonly description: string; - readonly parse?: "layout" | "cursorLine" | "positiveInt" | "tabWidth" | "collect"; + readonly parse?: + | "layout" + | "cursorLine" + | "positiveInt" + | "tabWidth" + | "wheelScrollLines" + | "collect"; readonly defaultValue?: string; /** Default applied directly by Commander (as opposed to a config-resolved default). */ readonly commanderDefault?: string; @@ -97,6 +108,12 @@ export const COMMON_REVIEW_OPTIONS = [ parse: "tabWidth", defaultValue: String(DEFAULT_TAB_WIDTH), }, + { + flag: "--wheel-scroll-lines ", + description: "rows per wheel event: auto or 1-10", + parse: "wheelScrollLines", + defaultValue: DEFAULT_WHEEL_SCROLL_LINES, + }, { flag: "--wrap", description: "wrap long diff lines" }, { flag: "--no-wrap", description: "truncate long diff lines to one row" }, { flag: "--hunk-headers", description: "show hunk metadata rows" }, @@ -351,6 +368,7 @@ function buildCommonOptions( fast?: boolean; transparentBackground?: boolean; tabWidth?: number; + wheelScrollLines?: WheelScrollLines; extension?: string[]; }, argv: string[], @@ -374,6 +392,7 @@ function buildCommonOptions( ), lineNumbers: resolveBooleanFlag(argv, "--line-numbers", "--no-line-numbers"), tabWidth: options.tabWidth, + wheelScrollLines: options.wheelScrollLines, wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"), hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"), sidebar: resolveBooleanFlag(argv, "--sidebar", "--no-sidebar"), @@ -398,6 +417,8 @@ function applyReferenceOption(command: Command, option: CliReferenceOption) { commanderOption.argParser(parsePositiveInt); } else if (option.parse === "tabWidth") { commanderOption.argParser(parseTabWidth); + } else if (option.parse === "wheelScrollLines") { + commanderOption.argParser(parseWheelScrollLines); } else if (option.parse === "collect") { commanderOption.argParser(collectRepeatedValue); } diff --git a/src/core/bootstrap.ts b/src/core/bootstrap.ts index bb54be443..958aa3cb2 100644 --- a/src/core/bootstrap.ts +++ b/src/core/bootstrap.ts @@ -15,6 +15,7 @@ import type { NamedCustomThemeConfig } from "../extension-api/types"; import type { Changeset } from "./changeset/model"; import type { CliInput, CursorLine, LayoutMode, SidebarVisibility } from "./run/commandInputs"; import type { UserKeyBinding } from "./run/config"; +import type { WheelScrollLines } from "./run/wheelScrollLines"; import type { StartupNotice } from "./process/startupNotice"; import type { TerminalThemeMode } from "./theme/detection"; import type { VcsCatalog } from "./vcs/types"; @@ -45,6 +46,7 @@ export interface AppBootstrap { customThemes?: readonly NamedCustomThemeConfig[]; initialShowLineNumbers?: boolean; initialTabWidth?: number; + initialWheelScrollLines?: WheelScrollLines; initialWrapLines?: boolean; initialShowHunkHeaders?: boolean; initialShowMenuBar?: boolean; diff --git a/src/core/changeset/loaders.ts b/src/core/changeset/loaders.ts index e979d3d0c..7ba412612 100644 --- a/src/core/changeset/loaders.ts +++ b/src/core/changeset/loaders.ts @@ -16,6 +16,7 @@ import { createFileSourceFetcher, type FileSourceSpec } from "./fileSource"; import { changesetFromPatch } from "./fromPatch"; import { DEFAULT_TAB_WIDTH } from "../run/tabWidth"; +import { DEFAULT_WHEEL_SCROLL_LINES } from "../run/wheelScrollLines"; import { getConfiguredVcsAdapter, isVcsReviewInput, @@ -330,6 +331,7 @@ export async function loadAppBootstrap( customThemes, initialShowLineNumbers: input.options.lineNumbers ?? true, initialTabWidth: input.options.tabWidth ?? DEFAULT_TAB_WIDTH, + initialWheelScrollLines: input.options.wheelScrollLines ?? DEFAULT_WHEEL_SCROLL_LINES, initialWrapLines: input.options.wrapLines ?? false, initialShowHunkHeaders: input.options.hunkHeaders ?? true, initialShowMenuBar: input.options.menuBar ?? true, diff --git a/src/core/run/commandInputs.ts b/src/core/run/commandInputs.ts index 54d4b58b3..ab1915c7a 100644 --- a/src/core/run/commandInputs.ts +++ b/src/core/run/commandInputs.ts @@ -14,6 +14,7 @@ import type { ExtensionVcsStashShowInput, } from "../../extension-api/types"; import type { InstallSource } from "../install/installSource"; +import type { WheelScrollLines } from "./wheelScrollLines"; export type LayoutMode = "auto" | "split" | "stack"; export type CursorLine = "row" | "number" | "off"; @@ -35,6 +36,7 @@ export interface CommonOptions { excludeUntracked?: boolean; lineNumbers?: boolean; tabWidth?: number; + wheelScrollLines?: WheelScrollLines; wrapLines?: boolean; hunkHeaders?: boolean; menuBar?: boolean; diff --git a/src/core/run/config.test.ts b/src/core/run/config.test.ts index 5ebe1adc0..6ca226356 100644 --- a/src/core/run/config.test.ts +++ b/src/core/run/config.test.ts @@ -328,6 +328,43 @@ describe("config resolution", () => { } }); + test("resolves wheel scroll lines from user config and CLI but not repository config", () => { + const home = createTempDir("hunk-config-wheel-home-"); + const repo = createTempDir("hunk-config-wheel-repo-"); + createRepo(repo); + const input = createPatchPagerInput(); + const env = { HOME: home }; + + expect( + resolveConfiguredCliInput(input, { cwd: repo, env }).input.options.wheelScrollLines, + ).toBe("auto"); + + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync(join(home, ".config", "hunk", "config.toml"), "wheel_scroll_lines = 3\n"); + mkdirSync(join(repo, ".hunk"), { recursive: true }); + writeFileSync(join(repo, ".hunk", "config.toml"), "wheel_scroll_lines = 8\n"); + + expect( + resolveConfiguredCliInput(input, { cwd: repo, env }).input.options.wheelScrollLines, + ).toBe(3); + expect( + resolveConfiguredCliInput(createPatchPagerInput({ wheelScrollLines: 6 }), { + cwd: repo, + env, + }).input.options.wheelScrollLines, + ).toBe(6); + + for (const invalid of ["0", "11", '"fast"']) { + writeFileSync( + join(home, ".config", "hunk", "config.toml"), + `wheel_scroll_lines = ${invalid}\n`, + ); + expect(() => resolveConfiguredCliInput(input, { cwd: repo, env })).toThrow( + /wheel_scroll_lines/, + ); + } + }); + test("resolves the sidebar preference from config, CLI flags, and the auto default", () => { const home = createTempDir("hunk-config-home-"); const repo = createTempDir("hunk-config-repo-"); @@ -1037,6 +1074,7 @@ describe("config resolution", () => { 'theme = "github-light-default"', "line_numbers = false", "tab_width = 8", + "wheel_scroll_lines = 4", "wrap_lines = true", "menu_bar = false", "sidebar = true", @@ -1066,6 +1104,7 @@ describe("config resolution", () => { expect(bootstrap.initialTheme).toBe("github-light-default"); expect(bootstrap.initialShowLineNumbers).toBe(false); expect(bootstrap.initialTabWidth).toBe(8); + expect(bootstrap.initialWheelScrollLines).toBe(4); expect(bootstrap.initialWrapLines).toBe(true); expect(bootstrap.initialShowMenuBar).toBe(false); expect(bootstrap.initialSidebar).toBe(true); diff --git a/src/core/run/config.ts b/src/core/run/config.ts index d04fc614d..2aa83b88f 100644 --- a/src/core/run/config.ts +++ b/src/core/run/config.ts @@ -19,6 +19,7 @@ import { import { resolveGlobalConfigPath } from "./paths"; import { LEGACY_CUSTOM_SYNTAX_NOTICES, type StartupNotice } from "../process/startupNotice"; import { DEFAULT_TAB_WIDTH, validateTabWidth } from "./tabWidth"; +import { DEFAULT_WHEEL_SCROLL_LINES, validateWheelScrollLines } from "./wheelScrollLines"; import { findProjectRootCandidate } from "../process/projectRoot"; import { createVcsCatalog, detectVcs } from "../vcs"; import type { VcsCatalog } from "../vcs/types"; @@ -248,6 +249,19 @@ function normalizeTabWidth(value: unknown) { return validateTabWidth(value, "tab_width"); } +/** Accept `auto` or a bounded integer wheel step from TOML configuration. */ +function normalizeWheelScrollLines(value: unknown) { + if (value === undefined || value === DEFAULT_WHEEL_SCROLL_LINES) { + return value; + } + + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new Error("Expected wheel_scroll_lines to be auto or an integer from 1 to 10."); + } + + return validateWheelScrollLines(value, "wheel_scroll_lines"); +} + /** One top-level configuration key shared by runtime parsing and generated reference docs. */ export interface ConfigReferenceOption { readonly key: string; @@ -262,6 +276,8 @@ export interface ConfigReferenceOption { readonly aliases?: readonly { key: string; deprecated?: boolean }[]; /** Ordered source keys preserve compatibility precedence where an old alias historically won. */ readonly runtimeKeys?: readonly string[]; + /** Machine-local input preferences do not resolve from repository config. */ + readonly userOnly?: boolean; } /** @@ -335,6 +351,16 @@ export const CONFIG_REFERENCE_OPTIONS: readonly ConfigReferenceOption[] = [ runtimeDefault: DEFAULT_TAB_WIDTH, description: "Set terminal-cell tab stops used for display and wrapping.", }, + { + key: "wheel_scroll_lines", + property: "wheelScrollLines", + type: "string or integer", + accepted: "`auto` or 1 through 10", + runtimeDefault: DEFAULT_WHEEL_SCROLL_LINES, + description: + "Set review rows per vertical wheel event. `auto` keeps cadence-based acceleration from one to three rows.", + userOnly: true, + }, { key: "wrap_lines", property: "wrapLines", @@ -894,6 +920,8 @@ function normalizeConfigReferenceValue(property: keyof CommonOptions, value: unk return normalizeString(value); case "tabWidth": return normalizeTabWidth(value); + case "wheelScrollLines": + return normalizeWheelScrollLines(value); case "sidebar": return normalizeSidebarVisibility(value); default: @@ -902,11 +930,18 @@ function normalizeConfigReferenceValue(property: keyof CommonOptions, value: unk } /** Read the view preferences stored at one TOML object level. */ -function readConfigPreferences(source: Record): CommonOptions { +function readConfigPreferences( + source: Record, + { includeUserOnly = true }: { includeUserOnly?: boolean } = {}, +): CommonOptions { const preferences: CommonOptions = {}; const mutable = preferences as Record; for (const option of CONFIG_REFERENCE_OPTIONS) { + if (option.userOnly && !includeUserOnly) { + continue; + } + const runtimeKeys = option.runtimeKeys ?? [ option.key, ...(option.aliases?.map(({ key }) => key) ?? []), @@ -952,6 +987,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti excludeUntracked: overrides.excludeUntracked ?? base.excludeUntracked, lineNumbers: overrides.lineNumbers ?? base.lineNumbers, tabWidth: overrides.tabWidth ?? base.tabWidth, + wheelScrollLines: overrides.wheelScrollLines ?? base.wheelScrollLines, wrapLines: overrides.wrapLines ?? base.wrapLines, hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders, menuBar: overrides.menuBar ?? base.menuBar, @@ -968,17 +1004,21 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti } /** Apply one parsed config object, including command/pager sections, to the current invocation. */ -function resolveConfigLayer(source: Record, input: CliInput): CommonOptions { - let resolved = readConfigPreferences(source); +function resolveConfigLayer( + source: Record, + input: CliInput, + { includeUserOnly = true }: { includeUserOnly?: boolean } = {}, +): CommonOptions { + let resolved = readConfigPreferences(source, { includeUserOnly }); const commandSection = CONFIG_COMMAND_SECTIONS[input.kind] ? source[input.kind] : undefined; if (isRecord(commandSection)) { - resolved = mergeOptions(resolved, readConfigPreferences(commandSection)); + resolved = mergeOptions(resolved, readConfigPreferences(commandSection, { includeUserOnly })); } const pagerSection = source.pager; if (input.options.pager && isRecord(pagerSection)) { - resolved = mergeOptions(resolved, readConfigPreferences(pagerSection)); + resolved = mergeOptions(resolved, readConfigPreferences(pagerSection, { includeUserOnly })); } return resolved; @@ -1152,7 +1192,7 @@ export function resolveConfiguredCliInput( if (repoConfigPath) { const repoConfig = readTomlRecord(repoConfigPath); - const repoLayer = resolveConfigLayer(repoConfig, input); + const repoLayer = resolveConfigLayer(repoConfig, input, { includeUserOnly: false }); explicitVcsId = repoLayer.vcs ?? explicitVcsId; resolvedOptions = mergeOptions(resolvedOptions, repoLayer); applyCustomThemeLayer(readCustomThemes(repoConfig)); @@ -1174,6 +1214,7 @@ export function resolveConfiguredCliInput( mode: resolvedOptions.mode ?? DEFAULT_VIEW_PREFERENCES.mode, lineNumbers: resolvedOptions.lineNumbers ?? DEFAULT_VIEW_PREFERENCES.showLineNumbers, tabWidth: resolvedOptions.tabWidth ?? DEFAULT_TAB_WIDTH, + wheelScrollLines: resolvedOptions.wheelScrollLines ?? DEFAULT_WHEEL_SCROLL_LINES, wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines, hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders, menuBar: resolvedOptions.menuBar ?? DEFAULT_VIEW_PREFERENCES.showMenuBar, diff --git a/src/core/run/wheelScrollLines.test.ts b/src/core/run/wheelScrollLines.test.ts new file mode 100644 index 000000000..217e51fa4 --- /dev/null +++ b/src/core/run/wheelScrollLines.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { + DEFAULT_WHEEL_SCROLL_LINES, + parseWheelScrollLines, + validateWheelScrollLines, +} from "./wheelScrollLines"; + +describe("wheel scroll lines", () => { + test("accepts auto and bounded integer CLI values", () => { + expect(parseWheelScrollLines("auto")).toBe(DEFAULT_WHEEL_SCROLL_LINES); + expect(parseWheelScrollLines("1")).toBe(1); + expect(parseWheelScrollLines("10")).toBe(10); + }); + + test("rejects malformed and out-of-range values", () => { + for (const value of ["0", "11", "1.5", "fast"]) { + expect(() => parseWheelScrollLines(value)).toThrow(/wheel scroll lines/); + } + + expect(() => validateWheelScrollLines(Number.NaN)).toThrow(/wheel scroll lines/); + }); +}); diff --git a/src/core/run/wheelScrollLines.ts b/src/core/run/wheelScrollLines.ts new file mode 100644 index 000000000..288f6a73c --- /dev/null +++ b/src/core/run/wheelScrollLines.ts @@ -0,0 +1,33 @@ +export const DEFAULT_WHEEL_SCROLL_LINES = "auto" as const; +export const MIN_WHEEL_SCROLL_LINES = 1; +export const MAX_WHEEL_SCROLL_LINES = 10; + +export type WheelScrollLines = typeof DEFAULT_WHEEL_SCROLL_LINES | number; + +/** Validate one wheel-scroll preference while keeping each event within a practical range. */ +export function validateWheelScrollLines(value: number, label = "wheel scroll lines") { + if ( + !Number.isSafeInteger(value) || + value < MIN_WHEEL_SCROLL_LINES || + value > MAX_WHEEL_SCROLL_LINES + ) { + throw new Error( + `Invalid ${label}: ${String(value)} (expected ${MIN_WHEEL_SCROLL_LINES}-${MAX_WHEEL_SCROLL_LINES} or auto)`, + ); + } + + return value; +} + +/** Parse one CLI wheel-scroll argument. */ +export function parseWheelScrollLines(value: string): WheelScrollLines { + if (value === DEFAULT_WHEEL_SCROLL_LINES) { + return value; + } + + if (!/^[1-9]\d*$/.test(value)) { + throw new Error(`Invalid wheel scroll lines: ${value}`); + } + + return validateWheelScrollLines(Number(value)); +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d53a9f600..a0674eb3d 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -2403,6 +2403,7 @@ export function App({ showHunkHeaders={showHunkHeaders} sourceStatusByFileId={review.sourceStatusByFileId} tabWidth={tabWidth} + wheelScrollLines={bootstrap.initialWheelScrollLines} wrapLines={wrapLines} wrapToggleScrollTop={wrapToggleScrollTopRef.current} layoutToggleScrollTop={layoutToggleScrollTopRef.current} diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index 1cc7725bd..aa6aa1d0e 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -15,6 +15,10 @@ import { type RefObject, } from "react"; import { DEFAULT_TAB_WIDTH } from "../../../core/run/tabWidth"; +import { + DEFAULT_WHEEL_SCROLL_LINES, + type WheelScrollLines, +} from "../../../core/run/wheelScrollLines"; import type { DiffFile } from "../../../core/changeset/model"; import type { CursorLine, LayoutMode } from "../../../core/run/commandInputs"; import type { UserNoteLineTarget } from "../../../core/liveComments"; @@ -289,6 +293,7 @@ export function DiffPane({ showHunkHeaders, sourceStatusByFileId = EMPTY_SOURCE_STATUS_BY_FILE_ID, tabWidth = DEFAULT_TAB_WIDTH, + wheelScrollLines = DEFAULT_WHEEL_SCROLL_LINES, wrapLines, wrapToggleScrollTop, layoutToggleScrollTop = null, @@ -352,6 +357,7 @@ export function DiffPane({ showHunkHeaders: boolean; sourceStatusByFileId?: Record; tabWidth?: number; + wheelScrollLines?: WheelScrollLines; wrapLines: boolean; wrapToggleScrollTop: number | null; layoutToggleScrollTop?: number | null; @@ -387,8 +393,8 @@ export function DiffPane({ const renderTopChrome = showTopChrome ?? !pagerMode; const renderer = useRenderer(); const mouseWheelScrollAcceleration = useMemo( - () => createReviewMouseWheelScrollAcceleration(), - [], + () => createReviewMouseWheelScrollAcceleration(wheelScrollLines), + [wheelScrollLines], ); const [currentLineRowPlan, setCurrentLineRowPlan] = useState<{ source: { file: DiffFile; theme: AppTheme; tabWidth: number }; diff --git a/src/ui/lib/scrollAcceleration.test.ts b/src/ui/lib/scrollAcceleration.test.ts new file mode 100644 index 000000000..632bf548c --- /dev/null +++ b/src/ui/lib/scrollAcceleration.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { createReviewMouseWheelScrollAcceleration } from "./scrollAcceleration"; + +describe("review mouse wheel acceleration", () => { + test("keeps the first auto tick precise", () => { + const acceleration = createReviewMouseWheelScrollAcceleration("auto"); + + expect(acceleration.tick(1_000)).toBe(1); + }); + + test("returns an exact configured row count for every wheel event", () => { + const acceleration = createReviewMouseWheelScrollAcceleration(3); + + expect(acceleration.tick(1_000)).toBe(3); + expect(acceleration.tick(1_001)).toBe(3); + acceleration.reset(); + expect(acceleration.tick(2_000)).toBe(3); + }); +}); diff --git a/src/ui/lib/scrollAcceleration.ts b/src/ui/lib/scrollAcceleration.ts index cdd616002..c3a75270d 100644 --- a/src/ui/lib/scrollAcceleration.ts +++ b/src/ui/lib/scrollAcceleration.ts @@ -1,12 +1,22 @@ import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"; +import { DEFAULT_WHEEL_SCROLL_LINES, type WheelScrollLines } from "../../core/run/wheelScrollLines"; /** - * Keep the first wheel tick precise, then ramp up during sustained bursts. + * Resolve wheel movement from the user's fixed row count or Hunk's cadence-based acceleration. * - * This matches the general pattern used by terminal UIs better than scaling by total diff size: - * short diffs stay controllable, while long repeated wheel gestures still speed up. + * Auto mode keeps the first tick precise, then ramps up during sustained bursts. A numeric + * preference returns that exact row count for every event so coarse wheels remain predictable. */ -export function createReviewMouseWheelScrollAcceleration(): ScrollAcceleration { +export function createReviewMouseWheelScrollAcceleration( + lines: WheelScrollLines = DEFAULT_WHEEL_SCROLL_LINES, +): ScrollAcceleration { + if (lines !== DEFAULT_WHEEL_SCROLL_LINES) { + return { + tick: () => lines, + reset: () => {}, + }; + } + return new MacOSScrollAccel({ A: 0.4, tau: 4, diff --git a/test/pty/harness.ts b/test/pty/harness.ts index ddf12aa80..a0c60936f 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -90,6 +90,35 @@ export async function measureKeyScroll(session: Session, key: Key, anchorRow: nu return anchorRow - movedTo; } +/** Count how many rows one mouse-wheel event moves the review stream. */ +export async function measureMouseWheelScroll( + session: Session, + direction: "down" | "up", + anchorRow: number, +) { + const before = (await session.text({ immediate: true })).split("\n"); + const anchor = before[anchorRow]?.trim() ?? ""; + if (anchor.length === 0) { + throw new Error(`measureMouseWheelScroll: anchor row ${anchorRow} is empty.`); + } + + if (direction === "down") { + await session.scrollDown(1); + } else { + await session.scrollUp(1); + } + + const after = (await session.text({ immediate: true })).split("\n"); + const movedTo = after.findIndex((line) => line.trim() === anchor); + if (movedTo < 0) { + throw new Error( + `measureMouseWheelScroll: anchor ${JSON.stringify(anchor)} left the screen after scrolling ${direction}.`, + ); + } + + return anchorRow - movedTo; +} + /** Send an SGR mouse motion event at zero-based terminal coordinates. */ export async function moveMouse(session: Session, x: number, y: number) { session.writeRaw(`\x1b[<35;${x + 1};${y + 1}M`); diff --git a/test/pty/scroll.test.ts b/test/pty/scroll.test.ts index 1da31e5c0..91c8be12a 100644 --- a/test/pty/scroll.test.ts +++ b/test/pty/scroll.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { createPtyHarness, dragMouse, lineIndexOf, measureKeyScroll } from "./harness"; +import { + createPtyHarness, + dragMouse, + lineIndexOf, + measureKeyScroll, + measureMouseWheelScroll, +} from "./harness"; const harness = createPtyHarness(); @@ -72,6 +78,26 @@ describe("PTY scrolling", () => { } }); + test("a fixed wheel-scroll setting moves exactly that many rows per event", async () => { + const fixture = harness.createPagerPatchFixture(60); + const session = await harness.launchHunkWithFileBackedStdin({ + stdinFile: fixture.patchFile, + args: ["pager", "--cursor-line", "off", "--wheel-scroll-lines", "3"], + cols: 140, + rows: 24, + }); + + try { + await session.waitForText(/scroll\.ts/, { timeout: 15_000 }); + await session.waitIdle({ timeout: 300 }); + + expect(await measureMouseWheelScroll(session, "down", 12)).toBe(3); + expect(await measureMouseWheelScroll(session, "up", 9)).toBe(-3); + } finally { + session.close(); + } + }); + test("step keys still move one row after a click in the review stream", async () => { const fixture = harness.createPinnedHeaderRepoFixture(); const session = await harness.launchHunk({ diff --git a/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md index 1c1529b00..69836c39e 100644 --- a/website/src/content/docs/docs/reference/cli.md +++ b/website/src/content/docs/docs/reference/cli.md @@ -16,30 +16,31 @@ This reference is generated from the command metadata used by Hunk itself. Run ` ## Common review options -| Option | Description | -| --------------------------- | --------------------------------------------------------------- | -| `--mode ` | layout mode: auto, split, stack | -| `--cursor-line