From 7d3c24fefa9bf747e757431792dd3345af998ec7 Mon Sep 17 00:00:00 2001 From: sionic-khope Date: Tue, 21 Jul 2026 13:43:55 +0900 Subject: [PATCH] Scroll terminal history with the mouse and repaint the prompt after clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal only paged through history via Page Up/Down keys — wheel and trackpad scrolling did nothing. Scroll events now accumulate into page steps (TerminalScrollAccumulator: carry below one step, multi-step flicks, direction reversals drop the opposite remainder, legacy line-delta wheels scaled into point space). Clearing (⌘K) wipes the whole buffer including the shell's prompt line and left the terminal fully blank until the next output; after clearing, the panel now sends Ctrl-L so the shell repaints its prompt immediately. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015peMZyaHEmxDzyzNyhYeR5 --- .../Terminal/Core/TerminalPaging.swift | 30 +++++++++++++++++++ .../Rendering/TerminalOutputTextView.swift | 24 +++++++++++++++ .../Terminal/Rendering/TerminalPanel.swift | 4 +++ .../TerminalPagingTests.swift | 29 ++++++++++++++++++ 4 files changed, 87 insertions(+) diff --git a/Sources/MikuCodeApp/Terminal/Core/TerminalPaging.swift b/Sources/MikuCodeApp/Terminal/Core/TerminalPaging.swift index 16d9a32..766fe05 100644 --- a/Sources/MikuCodeApp/Terminal/Core/TerminalPaging.swift +++ b/Sources/MikuCodeApp/Terminal/Core/TerminalPaging.swift @@ -27,6 +27,36 @@ enum TerminalPager { } } +/// Converts continuous scroll-wheel deltas into discrete page steps for the +/// page-based terminal display. Positive deltas (scrolling up) accumulate into +/// positive steps (earlier history); leftover delta below one step is carried +/// so slow scrolling still eventually turns the page. +struct TerminalScrollAccumulator { + private(set) var accumulated: CGFloat = 0 + let stepThreshold: CGFloat + + init(stepThreshold: CGFloat = 48) { + self.stepThreshold = max(1, stepThreshold) + } + + /// Adds one event's delta and returns how many whole page steps it + /// produced (positive = toward older history, negative = toward latest). + mutating func steps(adding delta: CGFloat) -> Int { + // Direction reversals discard the opposite-direction remainder so a + // flick back down is never dampened by leftover upward delta. + if delta > 0, accumulated < 0 { accumulated = 0 } + if delta < 0, accumulated > 0 { accumulated = 0 } + accumulated += delta + let steps = Int(accumulated / stepThreshold) + accumulated -= CGFloat(steps) * stepThreshold + return steps + } + + mutating func reset() { + accumulated = 0 + } +} + struct TerminalSearchMatch: Equatable, Sendable { let lineIndex: Int let column: Int diff --git a/Sources/MikuCodeApp/Terminal/Rendering/TerminalOutputTextView.swift b/Sources/MikuCodeApp/Terminal/Rendering/TerminalOutputTextView.swift index 7f6d837..56c07f2 100644 --- a/Sources/MikuCodeApp/Terminal/Rendering/TerminalOutputTextView.swift +++ b/Sources/MikuCodeApp/Terminal/Rendering/TerminalOutputTextView.swift @@ -132,6 +132,30 @@ final class TerminalOutputTextView: NSTextView { super.mouseDown(with: event) } + private var scrollAccumulator = TerminalScrollAccumulator() + + /// The terminal renders fixed pages, so wheel/trackpad scrolling steps + /// through history page by page instead of moving the text view. + override func scrollWheel(with event: NSEvent) { + if event.phase == .began { + scrollAccumulator.reset() + } + // Legacy line-based wheels report small line deltas; scale them into + // point space so one physical notch is a meaningful fraction of a step. + let delta = event.hasPreciseScrollingDeltas + ? event.scrollingDeltaY + : event.scrollingDeltaY * 16 + let steps = scrollAccumulator.steps(adding: delta) + guard steps != 0 else { return } + for _ in 0 ..< abs(steps) { + if steps > 0 { + onPageUp?() + } else { + onPageDown?() + } + } + } + override func keyDown(with event: NSEvent) { if event.keyCode == 116 { onPageUp?() diff --git a/Sources/MikuCodeApp/Terminal/Rendering/TerminalPanel.swift b/Sources/MikuCodeApp/Terminal/Rendering/TerminalPanel.swift index b1fde38..a152066 100644 --- a/Sources/MikuCodeApp/Terminal/Rendering/TerminalPanel.swift +++ b/Sources/MikuCodeApp/Terminal/Rendering/TerminalPanel.swift @@ -156,6 +156,10 @@ struct TerminalPanel: View { followsLatestPage = true activeSearchMatchIndex = nil activeSearchMatchIdentity = nil + // Clearing wipes the whole buffer, including the shell's prompt line — + // nudge the shell to repaint (zsh/bash clear-screen on Ctrl-L) so the + // terminal never sits fully blank after ⌘K. + session.sendRawInput(Data([0x0C])) } private func movePage(by delta: Int) { diff --git a/Tests/MikuCodeAppTests/TerminalPagingTests.swift b/Tests/MikuCodeAppTests/TerminalPagingTests.swift index 5b1ed6b..dfa4a90 100644 --- a/Tests/MikuCodeAppTests/TerminalPagingTests.swift +++ b/Tests/MikuCodeAppTests/TerminalPagingTests.swift @@ -6,6 +6,35 @@ import XCTest final class TerminalPagingTests: XCTestCase { private let lines = (0 ..< 7).map { TerminalLine(text: "line \($0)") } + func testScrollAccumulatorTurnsDeltasIntoPageSteps() { + var accumulator = TerminalScrollAccumulator(stepThreshold: 48) + + // Small deltas carry over until they add up to one step. + XCTAssertEqual(accumulator.steps(adding: 20), 0) + XCTAssertEqual(accumulator.steps(adding: 20), 0) + XCTAssertEqual(accumulator.steps(adding: 20), 1) + + // A large flick produces multiple steps at once, keeping the remainder. + XCTAssertEqual(accumulator.steps(adding: 110), 2) + XCTAssertEqual(accumulator.steps(adding: 22), 1) + + // Scrolling down yields negative steps. + XCTAssertEqual(accumulator.steps(adding: -96), -2) + } + + func testScrollAccumulatorDropsOppositeRemainderOnDirectionChange() { + var accumulator = TerminalScrollAccumulator(stepThreshold: 48) + + XCTAssertEqual(accumulator.steps(adding: 40), 0) + // Reversing direction must not be dampened by the leftover +40. + XCTAssertEqual(accumulator.steps(adding: -48), -1) + + accumulator.reset() + XCTAssertEqual(accumulator.steps(adding: 47), 0) + accumulator.reset() + XCTAssertEqual(accumulator.steps(adding: 1), 0) + } + func testPageSlicesClampAndReportIndices() { let first = TerminalPager.page(lines: lines, requestedPage: -2, pageSize: 3) XCTAssertEqual(first.pageIndex, 0)