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:
parent
bfe843f0bd
commit
2f6cb6ff38
8 changed files with 1092 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
2
ghostty
2
ghostty
|
|
@ -1 +1 @@
|
|||
Subproject commit 80d3fa07ff8ae86fe6089083371f71ac7634648f
|
||||
Subproject commit 7dd589824d4c9bda8265355718800cccaf7189a0
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue