From 6eb4ebb60bf58a2aee71834b54b1da4cde0cbed4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 19 May 2026 18:33:03 -0700 Subject: [PATCH] Add smooth terminal scrollback --- Resources/Localizable.xcstrings | 68 +++++++ Sources/App/WorkspaceRuntimeSettings.swift | 17 ++ Sources/CmuxSettingsJSONPathSupport.swift | 1 + .../CommandPaletteSettingsToggle.swift | 14 ++ Sources/GhosttyTerminalView.swift | 184 ++++++++++++++---- ...rdShortcutSettingsFileStore+Template.swift | 1 + .../KeyboardShortcutSettingsFileStore.swift | 10 + Sources/SettingsNavigation.swift | 2 + Sources/SettingsSearchAliases.swift | 1 + Sources/cmuxApp.swift | 36 ++++ .../CommandPaletteSettingsToggleTests.swift | 28 +++ ...hortcutSettingsFileStoreStartupTests.swift | 51 +++++ cmuxTests/SettingsSearchIndexTests.swift | 8 + ghostty | 2 +- scripts/ghosttykit-checksums.txt | 1 + web/app/[locale]/docs/configuration/page.tsx | 1 + web/data/cmux.schema.json | 5 + 17 files changed, 392 insertions(+), 38 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 528fb35c76..27be26063d 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -77105,6 +77105,23 @@ } } }, + "settings.search.alias.setting.terminal.smooth-scrolling": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "terminal.smoothScrolling smooth scroll scrolling pixel precise trackpad magic mouse scrollback" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "terminal.smoothScrolling smooth scroll scrolling pixel precise trackpad magic mouse scrollback ターミナル スムーズ スクロール トラックパッド" + } + } + } + }, "settings.search.alias.setting.sidebarAppearance.match-terminal": { "extractionState": "manual", "localizations": { @@ -107103,6 +107120,57 @@ } } }, + "settings.terminal.smoothScrolling": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Smooth Terminal Scrolling" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルのスムーズスクロール" + } + } + } + }, + "settings.terminal.smoothScrolling.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uses Ghostty's row-based scrollback handling for terminal wheel gestures." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルのホイールジェスチャには Ghostty の行単位スクロールバック処理を使います。" + } + } + } + }, + "settings.terminal.smoothScrolling.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uses pixel-precise scrollback for trackpads and Magic Mouse gestures. Mouse-reporting terminal apps keep their own wheel handling." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トラックパッドと Magic Mouse のジェスチャではピクセル単位のスクロールバックを使います。マウス入力を扱うターミナルアプリは独自のホイール処理を維持します。" + } + } + } + }, "settings.terminal.agentAutoResume": { "extractionState": "manual", "localizations": { diff --git a/Sources/App/WorkspaceRuntimeSettings.swift b/Sources/App/WorkspaceRuntimeSettings.swift index 848c1635b5..528881d9ab 100644 --- a/Sources/App/WorkspaceRuntimeSettings.swift +++ b/Sources/App/WorkspaceRuntimeSettings.swift @@ -109,6 +109,23 @@ enum TerminalScrollBarSettings { } } +enum TerminalSmoothScrollingSettings { + static let enabledKey = "terminal.smoothScrolling" + static let defaultEnabled = true + static let didChangeNotification = Notification.Name("cmux.terminalSmoothScrollingSettingsDidChange") + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: enabledKey) == nil { + return defaultEnabled + } + return defaults.bool(forKey: enabledKey) + } + + static func notifyDidChange(notificationCenter: NotificationCenter = .default) { + notificationCenter.post(name: didChangeNotification, object: nil) + } +} + enum AgentSessionAutoResumeSettings { static let autoResumeAgentSessionsKey = "terminal.autoResumeAgentSessions" static let defaultAutoResumeAgentSessions = true diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index e500825754..d8694cf049 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -62,6 +62,7 @@ extension CmuxSettingsFileStore { "app.renameSelectsExistingName", "app.commandPaletteSearchesAllSurfaces", "terminal.showScrollBar", + "terminal.smoothScrolling", "terminal.autoResumeAgentSessions", "notifications.dockBadge", "notifications.showInMenuBar", diff --git a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift index c30733d73a..6c3fb54520 100644 --- a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift +++ b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift @@ -364,6 +364,20 @@ enum CommandPaletteSettingsToggleCommands { TerminalScrollBarSettings.notifyDidChange(notificationCenter: notificationCenter) } ), + CommandPaletteSettingToggleDescriptor( + commandId: commandIdPrefix + "terminalSmoothScrolling", + settingsKey: "terminal.smoothScrolling", + title: { + String(localized: "settings.terminal.smoothScrolling", defaultValue: "Smooth Terminal Scrolling") + }, + sectionTitle: terminal, + keywords: ["terminal.smoothScrolling", "terminal", "smooth", "scroll", "scrollback", "trackpad"], + defaultValue: TerminalSmoothScrollingSettings.defaultEnabled, + defaultsKey: TerminalSmoothScrollingSettings.enabledKey, + didSet: { _, _, notificationCenter in + TerminalSmoothScrollingSettings.notifyDidChange(notificationCenter: notificationCenter) + } + ), CommandPaletteSettingToggleDescriptor( commandId: commandIdPrefix + "autoResumeAgentSessions", settingsKey: "terminal.autoResumeAgentSessions", diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3e044614df..64fc11a2d8 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9736,9 +9736,25 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func scrollWheel(with event: NSEvent) { + guard shouldForwardWheelToGhostty(event) else { + recordWheelScrollActivity(event) + super.scrollWheel(with: event) + return + } + sendWheelScrollToGhostty(event) + } + + fileprivate func shouldForwardWheelToGhostty(_: NSEvent) -> Bool { + guard TerminalSmoothScrollingSettings.isEnabled() else { return true } + guard let surface else { return true } + guard let scrollbar, scrollbar.total > scrollbar.len else { return true } + return ghostty_surface_mouse_captured(surface) + } + + fileprivate func sendWheelScrollToGhostty(_ event: NSEvent) { NotificationCenter.default.post(name: .ghosttyDidReceiveWheelScroll, object: self) guard let surface = surface else { return } - lastScrollEventTime = CACurrentMediaTime() + recordWheelScrollActivity(event) Self.focusLog("scrollWheel: surface=\(terminalSurface?.id.uuidString ?? "nil") firstResponder=\(String(describing: window?.firstResponder))") var x = event.scrollingDeltaX var y = event.scrollingDeltaY @@ -9772,11 +9788,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } mods |= momentum << 1 - // Track scroll state for lag detection - let hasMomentum = event.momentumPhase != [] && event.momentumPhase != .mayBegin - let momentumEnded = event.momentumPhase == .ended || event.momentumPhase == .cancelled - GhosttyApp.shared.markScrollActivity(hasMomentum: hasMomentum, momentumEnded: momentumEnded) - ghostty_surface_mouse_scroll( surface, x, @@ -9785,6 +9796,18 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) } + fileprivate func recordWheelScrollActivity(_ event: NSEvent) { + lastScrollEventTime = CACurrentMediaTime() + let hasMomentum = event.momentumPhase != [] && event.momentumPhase != .mayBegin + let momentumEnded = event.momentumPhase == .ended || event.momentumPhase == .cancelled + GhosttyApp.shared.markScrollActivity(hasMomentum: hasMomentum, momentumEnded: momentumEnded) + } + + func scrollToOffset(_ offset: Double) { + guard let surface else { return } + ghostty_surface_scroll_to_offset(surface, offset) + } + deinit { // Surface lifecycle is managed by TerminalSurface, not the view #if DEBUG @@ -10191,14 +10214,17 @@ private final class GhosttyScrollView: NSScrollView { return } - // Route wheel gestures to the terminal surface so Ghostty handles scrollback. - // Letting NSScrollView consume these events moves the wrapper viewport itself, - // which causes pane-content drift instead of terminal scrollback movement. + guard surfaceView.shouldForwardWheelToGhostty(event) else { + surfaceView.recordWheelScrollActivity(event) + super.scrollWheel(with: event) + return + } + GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: surface scroll") if window?.firstResponder !== surfaceView { window?.makeFirstResponder(surfaceView) } - surfaceView.scrollWheel(with: event) + surfaceView.sendWheelScrollToGhostty(event) } } @@ -10289,7 +10315,10 @@ final class GhosttySurfaceScrollView: NSView { private var windowObservers: [NSObjectProtocol] = [] private var scrollbarTrackingArea: NSTrackingArea? private var isLiveScrolling = false - private var lastSentRow: Int? + private var lastSentOffset: Double? + private var needsLiveScrollReconciliation = false + // Prevent programmatic AppKit viewport sync from echoing back into libghostty. + private var isSynchronizingScrollView = false /// Tracks whether the user has scrolled away from the bottom to review scrollback. /// When true, auto-scroll should be suspended to prevent the "doomscroll" bug /// where the terminal fights the user's scroll position. @@ -10727,9 +10756,7 @@ final class GhosttySurfaceScrollView: NSView { object: scrollView, queue: .main ) { [weak self] _ in - self?.isLiveScrolling = false - // Final scroll position check to update userScrolledAwayFromBottom state - self?.handleLiveScroll() + self?.handleEndLiveScroll() }) observers.append(NotificationCenter.default.addObserver( @@ -10814,6 +10841,14 @@ final class GhosttySurfaceScrollView: NSView { self?.handleTerminalScrollBarPreferenceChange() }) + observers.append(NotificationCenter.default.addObserver( + forName: TerminalSmoothScrollingSettings.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleTerminalSmoothScrollingPreferenceChange() + }) + } required init?(coder: NSCoder) { @@ -13083,6 +13118,38 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.frame.origin = visibleRect.origin } + private func currentRowOffset() -> CGFloat? { + let cellHeight = surfaceView.cellSize.height + guard cellHeight > 0 else { return nil } + let visibleRect = scrollView.contentView.documentVisibleRect + let rowOffset = (documentView.frame.height - visibleRect.origin.y - visibleRect.height) / cellHeight + let maxOffset = maxRowOffset() ?? rowOffset + return min(max(rowOffset, 0), maxOffset) + } + + private func maxRowOffset() -> CGFloat? { + let cellHeight = surfaceView.cellSize.height + guard cellHeight > 0 else { return nil } + let visibleRect = scrollView.contentView.documentVisibleRect + let rowOffset = (documentView.frame.height - visibleRect.height) / cellHeight + return max(rowOffset, 0) + } + + private func scroll(toRowOffset rowOffset: CGFloat) { + let cellHeight = surfaceView.cellSize.height + guard cellHeight > 0 else { return } + let visibleRect = scrollView.contentView.documentVisibleRect + let maxOffset = maxRowOffset() ?? rowOffset + let clampedRowOffset = min(max(rowOffset, 0), maxOffset) + let originY = documentView.frame.height - (clampedRowOffset * cellHeight) - visibleRect.height + let targetOrigin = CGPoint(x: 0, y: max(originY, 0)) + guard !pointApproximatelyEqual(scrollView.contentView.bounds.origin, targetOrigin) else { return } + isSynchronizingScrollView = true + defer { isSynchronizingScrollView = false } + scrollView.contentView.scroll(to: targetOrigin) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + /// Match upstream Ghostty behavior: use content area width (excluding non-content /// regions such as scrollbar space) when telling libghostty the terminal size. @discardableResult @@ -13144,25 +13211,28 @@ final class GhosttySurfaceScrollView: NSView { private func synchronizeScrollView() { var didChangeGeometry = false + let previousRowOffset = currentRowOffset() + let previousMaxRowOffset = maxRowOffset() let targetDocumentHeight = documentHeight() if abs(documentView.frame.height - targetDocumentHeight) > 0.5 { documentView.frame.size.height = targetDocumentHeight didChangeGeometry = true + if isLiveScrolling, + let previousRowOffset, + let previousMaxRowOffset, + let maxRowOffset = maxRowOffset() { + scroll(toRowOffset: previousRowOffset + maxRowOffset - previousMaxRowOffset) + } } if !isLiveScrolling { let cellHeight = surfaceView.cellSize.height if cellHeight > 0, let scrollbar = surfaceView.scrollbar { - let offsetY = - CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight - let targetOrigin = CGPoint(x: 0, y: offsetY) + let targetRowOffset = CGFloat(scrollbar.offset) // Check if we're currently at the bottom (with threshold for float drift) - let currentOrigin = scrollView.contentView.bounds.origin - let documentHeight = documentView.frame.height - let viewportHeight = scrollView.contentView.bounds.height - let distanceFromBottom = documentHeight - currentOrigin.y - viewportHeight - let isAtBottom = distanceFromBottom <= Self.scrollToBottomThreshold + let currentRowOffset = currentRowOffset() ?? 0 + let isAtBottom = currentRowOffset * cellHeight <= Self.scrollToBottomThreshold // Update userScrolledAwayFromBottom based on current position if isAtBottom { @@ -13174,11 +13244,10 @@ final class GhosttySurfaceScrollView: NSView { // still move the viewport to the requested scrollback position. let shouldAutoScroll = !userScrolledAwayFromBottom || allowExplicitScrollbarSync - if shouldAutoScroll && !pointApproximatelyEqual(currentOrigin, targetOrigin) { - scrollView.contentView.scroll(to: targetOrigin) - didChangeGeometry = true + if shouldAutoScroll { + scroll(toRowOffset: targetRowOffset) } - lastSentRow = Int(scrollbar.offset) + lastSentOffset = Double(scrollbar.offset) } } @@ -13191,28 +13260,51 @@ final class GhosttySurfaceScrollView: NSView { private func handleScrollChange() { synchronizeSurfaceView() + if !isSynchronizingScrollView { + handleLiveScroll() + } } - private func handleLiveScroll() { + private func handleLiveScroll(force: Bool = false) { let cellHeight = surfaceView.cellSize.height guard cellHeight > 0 else { return } - - let visibleRect = scrollView.contentView.documentVisibleRect - let documentHeight = documentView.frame.height - let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height + guard let rowOffset = currentRowOffset() else { return } + let reachedBottom = rowOffset <= 0.001 // Track if user has scrolled away from bottom to review scrollback - if scrollOffset > Self.scrollToBottomThreshold { + let pointOffset = rowOffset * cellHeight + if pointOffset > Self.scrollToBottomThreshold { userScrolledAwayFromBottom = true - } else if scrollOffset <= 0 { + } else if pointOffset <= 0 { userScrolledAwayFromBottom = false } - let row = Int(scrollOffset / cellHeight) + let offset = Double(rowOffset) + if !force, + let lastSentOffset, + abs(lastSentOffset - offset) < 0.01 { + let needsBottomCommit = reachedBottom && abs(lastSentOffset - offset) > 0.000001 + guard needsBottomCommit else { return } + } + + if reachedBottom, + lastSentOffset != 0 { + let bottomOffset = 0.0 + lastSentOffset = bottomOffset + surfaceView.scrollToOffset(bottomOffset) + return + } - guard row != lastSentRow else { return } - lastSentRow = row - _ = surfaceView.performBindingAction("scroll_to_row:\(row)") + lastSentOffset = offset + surfaceView.scrollToOffset(offset) + } + + private func handleEndLiveScroll() { + isLiveScrolling = false + if needsLiveScrollReconciliation { + needsLiveScrollReconciliation = false + } + handleLiveScroll(force: true) } private func handleScrollbarUpdate(_ notification: Notification) { @@ -13225,6 +13317,9 @@ final class GhosttySurfaceScrollView: NSView { allowExplicitScrollbarSync = true pendingExplicitWheelScroll = false } + if isLiveScrolling { + needsLiveScrollReconciliation = true + } surfaceView.scrollbar = scrollbar let isVisible = shouldShowTerminalScrollBar() if wasVisible != isVisible { @@ -13278,6 +13373,21 @@ final class GhosttySurfaceScrollView: NSView { _ = synchronizeGeometryAndContent() } + private func handleTerminalSmoothScrollingPreferenceChange() { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.handleTerminalSmoothScrollingPreferenceChange() + } + return + } + guard !TerminalSmoothScrollingSettings.isEnabled() else { return } + + isLiveScrolling = false + let snappedRowOffset = CGFloat((currentRowOffset() ?? 0).rounded()) + scroll(toRowOffset: snappedRowOffset) + handleLiveScroll(force: true) + } + private func updateWorkspaceTerminalScrollBarObserver(_ workspace: Workspace?) { if let observedWorkspaceTerminalScrollBar, observedWorkspaceTerminalScrollBar === workspace, diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 9b68425fbb..e587d937cd 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -80,6 +80,7 @@ extension CmuxSettingsFileStore { [ "terminal": [ "showScrollBar": TerminalScrollBarSettings.defaultShowScrollBar, + "smoothScrolling": TerminalSmoothScrollingSettings.defaultEnabled, "autoResumeAgentSessions": AgentSessionAutoResumeSettings.defaultAutoResumeAgentSessions, ], ], diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index f6a2620b9a..47f0fb55d7 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -493,6 +493,12 @@ final class CmuxSettingsFileStore { logInvalid("terminal.showScrollBar", sourcePath: sourcePath) } + if let value = jsonBool(section["smoothScrolling"]) { + snapshot.managedUserDefaults[TerminalSmoothScrollingSettings.enabledKey] = .bool(value) + } else if section.keys.contains("smoothScrolling") { + logInvalid("terminal.smoothScrolling", sourcePath: sourcePath) + } + if let value = jsonBool(section["autoResumeAgentSessions"]) { snapshot.managedUserDefaults[AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey] = .bool(value) } else if section.keys.contains("autoResumeAgentSessions") { @@ -1303,6 +1309,10 @@ final class CmuxSettingsFileStore { TerminalScrollBarSettings.notifyDidChange(notificationCenter: notificationCenter) } + if change.defaultsKey == TerminalSmoothScrollingSettings.enabledKey { + TerminalSmoothScrollingSettings.notifyDidChange(notificationCenter: notificationCenter) + } + if change.defaultsKey == AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey { agentSessionAutoResumeDidChange = true } diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index d760848751..64fe78ca42 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -318,6 +318,7 @@ enum SettingsSearchIndex { setting(.app, "rename-selects-name", String(localized: "settings.app.renameSelectsName", defaultValue: "Rename Selects Existing Name"), "command palette rename text selection"), setting(.app, "palette-search-all", String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"), "cmd p search terminal browser markdown"), setting(.terminal, "scrollbar", String(localized: "settings.terminal.scrollBar", defaultValue: "Show Terminal Scroll Bar"), "terminal shell scrollback"), + setting(.terminal, "smooth-scrolling", String(localized: "settings.terminal.smoothScrolling", defaultValue: "Smooth Terminal Scrolling"), "terminal.smoothScrolling smooth scroll scrolling scrollback pixel precise trackpad magic mouse"), setting(.terminal, "agent-auto-resume", String(localized: "settings.terminal.agentAutoResume", defaultValue: "Resume Agent Sessions on Reopen"), "terminal.autoResumeAgentSessions auto resume restore reopen relaunch quit sessions agents claude code codex opencode rovo dev rovodev toggle"), setting(.terminal, "resume-commands", String(localized: "settings.terminal.resumeCommands", defaultValue: "Resume Commands"), "surface resume command approvals prefixes auto restore prompt manual tmux hibernation"), setting(.sidebarAppearance, "match-terminal", String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), "sidebar material transparency"), @@ -425,6 +426,7 @@ enum SettingsSearchIndex { "sidebar.showProgress": settingID(for: .sidebarAppearance, idSuffix: "show-progress"), "sidebar.showCustomMetadata": settingID(for: .sidebarAppearance, idSuffix: "show-metadata"), "terminal.showScrollBar": settingID(for: .terminal, idSuffix: "scrollbar"), + "terminal.smoothScrolling": settingID(for: .terminal, idSuffix: "smooth-scrolling"), "terminal.autoResumeAgentSessions": settingID(for: .terminal, idSuffix: "agent-auto-resume"), "workspaceColors.indicatorStyle": settingID(for: .workspaceColors, idSuffix: "indicator"), "workspaceColors.selectionColor": settingID(for: .workspaceColors, idSuffix: "selection"), diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index 0b5bbf3878..27dc5b694c 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -68,6 +68,7 @@ enum SettingsSearchAliasIndex { "app:rename-selects-name": localized("settings.search.alias.setting.app.rename-selects-name", defaultValue: "app.renameSelectsExistingName rename select all existing title command palette workspace name"), "app:palette-search-all": localized("settings.search.alias.setting.app.palette-search-all", defaultValue: "app.commandPaletteSearchesAllSurfaces command palette search all surfaces cmd-p terminal browser markdown"), "terminal:scrollbar": localized("settings.search.alias.setting.terminal.scrollbar", defaultValue: "terminal.showScrollBar scrollback scrollbar scroll bar right edge alternate screen tui"), + "terminal:smooth-scrolling": localized("settings.search.alias.setting.terminal.smooth-scrolling", defaultValue: "terminal.smoothScrolling smooth scroll scrolling pixel precise trackpad magic mouse scrollback"), "terminal:resume-commands": localized("settings.search.alias.setting.terminal.resume-commands", defaultValue: "surface resume commands approvals command prefixes auto restore ask manual tmux hibernation sticky process"), "sidebarAppearance:match-terminal": localized("settings.search.alias.setting.sidebarAppearance.match-terminal", defaultValue: "sidebarAppearance.matchTerminalBackground transparent background material terminal background sync"), "sidebarAppearance:hide-sidebar-details": localized("settings.search.alias.setting.app.hide-sidebar-details", defaultValue: "sidebar.hideAllDetails compact sidebar hide details only title minimal left rail"), diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index e83c16da58..06abaa7e73 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5082,6 +5082,8 @@ struct SettingsView: View { private var paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled @AppStorage(TerminalScrollBarSettings.showScrollBarKey) private var showTerminalScrollBar = TerminalScrollBarSettings.defaultShowScrollBar + @AppStorage(TerminalSmoothScrollingSettings.enabledKey) + private var terminalSmoothScrollingEnabled = TerminalSmoothScrollingSettings.defaultEnabled @AppStorage(FileDropBehaviorSettings.defaultBehaviorKey) private var fileDropDefaultBehavior = FileDropBehaviorSettings.defaultBehavior.rawValue @AppStorage(AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) @@ -5223,6 +5225,17 @@ struct SettingsView: View { ) } + private var terminalSmoothScrollingBinding: Binding { + Binding( + get: { terminalSmoothScrollingEnabled }, + set: { newValue in + guard terminalSmoothScrollingEnabled != newValue else { return } + terminalSmoothScrollingEnabled = newValue + TerminalSmoothScrollingSettings.notifyDidChange() + } + ) + } + private var selectedFileDropDefaultBehavior: FileDropDefaultBehavior { FileDropBehaviorSettings.behavior(for: fileDropDefaultBehavior) } @@ -6300,6 +6313,24 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.smoothScrolling"), + String(localized: "settings.terminal.smoothScrolling", defaultValue: "Smooth Terminal Scrolling"), + subtitle: terminalSmoothScrollingEnabled + ? String(localized: "settings.terminal.smoothScrolling.subtitleOn", defaultValue: "Uses pixel-precise scrollback for trackpads and Magic Mouse gestures. Mouse-reporting terminal apps keep their own wheel handling.") + : String(localized: "settings.terminal.smoothScrolling.subtitleOff", defaultValue: "Uses Ghostty's row-based scrollback handling for terminal wheel gestures.") + ) { + Toggle("", isOn: terminalSmoothScrollingBinding) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsTerminalSmoothScrollingToggle") + .accessibilityLabel( + String(localized: "settings.terminal.smoothScrolling", defaultValue: "Smooth Terminal Scrolling") + ) + } + + SettingsCardDivider() + SettingsCardRow( configurationReview: .json("terminal.autoResumeAgentSessions"), String(localized: "settings.terminal.agentAutoResume", defaultValue: "Resume Agent Sessions on Reopen"), @@ -7489,6 +7520,11 @@ struct SettingsView: View { if previousShowTerminalScrollBar != showTerminalScrollBar { TerminalScrollBarSettings.notifyDidChange() } + let previousTerminalSmoothScrolling = terminalSmoothScrollingEnabled + terminalSmoothScrollingEnabled = TerminalSmoothScrollingSettings.defaultEnabled + if previousTerminalSmoothScrolling != terminalSmoothScrollingEnabled { + TerminalSmoothScrollingSettings.notifyDidChange() + } fileDropDefaultBehavior = FileDropBehaviorSettings.defaultBehavior.rawValue let previousAutoResumeAgentSessions = autoResumeAgentSessions autoResumeAgentSessions = AgentSessionAutoResumeSettings.defaultAutoResumeAgentSessions diff --git a/cmuxTests/CommandPaletteSettingsToggleTests.swift b/cmuxTests/CommandPaletteSettingsToggleTests.swift index b9114c9825..94dd4f6103 100644 --- a/cmuxTests/CommandPaletteSettingsToggleTests.swift +++ b/cmuxTests/CommandPaletteSettingsToggleTests.swift @@ -65,6 +65,34 @@ final class CommandPaletteSettingsToggleTests: XCTestCase { } } + func testTerminalSmoothScrollingTogglePostsChangeNotification() throws { + try withTemporaryDefaults { defaults in + let descriptor = try XCTUnwrap( + CommandPaletteSettingsToggleCommands.descriptor( + commandId: "palette.toggleSetting.terminalSmoothScrolling" + ) + ) + let notificationCenter = NotificationCenter() + var didNotify = false + let token = notificationCenter.addObserver( + forName: TerminalSmoothScrollingSettings.didChangeNotification, + object: nil, + queue: nil + ) { _ in + didNotify = true + } + defer { notificationCenter.removeObserver(token) } + + XCTAssertTrue(TerminalSmoothScrollingSettings.isEnabled(defaults: defaults)) + XCTAssertTrue(descriptor.isOn(defaults)) + descriptor.toggle(defaults: defaults, notificationCenter: notificationCenter) + + XCTAssertEqual(defaults.object(forKey: TerminalSmoothScrollingSettings.enabledKey) as? Bool, false) + XCTAssertFalse(TerminalSmoothScrollingSettings.isEnabled(defaults: defaults)) + XCTAssertTrue(didNotify) + } + } + func testShowMenuBarCommandIsUnavailableWhenMenuBarOnlyIsEnabled() throws { try withTemporaryDefaults { defaults in let descriptor = try XCTUnwrap( diff --git a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift index 9547c099e4..e44cb244e6 100644 --- a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +++ b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift @@ -568,15 +568,18 @@ final class KeyboardShortcutSettingsFileStoreStartupTests: XCTestCase { func testInitialSettingsFileLoadImportsDefaultsWithoutLiveDefaultNotifications() throws { let defaults = UserDefaults.standard let scrollBarKey = TerminalScrollBarSettings.showScrollBarKey + let smoothScrollingKey = TerminalSmoothScrollingSettings.enabledKey let autoResumeKey = AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey try preservingDefaults(keys: [ scrollBarKey, + smoothScrollingKey, autoResumeKey, settingsFileBackupsDefaultsKey, importedManagedDefaultsKey, ]) { defaults.removeObject(forKey: scrollBarKey) + defaults.removeObject(forKey: smoothScrollingKey) defaults.removeObject(forKey: autoResumeKey) defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) defaults.removeObject(forKey: importedManagedDefaultsKey) @@ -590,6 +593,7 @@ final class KeyboardShortcutSettingsFileStoreStartupTests: XCTestCase { { "terminal": { "showScrollBar": false, + "smoothScrolling": false, "autoResumeAgentSessions": false } } @@ -599,6 +603,7 @@ final class KeyboardShortcutSettingsFileStoreStartupTests: XCTestCase { let notificationCenter = NotificationCenter() var scrollBarNotificationCount = 0 + var smoothScrollingNotificationCount = 0 var autoResumeNotificationCount = 0 let scrollBarObserver = notificationCenter.addObserver( forName: TerminalScrollBarSettings.didChangeNotification, @@ -607,6 +612,13 @@ final class KeyboardShortcutSettingsFileStoreStartupTests: XCTestCase { ) { _ in scrollBarNotificationCount += 1 } + let smoothScrollingObserver = notificationCenter.addObserver( + forName: TerminalSmoothScrollingSettings.didChangeNotification, + object: nil, + queue: nil + ) { _ in + smoothScrollingNotificationCount += 1 + } let autoResumeObserver = notificationCenter.addObserver( forName: AgentSessionAutoResumeSettings.didChangeNotification, object: nil, @@ -616,6 +628,7 @@ final class KeyboardShortcutSettingsFileStoreStartupTests: XCTestCase { } defer { notificationCenter.removeObserver(scrollBarObserver) + notificationCenter.removeObserver(smoothScrollingObserver) notificationCenter.removeObserver(autoResumeObserver) } @@ -628,17 +641,55 @@ final class KeyboardShortcutSettingsFileStoreStartupTests: XCTestCase { ) XCTAssertEqual(defaults.object(forKey: scrollBarKey) as? Bool, false) + XCTAssertEqual(defaults.object(forKey: smoothScrollingKey) as? Bool, false) XCTAssertEqual(defaults.object(forKey: autoResumeKey) as? Bool, false) XCTAssertEqual(scrollBarNotificationCount, 0) + XCTAssertEqual(smoothScrollingNotificationCount, 0) XCTAssertEqual(autoResumeNotificationCount, 0) store.applyDeferredManagedDefaultSideEffects() XCTAssertEqual(scrollBarNotificationCount, 1) + XCTAssertEqual(smoothScrollingNotificationCount, 1) XCTAssertEqual(autoResumeNotificationCount, 1) } } + func testSettingsFileStoreAppliesTerminalSmoothScrollingSetting() throws { + let defaults = UserDefaults.standard + let key = TerminalSmoothScrollingSettings.enabledKey + + try preservingDefaults(keys: [key, settingsFileBackupsDefaultsKey, importedManagedDefaultsKey]) { + defaults.removeObject(forKey: key) + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + defaults.removeObject(forKey: importedManagedDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("cmux.json", isDirectory: false) + try writeSettingsFile( + """ + { + "terminal": { + "smoothScrolling": false + } + } + """, + to: settingsFileURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual(defaults.object(forKey: key) as? Bool, false) + XCTAssertFalse(TerminalSmoothScrollingSettings.isEnabled(defaults: defaults)) + } + } + func testSettingsFileStoreAppliesTerminalAgentAutoResumeSetting() throws { let defaults = UserDefaults.standard let key = AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey diff --git a/cmuxTests/SettingsSearchIndexTests.swift b/cmuxTests/SettingsSearchIndexTests.swift index 0f7898a445..0d45920855 100644 --- a/cmuxTests/SettingsSearchIndexTests.swift +++ b/cmuxTests/SettingsSearchIndexTests.swift @@ -17,6 +17,7 @@ final class SettingsSearchIndexTests: XCTestCase { assertSearch("http allowlist", contains: SettingsSearchIndex.settingID(for: .browser, idSuffix: "http-allowlist")) assertSearch("claude executable", contains: SettingsSearchIndex.settingID(for: .automation, idSuffix: "claude-path")) assertSearch("resume on reopen", contains: SettingsSearchIndex.settingID(for: .terminal, idSuffix: "agent-auto-resume")) + assertSearch("smooth trackpad scroll", contains: SettingsSearchIndex.settingID(for: .terminal, idSuffix: "smooth-scrolling")) assertSearch("workspace cwd", contains: SettingsSearchIndex.settingID(for: .app, idSuffix: "workspace-inherit-working-directory")) assertSearch("claude sessions", contains: SettingsSearchIndex.settingID(for: .terminal, idSuffix: "agent-auto-resume")) assertSearch("opencode resume", contains: SettingsSearchIndex.settingID(for: .terminal, idSuffix: "agent-auto-resume")) @@ -45,6 +46,13 @@ final class SettingsSearchIndexTests: XCTestCase { ) } + func testSettingsPathAnchorIncludesTerminalSmoothScrolling() { + XCTAssertEqual( + SettingsSearchIndex.anchorID(forSettingsPath: "terminal.smoothScrolling"), + SettingsSearchIndex.settingID(for: .terminal, idSuffix: "smooth-scrolling") + ) + } + func testSettingsPathAnchorIncludesWorkspaceWorkingDirectoryInheritance() { XCTAssertEqual( SettingsSearchIndex.anchorID(forSettingsPath: "app.workspaceInheritWorkingDirectory"), diff --git a/ghostty b/ghostty index ff6e1260d2..8577c2b516 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit ff6e1260d2e7767de55b8d9307b328e4060545b7 +Subproject commit 8577c2b5162a63eb0d12ffbb1f94c7b33ca9ced7 diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index bbec08c3f9..6cdc536e10 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -30,3 +30,4 @@ fe972c09579a7943f6fe9607fdd24f0f7c999cb1 4dde2bc84b27de84b14a38618ade03b2167a039 aef980e27b584a9d914f1ff0499b13c6ed1973e0 c6b8d560ad6b53d73396f80ba6995cb880ae9de9bfe8cae4dbd9ee72629798b5 6eed7af9240789ba18ccc617e51c384663be34a5 68bf3282478a92640d248c0b52b70cb41387aaed5baee9daa32e1019525f2d07 ff6e1260d2e7767de55b8d9307b328e4060545b7 02e5017a0d27ce5ada9ad92f675ce8c80dcebbc4bcfbe4060b6814b12b28cde9 +8577c2b5162a63eb0d12ffbb1f94c7b33ca9ced7 96e672eb965c81c6bcbaafcac5912cd100f1177be71db6669ded9c1948e2119e diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 60077d6421..48c956acd4 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -64,6 +64,7 @@ const settingsFileExample = `{ // "terminal": { // "showScrollBar": false, + // "smoothScrolling": true, // "autoResumeAgentSessions": true // }, diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 4c7289bf92..f1f818a09a 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -311,6 +311,11 @@ "default": true, "description": "Show the right-edge terminal scroll bar when scrollback is available. cmux automatically suppresses it for alternate-screen style TUI surfaces." }, + "smoothScrolling": { + "type": "boolean", + "default": true, + "description": "Use pixel-precise shell scrollback for trackpads and Magic Mouse gestures. Mouse-reporting terminal apps keep Ghostty's direct wheel handling." + }, "autoResumeAgentSessions": { "type": "boolean", "default": true,