From 35a08dce7bbc4df8e586917b0cf6648dacc72eb3 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Fri, 14 Nov 2025 15:33:22 +0545 Subject: [PATCH] Add Keyboardshortcutlview --- .../Views/KeyboardShortcutsListView.swift | 467 ++++++++++++++++++ VoiceInk/Views/Metrics/MetricsContent.swift | 29 +- .../Settings/EnhancementShortcutsView.swift | 50 +- 3 files changed, 530 insertions(+), 16 deletions(-) create mode 100644 VoiceInk/Views/KeyboardShortcutsListView.swift diff --git a/VoiceInk/Views/KeyboardShortcutsListView.swift b/VoiceInk/Views/KeyboardShortcutsListView.swift new file mode 100644 index 0000000..6358bcb --- /dev/null +++ b/VoiceInk/Views/KeyboardShortcutsListView.swift @@ -0,0 +1,467 @@ +import SwiftUI +import SwiftData +import KeyboardShortcuts + +struct KeyboardShortcutsListView: View { + @EnvironmentObject private var hotkeyManager: HotkeyManager + @ObservedObject private var shortcutSettings = EnhancementShortcutSettings.shared + @State private var customCancelShortcut: KeyboardShortcuts.Shortcut? + @State private var pasteOriginalShortcut: KeyboardShortcuts.Shortcut? + @State private var pasteEnhancedShortcut: KeyboardShortcuts.Shortcut? + @State private var retryShortcut: KeyboardShortcuts.Shortcut? + @State private var toggleHotkey1: KeyboardShortcuts.Shortcut? + @State private var toggleHotkey2: KeyboardShortcuts.Shortcut? + + var body: some View { + VStack(spacing: 0) { + // Header + VStack(alignment: .leading, spacing: 6) { + Text("Keyboard Shortcuts") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.primary) + Text("Quick reference for all VoiceInk shortcuts") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 28) + .padding(.vertical, 24) + .background( + Rectangle() + .fill(Color(NSColor.controlBackgroundColor).opacity(0.5)) + ) + + Divider() + .overlay(Color(NSColor.separatorColor).opacity(0.5)) + + // Content + ScrollView { + LazyVGrid(columns: [ + GridItem(.flexible(), spacing: 14), + GridItem(.flexible(), spacing: 14) + ], spacing: 14) { + // Recording Hotkeys + if hotkeyManager.selectedHotkey1 != .none { + ShortcutCard( + icon: "mic.fill", + iconColor: .blue, + title: "Toggle Recording", + subtitle: "Hotkey 1" + ) { + if hotkeyManager.selectedHotkey1 == .custom, let shortcut = toggleHotkey1 { + KeyboardShortcutBadge(shortcut: shortcut) + } else { + HotkeyBadge(text: hotkeyManager.selectedHotkey1.displayName) + } + } + } + + if hotkeyManager.selectedHotkey2 != .none { + ShortcutCard( + icon: "mic.fill", + iconColor: .purple, + title: "Toggle Recording", + subtitle: "Hotkey 2" + ) { + if hotkeyManager.selectedHotkey2 == .custom, let shortcut = toggleHotkey2 { + KeyboardShortcutBadge(shortcut: shortcut) + } else { + HotkeyBadge(text: hotkeyManager.selectedHotkey2.displayName) + } + } + } + + if hotkeyManager.isMiddleClickToggleEnabled { + ShortcutCard( + icon: "cursorarrow.click.2", + iconColor: .green, + title: "Middle-Click", + subtitle: "Hold to record" + ) { + MouseBadge() + } + } + + // Transcription Actions + ShortcutCard( + icon: "doc.text.fill", + iconColor: .orange, + title: "Paste Last Transcription (Orig.)", + subtitle: "Paste most recent original transcription" + ) { + if let shortcut = pasteOriginalShortcut { + KeyboardShortcutBadge(shortcut: shortcut) + } else { + NotSetBadge() + } + } + + ShortcutCard( + icon: "wand.and.stars", + iconColor: .pink, + title: "Paste Last Transcription (Enh.)", + subtitle: "Paste enhanced or original if unavailable" + ) { + if let shortcut = pasteEnhancedShortcut { + KeyboardShortcutBadge(shortcut: shortcut) + } else { + NotSetBadge() + } + } + + ShortcutCard( + icon: "arrow.clockwise", + iconColor: .blue, + title: "Retry Last Transcription", + subtitle: "Redo the last transcription" + ) { + if let shortcut = retryShortcut { + KeyboardShortcutBadge(shortcut: shortcut) + } else { + NotSetBadge() + } + } + + // Recording Session Shortcuts + ShortcutCard( + icon: "escape", + iconColor: .red, + title: "Dismiss Recorder", + subtitle: customCancelShortcut != nil ? "Custom shortcut or default: Double ESC" : "Default: Double ESC" + ) { + if let cancelShortcut = customCancelShortcut { + KeyboardShortcutBadge(shortcut: cancelShortcut) + } else { + StaticKeysBadge(keys: ["⎋", "⎋"]) + } + } + + ShortcutCard( + icon: "wand.and.stars", + iconColor: .purple, + title: "Toggle Enhancement", + subtitle: shortcutSettings.isToggleEnhancementShortcutEnabled ? "Enable/disable AI enhancement" : "Disabled in settings" + ) { + StaticKeysBadge(keys: ["⌘", "E"], isEnabled: shortcutSettings.isToggleEnhancementShortcutEnabled) + } + + ShortcutCard( + icon: "wand.and.stars", + iconColor: .orange, + title: "Switch Enhancement Prompt", + subtitle: "Use ⌘1–⌘0 (Command)" + ) { + StaticKeysBadge(keys: ["⌘", "1–0"]) + } + + ShortcutCard( + icon: "sparkles.square.fill.on.square", + iconColor: Color(red: 1.0, green: 0.8, blue: 0.0), + title: "Switch Power Mode", + subtitle: "Use ⌥1–⌥0 (Option)" + ) { + StaticKeysBadge(keys: ["⌥", "1–0"]) + } + } + .padding(.horizontal, 26) + .padding(.vertical, 26) + } + .background( + LinearGradient( + gradient: Gradient(colors: [ + Color(NSColor.windowBackgroundColor), + Color(NSColor.controlBackgroundColor).opacity(0.3) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + } + .frame(width: 820, height: 600) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .onAppear { + loadShortcuts() + } + .onReceive(Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()) { _ in + // Poll for shortcut changes every 0.5 seconds + loadShortcuts() + } + } + + private func loadShortcuts() { + customCancelShortcut = KeyboardShortcuts.getShortcut(for: .cancelRecorder) + pasteOriginalShortcut = KeyboardShortcuts.getShortcut(for: .pasteLastTranscription) + pasteEnhancedShortcut = KeyboardShortcuts.getShortcut(for: .pasteLastEnhancement) + retryShortcut = KeyboardShortcuts.getShortcut(for: .retryLastTranscription) + toggleHotkey1 = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) + toggleHotkey2 = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder2) + } +} + +// MARK: - Shortcut Card +private struct ShortcutCard: View { + let title: String + let subtitle: String + let shortcutView: Content + + init(icon: String = "", iconColor: Color = .clear, title: String, subtitle: String, @ViewBuilder shortcutView: () -> Content) { + self.title = title + self.subtitle = subtitle + self.shortcutView = shortcutView() + } + + var body: some View { + HStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + .lineLimit(1) + + Text(subtitle) + .font(.system(size: 12, weight: .regular)) + .foregroundColor(.secondary.opacity(0.8)) + .lineLimit(1) + } + + Spacer(minLength: 12) + + shortcutView + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 18) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(NSColor.controlBackgroundColor).opacity(0.7), + Color(NSColor.controlBackgroundColor).opacity(0.5) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder( + LinearGradient( + gradient: Gradient(colors: [ + Color(NSColor.separatorColor).opacity(0.5), + Color(NSColor.separatorColor).opacity(0.3) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1.5 + ) + ) + .shadow(color: Color(NSColor.shadowColor).opacity(0.2), radius: 4, x: 0, y: 2) + } +} + +// MARK: - Badge Components +private struct KeyboardShortcutBadge: View { + let shortcut: KeyboardShortcuts.Shortcut + + var body: some View { + HStack(spacing: 4) { + ForEach(shortcutComponents, id: \.self) { component in + KeyBadge(text: component) + } + } + } + + private var shortcutComponents: [String] { + var components: [String] = [] + if shortcut.modifiers.contains(.command) { components.append("⌘") } + if shortcut.modifiers.contains(.option) { components.append("⌥") } + if shortcut.modifiers.contains(.shift) { components.append("⇧") } + if shortcut.modifiers.contains(.control) { components.append("⌃") } + if let key = shortcut.key { + components.append(keyToString(key)) + } + return components + } + + private func keyToString(_ key: KeyboardShortcuts.Key) -> String { + switch key { + case .space: return "Space" + case .return: return "↩" + case .escape: return "⎋" + case .a: return "A" + case .b: return "B" + case .c: return "C" + case .d: return "D" + case .e: return "E" + case .f: return "F" + case .g: return "G" + case .h: return "H" + case .i: return "I" + case .j: return "J" + case .k: return "K" + case .l: return "L" + case .m: return "M" + case .n: return "N" + case .o: return "O" + case .p: return "P" + case .q: return "Q" + case .r: return "R" + case .s: return "S" + case .t: return "T" + case .u: return "U" + case .v: return "V" + case .w: return "W" + case .x: return "X" + case .y: return "Y" + case .z: return "Z" + case .zero: return "0" + case .one: return "1" + case .two: return "2" + case .three: return "3" + case .four: return "4" + case .five: return "5" + case .six: return "6" + case .seven: return "7" + case .eight: return "8" + case .nine: return "9" + default: return String(key.rawValue).uppercased() + } + } +} + +private struct StaticKeysBadge: View { + let keys: [String] + var isEnabled: Bool = true + + var body: some View { + HStack(spacing: 4) { + ForEach(keys, id: \.self) { key in + KeyBadge(text: key, isEnabled: isEnabled) + } + } + } +} + +private struct KeyBadge: View { + let text: String + var isEnabled: Bool = true + + var body: some View { + Text(text) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundColor(isEnabled ? .primary : .secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(NSColor.controlBackgroundColor).opacity(isEnabled ? 0.9 : 0.6), + Color(NSColor.controlBackgroundColor).opacity(isEnabled ? 0.7 : 0.5) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder( + Color(NSColor.separatorColor).opacity(isEnabled ? 0.4 : 0.2), + lineWidth: 1 + ) + ) + .shadow(color: Color(NSColor.shadowColor).opacity(isEnabled ? 0.15 : 0.05), radius: 2, x: 0, y: 1) + .opacity(isEnabled ? 1.0 : 0.6) + } +} + +private struct HotkeyBadge: View { + let text: String + + var body: some View { + Text(text) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.primary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(NSColor.controlBackgroundColor).opacity(0.9), + Color(NSColor.controlBackgroundColor).opacity(0.7) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(Color(NSColor.separatorColor).opacity(0.4), lineWidth: 1) + ) + .shadow(color: Color(NSColor.shadowColor).opacity(0.15), radius: 2, x: 0, y: 1) + } +} + +private struct MouseBadge: View { + var body: some View { + HStack(spacing: 6) { + Image(systemName: "button.vertical.right.press.fill") + .font(.system(size: 11)) + .foregroundColor(.primary) + Text("Middle Button") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.primary) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(NSColor.controlBackgroundColor).opacity(0.9), + Color(NSColor.controlBackgroundColor).opacity(0.7) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(Color(NSColor.separatorColor).opacity(0.4), lineWidth: 1) + ) + .shadow(color: Color(NSColor.shadowColor).opacity(0.15), radius: 2, x: 0, y: 1) + } +} + +private struct NotSetBadge: View { + var body: some View { + Text("Not Set") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor).opacity(0.3)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(Color(NSColor.separatorColor).opacity(0.25), lineWidth: 1) + ) + } +} + +#Preview { + KeyboardShortcutsListView() + .environmentObject(HotkeyManager(whisperState: WhisperState(modelContext: try! ModelContext(ModelContainer(for: Transcription.self))))) +} diff --git a/VoiceInk/Views/Metrics/MetricsContent.swift b/VoiceInk/Views/Metrics/MetricsContent.swift index 40b1ada..000f57d 100644 --- a/VoiceInk/Views/Metrics/MetricsContent.swift +++ b/VoiceInk/Views/Metrics/MetricsContent.swift @@ -3,7 +3,8 @@ import SwiftUI struct MetricsContent: View { let transcriptions: [Transcription] let licenseState: LicenseViewModel.LicenseState - + @State private var showKeyboardShortcuts = false + var body: some View { Group { if transcriptions.isEmpty { @@ -138,6 +139,7 @@ struct MetricsContent: View { private var footerActionsView: some View { HStack(spacing: 12) { + KeyboardShortcutsButton(showKeyboardShortcuts: $showKeyboardShortcuts) CopySystemInfoButton() FeedbackButton() } @@ -280,6 +282,31 @@ private struct FeedbackButton: View { } } +private struct KeyboardShortcutsButton: View { + @Binding var showKeyboardShortcuts: Bool + + var body: some View { + Button(action: { + showKeyboardShortcuts = true + }) { + HStack(spacing: 8) { + Image(systemName: "command") + .font(.system(size: 13, weight: .medium)) + + Text("Keyboard Shortcuts") + } + .font(.system(size: 13, weight: .medium)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Capsule().fill(.thinMaterial)) + } + .buttonStyle(.plain) + .popover(isPresented: $showKeyboardShortcuts, arrowEdge: .bottom) { + KeyboardShortcutsListView() + } + } +} + private struct CopySystemInfoButton: View { @State private var isCopied: Bool = false diff --git a/VoiceInk/Views/Settings/EnhancementShortcutsView.swift b/VoiceInk/Views/Settings/EnhancementShortcutsView.swift index 6a1775b..4c5be1f 100644 --- a/VoiceInk/Views/Settings/EnhancementShortcutsView.swift +++ b/VoiceInk/Views/Settings/EnhancementShortcutsView.swift @@ -105,12 +105,18 @@ private struct ShortcutRow: View { HStack(spacing: 8) { Text(title) .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) InfoTip(title: title, message: description, learnMoreURL: "https://tryvoiceink.com/docs/switching-enhancement-prompts") } + + Text(description) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) } - - Spacer(minLength: 0) - + + Spacer(minLength: 12) + if let isOn = isOn { keyDisplayView(isActive: isOn.wrappedValue) .onTapGesture { @@ -123,9 +129,11 @@ private struct ShortcutRow: View { keyDisplayView() } } - .padding(.horizontal, 8) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(CardBackground(isSelected: false)) } - + @ViewBuilder private func keyDisplayView(isActive: Bool? = nil) -> some View { HStack(spacing: 8) { @@ -142,21 +150,33 @@ private struct KeyChip: View { var body: some View { let active = isActive ?? true - + Text(label) - .font(.system(size: 13, weight: .semibold, design: .monospaced)) - .padding(.horizontal, 12) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundColor(active ? .primary : .secondary) + .padding(.horizontal, 10) .padding(.vertical, 6) .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(NSColor.controlBackgroundColor)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color(NSColor.separatorColor).opacity(active ? 0.7 : 0.3), lineWidth: 1) + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(NSColor.controlBackgroundColor).opacity(active ? 0.9 : 0.6), + Color(NSColor.controlBackgroundColor).opacity(active ? 0.7 : 0.5) + ]), + startPoint: .top, + endPoint: .bottom + ) ) ) - .foregroundColor(active ? .primary : .secondary) - .shadow(color: Color(NSColor.shadowColor).opacity(active ? 0.1 : 0), radius: 4, x: 0, y: 2) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder( + Color(NSColor.separatorColor).opacity(active ? 0.4 : 0.2), + lineWidth: 1 + ) + ) + .shadow(color: Color(NSColor.shadowColor).opacity(active ? 0.15 : 0.05), radius: 2, x: 0, y: 1) .opacity(active ? 1.0 : 0.6) } }