From cdc9e12dcf992d451f7e6dba66f09f23ed13c64b Mon Sep 17 00:00:00 2001 From: sathvik vang Date: Tue, 7 Apr 2026 23:23:35 -0400 Subject: [PATCH] Allow user to configure push-to-talk shortcut from panel The push-to-talk keyboard shortcut was hardcoded to Ctrl+Option. Users may prefer a different modifier combination to avoid conflicts with other apps or match their muscle memory. - Make ShortcutOption String-backed and CaseIterable so choices can be persisted to UserDefaults and enumerated in the picker UI - Change currentShortcutOption from a static let to a computed property that reads the user's preference (defaulting to Ctrl+Option) - Add a dropdown shortcut picker to the companion panel, styled to match the existing model picker - Update the instruction text to reflect the currently selected shortcut - Reset the monitor's pressed state on shortcut change to prevent the old combo from getting stuck in a pressed state Co-Authored-By: Oz --- leanring-buddy/BuddyDictationManager.swift | 48 ++++++++++++--- leanring-buddy/CompanionManager.swift | 12 ++++ leanring-buddy/CompanionPanelView.swift | 59 ++++++++++++++++++- .../GlobalPushToTalkShortcutMonitor.swift | 9 +++ 4 files changed, 118 insertions(+), 10 deletions(-) diff --git a/leanring-buddy/BuddyDictationManager.swift b/leanring-buddy/BuddyDictationManager.swift index 5bca2677..5127ac16 100644 --- a/leanring-buddy/BuddyDictationManager.swift +++ b/leanring-buddy/BuddyDictationManager.swift @@ -14,12 +14,14 @@ import Foundation import Speech enum BuddyPushToTalkShortcut { - enum ShortcutOption { - case shiftFunction - case controlOption - case shiftControl - case controlOptionSpace - case shiftControlSpace + /// The modifier key combinations available for the push-to-talk shortcut. + /// Backed by String raw values so the user's choice can be persisted to UserDefaults. + enum ShortcutOption: String, CaseIterable { + case shiftFunction = "shiftFunction" + case controlOption = "controlOption" + case shiftControl = "shiftControl" + case controlOptionSpace = "controlOptionSpace" + case shiftControlSpace = "shiftControlSpace" var displayText: String { switch self { @@ -36,6 +38,22 @@ enum BuddyPushToTalkShortcut { } } + /// Human-readable label for the shortcut picker UI in the companion panel. + var pickerLabel: String { + switch self { + case .shiftFunction: + return "Shift + Fn" + case .controlOption: + return "Ctrl + Option" + case .shiftControl: + return "Shift + Control" + case .controlOptionSpace: + return "Ctrl + Option + Space" + case .shiftControlSpace: + return "Shift + Control + Space" + } + } + var keyCapsuleLabels: [String] { switch self { case .shiftFunction: @@ -92,10 +110,22 @@ enum BuddyPushToTalkShortcut { case keyUp } - static let currentShortcutOption: ShortcutOption = .controlOption + /// The UserDefaults key used to persist the user's chosen push-to-talk shortcut. + static let pushToTalkShortcutUserDefaultsKey = "pushToTalkShortcutOption" + + /// Reads the user's chosen shortcut from UserDefaults, falling back to + /// Ctrl+Option if no preference has been saved yet. + static var currentShortcutOption: ShortcutOption { + guard let savedRawValue = UserDefaults.standard.string(forKey: pushToTalkShortcutUserDefaultsKey), + let savedOption = ShortcutOption(rawValue: savedRawValue) else { + return .controlOption + } + return savedOption + } + static let pushToTalkKeyCode: UInt16 = 49 // Space - static let pushToTalkDisplayText = currentShortcutOption.displayText - static let pushToTalkTooltipText = "push to talk (\(pushToTalkDisplayText))" + static var pushToTalkDisplayText: String { currentShortcutOption.displayText } + static var pushToTalkTooltipText: String { "push to talk (\(pushToTalkDisplayText))" } static func shortcutTransition( for event: NSEvent, diff --git a/leanring-buddy/CompanionManager.swift b/leanring-buddy/CompanionManager.swift index 0234cf19..1b9e4c95 100644 --- a/leanring-buddy/CompanionManager.swift +++ b/leanring-buddy/CompanionManager.swift @@ -107,6 +107,18 @@ final class CompanionManager: ObservableObject { /// Used by the panel to show accurate status text ("Active" vs "Ready"). @Published private(set) var isOverlayVisible: Bool = false + /// The user's chosen push-to-talk modifier combination. Persisted to UserDefaults + /// so it survives app restarts. Defaults to Ctrl+Option (the original hardcoded shortcut). + @Published var selectedPushToTalkModifierCombination: BuddyPushToTalkShortcut.ShortcutOption = BuddyPushToTalkShortcut.currentShortcutOption + + func setSelectedPushToTalkModifierCombination(_ newShortcutOption: BuddyPushToTalkShortcut.ShortcutOption) { + selectedPushToTalkModifierCombination = newShortcutOption + UserDefaults.standard.set(newShortcutOption.rawValue, forKey: BuddyPushToTalkShortcut.pushToTalkShortcutUserDefaultsKey) + // Reset the monitor's pressed state so the old shortcut doesn't get stuck + // in the "pressed" position when the user switches to a new combination. + globalPushToTalkShortcutMonitor.resetShortcutPressedState() + } + /// The Claude model used for voice responses. Persisted to UserDefaults. @Published var selectedModel: String = UserDefaults.standard.string(forKey: "selectedClaudeModel") ?? "claude-sonnet-4-6" diff --git a/leanring-buddy/CompanionPanelView.swift b/leanring-buddy/CompanionPanelView.swift index 76789b4c..e0858b69 100644 --- a/leanring-buddy/CompanionPanelView.swift +++ b/leanring-buddy/CompanionPanelView.swift @@ -29,6 +29,12 @@ struct CompanionPanelView: View { Spacer() .frame(height: 12) + pushToTalkShortcutPickerRow + .padding(.horizontal, 16) + + Spacer() + .frame(height: 4) + modelPickerRow .padding(.horizontal, 16) } @@ -127,7 +133,7 @@ struct CompanionPanelView: View { @ViewBuilder private var permissionsCopySection: some View { if companionManager.hasCompletedOnboarding && companionManager.allPermissionsGranted { - Text("Hold Control+Option to talk.") + Text("Hold \(companionManager.selectedPushToTalkModifierCombination.pickerLabel) to talk.") .font(.system(size: 12, weight: .medium)) .foregroundColor(DS.Colors.textSecondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -596,6 +602,57 @@ struct CompanionPanelView: View { .padding(.vertical, 4) } + // MARK: - Push-to-Talk Shortcut Picker + + private var pushToTalkShortcutPickerRow: some View { + HStack { + Text("Shortcut") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(DS.Colors.textSecondary) + + Spacer() + + Menu { + ForEach(BuddyPushToTalkShortcut.ShortcutOption.allCases, id: \.self) { shortcutOption in + Button(action: { + companionManager.setSelectedPushToTalkModifierCombination(shortcutOption) + }) { + HStack { + Text(shortcutOption.pickerLabel) + if shortcutOption == companionManager.selectedPushToTalkModifierCombination { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 4) { + Text(companionManager.selectedPushToTalkModifierCombination.pickerLabel) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(DS.Colors.textPrimary) + + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8, weight: .semibold)) + .foregroundColor(DS.Colors.textTertiary) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.white.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(DS.Colors.borderSubtle, lineWidth: 0.5) + ) + } + .menuStyle(.borderlessButton) + .fixedSize() + .pointerCursor() + } + .padding(.vertical, 4) + } + // MARK: - Model Picker private var modelPickerRow: some View { diff --git a/leanring-buddy/GlobalPushToTalkShortcutMonitor.swift b/leanring-buddy/GlobalPushToTalkShortcutMonitor.swift index 8020269b..c50b5506 100644 --- a/leanring-buddy/GlobalPushToTalkShortcutMonitor.swift +++ b/leanring-buddy/GlobalPushToTalkShortcutMonitor.swift @@ -27,6 +27,15 @@ final class GlobalPushToTalkShortcutMonitor: ObservableObject { stop() } + /// Resets the pressed state when the user changes the push-to-talk shortcut + /// in settings. Without this, switching shortcuts while the old one is held + /// would leave isShortcutCurrentlyPressed stuck as true. + func resetShortcutPressedState() { + guard isShortcutCurrentlyPressed else { return } + isShortcutCurrentlyPressed = false + shortcutTransitionPublisher.send(.released) + } + func start() { // If the event tap is already running, don't restart it. // Restarting resets isShortcutCurrentlyPressed, which would kill