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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue