Skip to content

Commit 0e0bbfa

Browse files
authored
macOS: Scrollbars (#9232)
Completes #111 for macOS This builds on #9225 and adds the native, macOS GUI scrollbars for terminals. This doesn't do GTK, but all the groundwork is there to make this easy on GTK, depending on how scrollviews work there. I have to look into that still. But in theory all the information and controls we provide out of core are generic to _any_ scrollbar drawing. ## Demo https://github.com/user-attachments/assets/85683bf9-1117-4f32-aaec-d926edd68c39 ## Details - The scrollbars respect your macOS system settings on style, color, visibility. - A new configuration `scrollbar` controls whether scrollbars are visible (defaults to `system` allowing the system to choose). - There is a new keybind action `scroll_to_row:N` that lets you jump to an absolute row number N. This is implemented efficiently. This is how grabbing the knob and scrolling works.
2 parents 5a9bd0e + 4b34b23 commit 0e0bbfa

File tree

19 files changed

+958
-27
lines changed

19 files changed

+958
-27
lines changed

include/ghostty.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,13 @@ typedef struct {
741741
uint64_t duration;
742742
} ghostty_action_command_finished_s;
743743

744+
// terminal.Scrollbar
745+
typedef struct {
746+
uint64_t total;
747+
uint64_t offset;
748+
uint64_t len;
749+
} ghostty_action_scrollbar_s;
750+
744751
// apprt.Action.Key
745752
typedef enum {
746753
GHOSTTY_ACTION_QUIT,
@@ -767,6 +774,7 @@ typedef enum {
767774
GHOSTTY_ACTION_RESET_WINDOW_SIZE,
768775
GHOSTTY_ACTION_INITIAL_SIZE,
769776
GHOSTTY_ACTION_CELL_SIZE,
777+
GHOSTTY_ACTION_SCROLLBAR,
770778
GHOSTTY_ACTION_RENDER,
771779
GHOSTTY_ACTION_INSPECTOR,
772780
GHOSTTY_ACTION_SHOW_GTK_INSPECTOR,
@@ -809,6 +817,7 @@ typedef union {
809817
ghostty_action_size_limit_s size_limit;
810818
ghostty_action_initial_size_s initial_size;
811819
ghostty_action_cell_size_s cell_size;
820+
ghostty_action_scrollbar_s scrollbar;
812821
ghostty_action_inspector_e inspector;
813822
ghostty_action_desktop_notification_s desktop_notification;
814823
ghostty_action_set_title_s set_title;

macos/Ghostty.xcodeproj/project.pbxproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
Ghostty/Ghostty.Surface.swift,
142142
Ghostty/InspectorView.swift,
143143
"Ghostty/NSEvent+Extension.swift",
144+
Ghostty/SurfaceScrollView.swift,
144145
Ghostty/SurfaceView_AppKit.swift,
145146
Helpers/AppInfo.swift,
146147
Helpers/CodableBridge.swift,

macos/Sources/Ghostty/Ghostty.Action.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ extension Ghostty.Action {
100100
let state: State
101101
let progress: UInt8?
102102
}
103+
104+
struct Scrollbar {
105+
let total: UInt64
106+
let offset: UInt64
107+
let len: UInt64
108+
109+
init(c: ghostty_action_scrollbar_s) {
110+
total = c.total
111+
offset = c.offset
112+
len = c.len
113+
}
114+
}
103115
}
104116

105117
// Putting the initializer in an extension preserves the automatic one.

macos/Sources/Ghostty/Ghostty.App.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,9 @@ extension Ghostty {
571571
case GHOSTTY_ACTION_REDO:
572572
return redo(app, target: target)
573573

574+
case GHOSTTY_ACTION_SCROLLBAR:
575+
scrollbar(app, target: target, v: action.action.scrollbar)
576+
574577
case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS:
575578
fallthrough
576579
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
@@ -1560,6 +1563,33 @@ extension Ghostty {
15601563
}
15611564
}
15621565

1566+
private static func scrollbar(
1567+
_ app: ghostty_app_t,
1568+
target: ghostty_target_s,
1569+
v: ghostty_action_scrollbar_s) {
1570+
switch (target.tag) {
1571+
case GHOSTTY_TARGET_APP:
1572+
Ghostty.logger.warning("scrollbar does nothing with an app target")
1573+
return
1574+
1575+
case GHOSTTY_TARGET_SURFACE:
1576+
guard let surface = target.target.surface else { return }
1577+
guard let surfaceView = self.surfaceView(from: surface) else { return }
1578+
1579+
let scrollbar = Ghostty.Action.Scrollbar(c: v)
1580+
NotificationCenter.default.post(
1581+
name: .ghosttyDidUpdateScrollbar,
1582+
object: surfaceView,
1583+
userInfo: [
1584+
SwiftUI.Notification.Name.ScrollbarKey: scrollbar
1585+
]
1586+
)
1587+
1588+
default:
1589+
assertionFailure()
1590+
}
1591+
}
1592+
15631593
private static func configReload(
15641594
_ app: ghostty_app_t,
15651595
target: ghostty_target_s,

macos/Sources/Ghostty/Ghostty.Config.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,17 @@ extension Ghostty {
603603
let str = String(cString: ptr)
604604
return MacShortcuts(rawValue: str) ?? defaultValue
605605
}
606+
607+
var scrollbar: Scrollbar {
608+
let defaultValue = Scrollbar.system
609+
guard let config = self.config else { return defaultValue }
610+
var v: UnsafePointer<Int8>? = nil
611+
let key = "scrollbar"
612+
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
613+
guard let ptr = v else { return defaultValue }
614+
let str = String(cString: ptr)
615+
return Scrollbar(rawValue: str) ?? defaultValue
616+
}
606617
}
607618
}
608619

@@ -641,6 +652,11 @@ extension Ghostty.Config {
641652
case ask
642653
}
643654

655+
enum Scrollbar: String {
656+
case system
657+
case never
658+
}
659+
644660
enum ResizeOverlay : String {
645661
case always
646662
case never

macos/Sources/Ghostty/Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,10 @@ extension Notification.Name {
344344

345345
/// Toggle maximize of current window
346346
static let ghosttyMaximizeDidToggle = Notification.Name("com.mitchellh.ghostty.maximizeDidToggle")
347+
348+
/// Notification sent when scrollbar updates
349+
static let ghosttyDidUpdateScrollbar = Notification.Name("com.mitchellh.ghostty.didUpdateScrollbar")
350+
static let ScrollbarKey = ghosttyDidUpdateScrollbar.rawValue + ".scrollbar"
347351
}
348352

349353
// NOTE: I am moving all of these to Notification.Name extensions over time. This
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import SwiftUI
2+
import Combine
3+
4+
/// Wraps a Ghostty surface view in an NSScrollView to provide native macOS scrollbar support.
5+
///
6+
/// ## Coordinate System
7+
/// AppKit uses a +Y-up coordinate system (origin at bottom-left), while terminals conceptually
8+
/// use +Y-down (row 0 at top). This class handles the inversion when converting between row
9+
/// offsets and pixel positions.
10+
///
11+
/// ## Architecture
12+
/// - `scrollView`: The outermost NSScrollView that manages scrollbar rendering and behavior
13+
/// - `documentView`: A blank NSView whose height represents total scrollback (in pixels)
14+
/// - `surfaceView`: The actual Ghostty renderer, positioned to fill the visible rect
15+
class SurfaceScrollView: NSView {
16+
private let scrollView: NSScrollView
17+
private let documentView: NSView
18+
private let surfaceView: Ghostty.SurfaceView
19+
private var observers: [NSObjectProtocol] = []
20+
private var cancellables: Set<AnyCancellable> = []
21+
private var isLiveScrolling = false
22+
23+
/// The last row position sent via scroll_to_row action. Used to avoid
24+
/// sending redundant actions when the user drags the scrollbar but stays
25+
/// on the same row.
26+
private var lastSentRow: Int?
27+
28+
init(contentSize: CGSize, surfaceView: Ghostty.SurfaceView) {
29+
self.surfaceView = surfaceView
30+
// The scroll view is our outermost view that controls all our scrollbar
31+
// rendering and behavior.
32+
scrollView = NSScrollView()
33+
scrollView.hasVerticalScroller = false
34+
scrollView.hasHorizontalScroller = false
35+
scrollView.autohidesScrollers = true
36+
scrollView.usesPredominantAxisScrolling = true
37+
38+
// The document view is what the scrollview is actually going
39+
// to be directly scrolling. We set it up to a "blank" NSView
40+
// with the desired content size.
41+
documentView = NSView(frame: NSRect(origin: .zero, size: contentSize))
42+
scrollView.documentView = documentView
43+
44+
// The document view contains our actual surface as a child.
45+
// We synchronize the scrolling of the document with this surface
46+
// so that our primary Ghostty renderer only needs to render the viewport.
47+
documentView.addSubview(surfaceView)
48+
49+
super.init(frame: .zero)
50+
51+
// Our scroll view is our only view
52+
addSubview(scrollView)
53+
54+
// Apply initial scrollbar settings
55+
synchronizeAppearance()
56+
57+
// We listen for scroll events through bounds notifications on our NSClipView.
58+
// This is based on: https://christiantietze.de/posts/2018/07/synchronize-nsscrollview/
59+
scrollView.contentView.postsBoundsChangedNotifications = true
60+
observers.append(NotificationCenter.default.addObserver(
61+
forName: NSView.boundsDidChangeNotification,
62+
object: scrollView.contentView,
63+
queue: .main
64+
) { [weak self] notification in
65+
self?.handleScrollChange(notification)
66+
})
67+
68+
// Listen for scrollbar updates from Ghostty
69+
observers.append(NotificationCenter.default.addObserver(
70+
forName: .ghosttyDidUpdateScrollbar,
71+
object: surfaceView,
72+
queue: .main
73+
) { [weak self] notification in
74+
self?.handleScrollbarUpdate(notification)
75+
})
76+
77+
// Listen for live scroll events
78+
observers.append(NotificationCenter.default.addObserver(
79+
forName: NSScrollView.willStartLiveScrollNotification,
80+
object: scrollView,
81+
queue: .main
82+
) { [weak self] _ in
83+
self?.isLiveScrolling = true
84+
})
85+
86+
observers.append(NotificationCenter.default.addObserver(
87+
forName: NSScrollView.didEndLiveScrollNotification,
88+
object: scrollView,
89+
queue: .main
90+
) { [weak self] _ in
91+
self?.isLiveScrolling = false
92+
})
93+
94+
observers.append(NotificationCenter.default.addObserver(
95+
forName: NSScrollView.didLiveScrollNotification,
96+
object: scrollView,
97+
queue: .main
98+
) { [weak self] _ in
99+
self?.handleLiveScroll()
100+
})
101+
102+
// Listen for derived config changes to update scrollbar settings live
103+
surfaceView.$derivedConfig
104+
.sink { [weak self] _ in
105+
DispatchQueue.main.async { [weak self] in
106+
self?.synchronizeAppearance()
107+
}
108+
}
109+
.store(in: &cancellables)
110+
}
111+
112+
required init?(coder: NSCoder) {
113+
fatalError("init(coder:) not implemented")
114+
}
115+
116+
deinit {
117+
observers.forEach { NotificationCenter.default.removeObserver($0) }
118+
}
119+
120+
override func setFrameSize(_ newSize: NSSize) {
121+
super.setFrameSize(newSize)
122+
123+
// Force layout to be called to fix up our various subviews.
124+
needsLayout = true
125+
}
126+
127+
override func layout() {
128+
super.layout()
129+
130+
// Fill entire bounds with scroll view
131+
scrollView.frame = bounds
132+
133+
// Use contentSize to account for visible scrollers
134+
//
135+
// Only update sizes if we have a valid (non-zero) content size. The content size
136+
// can be zero when this is added early to a view, or to an invisible hierarchy.
137+
// Practically, this happened in the quick terminal.
138+
let contentSize = scrollView.contentSize
139+
if contentSize.width > 0 && contentSize.height > 0 {
140+
// Keep document width synchronized with content width
141+
documentView.setFrameSize(CGSize(
142+
width: contentSize.width,
143+
height: documentView.frame.height
144+
))
145+
146+
// Inform the actual pty of our size change
147+
surfaceView.sizeDidChange(contentSize)
148+
}
149+
150+
// When our scrollview changes make sure our surface view is synchronized
151+
synchronizeSurfaceView()
152+
}
153+
154+
// MARK: Scrolling
155+
156+
private func synchronizeAppearance() {
157+
let scrollbarConfig = surfaceView.derivedConfig.scrollbar
158+
scrollView.hasVerticalScroller = scrollbarConfig != .never
159+
}
160+
161+
/// Positions the surface view to fill the currently visible rectangle.
162+
///
163+
/// This is called whenever the scroll position changes. The surface view (which does the
164+
/// actual terminal rendering) always fills exactly the visible portion of the document view,
165+
/// so the renderer only needs to render what's currently on screen.
166+
private func synchronizeSurfaceView() {
167+
let visibleRect = scrollView.contentView.documentVisibleRect
168+
surfaceView.frame = visibleRect
169+
}
170+
171+
// MARK: Notifications
172+
173+
/// Handles bounds changes in the scroll view's clip view, keeping the surface view synchronized.
174+
private func handleScrollChange(_ notification: Notification) {
175+
synchronizeSurfaceView()
176+
}
177+
178+
/// Handles live scroll events (user actively dragging the scrollbar).
179+
///
180+
/// Converts the current scroll position to a row number and sends a `scroll_to_row` action
181+
/// to the terminal core. Only sends actions when the row changes to avoid IPC spam.
182+
private func handleLiveScroll() {
183+
// If our cell height is currently zero then we avoid a div by zero below
184+
// and just don't scroll (there's no where to scroll anyways). This can
185+
// happen with a tiny terminal.
186+
let cellHeight = surfaceView.cellSize.height
187+
guard cellHeight > 0 else { return }
188+
189+
// AppKit views are +Y going up, so we calculate from the bottom
190+
let visibleRect = scrollView.contentView.documentVisibleRect
191+
let documentHeight = documentView.frame.height
192+
let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height
193+
let row = Int(scrollOffset / cellHeight)
194+
195+
// Only send action if the row changed to avoid action spam
196+
guard row != lastSentRow else { return }
197+
lastSentRow = row
198+
199+
// Use the keybinding action to scroll.
200+
_ = surfaceView.surfaceModel?.perform(action: "scroll_to_row:\(row)")
201+
}
202+
203+
/// Handles scrollbar state updates from the terminal core.
204+
///
205+
/// Updates the document view size to reflect total scrollback and adjusts scroll position
206+
/// to match the terminal's viewport. During live scrolling, updates document size but skips
207+
/// programmatic position changes to avoid fighting the user's drag.
208+
///
209+
/// ## Scrollbar State
210+
/// The scrollbar struct contains:
211+
/// - `total`: Total rows in scrollback + active area
212+
/// - `offset`: First visible row (0 = top of history)
213+
/// - `len`: Number of visible rows (viewport height)
214+
private func handleScrollbarUpdate(_ notification: Notification) {
215+
guard let scrollbar = notification.userInfo?[SwiftUI.Notification.Name.ScrollbarKey] as? Ghostty.Action.Scrollbar else {
216+
return
217+
}
218+
219+
// Convert row units to pixels using cell height, ignore zero height.
220+
let cellHeight = surfaceView.cellSize.height
221+
guard cellHeight > 0 else { return }
222+
223+
// Our width should be the content width to account for visible scrollers.
224+
// We don't do horizontal scrolling in terminals.
225+
let totalHeight = CGFloat(scrollbar.total) * cellHeight
226+
let newSize = CGSize(width: scrollView.contentSize.width, height: totalHeight)
227+
documentView.setFrameSize(newSize)
228+
229+
// Only update our actual scroll position if we're not actively scrolling.
230+
if !isLiveScrolling {
231+
// Invert coordinate system: terminal offset is from top, AppKit position from bottom
232+
let offsetY = CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight
233+
scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY))
234+
235+
// Track the current row position to avoid redundant movements when we
236+
// move the scrollbar.
237+
lastSentRow = Int(scrollbar.offset)
238+
}
239+
240+
// Always update our scrolled view with the latest dimensions
241+
scrollView.reflectScrolledClipView(scrollView.contentView)
242+
}
243+
}

0 commit comments

Comments
 (0)