Add keyboard copy mode for terminal scrollback (#792)

* Add keyboard copy mode for terminal scrollback

* Show vim copy mode indicator in terminal

* Fix vi copy-mode symbol keys and pending yank handling

* Refine copy-mode badge wording and font

* Rename keyboard copy-mode badge to VI MODE

* Address PR feedback for copy-mode routing and keyup handling

* Refresh copy-mode viewport row after scrolling
This commit is contained in:
Lawrence Chen 2026-03-03 19:01:21 -08:00 committed by GitHub
parent bfe843f0bd
commit 2f6cb6ff38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1092 additions and 1 deletions

View file

@ -271,6 +271,259 @@ func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget?
return .external(fallback)
}
enum TerminalKeyboardCopyModeSelectionMove: String, Equatable {
case left
case right
case up
case down
case pageUp = "page_up"
case pageDown = "page_down"
case home
case end
case beginningOfLine = "beginning_of_line"
case endOfLine = "end_of_line"
}
enum TerminalKeyboardCopyModeAction: Equatable {
case exit
case startSelection
case clearSelection
case copyAndExit
case copyLineAndExit
case scrollLines(Int)
case scrollPage(Int)
case scrollToTop
case scrollToBottom
case jumpToPrompt(Int)
case startSearch
case searchNext
case searchPrevious
case adjustSelection(TerminalKeyboardCopyModeSelectionMove)
}
struct TerminalKeyboardCopyModeInputState: Equatable {
var countPrefix: Int?
var pendingYankLine = false
mutating func reset() {
countPrefix = nil
pendingYankLine = false
}
}
enum TerminalKeyboardCopyModeResolution: Equatable {
case perform(TerminalKeyboardCopyModeAction, count: Int)
case consume
}
private let terminalKeyboardCopyModeMaxCount = 9_999
private func terminalKeyboardCopyModeClampCount(_ value: Int) -> Int {
min(max(value, 1), terminalKeyboardCopyModeMaxCount)
}
func terminalKeyboardCopyModeInitialViewportRow(
rows: Int,
imePointY: Double,
imeCellHeight: Double,
topPadding: Double = 0
) -> Int {
let clampedRows = max(rows, 1)
guard imeCellHeight > 0 else { return clampedRows - 1 }
// `ghostty_surface_ime_point` returns a top-origin Y coordinate at the
// cursor baseline plus one cell-height. Convert that to a zero-based row.
let estimatedRow = Int(floor(((imePointY - topPadding) / imeCellHeight) - 1))
return max(0, min(clampedRows - 1, estimatedRow))
}
private func terminalKeyboardCopyModeNormalizedModifiers(
_ modifierFlags: NSEvent.ModifierFlags
) -> NSEvent.ModifierFlags {
modifierFlags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
}
private func terminalKeyboardCopyModeChars(
_ charactersIgnoringModifiers: String?
) -> String {
guard let scalar = charactersIgnoringModifiers?.unicodeScalars.first else {
return ""
}
return String(scalar).lowercased()
}
func terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: NSEvent.ModifierFlags) -> Bool {
let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags)
return normalized.contains(.command)
}
func terminalKeyboardCopyModeAction(
keyCode: UInt16,
charactersIgnoringModifiers: String?,
modifierFlags: NSEvent.ModifierFlags,
hasSelection: Bool
) -> TerminalKeyboardCopyModeAction? {
let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags)
let chars = terminalKeyboardCopyModeChars(charactersIgnoringModifiers)
if keyCode == 53 { // Escape
return .exit
}
switch keyCode {
case 126: // Up
return hasSelection ? .adjustSelection(.up) : .scrollLines(-1)
case 125: // Down
return hasSelection ? .adjustSelection(.down) : .scrollLines(1)
case 123: // Left
return hasSelection ? .adjustSelection(.left) : nil
case 124: // Right
return hasSelection ? .adjustSelection(.right) : nil
case 116: // Page Up
return hasSelection ? .adjustSelection(.pageUp) : .scrollPage(-1)
case 121: // Page Down
return hasSelection ? .adjustSelection(.pageDown) : .scrollPage(1)
case 115: // Home
return hasSelection ? .adjustSelection(.home) : .scrollToTop
case 119: // End
return hasSelection ? .adjustSelection(.end) : .scrollToBottom
default:
break
}
if normalized == [.control] {
if chars == "u" || chars == "\u{15}" {
return hasSelection ? .adjustSelection(.pageUp) : .scrollPage(-1)
}
if chars == "d" || chars == "\u{04}" {
return hasSelection ? .adjustSelection(.pageDown) : .scrollPage(1)
}
if chars == "b" || chars == "\u{02}" {
return hasSelection ? .adjustSelection(.pageUp) : .scrollPage(-1)
}
if chars == "f" || chars == "\u{06}" {
return hasSelection ? .adjustSelection(.pageDown) : .scrollPage(1)
}
if chars == "y" || chars == "\u{19}" {
return hasSelection ? .adjustSelection(.up) : .scrollLines(-1)
}
if chars == "e" || chars == "\u{05}" {
return hasSelection ? .adjustSelection(.down) : .scrollLines(1)
}
return nil
}
guard normalized.isEmpty || normalized == [.shift] else { return nil }
switch chars {
case "q":
return .exit
case "v":
return hasSelection ? .clearSelection : .startSelection
case "y":
if normalized == [.shift], !hasSelection {
return .copyLineAndExit
}
return hasSelection ? .copyAndExit : nil
case "j":
return hasSelection ? .adjustSelection(.down) : .scrollLines(1)
case "k":
return hasSelection ? .adjustSelection(.up) : .scrollLines(-1)
case "h":
return hasSelection ? .adjustSelection(.left) : nil
case "l":
return hasSelection ? .adjustSelection(.right) : nil
case "g":
if normalized == [.shift] {
return hasSelection ? .adjustSelection(.end) : .scrollToBottom
}
return hasSelection ? .adjustSelection(.home) : .scrollToTop
case "0", "^":
return hasSelection ? .adjustSelection(.beginningOfLine) : nil
case "$", "4":
guard chars == "$" || normalized == [.shift] else { return nil }
return hasSelection ? .adjustSelection(.endOfLine) : nil
case "{", "[":
guard chars == "{" || normalized == [.shift] else { return nil }
return .jumpToPrompt(-1)
case "}", "]":
guard chars == "}" || normalized == [.shift] else { return nil }
return .jumpToPrompt(1)
case "/":
return .startSearch
case "n":
return normalized == [.shift] ? .searchPrevious : .searchNext
default:
return nil
}
}
func terminalKeyboardCopyModeResolve(
keyCode: UInt16,
charactersIgnoringModifiers: String?,
modifierFlags: NSEvent.ModifierFlags,
hasSelection: Bool,
state: inout TerminalKeyboardCopyModeInputState
) -> TerminalKeyboardCopyModeResolution {
let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags)
let chars = terminalKeyboardCopyModeChars(charactersIgnoringModifiers)
if keyCode == 53 { // Escape
state.reset()
return .perform(.exit, count: 1)
}
if state.pendingYankLine {
if chars == "y", normalized.isEmpty || normalized == [.shift] {
let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1)
state.reset()
return .perform(.copyLineAndExit, count: count)
}
// Only `yy`/`Y` are supported as line-yank operators, so cancel the
// pending yank and treat this key as a fresh command.
state.pendingYankLine = false
}
if normalized.isEmpty,
let scalar = chars.unicodeScalars.first,
scalar.isASCII,
scalar.value >= 48,
scalar.value <= 57 {
let digit = Int(scalar.value - 48)
if digit == 0 {
if let currentCount = state.countPrefix {
state.countPrefix = terminalKeyboardCopyModeClampCount(currentCount * 10)
return .consume
}
} else {
let currentCount = state.countPrefix ?? 0
state.countPrefix = terminalKeyboardCopyModeClampCount((currentCount * 10) + digit)
return .consume
}
}
if !hasSelection, chars == "y", normalized.isEmpty {
state.pendingYankLine = true
return .consume
}
guard let action = terminalKeyboardCopyModeAction(
keyCode: keyCode,
charactersIgnoringModifiers: charactersIgnoringModifiers,
modifierFlags: modifierFlags,
hasSelection: hasSelection
) else {
state.reset()
return .consume
}
let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1)
state.reset()
return .perform(action, count: count)
}
private final class GhosttySurfaceCallbackContext {
weak var surfaceView: GhosttyNSView?
weak var terminalSurface: TerminalSurface?
@ -1657,6 +1910,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
}
}
@Published private(set) var keyboardCopyModeActive: Bool = false
private var searchNeedleCancellable: AnyCancellable?
init(
@ -2326,6 +2580,29 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
}
@discardableResult
func toggleKeyboardCopyMode() -> Bool {
let handled = surfaceView.toggleKeyboardCopyMode()
if handled {
setKeyboardCopyModeActive(surfaceView.isKeyboardCopyModeActive)
}
return handled
}
func setKeyboardCopyModeActive(_ active: Bool) {
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.setKeyboardCopyModeActive(active)
}
return
}
if keyboardCopyModeActive != active {
keyboardCopyModeActive = active
}
hostedView.setKeyboardCopyModeIndicator(visible: active)
}
func hasSelection() -> Bool {
guard let surface = surface else { return false }
return ghostty_surface_has_selection(surface)
@ -2435,6 +2712,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private var lastLoggedWindowBackgroundSignature: String?
private var keySequence: [ghostty_input_trigger_s] = []
private var keyTables: [String] = []
fileprivate private(set) var keyboardCopyModeActive = false
private var keyboardCopyModeConsumedKeyUps: Set<UInt16> = []
private var keyboardCopyModeInputState = TerminalKeyboardCopyModeInputState()
private var keyboardCopyModeViewportRow: Int?
fileprivate var isKeyboardCopyModeActive: Bool { keyboardCopyModeActive }
#if DEBUG
private static let keyLatencyProbeEnabled: Bool = {
if ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" {
@ -2597,6 +2879,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
terminalSurface = surface
tabId = surface.tabId
surface.attachToView(self)
surface.setKeyboardCopyModeActive(keyboardCopyModeActive)
updateSurfaceSize()
applySurfaceBackground()
applySurfaceColorScheme(force: true)
@ -2865,6 +3148,195 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
}
@discardableResult
func toggleKeyboardCopyMode() -> Bool {
guard surface != nil else { return false }
setKeyboardCopyModeActive(!keyboardCopyModeActive)
if !keyboardCopyModeActive, let surface {
_ = ghostty_surface_clear_selection(surface)
}
return true
}
private func setKeyboardCopyModeActive(_ active: Bool) {
keyboardCopyModeInputState.reset()
keyboardCopyModeActive = active
if active, let surface {
keyboardCopyModeViewportRow = keyboardCopyModeSelectionAnchor(surface: surface)?.row
_ = ghostty_surface_clear_selection(surface)
if keyboardCopyModeViewportRow == nil {
keyboardCopyModeViewportRow = keyboardCopyModeImeViewportRow(surface: surface)
}
} else {
keyboardCopyModeViewportRow = nil
}
terminalSurface?.setKeyboardCopyModeActive(active)
}
private func performBindingAction(_ action: String, repeatCount: Int) {
let count = terminalKeyboardCopyModeClampCount(repeatCount)
for _ in 0 ..< count {
_ = performBindingAction(action)
}
}
private func currentKeyboardCopyModeViewportRow(surface: ghostty_surface_t) -> Int {
let rows = max(Int(ghostty_surface_size(surface).rows), 1)
let fallback = rows - 1
return max(0, min(rows - 1, keyboardCopyModeViewportRow ?? fallback))
}
private func keyboardCopyModeImeViewportRow(surface: ghostty_surface_t) -> Int {
let rows = max(Int(ghostty_surface_size(surface).rows), 1)
var x: Double = 0
var y: Double = 0
var width: Double = 0
var height: Double = 0
ghostty_surface_ime_point(surface, &x, &y, &width, &height)
return terminalKeyboardCopyModeInitialViewportRow(
rows: rows,
imePointY: y,
imeCellHeight: height
)
}
private func keyboardCopyModeSelectionAnchor(surface: ghostty_surface_t) -> (row: Int, y: Double)? {
let size = ghostty_surface_size(surface)
guard size.rows > 0, size.columns > 0 else { return nil }
guard ghostty_surface_select_cursor_cell(surface) else { return nil }
var text = ghostty_text_s()
guard ghostty_surface_read_selection(surface, &text) else { return nil }
defer { ghostty_surface_free_text(surface, &text) }
let rows = max(Int(size.rows), 1)
let cols = max(Int(size.columns), 1)
let rawRow = Int(text.offset_start) / cols
let clampedRow = max(0, min(rows - 1, rawRow))
return (row: clampedRow, y: text.tl_px_y)
}
private func refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: ghostty_surface_t) {
guard !ghostty_surface_has_selection(surface) else { return }
guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else { return }
keyboardCopyModeViewportRow = anchor.row
_ = ghostty_surface_clear_selection(surface)
}
private func copyCurrentViewportLinesToClipboard(
surface: ghostty_surface_t,
startRow: Int,
lineCount: Int
) -> Bool {
let clampedCount = terminalKeyboardCopyModeClampCount(lineCount)
let rows = max(Int(ghostty_surface_size(surface).rows), 1)
let targetRow = max(0, min(rows - 1, startRow))
let endRow = min(rows - 1, targetRow + clampedCount - 1)
guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else {
return false
}
_ = ghostty_surface_clear_selection(surface)
var imeX: Double = 0
var imeY: Double = 0
var imeWidth: Double = 0
var imeHeight: Double = 0
ghostty_surface_ime_point(surface, &imeX, &imeY, &imeWidth, &imeHeight)
let cellHeight = imeHeight > 0 ? imeHeight : max(bounds.height / Double(rows), 1)
let yMax = max(bounds.height - 1, 0)
let startRawY = anchor.y + (Double(targetRow - anchor.row) * cellHeight)
let endRawY = anchor.y + (Double(endRow - anchor.row) * cellHeight)
let startY = max(0, min(startRawY, yMax))
let endY = max(0, min(endRawY, yMax))
let xMax = max(bounds.width - 1, 0)
let startX = min(1, xMax)
let endX = xMax
let mods = ghostty_input_mods_e(rawValue: GHOSTTY_MODS_NONE.rawValue) ?? GHOSTTY_MODS_NONE
ghostty_surface_mouse_pos(surface, startX, startY, mods)
guard ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) else {
return false
}
defer {
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods)
}
ghostty_surface_mouse_pos(surface, endX, endY, mods)
guard ghostty_surface_has_selection(surface) else { return false }
return performBindingAction("copy_to_clipboard")
}
private func handleKeyboardCopyModeIfNeeded(_ event: NSEvent, surface: ghostty_surface_t) -> Bool {
guard keyboardCopyModeActive else { return false }
if terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: event.modifierFlags) {
keyboardCopyModeInputState.reset()
return false
}
let hasSelection = ghostty_surface_has_selection(surface)
let resolution = terminalKeyboardCopyModeResolve(
keyCode: event.keyCode,
charactersIgnoringModifiers: event.charactersIgnoringModifiers,
modifierFlags: event.modifierFlags,
hasSelection: hasSelection,
state: &keyboardCopyModeInputState
)
guard case let .perform(action, count) = resolution else {
return true
}
switch action {
case .exit:
_ = ghostty_surface_clear_selection(surface)
setKeyboardCopyModeActive(false)
case .startSelection:
_ = ghostty_surface_select_cursor_cell(surface)
case .clearSelection:
_ = ghostty_surface_clear_selection(surface)
case .copyAndExit:
_ = performBindingAction("copy_to_clipboard")
_ = ghostty_surface_clear_selection(surface)
setKeyboardCopyModeActive(false)
case .copyLineAndExit:
let startRow = currentKeyboardCopyModeViewportRow(surface: surface)
_ = copyCurrentViewportLinesToClipboard(
surface: surface,
startRow: startRow,
lineCount: count
)
_ = ghostty_surface_clear_selection(surface)
setKeyboardCopyModeActive(false)
case let .scrollLines(delta):
_ = performBindingAction("scroll_page_lines:\(delta * count)")
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
case let .scrollPage(delta):
performBindingAction(delta > 0 ? "scroll_page_down" : "scroll_page_up", repeatCount: count)
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
case .scrollToTop:
keyboardCopyModeViewportRow = 0
_ = performBindingAction("scroll_to_top")
case .scrollToBottom:
keyboardCopyModeViewportRow = max(Int(ghostty_surface_size(surface).rows) - 1, 0)
_ = performBindingAction("scroll_to_bottom")
case let .jumpToPrompt(delta):
_ = performBindingAction("jump_to_prompt:\(delta * count)")
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
case .startSearch:
_ = performBindingAction("start_search")
case .searchNext:
performBindingAction("navigate_search:next", repeatCount: count)
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
case .searchPrevious:
performBindingAction("navigate_search:previous", repeatCount: count)
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
case let .adjustSelection(direction):
performBindingAction("adjust_selection:\(direction.rawValue)", repeatCount: count)
}
return true
}
// MARK: - Input Handling
@IBAction func copy(_ sender: Any?) {
@ -3227,6 +3699,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
super.keyDown(with: event)
return
}
if handleKeyboardCopyModeIfNeeded(event, surface: surface) {
keyboardCopyModeConsumedKeyUps.insert(event.keyCode)
return
}
#if DEBUG
recordKeyLatency(path: "keyDown", event: event)
#endif
@ -3436,6 +3912,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return
}
if keyboardCopyModeConsumedKeyUps.remove(event.keyCode) != nil {
return
}
// Build release events from the same translation path as keyDown so
// consumers that depend on precise key identity (for example Space
// hold/release flows) receive consistent metadata.
@ -4088,6 +4568,14 @@ private final class GhosttyFlashOverlayView: NSView {
}
}
private final class GhosttyPassthroughVisualEffectView: NSVisualEffectView {
override var acceptsFirstResponder: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
}
final class GhosttySurfaceScrollView: NSView {
private let backgroundView: NSView
private let scrollView: GhosttyScrollView
@ -4099,6 +4587,8 @@ final class GhosttySurfaceScrollView: NSView {
private let notificationRingLayer: CAShapeLayer
private let flashOverlayView: GhosttyFlashOverlayView
private let flashLayer: CAShapeLayer
private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView
private let keyboardCopyModeBadgeLabel: NSTextField
private var searchOverlayHostingView: NSHostingView<SurfaceSearchOverlay>?
private var observers: [NSObjectProtocol] = []
private var windowObservers: [NSObjectProtocol] = []
@ -4253,6 +4743,8 @@ final class GhosttySurfaceScrollView: NSView {
notificationRingLayer = CAShapeLayer()
flashOverlayView = GhosttyFlashOverlayView(frame: .zero)
flashLayer = CAShapeLayer()
keyboardCopyModeBadgeView = GhosttyPassthroughVisualEffectView(frame: .zero)
keyboardCopyModeBadgeLabel = NSTextField(labelWithString: "VI MODE")
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = false
@ -4325,6 +4817,32 @@ final class GhosttySurfaceScrollView: NSView {
flashLayer.opacity = 0
flashOverlayView.layer?.addSublayer(flashLayer)
addSubview(flashOverlayView)
keyboardCopyModeBadgeView.translatesAutoresizingMaskIntoConstraints = false
keyboardCopyModeBadgeView.wantsLayer = true
keyboardCopyModeBadgeView.material = .hudWindow
keyboardCopyModeBadgeView.blendingMode = .withinWindow
keyboardCopyModeBadgeView.state = .active
keyboardCopyModeBadgeView.layer?.cornerRadius = 7
keyboardCopyModeBadgeView.layer?.masksToBounds = true
keyboardCopyModeBadgeView.layer?.borderWidth = 1
keyboardCopyModeBadgeView.layer?.borderColor = cmuxAccentNSColor().withAlphaComponent(0.45).cgColor
keyboardCopyModeBadgeView.alphaValue = 0.97
keyboardCopyModeBadgeLabel.translatesAutoresizingMaskIntoConstraints = false
keyboardCopyModeBadgeLabel.textColor = NSColor.labelColor
keyboardCopyModeBadgeLabel.lineBreakMode = .byClipping
keyboardCopyModeBadgeView.addSubview(keyboardCopyModeBadgeLabel)
NSLayoutConstraint.activate([
keyboardCopyModeBadgeLabel.leadingAnchor.constraint(equalTo: keyboardCopyModeBadgeView.leadingAnchor, constant: 8),
keyboardCopyModeBadgeLabel.trailingAnchor.constraint(equalTo: keyboardCopyModeBadgeView.trailingAnchor, constant: -8),
keyboardCopyModeBadgeLabel.topAnchor.constraint(equalTo: keyboardCopyModeBadgeView.topAnchor, constant: 4),
keyboardCopyModeBadgeLabel.bottomAnchor.constraint(equalTo: keyboardCopyModeBadgeView.bottomAnchor, constant: -4),
])
keyboardCopyModeBadgeView.isHidden = true
addSubview(keyboardCopyModeBadgeView)
NSLayoutConstraint.activate([
keyboardCopyModeBadgeView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
keyboardCopyModeBadgeView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
])
scrollView.contentView.postsBoundsChangedNotifications = true
observers.append(NotificationCenter.default.addObserver(
@ -4579,6 +5097,9 @@ final class GhosttySurfaceScrollView: NSView {
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
}
if !keyboardCopyModeBadgeView.isHidden {
addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay)
}
return
}
@ -4591,9 +5112,30 @@ final class GhosttySurfaceScrollView: NSView {
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
if !keyboardCopyModeBadgeView.isHidden {
addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay)
}
searchOverlayHostingView = overlay
}
func setKeyboardCopyModeIndicator(visible: Bool) {
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.setKeyboardCopyModeIndicator(visible: visible)
}
return
}
keyboardCopyModeBadgeView.isHidden = !visible
if visible {
if let overlay = searchOverlayHostingView {
addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay)
} else {
addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: nil)
}
}
}
private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect {
let padding: CGFloat = 4
switch zone {
@ -4916,6 +5458,10 @@ final class GhosttySurfaceScrollView: NSView {
return overlay.superview === self && !overlay.isHidden
}
func debugHasKeyboardCopyModeIndicator() -> Bool {
keyboardCopyModeBadgeView.superview === self && !keyboardCopyModeBadgeView.isHidden
}
#endif
/// Handle file/URL drops, forwarding to the terminal as shell-escaped paths.
@ -5797,6 +6343,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
)
hostedView.setNotificationRing(visible: showsUnreadNotificationRing)
hostedView.setSearchOverlay(searchState: searchState)
hostedView.setKeyboardCopyModeIndicator(visible: terminalSurface.keyboardCopyModeActive)
hostedView.setFocusHandler { onFocus?(terminalSurface.id) }
hostedView.setTriggerFlashHandler(onTriggerFlash)
let portalExpectedSurfaceId = terminalSurface.id