Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Sources/SwiftTerm/Mac/MacTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,15 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations,

open override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
// A view moved in the hierarchy mid-drag never receives the matching
// mouseUp, which is the only other place this timer is torn down, so end
// the drag here. Unconditionally: AppKit calls this for every hierarchy
// move, including a reparent within the same window and a direct move
// between windows, where the new window is non-nil. On first insertion
// there is no drag in flight and this is a no-op.
stopSelectionAutoScrollTimer()
autoScrollDelta = 0
lastSelectionDragPoint = nil
startWindowMouseMovedFallback()
updateTextBlinkLifecycle()
#if canImport(MetalKit)
Expand Down Expand Up @@ -623,6 +632,7 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations,
NotificationCenter.default.removeObserver (resignKeyObserver)
}
progressReportTimer?.invalidate()
selectionAutoScrollTimer?.invalidate()
stopTextBlinking()
}

Expand Down Expand Up @@ -2616,6 +2626,12 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations,
selectionAutoScrollTimer = timer
}

/// Test hook: whether a selection auto-scroll timer is currently armed.
/// The timer and its state are private; a test that only watched the
/// viewport could not tell "torn down" from "still ticking but not
/// scrolling", which is the failure mode this guards.
var hasActiveSelectionAutoScrollForTesting: Bool { selectionAutoScrollTimer != nil }

private func stopSelectionAutoScrollTimer() {
selectionAutoScrollTimer?.invalidate()
selectionAutoScrollTimer = nil
Expand Down
140 changes: 140 additions & 0 deletions Tests/SwiftTermTests/ScrollWheelMouseReportCountTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//
// ScrollWheelMouseReportCountTests.swift
//
// Repro for the claim that, after 91863f0 ("Line-accurate scroll wheel with
// optional sensitivity control (macOS)"), MacTerminalView.scrollWheel(with:)
// sends one button4/5 mouse-report event per *line* of scroll rather than
// one event per physical wheel *notch*, in the alternate-screen +
// mouse-tracking-enabled case (e.g. vim `set mouse=a`, htop).
//
// Enable with: RUN_APPKIT_TESTS=1 swift test --filter ScrollWheelMouseReportCountTests
//

#if os(macOS)
import AppKit
import CoreGraphics
import Foundation
import Testing
@testable import SwiftTerm

private func appKitTestsEnabled() -> Bool {
ProcessInfo.processInfo.environment["RUN_APPKIT_TESTS"] == "1"
}

@Suite(.enabled(if: appKitTestsEnabled()))
@MainActor
final class ScrollWheelMouseReportCountTests {
private final class CapturingDelegate: TerminalViewDelegate {
var sent: [UInt8] = []

func send(source: TerminalView, data: ArraySlice<UInt8>) {
sent.append(contentsOf: data)
}

func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {}
func setTerminalTitle(source: TerminalView, title: String) {}
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
func scrolled(source: TerminalView, position: Double) {}
func requestOpenLink(source: TerminalView, link: String, params: [String: String]) {}
func bell(source: TerminalView) {}
func clipboardCopy(source: TerminalView, content: Data) {}
func clipboardRead(source: TerminalView) -> Data? { nil }
func iTermContent(source: TerminalView, content: ArraySlice<UInt8>) {}
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}
}

/// Builds a classic (non-precise) mouse-wheel NSEvent the way a real USB
/// scroll wheel notch would arrive: `wheelCount: 1`, units `.line`, so
/// `hasPreciseScrollingDeltas == false`. `linesPerNotch` mimics what
/// AppKit/WindowServer already bakes into `scrollingDeltaY` for a single
/// physical notch (historically defaulted to 3 on a plain USB mouse).
private func classicWheelEvent(linesPerNotch: Int32, window: NSWindow) -> NSEvent {
let cgEvent = CGEvent(
scrollWheelEvent2Source: nil,
units: .line,
wheelCount: 1,
wheel1: linesPerNotch,
wheel2: 0,
wheel3: 0
)!
let event = NSEvent(cgEvent: cgEvent)!
return event
}

private func makeAltScreenMouseTrackingView() -> (TerminalView, CapturingDelegate, NSWindow) {
_ = NSApplication.shared

let view = TerminalView(frame: CGRect(x: 0, y: 0, width: 400, height: 200))
let capture = CapturingDelegate()
view.terminalDelegate = capture

let window = NSWindow(contentRect: view.frame,
styleMask: [.titled],
backing: .buffered,
defer: false)
window.contentView?.addSubview(view)
window.makeFirstResponder(view)

// Enter the alternate screen buffer (as vim/less/htop do) and turn on
// basic mouse click/wheel tracking with SGR extended coordinates (as
// `vim set mouse=a` or htop do -- SGR mode keeps each report's byte
// length uniform and easy to count).
view.feed(text: "\u{1b}[?1049h")
view.feed(text: "\u{1b}[?1000h")
view.feed(text: "\u{1b}[?1006h")

#expect(view.terminal.isDisplayBufferAlternate)
#expect(view.terminal.mouseMode != .off)

capture.sent.removeAll()
return (view, capture, window)
}

/// FACT CHECK: a single classic wheel notch, which macOS reports with
/// scrollingDeltaY already expressed in "lines" (here simulated as 3,
/// the historical default), produces 3 separate button-4 mouse reports,
/// not 1. xterm's X11-derived convention is one Button4 press per
/// physical notch; the receiving TUI (e.g. vim, whose default
/// 'mousescroll' is ver:3) then multiplies each received press by its
/// own lines-per-click, expecting one event per notch. Sending 3 events
/// for what the user felt as a single notch means a TUI honoring the
/// xterm convention scrolls 3x further than intended.
@Test func classicWheelNotchSendsOneEventPerLineNotPerNotch() {
let (view, capture, _) = makeAltScreenMouseTrackingView()

let event = classicWheelEvent(linesPerNotch: 3, window: view.window!)
#expect(event.hasPreciseScrollingDeltas == false)
#expect(event.scrollingDeltaY == 3)

view.scrollWheel(with: event)

let sentString = String(bytes: capture.sent, encoding: .utf8) ?? ""
let button4Reports = sentString.components(separatedBy: "\u{1b}[<64;").count - 1

// What we WANT to find, if the bug claim is right: 1 event (one
// notch -> one button4 press, xterm/X11 style).
// What SwiftTerm ACTUALLY does after 91863f0 (and in fact since the
// original PR #518/#506 that introduced mouse-report scroll
// forwarding): one event per accumulated line.
#expect(button4Reports == 3, "SwiftTerm sent \(button4Reports) button4 report(s) for a single 3-line wheel notch")
}

/// Same check against the pre-91863f0 step-function velocity to prove
/// this is not a regression introduced by that commit: even the old
/// `calcScrollingVelocity` fed `Int(abs(event.deltaY))` and looped that
/// many times over `sendEvent`, i.e. "N deltaY units in -> N button
/// presses out" already held before the rewrite.
@Test func singleLineNotchStillSendsExactlyOneEvent() {
let (view, capture, _) = makeAltScreenMouseTrackingView()

let event = classicWheelEvent(linesPerNotch: 1, window: view.window!)
#expect(event.scrollingDeltaY == 1)

view.scrollWheel(with: event)

let sentString = String(bytes: capture.sent, encoding: .utf8) ?? ""
let button4Reports = sentString.components(separatedBy: "\u{1b}[<64;").count - 1
#expect(button4Reports == 1)
}
}
#endif
144 changes: 144 additions & 0 deletions Tests/SwiftTermTests/SelectionAutoScrollTimerLifecycleTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
//
// SelectionAutoScrollTimerLifecycleTests.swift
//
// f971cbf ("Mac: restore auto-scroll while dragging a selection past an
// edge") introduced `selectionAutoScrollTimer`, a repeating Timer that keeps
// scrolling the viewport while the pointer is held past the top/bottom edge
// during a selection drag. It is armed in `mouseDragged` and, before this
// change, torn down only in `mouseUp`.
//
// A view taken out of the view hierarchy while a drag is in flight never
// receives that `mouseUp`: verified with a standalone AppKit probe, where a
// view removed from its superview mid-drag logged no `mouseUp` at all while
// a 20 Hz tick kept reporting the drag as still in progress for as long as
// the probe was left running. Closing a tab or a split pane during a
// selection drag is exactly that sequence.
//
// The timer would then keep running: still scrolling while the view is
// alive, and — because the timer block captures the view weakly — kept on
// the run loop with nothing left that can invalidate it once the view goes
// away. Hosts that close a tab or a split pane during a drag do this by
// detaching or reparenting the view; the probe verified the explicit
// removeFromSuperview form.
//
import Testing
@testable import SwiftTerm

#if os(macOS)
import AppKit

struct SelectionAutoScrollTimerLifecycleTests {
@MainActor private func wait(seconds: TimeInterval) async {
await withCheckedContinuation { continuation in
let timer = Timer(timeInterval: seconds, repeats: false) { _ in
continuation.resume()
}
RunLoop.main.add(timer, forMode: .common)
}
}

/// Builds a view with enough scrollback that an auto-scroll running
/// unchecked for hundreds of milliseconds still has room to move --
/// otherwise it could look "stopped" merely because it hit row 0.
@MainActor private func makeViewWithScrollback() -> (view: TerminalView, window: NSWindow) {
let view = TerminalView(frame: CGRect(x: 0, y: 0, width: 320, height: 160))
let window = NSWindow(
contentRect: view.frame,
styleMask: .borderless,
backing: .buffered,
defer: false
)
window.contentView = view

for i in 0..<4000 {
view.terminal.feed(text: "line \(i)\r\n")
}
return (view, window)
}

/// Arms the auto-scroll timer the way a real selection drag past the top
/// edge does.
@MainActor private func beginDragPastTopEdge(view: TerminalView, window: NSWindow) {
let pressPoint = CGPoint(x: 10, y: view.frame.height / 2)
// Above the top edge of the view: negative screenRow arms the
// scroll-up branch of mouseDragged.
let dragPoint = CGPoint(x: 10, y: view.frame.height + 3 * view.cellDimension.height)

let down = NSEvent.mouseEvent(
with: .leftMouseDown, location: pressPoint, modifierFlags: [], timestamp: 0,
windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1
)!
let drag = NSEvent.mouseEvent(
with: .leftMouseDragged, location: dragPoint, modifierFlags: [], timestamp: 0,
windowNumber: window.windowNumber, context: nil, eventNumber: 2, clickCount: 1, pressure: 1
)!

view.mouseDown(with: down)
view.mouseDragged(with: drag)
}

@Test @MainActor func removingTheViewMidDragStopsAutoScroll() async {
let (view, window) = makeViewWithScrollback()
beginDragPastTopEdge(view: view, window: window)

// Sanity: the drag really did arm the timer, otherwise the assertion
// below would pass for the wrong reason.
#expect(view.hasActiveSelectionAutoScrollForTesting, "precondition: dragging past the edge must arm the timer")

// The tab/pane goes away mid-drag. No mouseUp will ever arrive.
window.contentView = nil

#expect(view.hasActiveSelectionAutoScrollForTesting == false, "the timer must not outlive the view's place in the hierarchy")

let before = view.terminal.buffer.yDisp
// 8 ticks' worth of the timer's 0.05s interval: plenty of time for a
// surviving timer to show itself, short enough to keep the suite fast.
await wait(seconds: 0.4)
#expect(view.terminal.buffer.yDisp == before, "a torn-down timer cannot still be scrolling")
}

/// A live drag must keep auto-scrolling until mouseUp. This rules out
/// fixes that guess at whether the button is still held (see the header).
@Test @MainActor func normalDragKeepsAutoScrollRunning() async {
let (view, window) = makeViewWithScrollback()
beginDragPastTopEdge(view: view, window: window)

let before = view.terminal.buffer.yDisp
await wait(seconds: 0.4)

#expect(view.terminal.buffer.yDisp < before, "auto-scroll must keep running for as long as the drag is live")
#expect(view.hasActiveSelectionAutoScrollForTesting, "the timer stays armed until mouseUp")

let up = NSEvent.mouseEvent(
with: .leftMouseUp, location: .zero, modifierFlags: [], timestamp: 0,
windowNumber: window.windowNumber, context: nil, eventNumber: 3, clickCount: 1, pressure: 0
)!
view.mouseUp(with: up)
#expect(view.hasActiveSelectionAutoScrollForTesting == false, "mouseUp still tears the timer down")
}

/// A reparent that keeps the same window still ends the drag: AppKit calls
/// viewDidMoveToWindow for it (with a non-nil window), and the view will not
/// receive the mouseUp that would otherwise stop the timer.
@Test @MainActor func reparentingWithinTheSameWindowMidDragStopsAutoScroll() async {
let (view, window) = makeViewWithScrollback()
// Rehome the view under a plain root so it can be reparented between two
// sibling containers; a window's contentView cannot be moved into its own
// descendant. No drag is in flight yet, so this move is inert.
let root = NSView(frame: view.frame)
let boxA = NSView(frame: view.frame)
let boxB = NSView(frame: view.frame)
window.contentView = root
root.addSubview(boxA)
root.addSubview(boxB)
boxA.addSubview(view)

beginDragPastTopEdge(view: view, window: window)
#expect(view.hasActiveSelectionAutoScrollForTesting, "precondition: dragging past the edge must arm the timer")

boxB.addSubview(view) // same window, new superview

#expect(view.hasActiveSelectionAutoScrollForTesting == false, "a hierarchy move ends the drag even when the window is unchanged")
}
}
#endif
Loading