Skip to content
Merged
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
28 changes: 28 additions & 0 deletions rootshell/App/AppCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ final class MenuShortcutState: ObservableObject {
static let shared = MenuShortcutState()

@Published var shortcuts: [KeybindAction: KeyboardShortcut] = [:]
/// Nested count so overlapping capture views don't restore the menu rail
/// while another is still recording.
private var recordingCaptureCount = 0

/// Whether a menu bar exists to carry app shortcuts. Both menu rails dispatch
/// through UIApplication notifications rather than the responder chain, so a
Expand Down Expand Up @@ -56,7 +59,32 @@ final class MenuShortcutState: ObservableObject {
.store(in: &cancellables)
}

/// Drop menu key equivalents while a shortcut is being recorded so the
/// capture view's `keyCommands` see the physical chord. Otherwise the menu
/// rail steals registered shortcuts (⌘T, ⌘N, …) and they never reach the
/// editor. ⌘. stays on its dedicated Send Escape item — that reserved
/// chord never arrives as a key event on Catalyst.
func beginRecordingCapture() {
recordingCaptureCount += 1
if recordingCaptureCount == 1 {
shortcuts = [:]
}
}

func endRecordingCapture() {
guard recordingCaptureCount > 0 else { return }
recordingCaptureCount -= 1
if recordingCaptureCount == 0 {
rebuildShortcuts()
}
}

private func rebuildShortcuts() {
guard recordingCaptureCount == 0 else {
shortcuts = [:]
return
}

var newShortcuts: [KeybindAction: KeyboardShortcut] = [:]

for binding in KeybindManager.shared.activeBindings {
Expand Down
20 changes: 20 additions & 0 deletions rootshell/Core/Keybinds/Keybind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ struct Keybind: Codable, Identifiable, Hashable, Sendable {
/// Optional parameter for the action (e.g., "1" for "increase_font_size:1")
let actionParameter: String?

/// For an unbind targeting a parameterized action, the displaced binding's
/// parameter. Together with `sequence`, this identifies only that binding.
/// Optional so saved overrides from before parameterized unbinds still decode.
let unboundActionParameter: String?

/// Whether this is a user override (vs default)
let isUserOverride: Bool

Expand All @@ -34,13 +39,15 @@ struct Keybind: Codable, Identifiable, Hashable, Sendable {
sequence: KeySequence,
action: KeybindAction,
actionParameter: String? = nil,
unboundActionParameter: String? = nil,
isUserOverride: Bool = false,
source: KeybindSource = .default
) {
self.id = id
self.sequence = sequence
self.action = action
self.actionParameter = actionParameter
self.unboundActionParameter = unboundActionParameter
self.isUserOverride = isUserOverride
self.source = source
}
Expand All @@ -58,6 +65,7 @@ struct Keybind: Codable, Identifiable, Hashable, Sendable {
self.sequence = KeySequence(key: key, modifiers: modifiers)
self.action = action
self.actionParameter = actionParameter
self.unboundActionParameter = nil
self.isUserOverride = isUserOverride
self.source = source
}
Expand All @@ -74,6 +82,7 @@ struct Keybind: Codable, Identifiable, Hashable, Sendable {
self.sequence = KeySequence(trigger: trigger)
self.action = action
self.actionParameter = actionParameter
self.unboundActionParameter = nil
self.isUserOverride = isUserOverride
self.source = source
}
Expand Down Expand Up @@ -124,6 +133,7 @@ struct Keybind: Codable, Identifiable, Hashable, Sendable {
self.sequence = sequence
self.action = action
self.actionParameter = parameter
self.unboundActionParameter = nil
self.isUserOverride = (source == .userOverride)
self.source = source
}
Expand All @@ -136,6 +146,16 @@ struct Keybind: Codable, Identifiable, Hashable, Sendable {
return "keybind = \(sequence.ghosttyFormat)=\(action.rawValue)"
}

/// Ordinary unbinds suppress an entire action. Parameterized unbinds
/// suppress only the recorded sequence and parameter, preserving siblings.
func unbinds(_ binding: Keybind) -> Bool {
guard action == .unbind, actionParameter == binding.action.rawValue else {
return false
}
return !binding.action.isParameterized
|| (unboundActionParameter == binding.actionParameter && sequence == binding.sequence)
}

// MARK: - Escape Sequence Decoding

/// Decode escape sequences in text action parameters (Ghostty config format).
Expand Down
52 changes: 45 additions & 7 deletions rootshell/Core/Keybinds/KeybindManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,11 @@ final class KeybindManager: ObservableObject {
let paramLabel = parameter.map { ":\($0)" } ?? ""
Self.logger.info("Setting override: \(sequence.ghosttyFormat) -> \(action.rawValue)\(paramLabel)")

// Snapshot who we are about to displace, before userOverrides change.
let victims = action == .unbind
? []
: conflicts(for: sequence, excluding: action, excludingParameter: parameter)

if action == .unbind {
// Unbind is special: multiple actions can be unbound simultaneously.
// Only remove duplicate unbinds for the same sequence. Keep non-unbind
Expand All @@ -298,7 +303,27 @@ final class KeybindManager: ObservableObject {
}
}

// Add new override
if action != .unbind {
// Unbind the previous owners so they stay empty instead of
// falling back to a free default. Applied before the new
// binding so the new chord is not stripped.
for victim in victims {
let unbind = Keybind(
sequence: victim.sequence,
action: .unbind,
actionParameter: victim.action.rawValue,
unboundActionParameter: victim.action.isParameterized ? victim.actionParameter : nil,
isUserOverride: true,
source: .userOverride
)
userOverrides.removeAll {
unbind.unbinds($0) || $0.unbinds(victim)
}
userOverrides.append(unbind)
}
}

// New binding last so it wins over any victim unbind for this sequence.
let override = Keybind(
sequence: sequence,
action: action,
Expand Down Expand Up @@ -644,16 +669,22 @@ final class KeybindManager: ObservableObject {

// Apply user overrides (highest priority)
for override in userOverrides {
if override.action == .unbind {
// Match the displaced owner, not every binding using its
// sequence. Parameterized owners also match their parameter
// and sequence so sibling bindings remain available.
bindings.removeAll { override.unbinds($0) }
continue
}

// Remove any existing binding for this action (skip parameterized)
if !override.action.isParameterized {
bindings.removeAll { $0.action == override.action }
}
// Remove any existing binding for this sequence (handle conflicts)
bindings.removeAll { $0.sequence == override.sequence }

if override.action != .unbind {
bindings.append(override)
}
bindings.append(override)
}

// Sort by category and name for consistent ordering
Expand Down Expand Up @@ -798,10 +829,17 @@ final class KeybindManager: ObservableObject {
// MARK: - Conflict Detection

/// Check if a sequence would conflict with existing bindings
func conflicts(for sequence: KeySequence, excluding action: KeybindAction? = nil) -> [Keybind] {
func conflicts(
for sequence: KeySequence,
excluding action: KeybindAction? = nil,
excludingParameter parameter: String? = nil
) -> [Keybind] {
activeBindings.filter { binding in
// Skip the action we're checking for
if let excludedAction = action, binding.action == excludedAction {
// A parameterized editor owns only its matching parameter. A new
// profile has no parameter yet, so all existing profiles conflict.
if let excludedAction = action, binding.action == excludedAction,
!excludedAction.isParameterized
|| (parameter != nil && binding.actionParameter == parameter) {
return false
}

Expand Down
Loading