Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5276,6 +5276,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return nil
}

/// Sends the literal character of a bare-key chord prefix to the focused Ghostty
/// surface. Used by the implicit double-tap-leader behavior so a user with a
/// bare-key leader can still type the literal character by pressing it twice.
///
/// Returns `true` iff a focused terminal surface was found and `sendText` was
/// invoked. If no terminal is focused, returns `false` so the caller can decide
/// whether to consume the event.
private func sendLiteralChordPrefixToFocusedSurface(
prefix: ShortcutStroke,
event: NSEvent
) -> Bool {
let preferredWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
let responder = preferredWindow?.firstResponder
?? NSApp.keyWindow?.firstResponder
?? NSApp.mainWindow?.firstResponder
guard let ghosttyView = cmuxOwningGhosttyView(for: responder),
let surface = ghosttyView.terminalSurface else {
return false
}
let literal: String
if let characters = event.characters, !characters.isEmpty {
literal = characters
} else if let charactersIgnoringModifiers = event.charactersIgnoringModifiers,
!charactersIgnoringModifiers.isEmpty {
literal = charactersIgnoringModifiers
} else {
literal = prefix.key
}
surface.sendText(literal)
return true
}

private func focusedTerminalShortcutContext(preferredWindow: NSWindow? = nil) -> FocusedTerminalShortcutContext? {
let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow
let responder = targetWindow?.firstResponder
Expand Down Expand Up @@ -9954,6 +9986,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
}

/// Returns true iff at least one configured shortcut (across all live
/// MainWindowContext instances) has a chord whose first stroke has no modifiers.
///
/// This is intentionally computed fresh on every call rather than cached.
/// Caching would require per-context Combine subscriptions that must be
/// torn down on context removal. The cost of recomputing is low: the
/// built-in actions loop short-circuits on the first match; the per-context
/// shortcutActions() call filters and sorts a typically-single-digit list.
/// In the common no-bare-key case the function returns false quickly.
private func recomputeHasConfiguredBareKeyChordPrefix() -> Bool {
for action in configuredShortcutChordActions {
let shortcut = KeyboardShortcutSettings.shortcut(for: action)
guard shortcut.hasChord else { continue }
if shortcut.firstStroke.modifierFlags.isEmpty {
return true
}
}
for context in mainWindowContexts.values {
let configuredShortcuts = configuredCmuxShortcutActions(for: context)
.compactMap(\.shortcut)
for shortcut in configuredShortcuts {
guard shortcut.hasChord else { continue }
if shortcut.firstStroke.modifierFlags.isEmpty {
return true
}
}
}
return false
}

private func clearConfiguredShortcutChordState() {
pendingConfiguredShortcutChord = nil
activeConfiguredShortcutChordPrefixForCurrentEvent = nil
Expand Down Expand Up @@ -10589,7 +10651,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// history): after command-palette/notification handling and browser omnibar
// arrow navigation above, plain key events have no app-level shortcut behavior.
if normalizedFlags.isEmpty && activeConfiguredShortcutChordPrefixForCurrentEvent == nil {
return false
// Without modifiers and without an armed chord, the only way an event
// can still be a shortcut is if the user configured a bare-key chord
// leader (e.g. tmux-style ` as a prefix). Skip the early-return only
// when such a binding actually exists.
if !hasConfiguredBareKeyChordPrefix() {
return false
}
}

// Let omnibar-local Emacs navigation (Cmd/Ctrl+N/P) win while the browser
Expand Down Expand Up @@ -11187,6 +11255,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}

// Implicit `<leader><leader>` → send literal leader to focused terminal.
// Only fires when the armed chord prefix has no modifiers and the second
// event is the same bare key with no modifiers, AND no configured chord
// binding above matched. This gives bare-key leader users a free
// tmux-style send-prefix without any settings.json wiring; an explicit
// user binding for `<prefix><prefix>` always wins because the configured
// chord match loop above runs first.
if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent,
prefix.modifierFlags.isEmpty,
normalizedFlags.isEmpty,
matchShortcutStroke(event: event, stroke: prefix),
sendLiteralChordPrefixToFocusedSurface(prefix: prefix, event: event) {
return true
}

return false
}

Expand Down Expand Up @@ -12066,6 +12149,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return event.windowNumber > 0 ? event.windowNumber : nil
}

/// True iff at least one configured shortcut has a chord whose first stroke has no modifiers.
/// Used by `handleCustomShortcut` to know whether bare-key keyDown events still need to be
/// considered for chord arming, instead of being short-circuited as non-shortcut input.
/// Computed fresh on each call (see `recomputeHasConfiguredBareKeyChordPrefix()` for rationale).
private func hasConfiguredBareKeyChordPrefix() -> Bool {
recomputeHasConfiguredBareKeyChordPrefix()
}

private func armConfiguredShortcutChordIfNeeded(
event: NSEvent,
actions: [KeyboardShortcutSettings.Action]? = nil,
Expand Down
7 changes: 7 additions & 0 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4114,6 +4114,10 @@ final class TerminalSurface: Identifiable, ObservableObject {
private var runtimeSurfaceFreedOutOfBandForTesting = false
private let debugForceRefreshCountLock = NSLock()
private var debugForceRefreshCountValue = 0
/// Test-only: most recent text passed to `sendText`. Set in `sendText` before any
/// I/O happens, so a unit test can assert "the literal leader was forwarded here"
/// without running a real Ghostty surface.
static var debugLastSendTextRecorder: ((String) -> Void)?
#endif
private enum PortalLifecycleState: String {
case live
Expand Down Expand Up @@ -5233,6 +5237,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
}

func sendText(_ text: String) {
#if DEBUG
Self.debugLastSendTextRecorder?(text)
#endif
guard let data = text.data(using: .utf8), !data.isEmpty else { return }
guard let surface = surface else {
enqueuePendingSocketInput(.text(data))
Expand Down
6 changes: 5 additions & 1 deletion Sources/KeyboardShortcutSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2126,7 +2126,11 @@ extension StoredShortcut {
guard parsedStrokes.count == strokes.count, let firstStroke = parsedStrokes.first else {
return nil
}
guard !firstStroke.modifierFlags.isEmpty else { return nil }
// A single bare-key stroke (no modifiers) is always rejected; a two-stroke chord
// whose leader is a bare key is allowed because the bare key becomes a chord prefix
// that is silently consumed by the routing layer rather than passed to the terminal.
let isChord = strokes.count == 2
guard !firstStroke.modifierFlags.isEmpty || isChord else { return nil }
let secondStroke = parsedStrokes.count == 2 ? parsedStrokes[1] : nil
return StoredShortcut(first: firstStroke, second: secondStroke)
}
Expand Down
Loading