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
26 changes: 26 additions & 0 deletions rootshell/App/AppCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ final class MenuShortcutState: ObservableObject {
continue
}

#if targetEnvironment(macCatalyst)
// The fixed "Send Escape" menu item is the sole owner of ⌘. on
// Catalyst (the reserved chord only arrives via the menu rail);
// its handler dispatches a cmd+period binding itself. Publishing
// the shortcut here too would create a duplicate key equivalent.
if firstTrigger == .commandPeriod { continue }
#endif

let modifiers = firstTrigger.swiftUIEventModifiers
newShortcuts[binding.action] = KeyboardShortcut(keyEquivalent, modifiers: modifiers)
}
Expand Down Expand Up @@ -461,6 +469,24 @@ struct TerminalCommands: Commands {
}
}
.modifier(DynamicShortcut(action: .toggle_mouse_capture, shortcuts: shortcutState.shortcuts))

#if targetEnvironment(macCatalyst)
Divider()

// Cmd+Period never reaches responder UIKeyCommands or press events
// on Catalyst — a menu key equivalent (like Xcode's ⌘. Stop item)
// is the one rail that receives AND consumes the reserved chord.
// Fixed shortcut: MenuShortcutState excludes cmd+period so this
// item stays its sole owner; the handler dispatches a cmd+period
// keybind first and falls back to sending Escape.
Button("Send Escape") {
UIApplication.shared.sendAction(
#selector(Ghostty.TerminalView.menuSystemCancel(_:)),
to: nil, from: nil, for: nil
)
}
.keyboardShortcut(".", modifiers: .command)
#endif
}
}
}
Expand Down
25 changes: 24 additions & 1 deletion rootshell/App/CatalystAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ extension UIApplication {

// MARK: Terminal Menu Actions

/// Reserved Cmd+Period chord, delivered via the menu rail. The nil-target
/// walk starts at the first responder, so a recording ShortcutCaptureUIView
/// wins over the focused terminal's handler.
@objc func ghostty_systemCancel(_ sender: Any?) {
sendAction(#selector(Ghostty.TerminalView.menuSystemCancel(_:)), to: nil, from: sender, for: nil)
}

@objc func ghostty_increaseFontSize(_ sender: Any?) {
sendAction(#selector(Ghostty.TerminalView.increaseFontSize(_:)), to: nil, from: sender, for: nil)
}
Expand Down Expand Up @@ -1048,10 +1055,26 @@ class CatalystAppDelegate: AppDelegate {
toggleCompose, toggleMouseCapture
])

// Cmd+Period never reaches responder UIKeyCommands or press events on
// Catalyst — a menu key equivalent (like Xcode's ⌘. Stop item) is the
// one rail that receives AND consumes the reserved chord. The handler
// dispatches a cmd+period keybind first and falls back to sending
// Escape.
let sendEscape = UIKeyCommand(
title: String(localized: "Send Escape"),
action: #selector(UIApplication.ghostty_systemCancel(_:)),
input: ".",
modifierFlags: [.command]
)

let systemCancelGroup = UIMenu(title: "", options: .displayInline, children: [
sendEscape
])

let terminalMenu = UIMenu(
title: String(localized: "Terminal"),
identifier: UIMenu.Identifier("com.rootshell.terminal"),
children: [splitCreateGroup, focusSplitGroup, splitManageGroup, scrollGroup, composeGroup]
children: [splitCreateGroup, focusSplitGroup, splitManageGroup, scrollGroup, composeGroup, systemCancelGroup]
)

builder.insertSibling(terminalMenu, afterMenu: .view)
Expand Down
44 changes: 33 additions & 11 deletions rootshell/Core/Keybinds/KeyTrigger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,31 @@ enum KeyCode: String, Codable, CaseIterable, Hashable, Sendable {
}
}

/// UIKit's "UIKeyInput*" sentinels. They can arrive in `UIKey.characters`
/// for a translated chord and must never reach a terminal as literal text.
/// Derived from `uiKeyInput` so the two cannot drift apart.
static let uiKeyInputSentinels: [String: KeyCode] = Dictionary(
allCases
.filter { $0.uiKeyInput.hasPrefix("UIKeyInput") }
.map { ($0.uiKeyInput, $0) },
uniquingKeysWith: { first, _ in first }
)

/// The key a UIKit sentinel denotes, or nil when `text` is real text.
/// Matched exactly, never by prefix: `insertText` also receives pasted text.
static func sentinelKey(for text: String) -> KeyCode? {
uiKeyInputSentinels[text]
}

static func isUIKeyInputSentinel(_ text: String) -> Bool {
uiKeyInputSentinels[text] != nil
}

/// `uiKeyInput` when it is real text; nil for sentinels.
var literalKeyInput: String? {
KeyCode.isUIKeyInputSentinel(uiKeyInput) ? nil : uiKeyInput
}

/// Initialize from UIKeyboardHIDUsage
init?(hidUsage: UIKeyboardHIDUsage) {
switch hidUsage {
Expand Down Expand Up @@ -417,17 +442,11 @@ enum KeyCode: String, Codable, CaseIterable, Hashable, Sendable {
case "\u{08}": self = .backspace

default:
// Check against UIKeyCommand constants
if uiKeyInput == UIKeyCommand.inputEscape { self = .escape }
else if uiKeyInput == UIKeyCommand.inputUpArrow { self = .up }
else if uiKeyInput == UIKeyCommand.inputDownArrow { self = .down }
else if uiKeyInput == UIKeyCommand.inputLeftArrow { self = .left }
else if uiKeyInput == UIKeyCommand.inputRightArrow { self = .right }
else if uiKeyInput == UIKeyCommand.inputPageUp { self = .pageUp }
else if uiKeyInput == UIKeyCommand.inputPageDown { self = .pageDown }
else if uiKeyInput == UIKeyCommand.inputHome { self = .home }
else if uiKeyInput == UIKeyCommand.inputEnd { self = .end }
else { return nil }
// UIKeyCommand constants ("UIKeyInputEscape" and friends). The
// derived table also covers Delete and F1-F12, which the old
// hand-rolled chain missed.
guard let key = KeyCode.sentinelKey(for: uiKeyInput) else { return nil }
self = key
}
}

Expand Down Expand Up @@ -579,6 +598,9 @@ struct KeyTrigger: Codable, Hashable, CustomStringConvertible, Sendable {
let key: KeyCode
let modifiers: KeybindModifiers

/// The chord Apple platforms reserve as the system Cancel/Escape shortcut.
static let commandPeriod = KeyTrigger(key: .period, modifiers: .command)

init(key: KeyCode, modifiers: KeybindModifiers = []) {
self.key = key
self.modifiers = modifiers
Expand Down
17 changes: 17 additions & 0 deletions rootshell/Core/Keybinds/KeybindCommandGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,23 @@ final class KeybindCommandGenerator: ObservableObject {
}
#endif

// System Cancel chord (Cmd+Period). Apple reserves it as an Escape
// equivalent, so it defaults to a one-shot ESC. When a keybind claims
// cmd+period, the generic handleKeybindCommand command is emitted
// instead (same pattern as arrows/Tab/F-keys). No allowKeyRepeat():
// the chord is deliberately one-shot, matching system Cancel semantics.
#if !os(visionOS)
if !isClaimedByKeybind(.commandPeriod) {
let cancelCommand = UIKeyCommand(
input: ".",
modifierFlags: .command,
action: #selector(Ghostty.TerminalView.handleSystemCancelCommand(_:))
)
cancelCommand.wantsPriorityOverSystemBehavior = true
commands.append(cancelCommand)
}
#endif

// Tab key — each combo skipped if claimed by a binding or sequence prefix.
let tabVariants: [(UIKeyModifierFlags, Selector)] = [
([], #selector(Ghostty.TerminalView.handleTabKey(_:))),
Expand Down
24 changes: 24 additions & 0 deletions rootshell/UI/Keyboard/KeyboardTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,30 @@ class KeyboardTracker {
private static let softwareKeyboardHeightThreshold: CGFloat = 120
private static let appTransitionKeyboardPreservationDuration: Duration = .milliseconds(750)

/// True when the physical Cmd+Period system-cancel chord is down right now.
/// Pure GCKeyboard poll used only to disambiguate an event that already
/// arrived (translated Escape, or Period with Command stripped); it never
/// initiates dispatch, so a stale latched Command bit cannot inject input
/// on its own. Requiring Escape to NOT be down rejects a real Escape press
/// even under a stale Command latch.
@MainActor
static func isSystemCancelChordPhysicallyDown() -> Bool {
#if os(visionOS)
return false
#else
guard UIApplication.shared.applicationState == .active,
let input = GCKeyboard.coalesced?.keyboardInput,
input.button(forKeyCode: .period)?.isPressed == true,
input.button(forKeyCode: .escape)?.isPressed != true else { return false }
let commandDown = input.button(forKeyCode: .leftGUI)?.isPressed == true
|| input.button(forKeyCode: .rightGUI)?.isPressed == true
let extraModifier = [GCKeyCode.leftShift, .rightShift, .leftControl,
.rightControl, .leftAlt, .rightAlt]
.contains { input.button(forKeyCode: $0)?.isPressed == true }
return commandDown && !extraModifier
#endif
}

// MARK: - Initialization

private init() {
Expand Down
28 changes: 27 additions & 1 deletion rootshell/UI/Settings/Keyboard/KeybindEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,20 @@ class ShortcutCaptureUIView: UIView {
}
}

private func cancelCapture() {
guard !hasCompleted else { return }
hasCompleted = true
onCancel?()
}

/// Catalyst delivers the reserved Cmd+Period chord only through the menu
/// rail (nil-target menuSystemCancel action). The action reaches this view
/// first while it owns first responder, so recording wins over the
/// terminal handler.
@objc func menuSystemCancel(_ sender: Any?) {
processCapture(trigger: .commandPeriod)
}

override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
for press in presses {
guard let key = press.key else { continue }
Expand All @@ -484,9 +498,21 @@ class ShortcutCaptureUIView: UIView {
]
guard !modifierOnlyKeys.contains(key.keyCode) else { continue }

// iPadOS may deliver the reserved Cmd+Period chord as Period with
// Command stripped or as a translated Escape. Normalize either
// representation so the chord is recordable; the twin keyCommands
// delivery dedups via duplicateDeliveryWindow since both produce
// the identical trigger.
if (key.keyCode != .keyboardEscape && KeyCode.sentinelKey(for: key.characters) == .escape)
|| ((key.keyCode == .keyboardPeriod || key.keyCode == .keyboardEscape)
&& KeyboardTracker.isSystemCancelChordPhysicallyDown()) {
processCapture(trigger: .commandPeriod)
return
}

// Handle Escape to cancel
if key.keyCode == .keyboardEscape && key.modifierFlags.isEmpty {
onCancel?()
cancelCapture()
return
}

Expand Down
16 changes: 16 additions & 0 deletions rootshell/UI/Terminal/TerminalInputController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ final class TerminalInputController {
/// Whether GCKeyboard modifier snapshots can currently be trusted.
var isGCKeyboardModifierStateTrusted = true

/// UIKit can deliver one physical press of the Cmd+Period system-cancel
/// chord through both a UIKeyCommand and pressesBegan within milliseconds
/// (the same twin-delivery behavior ShortcutCaptureUIView dedups).
/// Time-based so a delivery whose release never reaches pressesEnded
/// (UIKeyCommand-only rail) cannot wedge the latch.
private var lastSystemCancelDeliveryTime: CFTimeInterval = 0
private static let systemCancelDuplicateWindow: CFTimeInterval = 0.05

/// True for the first delivery of a physical chord press; twins are rejected.
func consumeSystemCancelChordDelivery() -> Bool {
let now = CACurrentMediaTime()
guard now - lastSystemCancelDeliveryTime > Self.systemCancelDuplicateWindow else { return false }
lastSystemCancelDeliveryTime = now
return true
}

func invalidateKeyCommandCache() {
cachedKeyCommands = nil
#if targetEnvironment(macCatalyst)
Expand Down
Loading