diff --git a/azooKeyMac/Configs/KeyboardShortcut.swift b/azooKeyMac/Configs/KeyboardShortcut.swift new file mode 100644 index 0000000..d67815b --- /dev/null +++ b/azooKeyMac/Configs/KeyboardShortcut.swift @@ -0,0 +1,65 @@ +import Cocoa + +/// キーボードショートカットを表す構造体 +public struct KeyboardShortcut: Codable, Equatable, Hashable, Sendable { + public var key: String + public var modifiers: EventModifierFlags + + public init(key: String, modifiers: EventModifierFlags) { + self.key = key + self.modifiers = modifiers + } + + /// デフォルトのショートカット(Control+S) + public static let defaultTransformShortcut = KeyboardShortcut( + key: "s", + modifiers: .control + ) + + /// 表示用の文字列(例: "⌃S") + public var displayString: String { + var result = "" + + if modifiers.contains(.control) { + result += "⌃" + } + if modifiers.contains(.option) { + result += "⌥" + } + if modifiers.contains(.shift) { + result += "⇧" + } + if modifiers.contains(.command) { + result += "⌘" + } + + result += key.uppercased() + return result + } +} + +/// NSEvent.ModifierFlagsをCodable/Sendableにするためのラッパー +public struct EventModifierFlags: Codable, Equatable, Hashable, Sendable { + private var rawValue: UInt + + public init(rawValue: UInt) { + self.rawValue = rawValue + } + + public init(from nsModifiers: NSEvent.ModifierFlags) { + self.rawValue = nsModifiers.rawValue + } + + public var nsModifierFlags: NSEvent.ModifierFlags { + NSEvent.ModifierFlags(rawValue: rawValue) + } + + public static let control = EventModifierFlags(from: .control) + public static let option = EventModifierFlags(from: .option) + public static let shift = EventModifierFlags(from: .shift) + public static let command = EventModifierFlags(from: .command) + + public func contains(_ other: EventModifierFlags) -> Bool { + (rawValue & other.rawValue) == other.rawValue + } +} diff --git a/azooKeyMac/Configs/KeyboardShortcutConfigItem.swift b/azooKeyMac/Configs/KeyboardShortcutConfigItem.swift new file mode 100644 index 0000000..593f47d --- /dev/null +++ b/azooKeyMac/Configs/KeyboardShortcutConfigItem.swift @@ -0,0 +1,74 @@ +@_spi(Core) import Core +import Foundation + +protocol KeyboardShortcutConfigItem: ConfigItem { + static var `default`: KeyboardShortcut { get } +} + +extension KeyboardShortcutConfigItem { + public var value: KeyboardShortcut { + get { + guard let data = UserDefaults.standard.data(forKey: Self.key) else { + return Self.default + } + do { + let decoded = try JSONDecoder().decode(KeyboardShortcut.self, from: data) + return decoded + } catch { + return Self.default + } + } + nonmutating set { + do { + let encoded = try JSONEncoder().encode(newValue) + UserDefaults.standard.set(encoded, forKey: Self.key) + } catch { + // エンコード失敗時は何もしない + } + } + } +} + +extension Config { + /// いい感じ変換のキーボードショートカット + public struct TransformShortcut: KeyboardShortcutConfigItem { + public init() {} + + public static let `default`: KeyboardShortcut = .defaultTransformShortcut + public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.transform_shortcut" + } +} + +protocol StringConfigItemWithDefault: ConfigItem { + static var `default`: String { get } +} + +extension StringConfigItemWithDefault { + public var value: String { + get { + let stored = UserDefaults.standard.string(forKey: Self.key) ?? "" + return stored.isEmpty ? Self.default : stored + } + nonmutating set { + UserDefaults.standard.set(newValue, forKey: Self.key) + } + } +} + +extension Config { + /// 英数キーダブルタップのプロンプト + public struct EisuDoubleTapPrompt: StringConfigItemWithDefault { + public init() {} + + public static let `default`: String = "english" + public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.eisu_double_tap_prompt" + } + + /// かなキーダブルタップのプロンプト + public struct KanaDoubleTapPrompt: StringConfigItemWithDefault { + public init() {} + + public static let `default`: String = "japanese" + public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.kana_double_tap_prompt" + } +} diff --git a/azooKeyMac/InputController/azooKeyMacInputController.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index 415618d..27f57aa 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController.swift @@ -58,6 +58,64 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s return isDouble } + // MARK: - ダブルタッププロンプト取得 + private func getDoubleTapPrompt(isEisu: Bool) -> String? { + // Check pinned prompts first + if let data = UserDefaults.standard.data(forKey: "dev.ensan.inputmethod.azooKeyMac.preference.PromptHistory"), + let history = try? JSONDecoder().decode([PromptHistoryItem].self, from: data) { + if let matched = history.first(where: { $0.isPinned && (isEisu ? $0.isEisuDoubleTap : $0.isKanaDoubleTap) }) { + return matched.prompt + } + } + + // Fallback to config + if isEisu { + let prompt = Config.EisuDoubleTapPrompt().value + return prompt.isEmpty ? nil : prompt + } else { + let prompt = Config.KanaDoubleTapPrompt().value + return prompt.isEmpty ? nil : prompt + } + } + + // MARK: - カスタムプロンプトショートカット検出 + private func checkCustomPromptShortcut(event: NSEvent) -> String? { + guard let characters = event.charactersIgnoringModifiers, + !characters.isEmpty else { + return nil + } + + let key = characters.lowercased() + + // 必要な修飾キーのみをマスクして取得 + let relevantModifiers: NSEvent.ModifierFlags = [.control, .option, .shift, .command] + let eventModifiers = event.modifierFlags.intersection(relevantModifiers) + + // 修飾キーがない場合は早期リターン(通常の入力) + if eventModifiers.isEmpty { + return nil + } + + // Check pinned prompts with shortcuts + guard let data = UserDefaults.standard.data(forKey: "dev.ensan.inputmethod.azooKeyMac.preference.PromptHistory"), + let history = try? JSONDecoder().decode([PromptHistoryItem].self, from: data) else { + return nil + } + + let pinnedWithShortcuts = history.filter { $0.isPinned && $0.shortcut != nil } + if let matched = pinnedWithShortcuts.first(where: { item in + guard let itemShortcut = item.shortcut else { + return false + } + let shortcutModifiers = itemShortcut.modifiers.nsModifierFlags.intersection(relevantModifiers) + return itemShortcut.key == key && eventModifiers == shortcutModifiers + }) { + return matched.prompt + } + + return nil + } + override init!(server: IMKServer!, delegate: Any!, client inputClient: Any!) { let applicationDirectoryURL = if #available(macOS 13, *) { URL.applicationSupportDirectory @@ -229,6 +287,19 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s return false } + // カスタムプロンプトショートカットのチェック + if let matchedPrompt = checkCustomPromptShortcut(event: event) { + let aiBackendEnabled = Config.AIBackendPreference().value != .off + if aiBackendEnabled && !self.isPromptWindowVisible { + let selectedRange = client.selectedRange() + if selectedRange.length > 0 { + if self.triggerAiTranslation(initialPrompt: matchedPrompt) { + return true + } + } + } + } + let userAction = UserAction.getUserAction(eventCore: event.keyEventCore, inputLanguage: inputLanguage) // 英数キー(keyCode 102)の処理 @@ -238,8 +309,11 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s if isDoubleTap { let selectedRange = client.selectedRange() if selectedRange.length > 0 { - if self.triggerAiTranslation(initialPrompt: "english") { - return true + // Check pinned prompts for Eisu double-tap + if let prompt = getDoubleTapPrompt(isEisu: true) { + if self.triggerAiTranslation(initialPrompt: prompt) { + return true + } } } if !self.segmentsManager.isEmpty { @@ -256,8 +330,11 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s if isDoubleTap { let selectedRange = client.selectedRange() if selectedRange.length > 0 { - if self.triggerAiTranslation(initialPrompt: "japanese") { - return true + // Check pinned prompts for Kana double-tap + if let prompt = getDoubleTapPrompt(isEisu: false) { + if self.triggerAiTranslation(initialPrompt: prompt) { + return true + } } } } diff --git a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift index ae866db..243800d 100644 --- a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift +++ b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift @@ -9,8 +9,15 @@ extension azooKeyMacInputController { self.appMenu.autoenablesItems = true self.liveConversionToggleMenuItem = NSMenuItem(title: "ライブ変換", action: #selector(self.toggleLiveConversion(_:)), keyEquivalent: "") self.appMenu.addItem(self.liveConversionToggleMenuItem) - self.transformSelectedTextMenuItem = NSMenuItem(title: TransformMenuTitle.normal, action: #selector(self.performTransformSelectedText(_:)), keyEquivalent: "s") - self.transformSelectedTextMenuItem.keyEquivalentModifierMask = [.control] + + // ショートカット設定を読み込み + let shortcut = Config.TransformShortcut().value + self.transformSelectedTextMenuItem = NSMenuItem( + title: TransformMenuTitle.normal, + action: #selector(self.performTransformSelectedText(_:)), + keyEquivalent: shortcut.key + ) + self.transformSelectedTextMenuItem.keyEquivalentModifierMask = shortcut.modifiers.nsModifierFlags self.transformSelectedTextMenuItem.target = self self.appMenu.addItem(self.transformSelectedTextMenuItem) self.appMenu.addItem(NSMenuItem.separator()) diff --git a/azooKeyMac/Windows/KeyboardShortcutRecorder.swift b/azooKeyMac/Windows/KeyboardShortcutRecorder.swift new file mode 100644 index 0000000..095076c --- /dev/null +++ b/azooKeyMac/Windows/KeyboardShortcutRecorder.swift @@ -0,0 +1,161 @@ +import AppKit +import SwiftUI + +/// キーボードショートカットを記録するためのビュー +struct KeyboardShortcutRecorder: NSViewRepresentable { + @Binding var shortcut: KeyboardShortcut + var placeholder: String = "ショートカットを入力..." + + func makeNSView(context: Context) -> ShortcutRecorderView { + let view = ShortcutRecorderView() + view.shortcut = shortcut + view.placeholder = placeholder + view.onShortcutChanged = { newShortcut in + shortcut = newShortcut + } + return view + } + + func updateNSView(_ nsView: ShortcutRecorderView, context: Context) { + if nsView.shortcut != shortcut { + nsView.shortcut = shortcut + } + } +} + +/// NSViewベースのショートカットレコーダー +class ShortcutRecorderView: NSView { + var shortcut: KeyboardShortcut = .defaultTransformShortcut { + didSet { + needsDisplay = true + } + } + var placeholder: String = "ショートカットを入力..." + var onShortcutChanged: ((KeyboardShortcut) -> Void)? + + private var isRecording = false { + didSet { + needsDisplay = true + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupView() + } + + private func setupView() { + wantsLayer = true + layer?.cornerRadius = 4 + layer?.borderWidth = 1 + layer?.borderColor = NSColor.separatorColor.cgColor + } + + override var acceptsFirstResponder: Bool { + true + } + + override func becomeFirstResponder() -> Bool { + isRecording = true + return super.becomeFirstResponder() + } + + override func resignFirstResponder() -> Bool { + isRecording = false + return super.resignFirstResponder() + } + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + } + + override func keyDown(with event: NSEvent) { + guard isRecording else { + super.keyDown(with: event) + return + } + + if event.keyCode == 53 { + window?.makeFirstResponder(nil) + return + } + + if event.keyCode == 51 || event.keyCode == 117 { + shortcut = .defaultTransformShortcut + onShortcutChanged?(shortcut) + window?.makeFirstResponder(nil) + return + } + + guard let characters = event.charactersIgnoringModifiers, + !characters.isEmpty else { + return + } + + let key = characters.lowercased() + let modifiers = EventModifierFlags(from: event.modifierFlags) + + guard modifiers.contains(.control) || + modifiers.contains(.option) || + modifiers.contains(.shift) || + modifiers.contains(.command) else { + return + } + + let newShortcut = KeyboardShortcut(key: key, modifiers: modifiers) + shortcut = newShortcut + onShortcutChanged?(newShortcut) + window?.makeFirstResponder(nil) + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + let backgroundColor: NSColor = isRecording ? .controlAccentColor.withAlphaComponent(0.1) : .controlBackgroundColor + backgroundColor.setFill() + bounds.fill() + + let text: String + let textColor: NSColor + + if isRecording { + text = "キーを入力..." + textColor = .secondaryLabelColor + } else { + text = shortcut.displayString + textColor = .labelColor + } + + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 13), + .foregroundColor: textColor + ] + + let attributedString = NSAttributedString(string: text, attributes: attributes) + let textSize = attributedString.size() + let textRect = NSRect( + x: (bounds.width - textSize.width) / 2, + y: (bounds.height - textSize.height) / 2, + width: textSize.width, + height: textSize.height + ) + + attributedString.draw(in: textRect) + + if isRecording { + NSGraphicsContext.saveGraphicsState() + NSFocusRingPlacement.only.set() + bounds.fill() + NSGraphicsContext.restoreGraphicsState() + } + } + + override var intrinsicContentSize: NSSize { + NSSize(width: 120, height: 28) + } +} diff --git a/azooKeyMac/Windows/PromptInput/PromptHistoryItem.swift b/azooKeyMac/Windows/PromptInput/PromptHistoryItem.swift index 9ca3a6b..3aafd79 100644 --- a/azooKeyMac/Windows/PromptInput/PromptHistoryItem.swift +++ b/azooKeyMac/Windows/PromptInput/PromptHistoryItem.swift @@ -1,14 +1,34 @@ import Foundation // Structure for prompt history item with pinned status -struct PromptHistoryItem: Sendable, Codable { +struct PromptHistoryItem: Sendable, Codable, Identifiable { + var id: UUID let prompt: String - var isPinned: Bool = false - var lastUsed: Date = Date() + var isPinned: Bool + var lastUsed: Date + var shortcut: KeyboardShortcut? + var isEisuDoubleTap: Bool + var isKanaDoubleTap: Bool - init(prompt: String, isPinned: Bool = false) { + init(prompt: String, isPinned: Bool = false, shortcut: KeyboardShortcut? = nil, isEisuDoubleTap: Bool = false, isKanaDoubleTap: Bool = false) { + self.id = UUID() self.prompt = prompt self.isPinned = isPinned self.lastUsed = Date() + self.shortcut = shortcut + self.isEisuDoubleTap = isEisuDoubleTap + self.isKanaDoubleTap = isKanaDoubleTap + } + + // 後方互換性のためのカスタムデコーダー + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + self.prompt = try container.decode(String.self, forKey: .prompt) + self.isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false + self.lastUsed = try container.decodeIfPresent(Date.self, forKey: .lastUsed) ?? Date() + self.shortcut = try container.decodeIfPresent(KeyboardShortcut.self, forKey: .shortcut) + self.isEisuDoubleTap = try container.decodeIfPresent(Bool.self, forKey: .isEisuDoubleTap) ?? false + self.isKanaDoubleTap = try container.decodeIfPresent(Bool.self, forKey: .isKanaDoubleTap) ?? false } } diff --git a/azooKeyMac/Windows/PromptInput/PromptInputView.swift b/azooKeyMac/Windows/PromptInput/PromptInputView.swift index d163e5b..5ffa7e9 100644 --- a/azooKeyMac/Windows/PromptInput/PromptInputView.swift +++ b/azooKeyMac/Windows/PromptInput/PromptInputView.swift @@ -11,6 +11,8 @@ struct PromptInputView: View { @State private var hoveredHistoryIndex: Int? @State private var isNavigatingHistory: Bool = false @State private var includeContext: Bool = Config.IncludeContextInAITransform().value + @State private var editingShortcutFor: PromptHistoryItem? + @State private var showingSettings: Bool = false @FocusState private var isTextFieldFocused: Bool let initialPrompt: String? @@ -74,6 +76,16 @@ struct PromptInputView: View { Spacer() + Button { + showingSettings = true + } label: { + Image(systemName: "gearshape") + .foregroundColor(.secondary.opacity(0.8)) + .font(.system(size: 10, weight: .medium)) + } + .buttonStyle(PlainButtonStyle()) + .help("Settings") + Button(action: onCancel) { Image(systemName: "xmark") .foregroundColor(.secondary.opacity(0.8)) @@ -168,12 +180,55 @@ struct PromptInputView: View { .buttonStyle(PlainButtonStyle()) .help(item.isPinned ? "Unpin" : "Pin") + // Shortcut button (only for pinned items) + if item.isPinned { + Button { + editingShortcutFor = item + } label: { + if let shortcut = item.shortcut { + Text(shortcut.displayString) + .font(.system(size: 8, weight: .medium)) + .foregroundColor(.accentColor) + } else { + Image(systemName: "command") + .font(.system(size: 9)) + .foregroundColor(.secondary.opacity(0.4)) + } + } + .buttonStyle(PlainButtonStyle()) + .help(item.shortcut == nil ? "Set shortcut" : "Edit shortcut") + } + // Prompt text Text(item.prompt) .font(.system(size: 11)) .foregroundColor(hoveredHistoryIndex == index ? .primary : .secondary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) + + // Double-tap badges (only for pinned items) + if item.isPinned { + HStack(spacing: 2) { + if item.isEisuDoubleTap { + Text("E") + .font(.system(size: 7, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 3) + .padding(.vertical, 1) + .background(Color.blue) + .cornerRadius(2) + } + if item.isKanaDoubleTap { + Text("J") + .font(.system(size: 7, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 3) + .padding(.vertical, 1) + .background(Color.green) + .cornerRadius(2) + } + } + } } .padding(.horizontal, 8) .padding(.vertical, 3) @@ -357,6 +412,28 @@ struct PromptInputView: View { hoveredHistoryIndex = nil isTextFieldFocused = true } + .sheet(item: $editingShortcutFor) { item in + ShortcutEditorSheet( + item: item, + existingEisuPrompt: promptHistory.first(where: { $0.id != item.id && $0.isEisuDoubleTap })?.prompt, + existingKanaPrompt: promptHistory.first(where: { $0.id != item.id && $0.isKanaDoubleTap })?.prompt, + allItems: promptHistory, + onSave: { updatedItem in + updateShortcut(for: updatedItem) + editingShortcutFor = nil + }, + onCancel: { + editingShortcutFor = nil + } + ) + } + .sheet(isPresented: $showingSettings) { + MagicConversionSettingsSheet( + onClose: { + showingSettings = false + } + ) + } } private func requestPreview() { @@ -436,10 +513,13 @@ struct PromptInputView: View { // Add default pinned prompts if history is empty if promptHistory.isEmpty { - let defaultPinnedPrompts = ["elaborate", "rewrite", "formal", "english"] - promptHistory = defaultPinnedPrompts.map { prompt in - PromptHistoryItem(prompt: prompt, isPinned: true) - } + promptHistory = [ + PromptHistoryItem(prompt: "elaborate", isPinned: true), + PromptHistoryItem(prompt: "rewrite", isPinned: true), + PromptHistoryItem(prompt: "formal", isPinned: true), + PromptHistoryItem(prompt: "english", isPinned: true, isEisuDoubleTap: true), + PromptHistoryItem(prompt: "japanese", isPinned: true, isKanaDoubleTap: true) + ] savePinnedHistory() } } @@ -452,12 +532,43 @@ struct PromptInputView: View { } private func togglePin(for item: PromptHistoryItem) { - if let index = promptHistory.firstIndex(where: { $0.prompt == item.prompt }) { + if let index = promptHistory.firstIndex(where: { $0.id == item.id }) { promptHistory[index].isPinned.toggle() savePinnedHistory() } } + private func updateShortcut(for item: PromptHistoryItem) { + if let index = promptHistory.firstIndex(where: { $0.id == item.id }) { + // Clear double-tap flags from other items if this item is setting them + if item.isEisuDoubleTap { + for i in promptHistory.indices where i != index { + promptHistory[i].isEisuDoubleTap = false + } + } + if item.isKanaDoubleTap { + for i in promptHistory.indices where i != index { + promptHistory[i].isKanaDoubleTap = false + } + } + + // Clear conflicting keyboard shortcuts from other items + if let newShortcut = item.shortcut { + for i in promptHistory.indices where i != index { + if promptHistory[i].shortcut == newShortcut { + promptHistory[i].shortcut = nil + } + } + } + + // Update the item + promptHistory[index].shortcut = item.shortcut + promptHistory[index].isEisuDoubleTap = item.isEisuDoubleTap + promptHistory[index].isKanaDoubleTap = item.isKanaDoubleTap + savePinnedHistory() + } + } + private func savePromptToHistory(_ prompt: String) { // Check if prompt already exists and preserve its pinned status if let existingIndex = promptHistory.firstIndex(where: { $0.prompt == prompt }) { @@ -564,3 +675,343 @@ struct PromptInputView: View { ) .frame(width: 380) } + +// MARK: - Shortcut Editor Sheet +struct ShortcutEditorSheet: View { + @State private var item: PromptHistoryItem + @State private var shortcut: KeyboardShortcut + @State private var hasShortcut: Bool + @State private var isEisuDoubleTap: Bool + @State private var isKanaDoubleTap: Bool + let existingEisuPrompt: String? + let existingKanaPrompt: String? + let allItems: [PromptHistoryItem] + let onSave: (PromptHistoryItem) -> Void + let onCancel: () -> Void + + // Reserved system shortcuts + private let reservedShortcuts: [KeyboardShortcut] = [ + Config.TransformShortcut().value // いい感じ変換のショートカット + ] + + private var conflictingPrompt: String? { + guard hasShortcut else { + return nil + } + return allItems.first(where: { otherItem in + otherItem.id != item.id && + otherItem.shortcut == shortcut + })?.prompt + } + + private var isSystemShortcut: Bool { + guard hasShortcut else { + return false + } + return reservedShortcuts.contains(shortcut) + } + + init(item: PromptHistoryItem, existingEisuPrompt: String?, existingKanaPrompt: String?, allItems: [PromptHistoryItem], onSave: @escaping (PromptHistoryItem) -> Void, onCancel: @escaping () -> Void) { + self._item = State(initialValue: item) + self._shortcut = State(initialValue: item.shortcut ?? KeyboardShortcut(key: "a", modifiers: .control)) + self._hasShortcut = State(initialValue: item.shortcut != nil) + self._isEisuDoubleTap = State(initialValue: item.isEisuDoubleTap) + self._isKanaDoubleTap = State(initialValue: item.isKanaDoubleTap) + self.existingEisuPrompt = existingEisuPrompt + self.existingKanaPrompt = existingKanaPrompt + self.allItems = allItems + self.onSave = onSave + self.onCancel = onCancel + } + + var body: some View { + VStack(spacing: 16) { + Text("Set Shortcut for \"\(item.prompt)\"") + .font(.headline) + + VStack(spacing: 12) { + // Keyboard shortcut + VStack(alignment: .leading, spacing: 4) { + HStack { + Toggle("Keyboard Shortcut", isOn: $hasShortcut) + .toggleStyle(.checkbox) + .font(.caption) + .foregroundColor(.secondary) + } + + if hasShortcut { + KeyboardShortcutRecorder(shortcut: $shortcut) + .frame(height: 40) + + // Conflict warnings + if isSystemShortcut { + Text("⚠️ This shortcut is reserved for system function") + .font(.system(size: 9)) + .foregroundColor(.red) + } else if let conflicting = conflictingPrompt { + Text("⚠️ Already used by \"\(conflicting)\" (will be replaced)") + .font(.system(size: 9)) + .foregroundColor(.orange) + } + } + } + + Divider() + + // Double-tap settings + VStack(alignment: .leading, spacing: 8) { + Text("Double-Tap Keys") + .font(.caption) + .foregroundColor(.secondary) + + VStack(alignment: .leading, spacing: 4) { + Toggle("Eisu (英数) key double-tap", isOn: $isEisuDoubleTap) + .toggleStyle(.checkbox) + .font(.system(size: 11)) + + if isEisuDoubleTap, let existing = existingEisuPrompt { + Text("⚠️ Currently set to \"\(existing)\" (will be replaced)") + .font(.system(size: 9)) + .foregroundColor(.orange) + } + } + + VStack(alignment: .leading, spacing: 4) { + Toggle("Kana (かな) key double-tap", isOn: $isKanaDoubleTap) + .toggleStyle(.checkbox) + .font(.system(size: 11)) + + if isKanaDoubleTap, let existing = existingKanaPrompt { + Text("⚠️ Currently set to \"\(existing)\" (will be replaced)") + .font(.system(size: 9)) + .foregroundColor(.orange) + } + } + } + } + + HStack { + Button("Cancel") { + onCancel() + } + .keyboardShortcut(.escape) + + Spacer() + + if hasShortcut || item.isEisuDoubleTap || item.isKanaDoubleTap { + Button("Remove All") { + var updatedItem = item + updatedItem.shortcut = nil + updatedItem.isEisuDoubleTap = false + updatedItem.isKanaDoubleTap = false + onSave(updatedItem) + } + } + + Button("Save") { + var updatedItem = item + updatedItem.shortcut = hasShortcut ? shortcut : nil + updatedItem.isEisuDoubleTap = isEisuDoubleTap + updatedItem.isKanaDoubleTap = isKanaDoubleTap + onSave(updatedItem) + } + .keyboardShortcut(.return) + } + } + .padding(20) + .frame(width: 360) + } +} + +// MARK: - Magic Conversion Settings Sheet +struct MagicConversionSettingsSheet: View { + @State private var transformShortcut: KeyboardShortcut = Config.TransformShortcut().value + @State private var aiBackend: Config.AIBackendPreference.Value = Config.AIBackendPreference().value + @State private var openAiApiKey: String = Config.OpenAiApiKey().value + @State private var openAiModelName: String = Config.OpenAiModelName().value + @State private var openAiApiEndpoint: String = Config.OpenAiApiEndpoint().value + @State private var connectionTestInProgress = false + @State private var connectionTestResult: String? + @State private var foundationModelsAvailability: FoundationModelsAvailability? + @State private var availabilityCheckDone = false + + let onClose: () -> Void + + private func getErrorMessage(for error: OpenAIError) -> String { + switch error { + case .invalidURL: + return "エラー: 無効なURL形式です" + case .noServerResponse: + return "エラー: サーバーから応答がありません" + case .invalidResponseStatus(let code, let body): + return getHTTPErrorMessage(code: code, body: body) + case .parseError(let message): + return "エラー: レスポンス解析失敗 - \(message)" + case .invalidResponseStructure: + return "エラー: 予期しないレスポンス形式" + } + } + + private func getHTTPErrorMessage(code: Int, body: String) -> String { + switch code { + case 401: + return "エラー: APIキーが無効です" + case 403: + return "エラー: アクセスが拒否されました" + case 404: + return "エラー: エンドポイントが見つかりません" + case 429: + return "エラー: レート制限に達しました" + case 500...599: + return "エラー: サーバーエラー (コード: \(code))" + default: + return "エラー: HTTPステータス \(code)\n詳細: \(body.prefix(100))..." + } + } + + func testConnection() async { + connectionTestInProgress = true + connectionTestResult = nil + + do { + let testRequest = OpenAIRequest( + prompt: "テスト", + target: "", + modelName: openAiModelName.isEmpty ? Config.OpenAiModelName.default : openAiModelName + ) + _ = try await OpenAIClient.sendRequest( + testRequest, + apiKey: openAiApiKey, + apiEndpoint: openAiApiEndpoint + ) + + connectionTestResult = "接続成功" + } catch let error as OpenAIError { + connectionTestResult = getErrorMessage(for: error) + } catch { + connectionTestResult = "エラー: \(error.localizedDescription)" + } + + connectionTestInProgress = false + } + + var body: some View { + VStack(spacing: 16) { + Text("Magic Conversion Settings") + .font(.headline) + + Form { + // Shortcut section + Section { + LabeledContent { + KeyboardShortcutRecorder(shortcut: $transformShortcut) + .onChange(of: transformShortcut) { newValue in + Config.TransformShortcut().value = newValue + } + } label: { + Text("Shortcut") + } + } header: { + Label("Keyboard Shortcut", systemImage: "command") + } footer: { + Text("Click to record a new shortcut. Press Delete to reset to default.") + .font(.caption) + .foregroundColor(.secondary) + } + + // AI Backend section + Section { + Picker("Backend", selection: $aiBackend) { + Text("Off").tag(Config.AIBackendPreference.Value.off) + + if let availability = foundationModelsAvailability, availability.isAvailable { + Text("Foundation Models").tag(Config.AIBackendPreference.Value.foundationModels) + } + + Text("OpenAI API").tag(Config.AIBackendPreference.Value.openAI) + } + .onChange(of: aiBackend) { newValue in + Config.AIBackendPreference().value = newValue + UserDefaults.standard.set(true, forKey: "hasSetAIBackendManually") + } + + if aiBackend == .openAI { + SecureField("API Key", text: $openAiApiKey, prompt: Text("e.g. sk-xxxxxxxxxxx")) + .onChange(of: openAiApiKey) { newValue in + Config.OpenAiApiKey().value = newValue + } + TextField("Model Name", text: $openAiModelName, prompt: Text("e.g. gpt-4o-mini")) + .onChange(of: openAiModelName) { newValue in + Config.OpenAiModelName().value = newValue + } + TextField("Endpoint", text: $openAiApiEndpoint, prompt: Text("e.g. https://api.openai.com/v1/chat/completions")) + .onChange(of: openAiApiEndpoint) { newValue in + Config.OpenAiApiEndpoint().value = newValue + } + .help("e.g. https://api.openai.com/v1/chat/completions\nGemini: https://generativelanguage.googleapis.com/v1beta/openai/chat/completions") + + HStack { + Button("Test Connection") { + Task { + await testConnection() + } + } + .disabled(connectionTestInProgress || openAiApiKey.isEmpty) + + if connectionTestInProgress { + ProgressView() + .scaleEffect(0.8) + } + } + + if let result = connectionTestResult { + Text(result) + .foregroundColor(result.contains("成功") ? .green : .red) + .font(.caption) + .textSelection(.enabled) + } + } + } header: { + Label("AI Backend", systemImage: "sparkles") + } + } + .formStyle(.grouped) + .frame(height: 320) + + HStack { + Spacer() + Button("Close") { + onClose() + } + .keyboardShortcut(.escape) + } + } + .padding(20) + .frame(width: 400) + .onAppear { + if !availabilityCheckDone { + foundationModelsAvailability = FoundationModelsClientCompat.checkAvailability() + availabilityCheckDone = true + + // Auto-select Foundation Models if available and not manually set + let hasSetAIBackend = UserDefaults.standard.bool(forKey: "hasSetAIBackendManually") + if !hasSetAIBackend, + aiBackend == .off, + let availability = foundationModelsAvailability, + availability.isAvailable { + aiBackend = .foundationModels + Config.AIBackendPreference().value = .foundationModels + UserDefaults.standard.set(true, forKey: "hasSetAIBackendManually") + } + + // Fallback if Foundation Models not available + if aiBackend == .foundationModels, + let availability = foundationModelsAvailability, + !availability.isAvailable { + aiBackend = .off + Config.AIBackendPreference().value = .off + } + } + } + } +}