diff --git a/Sources/CodecCore/Preferences.swift b/Sources/CodecCore/Preferences.swift index 112f82d..397dd3b 100644 --- a/Sources/CodecCore/Preferences.swift +++ b/Sources/CodecCore/Preferences.swift @@ -38,6 +38,11 @@ public final class Preferences: ObservableObject { @Published public var shellPath: String { didSet { store(shellPath, .shellPath) } } @Published public var showSystemStats: Bool { didSet { store(showSystemStats, .showSystemStats) } } @Published public var prompt: PromptConfiguration { didSet { storePrompt() } } + /// How far the trackpad and the mouse wheel scroll, as a multiple of the system's own rate. At 1×, + /// SwiftTerm's, the scrollback follows the fingers and a wheel notch moves one line: many swipes to + /// get through a long output. + @Published public var scrollSpeed: Double { didSet { store(scrollSpeed, .scrollSpeed) } } + public static let scrollSpeeds: ClosedRange = 1...5 /// Copies text to the clipboard as soon as it's selected. @Published public var copyOnSelect: Bool { didSet { store(copyOnSelect, .copyOnSelect) } } /// Shows the rest of a matching earlier command, dimmed, as you type (zsh). @@ -74,7 +79,7 @@ public final class Preferences: ObservableObject { codecSounds, paneSounds, soundVolume, optionAsMeta, exitBehavior, shellIntegration, shellPath, showSystemStats, prompt, copyOnSelect, suggestCommands, historyKeys, friendlyCompletion, colorFileListings, ezaForLs, recordHistory, restoreWindows, restorePaneContents, showUsualDuration, activateVenvs, explainFailures, - checkForUpdates, commandSeparators, shadeAlternateCommands + checkForUpdates, commandSeparators, shadeAlternateCommands, scrollSpeed } private static let factoryDefaults: [Key: Any] = [ @@ -97,6 +102,7 @@ public final class Preferences: ObservableObject { .shellIntegration: true, .shellPath: "", .showSystemStats: true, + .scrollSpeed: 2.0, .copyOnSelect: false, .suggestCommands: true, .historyKeys: true, @@ -134,6 +140,7 @@ public final class Preferences: ObservableObject { shellIntegration = defaults.bool(forKey: Key.shellIntegration.rawValue) shellPath = defaults.string(forKey: Key.shellPath.rawValue) ?? "" showSystemStats = defaults.bool(forKey: Key.showSystemStats.rawValue) + scrollSpeed = Self.clamp(defaults.double(forKey: Key.scrollSpeed.rawValue), Self.scrollSpeeds, fallback: 2) copyOnSelect = defaults.bool(forKey: Key.copyOnSelect.rawValue) suggestCommands = defaults.bool(forKey: Key.suggestCommands.rawValue) historyKeys = defaults.bool(forKey: Key.historyKeys.rawValue) diff --git a/Sources/CodecUI/Pane.swift b/Sources/CodecUI/Pane.swift index 16d73ba..a89e898 100644 --- a/Sources/CodecUI/Pane.swift +++ b/Sources/CodecUI/Pane.swift @@ -1439,6 +1439,7 @@ final class Pane: LocalProcessTerminalViewDelegate { terminal.selectedTextBackgroundColor = theme.selection terminal.installColors(theme.terminalPalette) terminal.optionAsMetaKey = preferences.optionAsMeta + terminal.scrollSensitivity = CGFloat(preferences.scrollSpeed) view.apply(theme: theme, scanlines: preferences.scanlines) refreshCommandSeparators() } diff --git a/Sources/CodecUI/SettingsView.swift b/Sources/CodecUI/SettingsView.swift index 99217d3..2f11cfe 100644 --- a/Sources/CodecUI/SettingsView.swift +++ b/Sources/CodecUI/SettingsView.swift @@ -258,6 +258,21 @@ private struct ShellSettings: View { Text("Codec keeps where each command ran, how long it took and whether it failed, alongside your shell’s own history. Commands typed with a leading space aren’t kept. Search it with ⌘Y.") .foregroundStyle(.secondary) } + Section { + LabeledContent("Speed") { + HStack { + Slider(value: $prefs.scrollSpeed, in: Preferences.scrollSpeeds, step: 0.5) + Text(prefs.scrollSpeed.formatted(.number.precision(.fractionLength(0...1))) + "×") + .monospacedDigit() + .frame(width: 44, alignment: .trailing) + } + } + } header: { + Text("Scrolling") + } footer: { + Text("How far the trackpad and the mouse wheel scroll: at 1× the scrollback follows your fingers and moves a line for each notch of the wheel. Also applies in full-screen programs such as vim and less.") + .foregroundStyle(.secondary) + } Section { Toggle("Copy text as soon as it’s selected", isOn: $prefs.copyOnSelect) } header: { diff --git a/Tests/CodecCoreTests/PoliciesTests.swift b/Tests/CodecCoreTests/PoliciesTests.swift index ca38252..7c50201 100644 --- a/Tests/CodecCoreTests/PoliciesTests.swift +++ b/Tests/CodecCoreTests/PoliciesTests.swift @@ -77,6 +77,7 @@ struct PreferencesTests { #expect(!preferences.scanlines) #expect(preferences.commandSeparators) #expect(preferences.shadeAlternateCommands) + #expect(preferences.scrollSpeed == 2) } @Test func persistsChangesAcrossInstances() { @@ -88,12 +89,14 @@ struct PreferencesTests { first.bell = .codecLock first.exitBehavior = .keepOpen first.shellPath = "/bin/bash" + first.scrollSpeed = 3.5 let second = Preferences(defaults: defaults) #expect(second.themeID == "amber") #expect(second.fontSize == 15) #expect(second.bell == .codecLock) #expect(second.exitBehavior == .keepOpen) #expect(second.shellPath == "/bin/bash") + #expect(second.scrollSpeed == 3.5) } @Test func repairsInvalidStoredValues() { @@ -102,10 +105,12 @@ struct PreferencesTests { defaults.set(500.0, forKey: "fontSize") defaults.set("loud", forKey: "bell") defaults.set(-2.0, forKey: "opacity") + defaults.set(0.0, forKey: "scrollSpeed") let preferences = Preferences(defaults: defaults) #expect(preferences.fontSize == 13) #expect(preferences.bell == .visual) #expect(preferences.opacity == 0.9) + #expect(preferences.scrollSpeed == 2) } @Test func postsAChangeNotification() { diff --git a/Tests/CodecUITests/PaneTests.swift b/Tests/CodecUITests/PaneTests.swift index 74d208e..c3f369b 100644 --- a/Tests/CodecUITests/PaneTests.swift +++ b/Tests/CodecUITests/PaneTests.swift @@ -242,6 +242,35 @@ struct PaneTests { pane.changeFontSize(by: -100) #expect(pane.fontSize == 7) } + + // MARK: Scrolling + + @Test func aWheelNotchScrollsAsFarAsTheSettingSays() async throws { + let env = try TestEnvironment() + defer { env.tearDown() } + let pane = env.makePane() + defer { pane.shutDown() } + let window = makeTestWindow() + defer { window.close() } + window.contentView = pane.view + #expect(await waitUntil { pane.terminal.process.running }) + pane.terminal.feed(text: (1...200).map { "line \($0)\r\n" }.joined()) + func top() -> Int? { pane.terminal.terminalContentSnapshot(region: .viewport)?.rows.first?.absoluteRow } + func notchUp() throws { + let wheel = try #require(CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 1, wheel1: 1, wheel2: 0, wheel3: 0)) + pane.terminal.scrollWheel(with: try #require(NSEvent(cgEvent: wheel))) + } + let bottom = try #require(top()) + #expect(bottom > 10) + + // Twice the system's rate unless set otherwise. + try notchUp() + #expect(top() == bottom - 2) + env.preferences.scrollSpeed = 4 + pane.applyPreferences() + try notchUp() + #expect(top() == bottom - 6) + } } @Suite("Pane context menu", .serialized, .timeLimit(.minutes(1))) diff --git a/docs/command-line.md b/docs/command-line.md index cc9e770..3cf6d88 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -27,7 +27,7 @@ Hold ⌘ to see what opens. Every file and folder name on screen is washed in th ![⌘ held over the output of ls: every name marked, one tagged with the file that opens](images/open-file.png) -Copy on select is a setting (Settings › Shell › Selection). The scrollback keeps 10,000 lines; ⌘K clears it. +Copy on select is a setting (Settings › Shell › Selection). The scrollback keeps 10,000 lines; ⌘K clears it. The trackpad and the mouse wheel scroll it twice as far as elsewhere on the Mac; Settings › Shell › Scrolling sets how far. ⌘F searches the scrollback. Every match on screen is tinted and the current one is outlined; the bar counts them ("3 of 14"), and marks down the pane's right edge show where the rest are. Return or ⌘G goes to the next match, ⇧Return or ⇧⌘G to the previous one (↓ and ↑ do too), wrapping around; Esc closes the bar. **Aa** matches case, **ab** whole words, **.\*** a regular expression. With text selected, ⌘F (or ⌘E) searches for it. Matches keep up as the pane prints more. diff --git a/docs/settings.md b/docs/settings.md index 67addcf..4aa613e 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -21,6 +21,7 @@ Open Settings with ⌘,. Changes apply as you make them, unless a setting says i - **Project environments**: whether to [activate a project's Python environment](prompt-and-status-bar.md#project-environments-zsh) (`.venv`) when you go into it, and your answer for each project: change it, forget it so Codec asks again, or remove the ones about projects that can't be found anymore, which are flagged. - **Apple Intelligence**: whether failed commands offer to be [explained](commands-sidebar.md#why-did-it-fail) by Apple's on-device model. - **History**: whether Codec records the commands you run, and a button to forget everything recorded so far. Forgetting also hides older commands from your shell's history file in Codec, without changing the file. +- **Scrolling**: how far the trackpad and the mouse wheel scroll, from 1× (the scrollback follows your fingers, a line per notch of the wheel) to 5×. 2× by default. - **Selection**: copy text as soon as it's selected. ## Prompt