468 lines
18 KiB
Swift
468 lines
18 KiB
Swift
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<Content: View>: 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)))))
|
||
}
|