Skip to content

Commit ac4b196

Browse files
committed
Implement AppKit's scrollbar
`TerminalView` used a standalone `NSScroller`. An overlay `NSScroller` does not manage its own presentation in the same way as a scroller that belongs to an `NSScrollView`. The earlier implementation had these problems: - The legacy scrollbar used permanent horizontal space. - The overlay scrollbar did not reliably appear during scrolling. - The scrollbar could not be dragged. - The custom indicator used a different coordinate system from `NSScroller`. - The custom indicator was thicker than the small platform scrollbar. The new implementation addresses these problems. `TerminalView` now owns two related views: - `TerminalScroller` is an `NSScroller`. It handles pointer tracking and drag events. - `OverlayScrollerIndicator` draws the visible overlay knob above terminal content. The native scroller stays transparent in overlay mode. It remains available for hit testing and drag tracking. The custom indicator does not accept pointer events. The default style is `.overlay`. - It does not reduce the terminal width. - It appears when wheel or programmatic scrolling changes the position. - It remains visible while the user holds and drags the knob. - It starts a 1.5-second hide delay after scrolling or drag release. - It fades for 0.25 seconds. - It hides without motion when Reduce Motion is enabled. The existing `.legacy` style remains available as an opt-in style. It reserves its normal width. `NSScroller` uses flipped coordinates. The custom indicator initially used standard `NSView` coordinates. The visual code therefore needed an inversion, but that inversion made native drag tracking move in the wrong direction. `OverlayScrollerIndicator.isFlipped` is now `true`. The indicator and scroller use the same coordinate system. The code can use the terminal scroll position directly: ```swift scroller.doubleValue = state.doubleValue scroll(toPosition: scroller.doubleValue) ``` This also restores the standard page directions: - `.decrementPage` calls `pageUp()`. - `.incrementPage` calls `pageDown()`. Overlay mode uses `NSControl.ControlSize.small`. AppKit then supplies a four-point-wide knob and a 15-point interaction area. Legacy mode continues to use the regular control size. The terminal view sends pointer events in the visible overlay region directly to `TerminalScroller`. This is necessary because the native scroller has an alpha value of zero in overlay mode. The custom indicator stays above both the native scroller and the Metal rendering view. This prevents the renderer from covering it.
1 parent 1e382ee commit ac4b196

3 files changed

Lines changed: 200 additions & 8 deletions

File tree

Sources/SwiftTerm/Apple/AppleTerminalView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4009,6 +4009,9 @@ extension TerminalView {
40094009
frameDriver.markDirty()
40104010
terminalDelegate?.scrolled (source: self, position: scrollPosition)
40114011
updateScroller()
4012+
#if os(macOS)
4013+
showOverlayScroller()
4014+
#endif
40124015
} else {
40134016
#if os(iOS) || os(visionOS)
40144017
// resetManualScrollOffsetWithinRow() changed the visual offset even

Sources/SwiftTerm/Mac/MacTerminalView.swift

Lines changed: 161 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,59 @@ import MetalKit
2121
import os.log
2222
#endif
2323

24+
/// Reports the full duration of knob tracking to the terminal view.
25+
private final class TerminalScroller: NSScroller {
26+
var trackingChanged: ((Bool) -> Void)?
27+
28+
override var isOpaque: Bool {
29+
scrollerStyle == .legacy && super.isOpaque
30+
}
31+
32+
override func draw(_ dirtyRect: NSRect) {
33+
if scrollerStyle == .legacy {
34+
super.draw(dirtyRect)
35+
}
36+
}
37+
38+
override func mouseDown(with event: NSEvent) {
39+
trackingChanged?(true)
40+
defer { trackingChanged?(false) }
41+
super.mouseDown(with: event)
42+
}
43+
}
44+
45+
/// Draws the overlay knob while the parent scroller handles pointer events.
46+
private final class OverlayScrollerIndicator: NSView {
47+
weak var scroller: NSScroller?
48+
49+
init(scroller: NSScroller) {
50+
self.scroller = scroller
51+
super.init(frame: .zero)
52+
identifier = NSUserInterfaceItemIdentifier("SwiftTermOverlayScrollerIndicator")
53+
wantsLayer = true
54+
layer?.isOpaque = false
55+
layer?.backgroundColor = NSColor.clear.cgColor
56+
}
57+
58+
required init?(coder: NSCoder) {
59+
fatalError("init(coder:) has not been implemented")
60+
}
61+
62+
override var isOpaque: Bool { false }
63+
override var isFlipped: Bool { true }
64+
65+
override func draw(_ dirtyRect: NSRect) {
66+
NSGraphicsContext.current?.cgContext.clear(bounds)
67+
guard let knob = scroller?.rect(for: .knob), !knob.isEmpty else { return }
68+
NSColor.labelColor.withAlphaComponent(0.55).setFill()
69+
NSBezierPath(roundedRect: knob, xRadius: knob.width / 2, yRadius: knob.width / 2).fill()
70+
}
71+
72+
override func hitTest(_ point: NSPoint) -> NSView? {
73+
nil
74+
}
75+
}
76+
2477
/**
2578
* TerminalView provides an AppKit front-end to the `Terminal` termininal emulator.
2679
* It is up to a subclass to either wire the terminal emulator to a remote terminal
@@ -428,6 +481,10 @@ open class TerminalView: NSView, NSUserInterfaceValidations, TerminalDelegate {
428481
/// programmatically (see `SelectionService`).
429482
var selection: SelectionService!
430483
private var scroller: NSScroller!
484+
private var overlayScrollerIndicator: OverlayScrollerIndicator!
485+
private var overlayScrollerHideTimer: Timer?
486+
private var overlayScrollerFadeGeneration = 0
487+
private var overlayScrollerIsTracking = false
431488

432489
// Attribute dictionary, maps a console attribute (color, flags) to the corresponding dictionary
433490
// of attributes for an NSAttributedString
@@ -762,6 +819,7 @@ open class TerminalView: NSView, NSUserInterfaceValidations, TerminalDelegate {
762819
}
763820
if let scroller = scroller {
764821
addSubview(scroller, positioned: .above, relativeTo: newView)
822+
addSubview(overlayScrollerIndicator, positioned: .above, relativeTo: scroller)
765823
}
766824
}
767825

@@ -1052,6 +1110,8 @@ open class TerminalView: NSView, NSUserInterfaceValidations, TerminalDelegate {
10521110
stopFocusNotifications()
10531111
stopTextBlinking()
10541112
clearProgressReport()
1113+
overlayScrollerHideTimer?.invalidate()
1114+
overlayScrollerHideTimer = nil
10551115
uiShutdownState = .stopped
10561116
return true
10571117
}
@@ -1270,10 +1330,10 @@ open class TerminalView: NSView, NSUserInterfaceValidations, TerminalDelegate {
12701330
switch scroller.hitPart {
12711331
case .decrementPage:
12721332
pageUp()
1273-
scroller.doubleValue = scrollPosition
1333+
scroller.doubleValue = scrollPosition
12741334
case .incrementPage:
12751335
pageDown()
1276-
scroller.doubleValue = scrollPosition
1336+
scroller.doubleValue = scrollPosition
12771337
case .knob:
12781338
scroll(toPosition: scroller.doubleValue)
12791339
case .knobSlot:
@@ -1289,15 +1349,24 @@ open class TerminalView: NSView, NSUserInterfaceValidations, TerminalDelegate {
12891349
}
12901350
}
12911351

1292-
/// Style for the terminal's scroll indicator. Defaults to `.overlay` which auto-hides.
1293-
/// Set to `.legacy` for an always-visible scrollbar.
1352+
/// Style for the terminal's scroll indicator. The default overlay scroller
1353+
/// appears during scrolling and does not reduce the terminal width.
12941354
public var scrollerStyle: NSScroller.Style = .overlay {
12951355
didSet {
12961356
scroller?.scrollerStyle = scrollerStyle
12971357
if let scroller {
1298-
let width = NSScroller.scrollerWidth(for: .regular, scrollerStyle: scrollerStyle)
1358+
scroller.controlSize = scrollerControlSize
1359+
let width = NSScroller.scrollerWidth(
1360+
for: scrollerControlSize,
1361+
scrollerStyle: scrollerStyle)
12991362
scroller.constraints.first(where: { $0.firstAttribute == .width })?.constant = width
1363+
scroller.alphaValue = scrollerStyle == .overlay ? 0 : 1
1364+
scroller.isHidden = scrollerStyle == .overlay
1365+
overlayScrollerIndicator?.isHidden = true
1366+
overlayScrollerIndicator?.alphaValue = 0
13001367
}
1368+
overlayScrollerHideTimer?.invalidate()
1369+
overlayScrollerHideTimer = nil
13011370
if oldValue != scrollerStyle, cellDimension != nil,
13021371
frame.width > 0, frame.height > 0 {
13031372
_ = processSizeChange(newSize: frame.size)
@@ -1313,20 +1382,38 @@ open class TerminalView: NSView, NSUserInterfaceValidations, TerminalDelegate {
13131382
func setupScroller()
13141383
{
13151384
if scroller == nil {
1316-
scroller = NSScroller(frame: .zero)
1385+
let terminalScroller = TerminalScroller(frame: .zero)
1386+
terminalScroller.trackingChanged = { [weak self] isTracking in
1387+
self?.setOverlayScrollerTracking(isTracking)
1388+
}
1389+
scroller = terminalScroller
13171390
scroller.translatesAutoresizingMaskIntoConstraints = false
13181391
addSubview(scroller)
13191392

1393+
overlayScrollerIndicator = OverlayScrollerIndicator(scroller: scroller)
1394+
overlayScrollerIndicator.translatesAutoresizingMaskIntoConstraints = false
1395+
overlayScrollerIndicator.isHidden = true
1396+
overlayScrollerIndicator.alphaValue = 0
1397+
addSubview(overlayScrollerIndicator, positioned: .above, relativeTo: scroller)
1398+
13201399
// Use Auto Layout to position the scroller. This ensures correct layout
13211400
// whether the parent view uses frame-based or constraint-based layout.
13221401
NSLayoutConstraint.activate([
13231402
scroller.trailingAnchor.constraint(equalTo: trailingAnchor),
13241403
scroller.topAnchor.constraint(equalTo: topAnchor),
13251404
scroller.bottomAnchor.constraint(equalTo: bottomAnchor),
1326-
scroller.widthAnchor.constraint(equalToConstant: scrollerWidth)
1405+
scroller.widthAnchor.constraint(equalToConstant: scrollerWidth),
1406+
overlayScrollerIndicator.trailingAnchor.constraint(equalTo: scroller.trailingAnchor),
1407+
overlayScrollerIndicator.topAnchor.constraint(equalTo: scroller.topAnchor),
1408+
overlayScrollerIndicator.bottomAnchor.constraint(equalTo: scroller.bottomAnchor),
1409+
overlayScrollerIndicator.widthAnchor.constraint(equalTo: scroller.widthAnchor)
13271410
])
13281411
}
13291412
scroller.scrollerStyle = scrollerStyle
1413+
scroller.controlSize = scrollerControlSize
1414+
scroller.alphaValue = scrollerStyle == .overlay ? 0 : 1
1415+
scroller.isHidden = scrollerStyle == .overlay
1416+
overlayScrollerIndicator.isHidden = true
13301417
scroller.knobProportion = 0.1
13311418
scroller.isEnabled = false
13321419
if let progressBarView {
@@ -1340,6 +1427,13 @@ open class TerminalView: NSView, NSUserInterfaceValidations, TerminalDelegate {
13401427
// Scroller position is managed by Auto Layout constraints
13411428
}
13421429

1430+
open override func hitTest(_ point: NSPoint) -> NSView? {
1431+
if scrollerStyle == .overlay, !scroller.isHidden, scroller.frame.contains(point) {
1432+
return scroller
1433+
}
1434+
return super.hitTest(point)
1435+
}
1436+
13431437
/// This method sents the `nativeForegroundColor` and `nativeBackgroundColor`
13441438
/// to match macOS default colors for text and its background.
13451439
public func configureNativeColors ()
@@ -1361,7 +1455,11 @@ open class TerminalView: NSView, NSUserInterfaceValidations, TerminalDelegate {
13611455
}
13621456

13631457
private var scrollerWidth: CGFloat {
1364-
NSScroller.scrollerWidth(for: .regular, scrollerStyle: scrollerStyle)
1458+
NSScroller.scrollerWidth(for: scrollerControlSize, scrollerStyle: scrollerStyle)
1459+
}
1460+
1461+
private var scrollerControlSize: NSControl.ControlSize {
1462+
scrollerStyle == .overlay ? .small : .regular
13651463
}
13661464

13671465
private var reservedScrollerWidth: CGFloat {
@@ -1486,6 +1584,60 @@ open class TerminalView: NSView, NSUserInterfaceValidations, TerminalDelegate {
14861584
scroller.isEnabled = state.isEnabled
14871585
scroller.doubleValue = state.doubleValue
14881586
scroller.knobProportion = state.knobProportion
1587+
overlayScrollerIndicator.needsDisplay = true
1588+
if !state.isEnabled, scrollerStyle == .overlay {
1589+
hideOverlayScroller(animated: false)
1590+
}
1591+
}
1592+
1593+
/// Shows the overlay scroller and restarts its fade delay.
1594+
func showOverlayScroller() {
1595+
guard scrollerStyle == .overlay, scroller.isEnabled else { return }
1596+
overlayScrollerHideTimer?.invalidate()
1597+
overlayScrollerFadeGeneration += 1
1598+
scroller.isHidden = false
1599+
scroller.alphaValue = 0
1600+
scroller.needsDisplay = true
1601+
overlayScrollerIndicator.isHidden = false
1602+
overlayScrollerIndicator.alphaValue = 1
1603+
overlayScrollerIndicator.needsDisplay = true
1604+
overlayScrollerIndicator.displayIfNeeded()
1605+
guard !overlayScrollerIsTracking else { return }
1606+
overlayScrollerHideTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) {
1607+
[weak self] _ in
1608+
MainActor.assumeIsolated {
1609+
self?.hideOverlayScroller(animated: true)
1610+
}
1611+
}
1612+
}
1613+
1614+
private func hideOverlayScroller(animated: Bool) {
1615+
overlayScrollerHideTimer?.invalidate()
1616+
overlayScrollerHideTimer = nil
1617+
overlayScrollerFadeGeneration += 1
1618+
let generation = overlayScrollerFadeGeneration
1619+
if animated && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion {
1620+
NSAnimationContext.runAnimationGroup({ context in
1621+
context.duration = 0.25
1622+
overlayScrollerIndicator.animator().alphaValue = 0
1623+
}, completionHandler: { [weak self] in
1624+
MainActor.assumeIsolated {
1625+
guard let self, self.overlayScrollerFadeGeneration == generation else { return }
1626+
self.overlayScrollerIndicator.isHidden = true
1627+
self.scroller.isHidden = true
1628+
}
1629+
})
1630+
} else {
1631+
scroller.alphaValue = 0
1632+
scroller.isHidden = true
1633+
overlayScrollerIndicator.alphaValue = 0
1634+
overlayScrollerIndicator.isHidden = true
1635+
}
1636+
}
1637+
1638+
private func setOverlayScrollerTracking(_ isTracking: Bool) {
1639+
overlayScrollerIsTracking = isTracking
1640+
showOverlayScroller()
14891641
}
14901642

14911643
var userScrolling = false
@@ -3742,6 +3894,7 @@ open class TerminalView: NSView, NSUserInterfaceValidations, TerminalDelegate {
37423894
} else {
37433895
scrollDown(lines: magnitude)
37443896
}
3897+
showOverlayScroller()
37453898
}
37463899
}
37473900

Tests/SwiftTermTests/FontResizeColumnsTests.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,42 @@ struct FontResizeColumnsTests {
4242
#expect(usableWidth - renderedWidth < view.cellDimension.width)
4343
}
4444

45+
@Test func testOverlayScrollerAppearsOnDemand() async throws {
46+
let view = TerminalView(frame: CGRect(x: 0, y: 0, width: 400, height: 200))
47+
let scroller = view.subviews.compactMap { $0 as? NSScroller }.first
48+
let indicator = view.subviews.first {
49+
$0.identifier?.rawValue == "SwiftTermOverlayScrollerIndicator"
50+
}
51+
52+
#expect(scroller?.alphaValue == 0)
53+
#expect(scroller?.isHidden == true)
54+
#expect(scroller?.controlSize == .small)
55+
#expect(indicator?.isHidden == true)
56+
#expect(indicator?.isFlipped == true)
57+
view.applyScrollerState(.init(isEnabled: true, doubleValue: 0.5, knobProportion: 0.2))
58+
view.showOverlayScroller()
59+
#expect(scroller?.alphaValue == 0)
60+
#expect(scroller?.isHidden == false)
61+
#expect(indicator?.isHidden == false)
62+
#expect(indicator?.alphaValue == 1)
63+
view.layoutSubtreeIfNeeded()
64+
if let scroller {
65+
let point = CGPoint(x: scroller.frame.midX, y: scroller.frame.midY)
66+
#expect(view.hitTest(point) === scroller)
67+
}
68+
try await Task.sleep(for: .seconds(2))
69+
#expect(scroller?.isHidden == true)
70+
#expect(indicator?.isHidden == true)
71+
}
72+
73+
@Test func testScrollerUsesTerminalScrollPositionDirectly() {
74+
let view = TerminalView(frame: CGRect(x: 0, y: 0, width: 400, height: 200))
75+
let scroller = view.subviews.compactMap { $0 as? NSScroller }.first
76+
77+
view.applyScrollerState(.init(isEnabled: true, doubleValue: 1, knobProportion: 0.2))
78+
#expect(scroller?.doubleValue == 1)
79+
}
80+
4581
/// The font-change path (`resetFont`) and the live-resize path
4682
/// (`processSizeChange`) must agree on the column count for a given frame,
4783
/// so zooming the font in and back out never drifts the column count.

0 commit comments

Comments
 (0)