From 2f6cb6ff38b39bfcfce107c8a7ac4e5ef5696a4f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:01:21 -0800 Subject: [PATCH] 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 --- Sources/AppDelegate.swift | 13 + Sources/GhosttyTerminalView.swift | 547 ++++++++++++++++++ Sources/KeyboardShortcutSettings.swift | 5 + Sources/TabManager.swift | 6 + .../AppDelegateShortcutRoutingTests.swift | 29 + cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 489 ++++++++++++++++ ghostty | 2 +- ghostty.h | 2 + 8 files changed, 1092 insertions(+), 1 deletion(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 64150724..10e1c47b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -6030,6 +6030,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleTerminalCopyMode)) { + let handled = tabManager?.toggleFocusedTerminalCopyMode() ?? false +#if DEBUG + dlog( + "shortcut.action name=toggleTerminalCopyMode handled=\(handled ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" + ) +#endif + // Only consume when a focused terminal actually handled the toggle. + // Otherwise allow the event to continue through the responder chain. + return handled + } + // Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[ if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSidebarTab)) { #if DEBUG diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0a6b2489..da2dda28 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -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 = [] + 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? 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 diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 13095d90..922fe5f2 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -23,6 +23,7 @@ enum KeyboardShortcutSettings { case renameWorkspace case closeWorkspace case newSurface + case toggleTerminalCopyMode // Panes / splits case focusLeft @@ -60,6 +61,7 @@ enum KeyboardShortcutSettings { case .renameWorkspace: return "Rename Workspace" case .closeWorkspace: return "Close Workspace" case .newSurface: return "New Surface" + case .toggleTerminalCopyMode: return "Toggle Terminal Copy Mode" case .focusLeft: return "Focus Pane Left" case .focusRight: return "Focus Pane Right" case .focusUp: return "Focus Pane Up" @@ -102,6 +104,7 @@ enum KeyboardShortcutSettings { case .nextSurface: return "shortcut.nextSurface" case .prevSurface: return "shortcut.prevSurface" case .newSurface: return "shortcut.newSurface" + case .toggleTerminalCopyMode: return "shortcut.toggleTerminalCopyMode" case .openBrowser: return "shortcut.openBrowser" case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools" case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole" @@ -160,6 +163,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "[", command: true, shift: true, option: false, control: false) case .newSurface: return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false) + case .toggleTerminalCopyMode: + return StoredShortcut(key: "m", command: true, shift: true, option: false, control: false) case .openBrowser: return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false) case .toggleBrowserDeveloperTools: diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 39b26771..aaeac1fe 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -753,6 +753,12 @@ class TabManager: ObservableObject { _ = selectedTerminalPanel?.performBindingAction("search:previous") } + @discardableResult + func toggleFocusedTerminalCopyMode() -> Bool { + guard let panel = selectedTerminalPanel else { return false } + return panel.surface.toggleKeyboardCopyMode() + } + func hideFind() { selectedTerminalPanel?.searchState = nil } diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index eaf8fb61..41689662 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -476,6 +476,35 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager") } + func testCmdShiftMReturnsFalseWhenNoFocusedTerminalCanHandle() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + // Force unresolved shortcut routing context and no active manager. + appDelegate.tabManager = nil + + guard let event = makeKeyDownEvent( + key: "m", + modifiers: [.command, .shift], + keyCode: 46, // kVK_ANSI_M + windowNumber: Int.max + ) else { + XCTFail("Failed to construct Cmd+Shift+M event") + return + } + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: event), + "Cmd+Shift+M should not be consumed when no terminal can toggle copy mode" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + func testPresentPreferencesWindowShowsCustomSettingsWindowAndActivates() { var showFallbackSettingsWindowCallCount = 0 var activateApplicationCallCount = 0 diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index cc75f384..0dd21b27 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1217,6 +1217,21 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { XCTAssertTrue(prevShortcut.eventModifiers.contains(.control)) } + func testToggleTerminalCopyModeShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.toggleTerminalCopyMode.label, "Toggle Terminal Copy Mode") + XCTAssertEqual( + KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultsKey, + "shortcut.toggleTerminalCopyMode" + ) + + let shortcut = KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultShortcut + XCTAssertEqual(shortcut.key, "m") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + } + func testMenuItemKeyEquivalentHandlesArrowAndTabKeys() { XCTAssertNotNil(StoredShortcut(key: "←", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) XCTAssertNotNil(StoredShortcut(key: "→", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) @@ -1234,6 +1249,463 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { } } +final class TerminalKeyboardCopyModeActionTests: XCTestCase { + func testCopyModeBypassAllowsOnlyCommandShortcuts() { + XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command])) + XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .shift])) + XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .option])) + XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option])) + XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option, .shift])) + XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.control])) + } + + func testJKWithoutSelectionScrollByLine() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 38, + charactersIgnoringModifiers: "j", + modifierFlags: [], + hasSelection: false + ), + .scrollLines(1) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 40, + charactersIgnoringModifiers: "k", + modifierFlags: [], + hasSelection: false + ), + .scrollLines(-1) + ) + } + + func testCapsLockDoesNotBlockLetterMappings() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 38, + charactersIgnoringModifiers: "j", + modifierFlags: [.capsLock], + hasSelection: false + ), + .scrollLines(1) + ) + } + + func testJKWithSelectionAdjustSelection() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 38, + charactersIgnoringModifiers: "j", + modifierFlags: [], + hasSelection: true + ), + .adjustSelection(.down) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 40, + charactersIgnoringModifiers: "k", + modifierFlags: [], + hasSelection: true + ), + .adjustSelection(.up) + ) + } + + func testControlPagingSupportsPrintableAndControlCharacters() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{15}", + modifierFlags: [.control], + hasSelection: false + ), + .scrollPage(-1) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{04}", + modifierFlags: [.control], + hasSelection: true + ), + .adjustSelection(.pageDown) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{02}", + modifierFlags: [.control], + hasSelection: false + ), + .scrollPage(-1) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{06}", + modifierFlags: [.control], + hasSelection: true + ), + .adjustSelection(.pageDown) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{19}", + modifierFlags: [.control], + hasSelection: false + ), + .scrollLines(-1) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{05}", + modifierFlags: [.control], + hasSelection: true + ), + .adjustSelection(.down) + ) + } + + func testVGYMapping() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 9, + charactersIgnoringModifiers: "v", + modifierFlags: [], + hasSelection: false + ), + .startSelection + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 9, + charactersIgnoringModifiers: "v", + modifierFlags: [], + hasSelection: true + ), + .clearSelection + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 16, + charactersIgnoringModifiers: "y", + modifierFlags: [], + hasSelection: true + ), + .copyAndExit + ) + } + + func testGAndShiftGMapping() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 5, + charactersIgnoringModifiers: "g", + modifierFlags: [], + hasSelection: false + ), + .scrollToTop + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 5, + charactersIgnoringModifiers: "g", + modifierFlags: [.shift], + hasSelection: false + ), + .scrollToBottom + ) + } + + func testLineBoundaryPromptAndSearchMappings() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 29, + charactersIgnoringModifiers: "0", + modifierFlags: [], + hasSelection: true + ), + .adjustSelection(.beginningOfLine) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 20, + charactersIgnoringModifiers: "^", + modifierFlags: [.shift], + hasSelection: true + ), + .adjustSelection(.beginningOfLine) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 21, + charactersIgnoringModifiers: "4", + modifierFlags: [.shift], + hasSelection: true + ), + .adjustSelection(.endOfLine) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 33, + charactersIgnoringModifiers: "[", + modifierFlags: [.shift], + hasSelection: false + ), + .jumpToPrompt(-1) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 30, + charactersIgnoringModifiers: "]", + modifierFlags: [.shift], + hasSelection: false + ), + .jumpToPrompt(1) + ) + XCTAssertNil( + terminalKeyboardCopyModeAction( + keyCode: 21, + charactersIgnoringModifiers: "4", + modifierFlags: [], + hasSelection: true + ) + ) + XCTAssertNil( + terminalKeyboardCopyModeAction( + keyCode: 33, + charactersIgnoringModifiers: "[", + modifierFlags: [], + hasSelection: false + ) + ) + XCTAssertNil( + terminalKeyboardCopyModeAction( + keyCode: 30, + charactersIgnoringModifiers: "]", + modifierFlags: [], + hasSelection: false + ) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 44, + charactersIgnoringModifiers: "/", + modifierFlags: [], + hasSelection: false + ), + .startSearch + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 45, + charactersIgnoringModifiers: "n", + modifierFlags: [], + hasSelection: false + ), + .searchNext + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 45, + charactersIgnoringModifiers: "n", + modifierFlags: [.shift], + hasSelection: false + ), + .searchPrevious + ) + } + + func testShiftVMatchesVisualToggleBehavior() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 9, + charactersIgnoringModifiers: "v", + modifierFlags: [.shift], + hasSelection: false + ), + .startSelection + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 9, + charactersIgnoringModifiers: "v", + modifierFlags: [.shift], + hasSelection: true + ), + .clearSelection + ) + } + + func testEscapeAlwaysExits() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 53, + charactersIgnoringModifiers: "", + modifierFlags: [], + hasSelection: false + ), + .exit + ) + } + + func testQAlwaysExits() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 12, // kVK_ANSI_Q + charactersIgnoringModifiers: "q", + modifierFlags: [], + hasSelection: false + ), + .exit + ) + } +} + +final class TerminalKeyboardCopyModeResolveTests: XCTestCase { + private func resolve( + _ keyCode: UInt16, + chars: String, + modifiers: NSEvent.ModifierFlags = [], + hasSelection: Bool, + state: inout TerminalKeyboardCopyModeInputState + ) -> TerminalKeyboardCopyModeResolution { + terminalKeyboardCopyModeResolve( + keyCode: keyCode, + charactersIgnoringModifiers: chars, + modifierFlags: modifiers, + hasSelection: hasSelection, + state: &state + ) + } + + func testCountPrefixAppliesToMotion() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 3)) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + func testZeroAppendsCountOrActsAsMotion() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(19, chars: "2", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(29, chars: "0", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(40, chars: "k", hasSelection: false, state: &state), .perform(.scrollLines(-1), count: 20)) + + var selectionState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual( + resolve(29, chars: "0", hasSelection: true, state: &selectionState), + .perform(.adjustSelection(.beginningOfLine), count: 1) + ) + } + + func testYankLineOperatorSupportsYYAndYWithCounts() { + var yyState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .consume) + XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .perform(.copyLineAndExit, count: 1)) + + var countedState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(21, chars: "4", hasSelection: false, state: &countedState), .consume) + XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .consume) + XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .perform(.copyLineAndExit, count: 4)) + + var shiftYState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &shiftYState), .consume) + XCTAssertEqual( + resolve(16, chars: "y", modifiers: [.shift], hasSelection: false, state: &shiftYState), + .perform(.copyLineAndExit, count: 3) + ) + } + + func testPendingYankLineDoesNotSwallowNextCommand() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 1)) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + func testSearchAndPromptMotionsUseCounts() { + var promptState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &promptState), .consume) + XCTAssertEqual( + resolve(30, chars: "]", modifiers: [.shift], hasSelection: false, state: &promptState), + .perform(.jumpToPrompt(1), count: 3) + ) + + var searchState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &searchState), .consume) + XCTAssertEqual(resolve(45, chars: "n", hasSelection: false, state: &searchState), .perform(.searchNext, count: 2)) + } + + func testInvalidKeyClearsPendingState() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(7, chars: "x", hasSelection: false, state: &state), .consume) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } +} + +final class TerminalKeyboardCopyModeViewportRowTests: XCTestCase { + func testInitialViewportRowUsesImePointBaseline() { + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 24, + imeCellHeight: 24 + ), + 0 + ) + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 240, + imeCellHeight: 24 + ), + 9 + ) + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 48, + imeCellHeight: 24, + topPadding: 24 + ), + 0 + ) + } + + func testInitialViewportRowClampsBoundsAndFallsBackWhenHeightMissing() { + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 0, + imeCellHeight: 24 + ), + 0 + ) + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 9999, + imeCellHeight: 24 + ), + 23 + ) + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 123, + imeCellHeight: 0 + ), + 23 + ) + } +} + @MainActor final class BrowserDeveloperToolsConfigurationTests: XCTestCase { func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() { @@ -7432,6 +7904,23 @@ final class GhosttySurfaceOverlayTests: XCTestCase { XCTAssertFalse(hostedView.debugHasSearchOverlay()) } + func testKeyboardCopyModeIndicatorMountsAndUnmounts() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator()) + + hostedView.setKeyboardCopyModeIndicator(visible: true) + XCTAssertTrue(hostedView.debugHasKeyboardCopyModeIndicator()) + + hostedView.setKeyboardCopyModeIndicator(visible: false) + XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator()) + } + func testForceRefreshNoopsAfterSurfaceReleaseDuringGeometryReconcile() throws { #if DEBUG let window = NSWindow( diff --git a/ghostty b/ghostty index 80d3fa07..7dd58982 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 80d3fa07ff8ae86fe6089083371f71ac7634648f +Subproject commit 7dd589824d4c9bda8265355718800cccaf7189a0 diff --git a/ghostty.h b/ghostty.h index 3d397308..b54e84f1 100644 --- a/ghostty.h +++ b/ghostty.h @@ -1108,6 +1108,8 @@ void ghostty_surface_complete_clipboard_request(ghostty_surface_t, void*, bool); bool ghostty_surface_has_selection(ghostty_surface_t); +bool ghostty_surface_select_cursor_cell(ghostty_surface_t); +bool ghostty_surface_clear_selection(ghostty_surface_t); bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); bool ghostty_surface_read_text(ghostty_surface_t, ghostty_selection_s,