From 703057ef3021aaeab3ffdddaa14aa94b5807d975 Mon Sep 17 00:00:00 2001 From: Aryamirsepasi Date: Thu, 5 Dec 2024 23:35:18 +0100 Subject: [PATCH 01/10] added animations for loadings, added reset via terminal, added comments for documentation --- .../AI Providers/AIProvider.swift | 6 + macOS/writing-tools/AppDelegate.swift | 137 ++++++++++++++++-- macOS/writing-tools/AppState.swift | 3 + macOS/writing-tools/UI/AboutView.swift | 5 +- macOS/writing-tools/UI/LoadingModifier.swift | 68 +++++++++ macOS/writing-tools/UI/PopupView.swift | 28 +++- macOS/writing-tools/UI/PopupWindow.swift | 2 +- macOS/writing-tools/UI/ResponseView.swift | 43 ++++-- macOS/writing-tools/UI/SettingsView.swift | 6 +- macOS/writing-tools/UI/WindowManager.swift | 11 +- 10 files changed, 273 insertions(+), 36 deletions(-) create mode 100644 macOS/writing-tools/UI/LoadingModifier.swift diff --git a/macOS/writing-tools/AI Providers/AIProvider.swift b/macOS/writing-tools/AI Providers/AIProvider.swift index a437b0e..34e458a 100644 --- a/macOS/writing-tools/AI Providers/AIProvider.swift +++ b/macOS/writing-tools/AI Providers/AIProvider.swift @@ -1,7 +1,13 @@ import Foundation protocol AIProvider: ObservableObject { + + // Indicates if provider is processing a request var isProcessing: Bool { get set } + + // Process text with optional system prompt func processText(systemPrompt: String?, userPrompt: String) async throws -> String + + // Cancel ongoing requests func cancel() } diff --git a/macOS/writing-tools/AppDelegate.swift b/macOS/writing-tools/AppDelegate.swift index 9471536..42a1d4b 100644 --- a/macOS/writing-tools/AppDelegate.swift +++ b/macOS/writing-tools/AppDelegate.swift @@ -3,7 +3,14 @@ import HotKey import Carbon.HIToolbox class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { + + // Static status item to prevent deallocation private static var sharedStatusItem: NSStatusItem? + + // Property to track service-triggered popups + private var isServiceTriggered: Bool = false + + // Computed property to manage the menu bar status item var statusBarItem: NSStatusItem! { get { if AppDelegate.sharedStatusItem == nil { @@ -25,7 +32,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { private var aboutHostingView: NSHostingView? private let windowAccessQueue = DispatchQueue(label: "com.example.writingtools.windowQueue") + // Called when app launches - initializes core functionality func applicationDidFinishLaunching(_ notification: Notification) { + + NSApp.servicesProvider = self + + if CommandLine.arguments.contains("--reset") { + DispatchQueue.main.async { [weak self] in + self?.performRecoveryReset() + } + return + } + DispatchQueue.main.async { [weak self] in self?.setupMenuBar() self?.setupHotKey() @@ -42,20 +60,24 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } + // Called when app is about to close - performs cleanup func applicationWillTerminate(_ notification: Notification) { WindowManager.shared.cleanupWindows() } + // Recreates the menu bar item if it was lost private func recreateStatusBarItem() { AppDelegate.sharedStatusItem = nil _ = self.statusBarItem } + // Sets up the status bar item's icon private func configureStatusBarItem() { guard let button = statusBarItem?.button else { return } button.image = NSImage(systemSymbolName: "pencil.circle", accessibilityDescription: "Writing Tools") } + // Creates the menu that appears when clicking the status bar icon private func setupMenuBar() { guard let statusBarItem = self.statusBarItem else { print("Failed to create status bar item") @@ -72,6 +94,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { statusBarItem.menu = menu } + // Resets app to default state when triggered from menu @objc private func resetApp() { hotKey = nil WindowManager.shared.cleanupWindows() @@ -89,6 +112,32 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { alert.runModal() } + // Full app reset when launched with --reset flag + private func performRecoveryReset() { + // Reset all app defaults + let domain = Bundle.main.bundleIdentifier! + UserDefaults.standard.removePersistentDomain(forName: domain) + UserDefaults.standard.synchronize() + + // Reset the app state + hotKey = nil + WindowManager.shared.cleanupWindows() + + // Recreate status bar and setup + recreateStatusBarItem() + setupMenuBar() + setupHotKey() + + // Show confirmation + let alert = NSAlert() + alert.messageText = "Recovery Complete" + alert.informativeText = "The app has been reset to its default state." + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } + + // Checks and requests accessibility permissions needed for app functionality private func requestAccessibilityPermissions() { let trusted = AXIsProcessTrusted() if !trusted { @@ -105,6 +154,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } + // Initializes keyboard shortcut handling private func setupHotKey() { updateHotKey() @@ -117,6 +167,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { ) } + // Handles changes to keyboard shortcut settings @objc private func shortcutChanged() { DispatchQueue.main.async { [weak self] in if UserDefaults.standard.string(forKey: "shortcut") != nil { @@ -125,6 +176,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } + // Updates the active keyboard shortcut based on settings private func updateHotKey() { // Remove existing hotkey first hotKey = nil @@ -171,6 +223,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } + // Shows the first-time setup/onboarding window private func showOnboarding() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 500, height: 400), @@ -192,6 +245,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { window.makeKeyAndOrderFront(nil) } + // Opens the settings window @objc private func showSettings() { settingsWindow?.close() settingsWindow = nil @@ -215,6 +269,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { settingsWindow?.makeKeyAndOrderFront(nil) } + // Opens the about window @objc private func showAbout() { aboutWindow?.close() aboutWindow = nil @@ -238,6 +293,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { aboutWindow?.makeKeyAndOrderFront(nil) } + // Shows the main popup window when shortcut is triggered private func showPopup() { appState.activeProvider.cancel() @@ -286,6 +342,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } + // Closes and cleans up the popup window private func closePopupWindow() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } @@ -300,24 +357,78 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } + // Handles window cleanup when any window is closed func windowWillClose(_ notification: Notification) { - guard let window = notification.object as? NSWindow else { return } - DispatchQueue.main.async { [weak self] in - if window == self?.settingsWindow { - self?.settingsHostingView = nil - self?.settingsWindow = nil - } else if window == self?.aboutWindow { - self?.aboutHostingView = nil - self?.aboutWindow = nil - } else if window == self?.popupWindow { - self?.popupWindow?.delegate = nil - self?.popupWindow = nil + guard !isServiceTriggered else { return } + + guard let window = notification.object as? NSWindow else { return } + DispatchQueue.main.async { [weak self] in + if window == self?.settingsWindow { + self?.settingsHostingView = nil + self?.settingsWindow = nil + } else if window == self?.aboutWindow { + self?.aboutHostingView = nil + self?.aboutWindow = nil + } else if window == self?.popupWindow { + self?.popupWindow?.delegate = nil + self?.popupWindow = nil + } + } + } + + // Service handler for processing selected text + @objc func handleSelectedText(_ pboard: NSPasteboard, userData: String, error: AutoreleasingUnsafeMutablePointer) { + let types: [NSPasteboard.PasteboardType] = [ + .string, + .rtf, + NSPasteboard.PasteboardType("public.plain-text") + ] + + guard let selectedText = types.lazy.compactMap({ pboard.string(forType: $0) }).first, + !selectedText.isEmpty else { + error.pointee = "No text was selected" as NSString + return + } + + // Store the selected text + appState.selectedText = selectedText + + // Set service trigger flag + isServiceTriggered = true + + // Show the popup + DispatchQueue.main.async { [weak self] in + if let frontmostApp = NSWorkspace.shared.frontmostApplication { + self?.appState.previousApplication = frontmostApp + } + + guard let self = self else { return } + + if !selectedText.isEmpty { + let window = PopupWindow(appState: self.appState) + window.delegate = self + + self.closePopupWindow() + self.popupWindow = window + + // Configure window for service mode + window.level = .floating + window.collectionBehavior = [.moveToActiveSpace] + + window.positionNearMouse() + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + } + + // Reset the flag after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.isServiceTriggered = false + } } } - } } -// Extension to convert ModifierFlags to Carbon flags +// Converts SwiftUI modifier flags to Carbon modifier flags for HotKey library extension NSEvent.ModifierFlags { var carbonFlags: UInt32 { var carbon: UInt32 = 0 diff --git a/macOS/writing-tools/AppState.swift b/macOS/writing-tools/AppState.swift index 5650cdf..756d337 100644 --- a/macOS/writing-tools/AppState.swift +++ b/macOS/writing-tools/AppState.swift @@ -47,6 +47,7 @@ class AppState: ObservableObject { } } + // Save Gemini API configuration func saveGeminiConfig(apiKey: String, model: GeminiModel) { UserDefaults.standard.setValue(apiKey, forKey: "gemini_api_key") UserDefaults.standard.setValue(model.rawValue, forKey: "gemini_model") @@ -55,6 +56,7 @@ class AppState: ObservableObject { geminiProvider = GeminiProvider(config: config) } + // Save OpenAI API configuration func saveOpenAIConfig(apiKey: String, baseURL: String, organization: String?, project: String?, model: String) { UserDefaults.standard.setValue(apiKey, forKey: "openai_api_key") UserDefaults.standard.setValue(baseURL, forKey: "openai_base_url") @@ -72,6 +74,7 @@ class AppState: ObservableObject { openAIProvider = OpenAIProvider(config: config) } + // Update the current AI provider func setCurrentProvider(_ provider: String) { currentProvider = provider UserDefaults.standard.setValue(provider, forKey: "current_provider") diff --git a/macOS/writing-tools/UI/AboutView.swift b/macOS/writing-tools/UI/AboutView.swift index 9f9d73e..585aa0e 100644 --- a/macOS/writing-tools/UI/AboutView.swift +++ b/macOS/writing-tools/UI/AboutView.swift @@ -1,6 +1,8 @@ import SwiftUI struct AboutView: View { + @State private var useGradientTheme = UserDefaults.standard.bool(forKey: "use_gradient_theme") + var body: some View { VStack(spacing: 20) { Text("About Writing Tools") @@ -34,7 +36,7 @@ struct AboutView: View { Divider() - Text("Version: Beta 4 (Based on Windows Port version 5.0)") + Text("Version: 1.0 (Based on Windows Port version 5.0)") .font(.caption) Button("Check for Updates") { @@ -44,5 +46,6 @@ struct AboutView: View { } .padding() .frame(width: 400, height: 400) + .windowBackground(useGradient: useGradientTheme) } } diff --git a/macOS/writing-tools/UI/LoadingModifier.swift b/macOS/writing-tools/UI/LoadingModifier.swift new file mode 100644 index 0000000..5a1db7c --- /dev/null +++ b/macOS/writing-tools/UI/LoadingModifier.swift @@ -0,0 +1,68 @@ +import SwiftUI + +struct LoadingBorderModifier: ViewModifier { + let isLoading: Bool + @State private var rotation: Double = 0 + + private let aiPink = Color(red: 255/255, green: 197/255, blue: 211/255) + + func body(content: Content) -> some View { + content + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder( + AngularGradient( + gradient: Gradient(colors: [aiPink.opacity(0.3), aiPink]), + center: .center, + startAngle: .degrees(rotation), + endAngle: .degrees(rotation + 360) + ), + lineWidth: 2 + ) + .opacity(isLoading ? 1 : 0) + ) + .onChange(of: isLoading) { _, newValue in + if newValue { + withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { + rotation = 360 + } + } else { + rotation = 0 + } + } + } +} +// Shared color extension +extension Color { + static let aiPink = Color(red: 255/255, green: 197/255, blue: 211/255) +} + + +// Loading button style for option buttons +struct LoadingButtonStyle: ButtonStyle { + let isLoading: Bool + @State private var rotation: Double = 0 + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .opacity(isLoading ? 0.6 : 1.0) + .overlay( + Group { + if isLoading { + Color.aiPink.mask { + ProgressView() + } + } + } + ) + .animation(.easeInOut(duration: 0.2), value: isLoading) + } +} + +// Extension to handle loading state buttons +extension View { + func loadingBorder(isLoading: Bool) -> some View { + modifier(LoadingBorderModifier(isLoading: isLoading)) + } +} + diff --git a/macOS/writing-tools/UI/PopupView.swift b/macOS/writing-tools/UI/PopupView.swift index f30caf7..3ae86e4 100644 --- a/macOS/writing-tools/UI/PopupView.swift +++ b/macOS/writing-tools/UI/PopupView.swift @@ -7,6 +7,8 @@ struct PopupView: View { let closeAction: () -> Void @AppStorage("use_gradient_theme") private var useGradientTheme = false @State private var customText: String = "" + @State private var loadingOptions: Set = [] + @State private var isCustomLoading: Bool = false var body: some View { VStack(spacing: 16) { @@ -30,10 +32,10 @@ struct PopupView: View { text: $customText ) .textFieldStyle(RoundedBorderTextFieldStyle()) + .loadingBorder(isLoading: isCustomLoading) .onSubmit { processCustomChange() } - Button(action: processCustomChange) { Image(systemName: "paperplane.fill") .foregroundColor(.white) @@ -46,16 +48,17 @@ struct PopupView: View { } .padding(.horizontal) - // Only show options grid if text is selected if !appState.selectedText.isEmpty { LazyVGrid(columns: [ GridItem(.flexible()), GridItem(.flexible()) ], spacing: 16) { ForEach(WritingOption.allCases) { option in - OptionButton(option: option) { - processOption(option) - } + OptionButton( + option: option, + action: { processOption(option) }, + isLoading: loadingOptions.contains(option) + ) } } .padding(.horizontal) @@ -71,15 +74,23 @@ struct PopupView: View { .shadow(color: Color.black.opacity(0.2), radius: 10, y: 5) } + // Process custom text changes private func processCustomChange() { guard !customText.isEmpty else { return } + isCustomLoading = true processCustomInstruction(customText) } + // Process predefined writing options private func processOption(_ option: WritingOption) { + loadingOptions.insert(option) appState.isProcessing = true Task { + defer { + loadingOptions.remove(option) + appState.isProcessing = false + } do { let result = try await appState.activeProvider.processText( systemPrompt: option.systemPrompt, @@ -108,6 +119,7 @@ struct PopupView: View { } } + // Process custom instructions private func processCustomInstruction(_ instruction: String) { guard !instruction.isEmpty else { return } appState.isProcessing = true @@ -148,6 +160,7 @@ struct PopupView: View { } } + // Show response window for certain options private func showResponseWindow(for option: WritingOption, with result: String) { DispatchQueue.main.async { let window = ResponseWindow( @@ -165,6 +178,7 @@ struct PopupView: View { } } + // Simulate paste command private func simulatePaste() { guard let source = CGEventSource(stateID: .hidSystemState) else { return } @@ -185,6 +199,7 @@ struct PopupView: View { struct OptionButton: View { let option: WritingOption let action: () -> Void + let isLoading: Bool var body: some View { Button(action: action) { @@ -197,6 +212,7 @@ struct OptionButton: View { .background(Color(.controlBackgroundColor)) .cornerRadius(8) } - .buttonStyle(.plain) + .buttonStyle(LoadingButtonStyle(isLoading: isLoading)) + .disabled(isLoading) } } diff --git a/macOS/writing-tools/UI/PopupWindow.swift b/macOS/writing-tools/UI/PopupWindow.swift index a66cd98..a9e6ded 100644 --- a/macOS/writing-tools/UI/PopupWindow.swift +++ b/macOS/writing-tools/UI/PopupWindow.swift @@ -98,7 +98,7 @@ class PopupWindow: NSWindow { } override func mouseDragged(with event: NSEvent) { - guard let contentView = contentView, + guard let _ = contentView, let initialLocation = initialLocation, let screen = screen else { return } diff --git a/macOS/writing-tools/UI/ResponseView.swift b/macOS/writing-tools/UI/ResponseView.swift index b94843c..4eddc3a 100644 --- a/macOS/writing-tools/UI/ResponseView.swift +++ b/macOS/writing-tools/UI/ResponseView.swift @@ -15,6 +15,7 @@ final class ResponseViewModel: ObservableObject { self.option = option } + // Regenerate content using AI provider func regenerateContent() async { do { let result = try await AppState.shared.activeProvider.processText( @@ -29,6 +30,7 @@ final class ResponseViewModel: ObservableObject { } } + // Copy content to clipboard func copyContent() { NSPasteboard.general.clearContents() NSPasteboard.general.setString(content, forType: .string) @@ -48,7 +50,8 @@ struct ResponseView: View { @StateObject private var viewModel: ResponseViewModel @Environment(\.colorScheme) var colorScheme @AppStorage("use_gradient_theme") private var useGradientTheme = false - + @State private var isRegenerating: Bool = false + init(content: String, selectedText: String, option: WritingOption) { self._viewModel = StateObject(wrappedValue: ResponseViewModel( content: content, @@ -56,26 +59,44 @@ struct ResponseView: View { option: option )) } - + var body: some View { VStack(spacing: 16) { ScrollView { - Markdown(viewModel.content) - .font(.system(size: viewModel.fontSize)) - .textSelection(.enabled) + if isRegenerating { + VStack { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color.aiPink)) + .scaleEffect(1.2) + Text("Regenerating...") + .foregroundColor(.aiPink) + .padding(.top, 8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() - .frame(maxWidth: .infinity, alignment: .leading) - } + } else { + Markdown(viewModel.content) + .font(.system(size: viewModel.fontSize)) + .textSelection(.enabled) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + } + HStack { HStack(spacing: 12) { Button(action: { + isRegenerating = true Task { await viewModel.regenerateContent() + isRegenerating = false } }) { Label("Regenerate", systemImage: "arrow.clockwise") + .foregroundColor(isRegenerating ? .aiPink : nil) } + .disabled(isRegenerating) Button(action: { viewModel.copyContent() @@ -85,19 +106,19 @@ struct ResponseView: View { } .animation(.easeInOut, value: viewModel.showCopyConfirmation) } - + Spacer() - + HStack(spacing: 8) { Button(action: { viewModel.fontSize = max(10, viewModel.fontSize - 2) }) { Image(systemName: "minus.magnifyingglass") } .disabled(viewModel.fontSize <= 10) - + Button(action: { viewModel.fontSize = 14 }) { Image(systemName: "arrow.clockwise") } - + Button(action: { viewModel.fontSize = min(24, viewModel.fontSize + 2) }) { Image(systemName: "plus.magnifyingglass") } diff --git a/macOS/writing-tools/UI/SettingsView.swift b/macOS/writing-tools/UI/SettingsView.swift index 9cf9eff..223f48a 100644 --- a/macOS/writing-tools/UI/SettingsView.swift +++ b/macOS/writing-tools/UI/SettingsView.swift @@ -130,11 +130,11 @@ struct SettingsView: View { struct LinkText: View { var body: some View { HStack(spacing: 4) { - Text("Local LLMs: use instructions at") + Text("Local LLMs: use the instructions on") .font(.caption) .foregroundColor(.secondary) - Text("GitHub Guide") + Text("GitHub Page") .font(.caption) .foregroundColor(.blue) .underline() @@ -192,7 +192,7 @@ struct SettingsView: View { TextField("Model Name", text: $openAIModelName) .textFieldStyle(.roundedBorder) - Text("OpenAI models include: gpt-4o, gpt-3.5-turbo") + Text("OpenAI models include: gpt-4o, gpt-3.5-turbo, etc.") .font(.caption) .foregroundColor(.secondary) diff --git a/macOS/writing-tools/UI/WindowManager.swift b/macOS/writing-tools/UI/WindowManager.swift index 122dfa1..47f9da9 100644 --- a/macOS/writing-tools/UI/WindowManager.swift +++ b/macOS/writing-tools/UI/WindowManager.swift @@ -11,6 +11,7 @@ class WindowManager: NSObject, NSWindowDelegate { private var popupWindow = NSMapTable>.strongToWeakObjects() private var responseWindows = NSHashTable.weakObjects() + // Execute operation on main thread private func performOnMainThread(_ operation: @escaping () -> Void) { if Thread.isMainThread { operation() @@ -19,6 +20,7 @@ class WindowManager: NSObject, NSWindowDelegate { } } + // Execute operation on window queue private func performOnWindowQueue(_ operation: @escaping () -> Void) { windowQueue.async { [weak self] in guard self != nil else { return } @@ -26,6 +28,7 @@ class WindowManager: NSObject, NSWindowDelegate { } } + // Add a new response window func addResponseWindow(_ window: ResponseWindow) { performOnMainThread { [weak self] in guard let self = self, !window.isReleasedWhenClosed else { @@ -40,6 +43,7 @@ class WindowManager: NSObject, NSWindowDelegate { } } + // Remove a response window func removeResponseWindow(_ window: ResponseWindow) { performOnMainThread { [weak self] in guard let self = self else { return } @@ -47,6 +51,7 @@ class WindowManager: NSObject, NSWindowDelegate { } } + // Transition from onboarding to settings window func transitonFromOnboardingToSettings(appState: AppState) { performOnMainThread { [weak self] in guard let self = self else { return } @@ -77,6 +82,7 @@ class WindowManager: NSObject, NSWindowDelegate { } } + // Set up onboarding window func setOnboardingWindow(_ window: NSWindow, hostingView: NSHostingView) { performOnMainThread { [weak self] in guard let self = self else { return } @@ -90,7 +96,7 @@ class WindowManager: NSObject, NSWindowDelegate { } } - + // Handle window becoming key func windowDidBecomeKey(_ notification: Notification) { guard let window = notification.object as? NSWindow else { return } @@ -101,6 +107,7 @@ class WindowManager: NSObject, NSWindowDelegate { } } + // Clean up all windows func cleanupWindows() { performOnWindowQueue { [weak self] in guard let self = self else { return } @@ -114,6 +121,7 @@ class WindowManager: NSObject, NSWindowDelegate { } } + // Get all managed windows private func getAllWindows() -> [NSWindow] { var windows: [NSWindow] = [] @@ -133,6 +141,7 @@ class WindowManager: NSObject, NSWindowDelegate { return windows } + // Clear all window references private func clearAllWindows() { performOnMainThread { [weak self] in self?.onboardingWindow.removeAllObjects() From 0b1f75f7117e06db4dc58a4211496f60113d675f Mon Sep 17 00:00:00 2001 From: Aryamirsepasi Date: Tue, 10 Dec 2024 20:12:57 +0100 Subject: [PATCH 02/10] added custom command options + loading animation --- .../Commands/CustomCommand.swift | 54 ++++ .../Commands/CustomCommandsView.swift | 290 ++++++++++++++++++ .../{ => Commands}/WritingOption.swift | 0 macOS/writing-tools/UI/PopupView.swift | 107 ++++++- macOS/writing-tools/UI/PopupWindow.swift | 64 +++- macOS/writing-tools/UI/ResponseView.swift | 1 - 6 files changed, 496 insertions(+), 20 deletions(-) create mode 100644 macOS/writing-tools/Commands/CustomCommand.swift create mode 100644 macOS/writing-tools/Commands/CustomCommandsView.swift rename macOS/writing-tools/{ => Commands}/WritingOption.swift (100%) diff --git a/macOS/writing-tools/Commands/CustomCommand.swift b/macOS/writing-tools/Commands/CustomCommand.swift new file mode 100644 index 0000000..1a5499b --- /dev/null +++ b/macOS/writing-tools/Commands/CustomCommand.swift @@ -0,0 +1,54 @@ +import Foundation + +struct CustomCommand: Codable, Identifiable, Equatable { + let id: UUID + var name: String + var prompt: String + var emoji: String + + init(id: UUID = UUID(), name: String, prompt: String, emoji: String) { + self.id = id + self.name = name + self.prompt = prompt + self.emoji = emoji + } +} + +class CustomCommandsManager: ObservableObject { + @Published private(set) var commands: [CustomCommand] = [] + private let saveKey = "custom_commands" + + init() { + loadCommands() + } + + private func loadCommands() { + if let data = UserDefaults.standard.data(forKey: saveKey), + let decoded = try? JSONDecoder().decode([CustomCommand].self, from: data) { + commands = decoded + } + } + + private func saveCommands() { + if let encoded = try? JSONEncoder().encode(commands) { + UserDefaults.standard.set(encoded, forKey: saveKey) + } + } + + func addCommand(_ command: CustomCommand) { + commands.append(command) + saveCommands() + } + + func updateCommand(_ command: CustomCommand) { + if let index = commands.firstIndex(where: { $0.id == command.id }) { + commands[index] = command + saveCommands() + } + } + + func deleteCommand(_ command: CustomCommand) { + commands.removeAll { $0.id == command.id } + saveCommands() + } +} diff --git a/macOS/writing-tools/Commands/CustomCommandsView.swift b/macOS/writing-tools/Commands/CustomCommandsView.swift new file mode 100644 index 0000000..501e8a9 --- /dev/null +++ b/macOS/writing-tools/Commands/CustomCommandsView.swift @@ -0,0 +1,290 @@ +import SwiftUI + +struct CustomCommandsView: View { + @ObservedObject var commandsManager: CustomCommandsManager + @Environment(\.dismiss) var dismiss + @State private var isAddingNew = false + @State private var selectedCommand: CustomCommand? + @State private var editingCommand: CustomCommand? + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Custom Commands") + .font(.headline) + Spacer() + Button(action: { dismiss() }) { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + .padding() + + // List of commands + List { + ForEach(commandsManager.commands) { command in + CustomCommandRow(command: command) + .contextMenu { + Button("Edit") { + editingCommand = command + } + Button("Delete", role: .destructive) { + commandsManager.deleteCommand(command) + } + } + .onTapGesture { + selectedCommand = command + } + } + } + + Divider() + + // Add button + HStack { + Button(action: { isAddingNew = true }) { + Label("Add Custom Command", systemImage: "plus.circle.fill") + .font(.body) + } + .controlSize(.large) + .padding() + + Spacer() + } + } + .frame(width: 500, height: 400) + .background(Color(.windowBackgroundColor)) + .sheet(isPresented: $isAddingNew) { + CustomCommandEditor( + commandsManager: commandsManager, + isPresented: $isAddingNew + ) + } + .sheet(item: $editingCommand) { command in + CustomCommandEditor( + commandsManager: commandsManager, + isPresented: .constant(true), + editingCommand: command + ) + } + } +} + +struct CustomCommandRow: View { + let command: CustomCommand + + var body: some View { + HStack(spacing: 12) { + Image(systemName: command.emoji) + .font(.title2) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 4) { + Text(command.name) + .font(.headline) + Text(command.prompt) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + .padding(.vertical, 8) + } +} + +struct CustomCommandEditor: View { + @ObservedObject var commandsManager: CustomCommandsManager + @Binding var isPresented: Bool + @Environment(\.dismiss) var dismiss + + var editingCommand: CustomCommand? + + @State private var name: String = "" + @State private var prompt: String = "" + @State private var selectedIcon: String = "star.fill" + @State private var showingIconPicker = false + + init(commandsManager: CustomCommandsManager, isPresented: Binding, editingCommand: CustomCommand? = nil) { + self.commandsManager = commandsManager + self._isPresented = isPresented + self.editingCommand = editingCommand + + if let command = editingCommand { + _name = State(initialValue: command.name) + _prompt = State(initialValue: command.prompt) + _selectedIcon = State(initialValue: command.emoji) + } + } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text(editingCommand != nil ? "Edit Command" : "New Command") + .font(.headline) + Spacer() + Button(action: { dismiss() }) { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + .padding() + + ScrollView { + VStack(spacing: 20) { + // Name field + VStack(alignment: .leading, spacing: 8) { + Text("Name") + .font(.headline) + TextField("Command Name", text: $name) + .textFieldStyle(.roundedBorder) + } + + // Icon selector + VStack(alignment: .leading, spacing: 8) { + Text("Icon") + .font(.headline) + Button(action: { showingIconPicker = true }) { + HStack { + Image(systemName: selectedIcon) + .font(.title2) + .foregroundColor(.accentColor) + Text("Change Icon") + .foregroundColor(.accentColor) + } + .padding(8) + .background(Color(.controlBackgroundColor)) + .cornerRadius(6) + } + .buttonStyle(.plain) + } + + // Prompt field + VStack(alignment: .leading, spacing: 8) { + Text("Prompt") + .font(.headline) + TextEditor(text: $prompt) + .frame(height: 150) + .font(.body) + .padding(4) + .background(Color(.textBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + } + } + .padding() + } + + Divider() + + // Bottom buttons + HStack { + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.escape, modifiers: []) + + Spacer() + + Button("Save") { + let command = CustomCommand( + id: editingCommand?.id ?? UUID(), + name: name, + prompt: prompt, + emoji: selectedIcon + ) + + if editingCommand != nil { + commandsManager.updateCommand(command) + } else { + commandsManager.addCommand(command) + } + + dismiss() + } + .keyboardShortcut(.return, modifiers: [.command]) + .disabled(name.isEmpty || prompt.isEmpty) + } + .padding() + } + .frame(width: 500, height: 600) + .background(Color(.windowBackgroundColor)) + .sheet(isPresented: $showingIconPicker) { + IconPickerView(selectedIcon: $selectedIcon) + } + } +} + +struct IconPickerView: View { + @Environment(\.dismiss) var dismiss + @Binding var selectedIcon: String + + let icons = [ + "star.fill", "heart.fill", "bolt.fill", "leaf.fill", "globe", + "text.bubble.fill", "pencil", "doc.fill", "book.fill", "bookmark.fill", + "tag.fill", "checkmark.circle.fill", "bell.fill", "flag.fill", "paperclip", + "link", "quote.bubble.fill", "list.bullet", "chart.bar.fill", "arrow.right.circle.fill", + "arrow.triangle.2.circlepath", "magnifyingglass", "lightbulb.fill", "wand.and.stars", + "brain.head.profile", "character.bubble", "globe.europe.africa.fill", + "globe.americas.fill", "globe.asia.australia.fill", "character", "textformat", + "folder.fill", "pencil.tip.crop.circle", "paintbrush", "text.justify", "scissors", + "doc.on.clipboard", "arrow.up.doc", "arrow.down.doc", "doc.badge.plus", + "bookmark.circle.fill", "bubble.left.and.bubble.right", "doc.text.magnifyingglass", + "checkmark.rectangle", "trash", "quote.bubble", "abc", "globe.badge.chevron.backward", + "character.book.closed", "book", "rectangle.and.text.magnifyingglass", + "keyboard", "text.redaction", "a.magnify", "character.textbox", + "character.cursor.ibeam", "cursorarrow.and.square.on.square.dashed", "rectangle.and.pencil.and.ellipsis", + "bubble.middle.bottom", "bubble.left", "text.badge.star", "text.insert", "arrow.uturn.backward.circle.fill" + ] + + let columns = Array(repeating: GridItem(.flexible()), count: 8) + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Select Icon") + .font(.headline) + Spacer() + Button(action: { dismiss() }) { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + .padding() + + // Icons grid + ScrollView { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(icons, id: \.self) { icon in + Button(action: { + selectedIcon = icon + dismiss() + }) { + Image(systemName: icon) + .font(.title2) + .frame(width: 32, height: 32) + .foregroundColor(selectedIcon == icon ? .white : .primary) + .background(selectedIcon == icon ? Color.accentColor : Color.clear) + .cornerRadius(6) + } + .buttonStyle(.plain) + } + } + .padding() + } + } + .frame(width: 400, height: 300) + .background(Color(.windowBackgroundColor)) + } +} diff --git a/macOS/writing-tools/WritingOption.swift b/macOS/writing-tools/Commands/WritingOption.swift similarity index 100% rename from macOS/writing-tools/WritingOption.swift rename to macOS/writing-tools/Commands/WritingOption.swift diff --git a/macOS/writing-tools/UI/PopupView.swift b/macOS/writing-tools/UI/PopupView.swift index 3ae86e4..e29ca79 100644 --- a/macOS/writing-tools/UI/PopupView.swift +++ b/macOS/writing-tools/UI/PopupView.swift @@ -4,17 +4,29 @@ import ApplicationServices struct PopupView: View { @ObservedObject var appState: AppState @Environment(\.colorScheme) var colorScheme + @StateObject private var commandsManager = CustomCommandsManager() let closeAction: () -> Void @AppStorage("use_gradient_theme") private var useGradientTheme = false @State private var customText: String = "" - @State private var loadingOptions: Set = [] + @State private var loadingOptions: Set = [] @State private var isCustomLoading: Bool = false + @State private var showingCustomCommands = false var body: some View { VStack(spacing: 16) { - // Close button + // Top bar with close and add buttons HStack { + Button(action: { showingCustomCommands = true }) { + Image(systemName: "plus.circle.fill") + .font(.title2) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .padding(.top, 8) + .padding(.leading, 8) + Spacer() + Button(action: closeAction) { Image(systemName: "xmark.circle.fill") .font(.title2) @@ -49,16 +61,28 @@ struct PopupView: View { .padding(.horizontal) if !appState.selectedText.isEmpty { - LazyVGrid(columns: [ - GridItem(.flexible()), - GridItem(.flexible()) - ], spacing: 16) { - ForEach(WritingOption.allCases) { option in - OptionButton( - option: option, - action: { processOption(option) }, - isLoading: loadingOptions.contains(option) - ) + ScrollView { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 16) { + // Built-in options + ForEach(WritingOption.allCases) { option in + OptionButton( + option: option, + action: { processOption(option) }, + isLoading: loadingOptions.contains(option.id) + ) + } + + // Custom commands + ForEach(commandsManager.commands) { command in + CustomOptionButton( + command: command, + action: { processCustomCommand(command) }, + isLoading: loadingOptions.contains(command.id.uuidString) + ) + } } } .padding(.horizontal) @@ -72,8 +96,42 @@ struct PopupView: View { ) .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(color: Color.black.opacity(0.2), radius: 10, y: 5) + .sheet(isPresented: $showingCustomCommands) { + CustomCommandsView(commandsManager: commandsManager) + } } + private func processCustomCommand(_ command: CustomCommand) { + loadingOptions.insert(command.id.uuidString) + appState.isProcessing = true + + Task { + defer { + loadingOptions.remove(command.id.uuidString) + appState.isProcessing = false + } + + do { + let result = try await appState.activeProvider.processText( + systemPrompt: command.prompt, + userPrompt: appState.selectedText + ) + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(result, forType: .string) + + closeAction() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + simulatePaste() + } + } catch { + print("Error processing custom command: \(error.localizedDescription)") + } + } + } + + // Process custom text changes private func processCustomChange() { guard !customText.isEmpty else { return } @@ -83,12 +141,12 @@ struct PopupView: View { // Process predefined writing options private func processOption(_ option: WritingOption) { - loadingOptions.insert(option) + loadingOptions.insert(option.id) appState.isProcessing = true Task { defer { - loadingOptions.remove(option) + loadingOptions.remove(option.id) appState.isProcessing = false } do { @@ -216,3 +274,24 @@ struct OptionButton: View { .disabled(isLoading) } } + +struct CustomOptionButton: View { + let command: CustomCommand + let action: () -> Void + let isLoading: Bool + + var body: some View { + Button(action: action) { + HStack { + Image(systemName: command.emoji) + Text(command.name) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color(.controlBackgroundColor)) + .cornerRadius(8) + } + .buttonStyle(LoadingButtonStyle(isLoading: isLoading)) + .disabled(isLoading) + } +} diff --git a/macOS/writing-tools/UI/PopupWindow.swift b/macOS/writing-tools/UI/PopupWindow.swift index a9e6ded..4d663e6 100644 --- a/macOS/writing-tools/UI/PopupWindow.swift +++ b/macOS/writing-tools/UI/PopupWindow.swift @@ -5,21 +5,38 @@ class PopupWindow: NSWindow { private var retainedHostingView: NSHostingView? private var trackingArea: NSTrackingArea? private let appState: AppState + private let commandsManager: CustomCommandsManager init(appState: AppState) { self.appState = appState + self.commandsManager = CustomCommandsManager() + // Initialize with minimum size first super.init( - contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + contentRect: NSRect(x: 0, y: 0, width: 400, height: 100), styleMask: [.borderless], backing: .buffered, - defer: false + defer: true // Change to true to defer window creation ) self.isReleasedWhenClosed = false + // Configure window after init configureWindow() setupTrackingArea() + + // Calculate and set correct size immediately + DispatchQueue.main.async { [weak self] in + self?.updateWindowSize() + } + + // Listen for changes in custom commands + NotificationCenter.default.addObserver( + self, + selector: #selector(updateWindowSize), + name: UserDefaults.didChangeNotification, + object: nil + ) } private func configureWindow() { @@ -42,11 +59,48 @@ class PopupWindow: NSWindow { contentView = hostingView retainedHostingView = hostingView - if appState.selectedText.isEmpty { - setContentSize(NSSize(width: 400, height: 100)) + updateWindowSize() + } + + @objc private func updateWindowSize() { + let baseHeight: CGFloat = 100 // Height for header and input field + let buttonHeight: CGFloat = 55 // Height for each button row + let spacing: CGFloat = 16 // Vertical spacing between elements + let padding: CGFloat = 16 // Bottom padding + + let numBuiltInOptions = WritingOption.allCases.count + let numCustomOptions = commandsManager.commands.count + let totalOptions = appState.selectedText.isEmpty ? 0 : (numBuiltInOptions + numCustomOptions) + let numRows = ceil(Double(totalOptions) / 2.0) // 2 columns + + let contentHeight = appState.selectedText.isEmpty ? + baseHeight : + baseHeight + (buttonHeight * CGFloat(numRows)) + spacing + padding + + // Set size on main thread + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.setContentSize(NSSize(width: 400, height: contentHeight)) + + // Maintain window position relative to the mouse + if let screen = self.screen { + var frame = self.frame + frame.size.height = contentHeight + + // Ensure window stays within screen bounds + if frame.maxY > screen.visibleFrame.maxY { + frame.origin.y = screen.visibleFrame.maxY - frame.height + } + + self.setFrame(frame, display: true) + } } } + deinit { + NotificationCenter.default.removeObserver(self) + } + private func setupTrackingArea() { guard let contentView = contentView else { return } @@ -127,7 +181,7 @@ class PopupWindow: NSWindow { } - // MARK: - Window Positioning + // Window Positioning // Find the screen where the mouse cursor is located func screenAt(point: NSPoint) -> NSScreen? { diff --git a/macOS/writing-tools/UI/ResponseView.swift b/macOS/writing-tools/UI/ResponseView.swift index 4eddc3a..503edfe 100644 --- a/macOS/writing-tools/UI/ResponseView.swift +++ b/macOS/writing-tools/UI/ResponseView.swift @@ -94,7 +94,6 @@ struct ResponseView: View { } }) { Label("Regenerate", systemImage: "arrow.clockwise") - .foregroundColor(isRegenerating ? .aiPink : nil) } .disabled(isRegenerating) From 438ed5598770b608f8e715e3b76b9f416fa90198 Mon Sep 17 00:00:00 2001 From: Aryamirsepasi Date: Wed, 11 Dec 2024 18:03:08 +0100 Subject: [PATCH 03/10] a better custom command editor + additional prompt in response window --- .../Commands/CustomCommand.swift | 6 +- .../Commands/CustomCommandsView.swift | 95 +++++++++++-------- macOS/writing-tools/UI/PopupView.swift | 15 +-- macOS/writing-tools/UI/ResponseView.swift | 94 +++++++++++------- 4 files changed, 127 insertions(+), 83 deletions(-) diff --git a/macOS/writing-tools/Commands/CustomCommand.swift b/macOS/writing-tools/Commands/CustomCommand.swift index 1a5499b..e65b531 100644 --- a/macOS/writing-tools/Commands/CustomCommand.swift +++ b/macOS/writing-tools/Commands/CustomCommand.swift @@ -4,13 +4,13 @@ struct CustomCommand: Codable, Identifiable, Equatable { let id: UUID var name: String var prompt: String - var emoji: String + var icon: String - init(id: UUID = UUID(), name: String, prompt: String, emoji: String) { + init(id: UUID = UUID(), name: String, prompt: String, icon: String) { self.id = id self.name = name self.prompt = prompt - self.emoji = emoji + self.icon = icon } } diff --git a/macOS/writing-tools/Commands/CustomCommandsView.swift b/macOS/writing-tools/Commands/CustomCommandsView.swift index 501e8a9..d0d26ca 100644 --- a/macOS/writing-tools/Commands/CustomCommandsView.swift +++ b/macOS/writing-tools/Commands/CustomCommandsView.swift @@ -26,18 +26,11 @@ struct CustomCommandsView: View { // List of commands List { ForEach(commandsManager.commands) { command in - CustomCommandRow(command: command) - .contextMenu { - Button("Edit") { - editingCommand = command - } - Button("Delete", role: .destructive) { - commandsManager.deleteCommand(command) - } - } - .onTapGesture { - selectedCommand = command - } + CustomCommandRow( + command: command, + onEdit: { editingCommand = $0 }, + onDelete: { commandsManager.deleteCommand($0) } + ) } } @@ -75,13 +68,17 @@ struct CustomCommandsView: View { struct CustomCommandRow: View { let command: CustomCommand - + var onEdit: (CustomCommand) -> Void + var onDelete: (CustomCommand) -> Void + var body: some View { HStack(spacing: 12) { - Image(systemName: command.emoji) + // Icon + Image(systemName: command.icon) .font(.title2) .frame(width: 30) + // Command Details VStack(alignment: .leading, spacing: 4) { Text(command.name) .font(.headline) @@ -90,6 +87,24 @@ struct CustomCommandRow: View { .foregroundColor(.secondary) .lineLimit(2) } + Spacer() + + // Edit Button + Button(action: { onEdit(command) }) { + Image(systemName: "pencil") + .font(.title2) + } + .buttonStyle(.plain) + .padding(.horizontal, 4) + + // Delete Button + Button(action: { onDelete(command) }) { + Image(systemName: "trash") + .font(.title2) + .foregroundColor(.red) + } + .buttonStyle(.plain) + .padding(.horizontal, 4) } .padding(.vertical, 8) } @@ -115,7 +130,7 @@ struct CustomCommandEditor: View { if let command = editingCommand { _name = State(initialValue: command.name) _prompt = State(initialValue: command.prompt) - _selectedIcon = State(initialValue: command.emoji) + _selectedIcon = State(initialValue: command.icon) } } @@ -137,31 +152,33 @@ struct CustomCommandEditor: View { ScrollView { VStack(spacing: 20) { - // Name field - VStack(alignment: .leading, spacing: 8) { - Text("Name") - .font(.headline) - TextField("Command Name", text: $name) - .textFieldStyle(.roundedBorder) - } - - // Icon selector - VStack(alignment: .leading, spacing: 8) { - Text("Icon") - .font(.headline) - Button(action: { showingIconPicker = true }) { - HStack { - Image(systemName: selectedIcon) - .font(.title2) - .foregroundColor(.accentColor) - Text("Change Icon") - .foregroundColor(.accentColor) + HStack(alignment: .top, spacing: 16) { + // Name field + VStack(alignment: .leading, spacing: 8) { + Text("Name") + .font(.headline) + TextField("Command Name", text: $name) + .textFieldStyle(.roundedBorder) + } + + // Icon selector + VStack(alignment: .leading, spacing: 8) { + Text("Icon") + .font(.headline) + Button(action: { showingIconPicker = true }) { + HStack { + Image(systemName: selectedIcon) + .font(.title2) + .foregroundColor(.accentColor) + Text("Change Icon") + .foregroundColor(.accentColor) + } + .padding(8) + .background(Color(.controlBackgroundColor)) + .cornerRadius(6) } - .padding(8) - .background(Color(.controlBackgroundColor)) - .cornerRadius(6) + .buttonStyle(.plain) } - .buttonStyle(.plain) } // Prompt field @@ -199,7 +216,7 @@ struct CustomCommandEditor: View { id: editingCommand?.id ?? UUID(), name: name, prompt: prompt, - emoji: selectedIcon + icon: selectedIcon ) if editingCommand != nil { diff --git a/macOS/writing-tools/UI/PopupView.swift b/macOS/writing-tools/UI/PopupView.swift index e29ca79..3bf4701 100644 --- a/macOS/writing-tools/UI/PopupView.swift +++ b/macOS/writing-tools/UI/PopupView.swift @@ -16,8 +16,9 @@ struct PopupView: View { VStack(spacing: 16) { // Top bar with close and add buttons HStack { - Button(action: { showingCustomCommands = true }) { - Image(systemName: "plus.circle.fill") + + Button(action: closeAction) { + Image(systemName: "xmark.circle.fill") .font(.title2) .foregroundColor(.secondary) } @@ -27,8 +28,8 @@ struct PopupView: View { Spacer() - Button(action: closeAction) { - Image(systemName: "xmark.circle.fill") + Button(action: { showingCustomCommands = true }) { + Image(systemName: "plus.circle.fill") .font(.title2) .foregroundColor(.secondary) } @@ -40,8 +41,8 @@ struct PopupView: View { // Custom input with send button HStack(spacing: 8) { TextField( - appState.selectedText.isEmpty ? "Enter your instruction..." : "Describe your change...", - text: $customText + appState.selectedText.isEmpty ? "Enter your instruction..." : "Describe your change...", + text: $customText ) .textFieldStyle(RoundedBorderTextFieldStyle()) .loadingBorder(isLoading: isCustomLoading) @@ -283,7 +284,7 @@ struct CustomOptionButton: View { var body: some View { Button(action: action) { HStack { - Image(systemName: command.emoji) + Image(systemName: command.icon) Text(command.name) } .frame(maxWidth: .infinity) diff --git a/macOS/writing-tools/UI/ResponseView.swift b/macOS/writing-tools/UI/ResponseView.swift index 503edfe..6a71994 100644 --- a/macOS/writing-tools/UI/ResponseView.swift +++ b/macOS/writing-tools/UI/ResponseView.swift @@ -5,6 +5,7 @@ final class ResponseViewModel: ObservableObject { @Published var content: String @Published var fontSize: CGFloat = 14 @Published var showCopyConfirmation: Bool = false + @Published var additionalPrompt: String = "" let selectedText: String let option: WritingOption @@ -18,12 +19,24 @@ final class ResponseViewModel: ObservableObject { // Regenerate content using AI provider func regenerateContent() async { do { + let combinedPrompt = if !additionalPrompt.isEmpty { + """ + Original System Prompt: \(option.systemPrompt) + Additional Instructions: \(additionalPrompt) + + Apply both the original system prompt and the additional instructions to process the following text. + """ + } else { + option.systemPrompt + } + let result = try await AppState.shared.activeProvider.processText( - systemPrompt: option.systemPrompt, + systemPrompt: combinedPrompt, userPrompt: selectedText ) await MainActor.run { self.content = result + self.additionalPrompt = "" } } catch { print("Error regenerating content: \(error.localizedDescription)") @@ -45,7 +58,6 @@ final class ResponseViewModel: ObservableObject { } } -/// Main ResponseView struct ResponseView: View { @StateObject private var viewModel: ResponseViewModel @Environment(\.colorScheme) var colorScheme @@ -81,47 +93,61 @@ struct ResponseView: View { .padding() .frame(maxWidth: .infinity, alignment: .leading) } - } - HStack { - HStack(spacing: 12) { - Button(action: { - isRegenerating = true - Task { - await viewModel.regenerateContent() - isRegenerating = false - } - }) { - Label("Regenerate", systemImage: "arrow.clockwise") - } - .disabled(isRegenerating) + // Control bar + VStack(spacing: 12) { + // Additional Prompt Input + VStack(alignment: .leading, spacing: 4) { + Text("Additional Instructions (optional):") + .font(.caption) + .foregroundColor(.secondary) - Button(action: { - viewModel.copyContent() - }) { - Label(viewModel.showCopyConfirmation ? "Copied!" : "Copy", - systemImage: viewModel.showCopyConfirmation ? "checkmark" : "doc.on.doc") - } - .animation(.easeInOut, value: viewModel.showCopyConfirmation) + TextField("Enter additional instructions for regeneration...", text: $viewModel.additionalPrompt) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .font(.system(size: 12)) } - Spacer() - - HStack(spacing: 8) { - Button(action: { viewModel.fontSize = max(10, viewModel.fontSize - 2) }) { - Image(systemName: "minus.magnifyingglass") + HStack { + HStack(spacing: 12) { + Button(action: { + isRegenerating = true + Task { + await viewModel.regenerateContent() + isRegenerating = false + } + }) { + Label("Regenerate", systemImage: "arrow.clockwise") + .frame(minWidth: 100) + } + .disabled(isRegenerating) + + Button(action: { + viewModel.copyContent() + }) { + Label(viewModel.showCopyConfirmation ? "Copied!" : "Copy", + systemImage: viewModel.showCopyConfirmation ? "checkmark" : "doc.on.doc") + } + .animation(.easeInOut, value: viewModel.showCopyConfirmation) } - .disabled(viewModel.fontSize <= 10) - Button(action: { viewModel.fontSize = 14 }) { - Image(systemName: "arrow.clockwise") - } + Spacer() - Button(action: { viewModel.fontSize = min(24, viewModel.fontSize + 2) }) { - Image(systemName: "plus.magnifyingglass") + HStack(spacing: 8) { + Button(action: { viewModel.fontSize = max(10, viewModel.fontSize - 2) }) { + Image(systemName: "minus.magnifyingglass") + } + .disabled(viewModel.fontSize <= 10) + + Button(action: { viewModel.fontSize = 14 }) { + Image(systemName: "arrow.clockwise") + } + + Button(action: { viewModel.fontSize = min(24, viewModel.fontSize + 2) }) { + Image(systemName: "plus.magnifyingglass") + } + .disabled(viewModel.fontSize >= 24) } - .disabled(viewModel.fontSize >= 24) } } .padding() From 3d36d1a7c6a302643dcd14e8c9ee970934c9146f Mon Sep 17 00:00:00 2001 From: Aryamirsepasi Date: Fri, 13 Dec 2024 23:49:32 +0100 Subject: [PATCH 04/10] added a better styled text field + send button --- .../AppleStyleTextFieldModifier.swift | 99 +++++++++++ .../BackgroundModifier.swift | 0 .../LoadingModifier.swift | 0 macOS/writing-tools/UI/OnboardingView.swift | 167 +++++++++++++++--- macOS/writing-tools/UI/PopupView.swift | 24 +-- macOS/writing-tools/UI/PopupWindow.swift | 5 +- 6 files changed, 254 insertions(+), 41 deletions(-) create mode 100644 macOS/writing-tools/UI/Custom Modifiers/AppleStyleTextFieldModifier.swift rename macOS/writing-tools/UI/{ => Custom Modifiers}/BackgroundModifier.swift (100%) rename macOS/writing-tools/UI/{ => Custom Modifiers}/LoadingModifier.swift (100%) diff --git a/macOS/writing-tools/UI/Custom Modifiers/AppleStyleTextFieldModifier.swift b/macOS/writing-tools/UI/Custom Modifiers/AppleStyleTextFieldModifier.swift new file mode 100644 index 0000000..eb89f1a --- /dev/null +++ b/macOS/writing-tools/UI/Custom Modifiers/AppleStyleTextFieldModifier.swift @@ -0,0 +1,99 @@ +import SwiftUI + +struct AppleStyleTextFieldModifier: ViewModifier { + @Environment(\.colorScheme) var colorScheme + let isLoading: Bool + let text: String + let onSubmit: () -> Void + + @State private var isAnimating: Bool = false + + func body(content: Content) -> some View { + ZStack(alignment: .trailing) { + HStack(spacing: 0) { + content + .font(.system(size: 14)) + .foregroundColor(.white) + .padding(12) + .onSubmit { + withAnimation { + isAnimating = true + } + onSubmit() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isAnimating = false + } + } + + Spacer(minLength: 0) + } + + // Integrated send button + if !text.isEmpty { + Button(action: { + withAnimation { + isAnimating = true + } + onSubmit() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isAnimating = false + } + }) { + Image(systemName: "paperplane.fill") + .foregroundColor(.white) + .font(.system(size: 14)) + .frame(width: 28, height: 28) + .background( + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.2, green: 0.5, blue: 1.0), + Color(red: 0.6, green: 0.3, blue: 0.9) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .padding(.trailing, 2) + .transition(.opacity) + } + } + .frame(height: 32) + .background( + ZStack { + Color.black.opacity(0.3) + .blur(radius: 0.5) + + if isLoading { + Color.black.opacity(0.2) + } + } + ) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder( + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.2, green: 0.5, blue: 1.0).opacity(isAnimating ? 0.8 : 0.1), + Color(red: 0.6, green: 0.3, blue: 0.9).opacity(isAnimating ? 0.8 : 0.1) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: isAnimating ? 2 : 0.5 + ) + .animation(.easeInOut(duration: 0.3), value: isAnimating) + ) + } +} + +extension View { + func appleStyleTextField(text: String, isLoading: Bool = false, onSubmit: @escaping () -> Void) -> some View { + self.modifier(AppleStyleTextFieldModifier(isLoading: isLoading, text: text, onSubmit: onSubmit)) + } +} diff --git a/macOS/writing-tools/UI/BackgroundModifier.swift b/macOS/writing-tools/UI/Custom Modifiers/BackgroundModifier.swift similarity index 100% rename from macOS/writing-tools/UI/BackgroundModifier.swift rename to macOS/writing-tools/UI/Custom Modifiers/BackgroundModifier.swift diff --git a/macOS/writing-tools/UI/LoadingModifier.swift b/macOS/writing-tools/UI/Custom Modifiers/LoadingModifier.swift similarity index 100% rename from macOS/writing-tools/UI/LoadingModifier.swift rename to macOS/writing-tools/UI/Custom Modifiers/LoadingModifier.swift diff --git a/macOS/writing-tools/UI/OnboardingView.swift b/macOS/writing-tools/UI/OnboardingView.swift index 99f7de3..e3d2d24 100644 --- a/macOS/writing-tools/UI/OnboardingView.swift +++ b/macOS/writing-tools/UI/OnboardingView.swift @@ -2,58 +2,181 @@ import SwiftUI struct OnboardingView: View { @ObservedObject var appState: AppState + @State private var currentStep = 0 @State private var shortcutText = "⌘ Space" @State private var useGradientTheme = true @State private var isShowingSettings = false + private let steps = [ + OnboardingStep( + title: "Welcome to WritingTools!", + description: "Let's get you set up with just a few quick steps.", + isPermissionStep: false + ), + OnboardingStep( + title: "Enable Accessibility Access", + description: "WritingTools needs accessibility access to detect text selection and enhance your writing experience.", + isPermissionStep: true + ), + OnboardingStep( + title: "Customize Your Experience", + description: "Set up your preferred shortcut and theme.", + isPermissionStep: false + ) + ] + var body: some View { + VStack(spacing: 0) { + // Content area + VStack(spacing: 20) { + // Step content + switch currentStep { + case 0: + welcomeStep + case 1: + accessibilityStep + case 2: + customizationStep + default: + EmptyView() + } + + Spacer(minLength: 0) + } + .padding(.horizontal) + .padding(.top, 20) + + // Bottom navigation area + VStack(spacing: 16) { + // Progress indicators + HStack(spacing: 8) { + ForEach(0..= index ? Color.accentColor : Color.gray.opacity(0.3)) + .frame(width: 8, height: 8) + } + } + + // Navigation buttons + HStack { + if currentStep > 0 { + Button("Back") { + withAnimation { + currentStep -= 1 + } + } + .buttonStyle(.bordered) + } + + Spacer() + + Button(currentStep == steps.count - 1 ? "Finish" : "Next") { + if currentStep == steps.count - 1 { + saveSettingsAndContinue() + } else { + withAnimation { + currentStep += 1 + } + } + } + .buttonStyle(.borderedProminent) + } + } + .padding() + .background(Color(.windowBackgroundColor)) + } + .frame(width: 500, height: 500) + .onAppear { + isShowingSettings = false + } + } + + private var welcomeStep: some View { VStack(spacing: 20) { - Text("Welcome to Writing Tools!") + Image(systemName: "sparkles") + .resizable() + .frame(width: 60, height: 60) + .foregroundColor(.accentColor) + + Text(steps[0].title) .font(.largeTitle) .bold() VStack(alignment: .center, spacing: 10) { Text("• Improves your writing with AI") - Text("• Works in any application in just a click") - Text("• Powered by Google's Gemini AI") + Text("• Works in any application") } .font(.title3) + } + } + + private var accessibilityStep: some View { + VStack(spacing: 20) { + Text(steps[1].title) + .font(.title) + .bold() + + Text(steps[1].description) + .multilineTextAlignment(.center) + + VStack(alignment: .leading, spacing: 15) { + Text("How to enable accessibility access:") + .font(.headline) + + Text("1. Click the button below to open System Settings") + Text("2. Click the '+' button in the accessibility section") + Text("3. Navigate to Applications and select writing-tools") + Text("4. Enable the checkbox next to writing-tools") + } + .frame(maxWidth: .infinity, alignment: .leading) - Divider() + Button("Open System Settings") { + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!) + } + .buttonStyle(.borderedProminent) + } + } + + private var customizationStep: some View { + VStack(spacing: 20) { + Text(steps[2].title) + .font(.title) + .bold() - VStack(alignment: .leading, spacing: 10) { - Text("Customize your shortcut key:") + VStack(alignment: .leading, spacing: 15) { + Text("Set your keyboard shortcut:") .font(.headline) ShortcutRecorderView(shortcutText: $shortcutText) .frame(maxWidth: .infinity) - Text("Theme:") + Text("Important: For reliable shortcuts, try:") + .font(.subheadline) + .foregroundColor(.secondary) + + Text("• Control (⌃) + Letter (e.g., ⌃ D)") + Text("• Option (⌥) + Space") + Text("• Command (⌘) + Letter") + + Divider() + + Text("Choose your theme:") .font(.headline) Toggle("Use Gradient Theme", isOn: $useGradientTheme) } - .frame(maxWidth: .infinity, alignment: .leading) - - Spacer() - - Button("Next") { - saveSettingsAndContinue() - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - } - .padding() - .frame(width: 500, height: 400) - .onAppear { - isShowingSettings = false } } private func saveSettingsAndContinue() { UserDefaults.standard.set(shortcutText, forKey: "shortcut") UserDefaults.standard.set(useGradientTheme, forKey: "use_gradient_theme") - WindowManager.shared.transitonFromOnboardingToSettings(appState: appState) } } + +struct OnboardingStep { + let title: String + let description: String + let isPermissionStep: Bool +} diff --git a/macOS/writing-tools/UI/PopupView.swift b/macOS/writing-tools/UI/PopupView.swift index 3bf4701..8782196 100644 --- a/macOS/writing-tools/UI/PopupView.swift +++ b/macOS/writing-tools/UI/PopupView.swift @@ -41,23 +41,15 @@ struct PopupView: View { // Custom input with send button HStack(spacing: 8) { TextField( - appState.selectedText.isEmpty ? "Enter your instruction..." : "Describe your change...", - text: $customText + appState.selectedText.isEmpty ? "Describe your change..." : "Describe your change...", + text: $customText + ) + .textFieldStyle(.plain) + .appleStyleTextField( + text: customText, + isLoading: isCustomLoading, + onSubmit: processCustomChange ) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .loadingBorder(isLoading: isCustomLoading) - .onSubmit { - processCustomChange() - } - Button(action: processCustomChange) { - Image(systemName: "paperplane.fill") - .foregroundColor(.white) - .padding(6) - .background(Color.blue) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .disabled(customText.isEmpty) } .padding(.horizontal) diff --git a/macOS/writing-tools/UI/PopupWindow.swift b/macOS/writing-tools/UI/PopupWindow.swift index 4d663e6..81df9ae 100644 --- a/macOS/writing-tools/UI/PopupWindow.swift +++ b/macOS/writing-tools/UI/PopupWindow.swift @@ -11,12 +11,11 @@ class PopupWindow: NSWindow { self.appState = appState self.commandsManager = CustomCommandsManager() - // Initialize with minimum size first super.init( contentRect: NSRect(x: 0, y: 0, width: 400, height: 100), styleMask: [.borderless], backing: .buffered, - defer: true // Change to true to defer window creation + defer: true ) self.isReleasedWhenClosed = false @@ -66,7 +65,7 @@ class PopupWindow: NSWindow { let baseHeight: CGFloat = 100 // Height for header and input field let buttonHeight: CGFloat = 55 // Height for each button row let spacing: CGFloat = 16 // Vertical spacing between elements - let padding: CGFloat = 16 // Bottom padding + let padding: CGFloat = 20 // Bottom padding let numBuiltInOptions = WritingOption.allCases.count let numCustomOptions = commandsManager.commands.count From 14da22ee1930914fde61fbb4a18d4a63f727abbc Mon Sep 17 00:00:00 2001 From: Aryamirsepasi Date: Thu, 19 Dec 2024 00:52:37 +0100 Subject: [PATCH 05/10] updated instructions --- macOS/README.md | 172 +++++++++++++--------- macOS/writing-tools/UI/ResponseView.swift | 1 - 2 files changed, 105 insertions(+), 68 deletions(-) diff --git a/macOS/README.md b/macOS/README.md index 16683db..7d922ff 100644 --- a/macOS/README.md +++ b/macOS/README.md @@ -1,67 +1,105 @@ -# Writing Tools for macOS - -This is a new, **native macOS port of Writing Tools**, created entirely by @Aryamirsepasi 🎉 - -Core functionality works well, and it is still an ongoing work in progress. - ---- - -## Working Features -- All of the tools, including the new response windows and the manual chat option. -- Input Window even when no text is selected -- Gemini, OpenAI and Local LLM Support. -- The Gradient Theme (Dark Mode and Light Mode are supported). -- Initial Setup, Settings, and About pages. - ---- - -## Not Yet Available -- All of the original port's features are now available; however, more optimizations and improvements are coming soon. - ---- - -## System Requirements -Due to the accessibility features the app uses (e.g., automatically selecting the window containing the text and pasting the updated version), **the minimum macOS version required is 14.0**. - ---- - -## How to Build This Project - -Since the `.xcodeproj` file is excluded, you can still build the project manually by following these steps: - -1. **Install Xcode** - - Ensure you have Xcode installed on your macOS system. - - Download it from the [Mac App Store](https://apps.apple.com/us/app/xcode/id497799835). - -2. **Clone the Repository** - - Clone this repository to your local machine: - ```bash - git clone https://github.com/theJayTea/WritingTools.git - cd WritingTools - ``` - -3. **Open the Project in Xcode** - - Open Xcode. - - Select **File > Open** from the menu bar. - - Navigate to the `macOS` folder and select it. - -4. **Generate the Project File** - - Run the following command to generate the `.xcodeproj` file: - ```bash - swift package generate-xcodeproj - ``` - -5. **Build the Project** - - Select your target device as **My Mac** in Xcode. - - Build the project by clicking the **Play** button (or pressing `Command + R`). - -6. **Run the App** - - After the build is successful, the app will launch automatically. - ---- - -## Credits - -The macOS port is being developed by **Aryamirsepasi**. - -GitHub: [https://github.com/Aryamirsepasi](https://github.com/Aryamirsepasi) +# Writing Tools for macOS + +This is a new, **native macOS port of Writing Tools**, created entirely by @Aryamirsepasi 🎉 + +Core functionality works well, and it is still an ongoing work in progress. + +--- + +## Working Features +- All of the tools, including the new response windows and the manual chat option. +- Input Window even when no text is selected +- Gemini, OpenAI and Local LLM Support. +- The Gradient Theme (Dark Mode and Light Mode are supported). +- Initial Setup, Settings, and About pages. + +--- + +## Not Yet Available +- All of the original port's features are now available; however, more optimizations and improvements are coming soon. + +--- + +## System Requirements +Due to the accessibility features the app uses (e.g., automatically selecting the window containing the text and pasting the updated version), **the minimum macOS version required is 14.0**. + +--- + +## How to Build This Project + +Since the `.xcodeproj` file is excluded, you can still build the project manually by following these steps: +This guide will help you properly set up the Writing Tools macOS project in Xcode. + +## System Requirements +- macOS 14.0 or later +- Xcode 15.0 or later +- Git + +## Installation Steps + +1. **Install Xcode** + - Download and install Xcode from the App Store + - Launch Xcode once installed and complete any additional component installations + +2. **Clone the Repository** + ```bash + git clone https://github.com/theJayTea/WritingTools.git + cd WritingTools + ``` + +3. **Create Xcode Project** + - Open Terminal and navigate to the project's macOS directory: + ```bash + cd macOS + ``` + - Create a new Xcode project: + ```bash + xcodebuild -project writing-tools.xcodeproj + ``` + +4. **Open in Xcode** + - Double-click the generated `writing-tools.xcodeproj` file + - Or open Xcode and select "Open a Project or File" + - Navigate to the `WritingTools/macOS/writing-tools.xcodeproj` file + +5. **Configure Project Settings** + - In Xcode, select the project in the navigator + - Under "Targets", select "writing-tools" + - Set the following: + - Deployment Target: macOS 14.0 + - Signing & Capabilities: Add your development team + +6. **Install Dependencies** + - In Terminal, run: + ```bash + cd macOS + swift package resolve + ``` + +7. **Build and Run** + - In Xcode, select "My Mac" as the run destination + - Click the Play button or press ⌘R to build and run + +## Troubleshooting + +If you encounter the "Could not open file" error: +1. Ensure you're opening the `.xcodeproj` file, not the folder +2. If the error persists, try: + ```bash + cd WritingTools/macOS + rm -rf writing-tools.xcodeproj + xcodebuild -project writing-tools.xcodeproj + ``` + +## Additional Notes +- The project requires macOS 14.0+ due to accessibility features +- Make sure all required permissions are granted when first launching the app +- For development, ensure you have the latest Xcode Command Line Tools installed + +--- + +## Credits + +The macOS port is being developed by **Aryamirsepasi**. + +GitHub: [https://github.com/Aryamirsepasi](https://github.com/Aryamirsepasi) diff --git a/macOS/writing-tools/UI/ResponseView.swift b/macOS/writing-tools/UI/ResponseView.swift index 6a71994..ec57b29 100644 --- a/macOS/writing-tools/UI/ResponseView.swift +++ b/macOS/writing-tools/UI/ResponseView.swift @@ -81,7 +81,6 @@ struct ResponseView: View { .progressViewStyle(CircularProgressViewStyle(tint: Color.aiPink)) .scaleEffect(1.2) Text("Regenerating...") - .foregroundColor(.aiPink) .padding(.top, 8) } .frame(maxWidth: .infinity, maxHeight: .infinity) From 4223a66d5109666e1aa1b844ed6f06be443dabf9 Mon Sep 17 00:00:00 2001 From: Aryamirsepasi Date: Sun, 22 Dec 2024 20:37:03 +0100 Subject: [PATCH 06/10] finally fixed keyboard shortcut issues --- macOS/writing-tools/AppDelegate.swift | 53 ++--- macOS/writing-tools/UI/AboutView.swift | 2 +- macOS/writing-tools/UI/OnboardingView.swift | 2 +- macOS/writing-tools/UI/ResponseView.swift | 4 +- macOS/writing-tools/UI/SettingsView.swift | 249 +++++++++++++++----- 5 files changed, 214 insertions(+), 96 deletions(-) diff --git a/macOS/writing-tools/AppDelegate.swift b/macOS/writing-tools/AppDelegate.swift index 42a1d4b..ad01724 100644 --- a/macOS/writing-tools/AppDelegate.swift +++ b/macOS/writing-tools/AppDelegate.swift @@ -178,46 +178,41 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { // Updates the active keyboard shortcut based on settings private func updateHotKey() { - // Remove existing hotkey first + // Clear any existing hotKey hotKey = nil - let shortcutText = UserDefaults.standard.string(forKey: "shortcut") ?? "⌥ Space" + // Retrieve raw code & modifiers from UserDefaults + let rawKeyCode = UserDefaults.standard.integer(forKey: "hotKey_keyCode") + let rawModifiers = UserDefaults.standard.integer(forKey: "hotKey_modifiers") - var modifiers: NSEvent.ModifierFlags = [] - var keyCode: UInt32 = 0 - - let components = shortcutText.components(separatedBy: " ") - for component in components { - switch component { - case "⌘": modifiers.insert(.command) - case "⌥": modifiers.insert(.option) - case "⌃": modifiers.insert(.control) - case "⇧": modifiers.insert(.shift) - case "Space": keyCode = UInt32(kVK_Space) - case "Return": keyCode = UInt32(kVK_Return) - case "D": keyCode = UInt32(kVK_ANSI_D) - default: - if let firstChar = component.first, - let asciiValue = firstChar.uppercased().first?.asciiValue { - keyCode = UInt32(asciiValue) - UInt32(0x41) + UInt32(kVK_ANSI_A) - } - } + // If user never recorded anything, set a default. + if rawKeyCode == 0 && rawModifiers == 0 { + // Provide default if needed + let defaultKeyCode = kVK_ANSI_D + let defaultFlags = NSEvent.ModifierFlags.control.carbonFlags + + UserDefaults.standard.set(Int(defaultKeyCode), forKey: "hotKey_keyCode") + UserDefaults.standard.set(Int(defaultFlags), forKey: "hotKey_modifiers") + + // Re-read from UserDefaults so code proceeds + return updateHotKey() } - guard keyCode != 0 else { - print("Invalid key code, resetting to default shortcut") - UserDefaults.standard.set("⌥ Space", forKey: "shortcut") - updateHotKey() - return - } + // Construct the HotKey from those raw integers + let carbonKeyCode = UInt32(rawKeyCode) + let carbonModifiers = UInt32(rawModifiers) + + // Create the HotKey instance + hotKey = HotKey(keyCombo: KeyCombo( + carbonKeyCode: carbonKeyCode, + carbonModifiers: carbonModifiers + )) - hotKey = HotKey(keyCombo: KeyCombo(carbonKeyCode: keyCode, carbonModifiers: modifiers.carbonFlags)) hotKey?.keyDownHandler = { [weak self] in DispatchQueue.main.async { if let frontmostApp = NSWorkspace.shared.frontmostApplication { self?.appState.previousApplication = frontmostApp } - self?.showPopup() } } diff --git a/macOS/writing-tools/UI/AboutView.swift b/macOS/writing-tools/UI/AboutView.swift index 585aa0e..b69997d 100644 --- a/macOS/writing-tools/UI/AboutView.swift +++ b/macOS/writing-tools/UI/AboutView.swift @@ -36,7 +36,7 @@ struct AboutView: View { Divider() - Text("Version: 1.0 (Based on Windows Port version 5.0)") + Text("Version: Beta 5 (Based on Windows Port version 5.0)") .font(.caption) Button("Check for Updates") { diff --git a/macOS/writing-tools/UI/OnboardingView.swift b/macOS/writing-tools/UI/OnboardingView.swift index e3d2d24..b2959d3 100644 --- a/macOS/writing-tools/UI/OnboardingView.swift +++ b/macOS/writing-tools/UI/OnboardingView.swift @@ -147,7 +147,7 @@ struct OnboardingView: View { Text("Set your keyboard shortcut:") .font(.headline) - ShortcutRecorderView(shortcutText: $shortcutText) + ShortcutRecorderView() .frame(maxWidth: .infinity) Text("Important: For reliable shortcuts, try:") diff --git a/macOS/writing-tools/UI/ResponseView.swift b/macOS/writing-tools/UI/ResponseView.swift index ec57b29..f41fa92 100644 --- a/macOS/writing-tools/UI/ResponseView.swift +++ b/macOS/writing-tools/UI/ResponseView.swift @@ -98,11 +98,11 @@ struct ResponseView: View { VStack(spacing: 12) { // Additional Prompt Input VStack(alignment: .leading, spacing: 4) { - Text("Additional Instructions (optional):") + Text("Follow-up question(optional):") .font(.caption) .foregroundColor(.secondary) - TextField("Enter additional instructions for regeneration...", text: $viewModel.additionalPrompt) + TextField("Ask a follow-up question / describe a change", text: $viewModel.additionalPrompt) .textFieldStyle(RoundedBorderTextFieldStyle()) .font(.system(size: 12)) } diff --git a/macOS/writing-tools/UI/SettingsView.swift b/macOS/writing-tools/UI/SettingsView.swift index 223f48a..59c3cce 100644 --- a/macOS/writing-tools/UI/SettingsView.swift +++ b/macOS/writing-tools/UI/SettingsView.swift @@ -2,13 +2,14 @@ import SwiftUI import Carbon.HIToolbox struct ShortcutRecorderView: View { - @Binding var shortcutText: String - @State private var isRecording = false + @State private var displayText = "" + @FocusState private var isFocused: Bool + @State private var isRecording = false var body: some View { HStack { - Text(shortcutText) + Text(displayText.isEmpty ? "Click to record" : displayText) .frame(maxWidth: .infinity, alignment: .leading) .padding(8) .background(Color(.textBackgroundColor)) @@ -16,10 +17,11 @@ struct ShortcutRecorderView: View { .onTapGesture { isFocused = true isRecording = true + displayText = "Recording..." } if isRecording { - Text("Recording...") + Text("Press desired shortcut…") .foregroundColor(.secondary) } } @@ -29,81 +31,106 @@ struct ShortcutRecorderView: View { NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { event in if isRecording { handleKeyEvent(event) + // Return nil to indicate the event is handled return nil } + // Otherwise, pass it on return event } } } private func handleKeyEvent(_ event: NSEvent) { - if event.type == .keyDown { - var components: [String] = [] + // Modifiers + let carbonModifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask).carbonFlags - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + // The physical key code from the system + let rawKeyCode = UInt32(event.keyCode) - if flags.contains(.control) { components.append("⌃") } - if flags.contains(.option) { components.append("⌥") } - if flags.contains(.shift) { components.append("⇧") } - if flags.contains(.command) { components.append("⌘") } + // Save them in UserDefaults + UserDefaults.standard.set(Int(rawKeyCode), forKey: "hotKey_keyCode") + UserDefaults.standard.set(Int(carbonModifiers), forKey: "hotKey_modifiers") - if let specialKey = specialKeyMapping[event.keyCode] { - components.append(specialKey) - } else { - if let keyChar = virtualKeyCodeToChar[event.keyCode] { - components.append(keyChar) - } - } + displayText = describeShortcut(keyCode: event.keyCode, + flags: event.modifierFlags) - if !components.isEmpty { - print("Final components: \(components)") - shortcutText = components.joined(separator: " ") - isRecording = false - isFocused = false - } + // Done recording + isRecording = false + isFocused = false } } - private let specialKeyMapping: [UInt16: String] = [ - UInt16(kVK_Space): "Space", - UInt16(kVK_Escape): "Esc", - UInt16(kVK_Delete): "Delete", - UInt16(kVK_Tab): "Tab", - UInt16(kVK_Return): "Return", - UInt16(kVK_UpArrow): "↑", - UInt16(kVK_DownArrow): "↓", - UInt16(kVK_LeftArrow): "←", - UInt16(kVK_RightArrow): "→" - ] + // helper to produce a “Ctrl + D” style string. + private func describeShortcut(keyCode: UInt16, flags: NSEvent.ModifierFlags) -> String { + var parts: [String] = [] + + // 1) Collect modifier flags + if flags.contains(.command) { parts.append("⌘") } + if flags.contains(.option) { parts.append("⌥") } + if flags.contains(.control) { parts.append("⌃") } + if flags.contains(.shift) { parts.append("⇧") } + + // 2) Convert keyCode -> Int + let keyCodeInt = Int(keyCode) + + // 3) Check if it matches certain special/symbol keys + switch keyCodeInt { + case kVK_Space: + parts.append("Space") + case kVK_Return: + parts.append("Return") + case kVK_ANSI_Equal: + parts.append("=") + case kVK_ANSI_Minus: + parts.append("-") + case kVK_ANSI_LeftBracket: + parts.append("[") + case kVK_ANSI_RightBracket: + parts.append("]") + // Add more symbol keys if needed (e.g., kVK_ANSI_Semicolon, etc.) + + default: + // 4) If we find a letter in our dictionary, use it; else show the numeric code. + if let letter = keyCodeToLetter[keyCodeInt] { + parts.append(letter) + } else { + parts.append("(\(keyCode))") // Fallback for anything unrecognized + } + } + + // 5) Combine with spaces, e.g. "⌃ D", "⌘ =" + return parts.joined(separator: " ") + } - private let virtualKeyCodeToChar: [UInt16: String] = [ - UInt16(kVK_ANSI_A): "A", - UInt16(kVK_ANSI_B): "B", - UInt16(kVK_ANSI_C): "C", - UInt16(kVK_ANSI_D): "D", - UInt16(kVK_ANSI_E): "E", - UInt16(kVK_ANSI_F): "F", - UInt16(kVK_ANSI_G): "G", - UInt16(kVK_ANSI_H): "H", - UInt16(kVK_ANSI_I): "I", - UInt16(kVK_ANSI_J): "J", - UInt16(kVK_ANSI_K): "K", - UInt16(kVK_ANSI_L): "L", - UInt16(kVK_ANSI_M): "M", - UInt16(kVK_ANSI_N): "N", - UInt16(kVK_ANSI_O): "O", - UInt16(kVK_ANSI_P): "P", - UInt16(kVK_ANSI_Q): "Q", - UInt16(kVK_ANSI_R): "R", - UInt16(kVK_ANSI_S): "S", - UInt16(kVK_ANSI_T): "T", - UInt16(kVK_ANSI_U): "U", - UInt16(kVK_ANSI_V): "V", - UInt16(kVK_ANSI_W): "W", - UInt16(kVK_ANSI_X): "X", - UInt16(kVK_ANSI_Y): "Y", - UInt16(kVK_ANSI_Z): "Z" + // Maps the Carbon virtual key code (e.g. kVK_ANSI_D = 0x02) to the actual letter "D". + private let keyCodeToLetter: [Int: String] = [ + kVK_ANSI_A: "A", + kVK_ANSI_B: "B", + kVK_ANSI_C: "C", + kVK_ANSI_D: "D", + kVK_ANSI_E: "E", + kVK_ANSI_F: "F", + kVK_ANSI_G: "G", + kVK_ANSI_H: "H", + kVK_ANSI_I: "I", + kVK_ANSI_J: "J", + kVK_ANSI_K: "K", + kVK_ANSI_L: "L", + kVK_ANSI_M: "M", + kVK_ANSI_N: "N", + kVK_ANSI_O: "O", + kVK_ANSI_P: "P", + kVK_ANSI_Q: "Q", + kVK_ANSI_R: "R", + kVK_ANSI_S: "S", + kVK_ANSI_T: "T", + kVK_ANSI_U: "U", + kVK_ANSI_V: "V", + kVK_ANSI_W: "W", + kVK_ANSI_X: "X", + kVK_ANSI_Y: "Y", + kVK_ANSI_Z: "Z" ] } @@ -124,6 +151,8 @@ struct SettingsView: View { @State private var openAIProject = UserDefaults.standard.string(forKey: "openai_project") ?? "" @State private var openAIModelName = UserDefaults.standard.string(forKey: "openai_model") ?? OpenAIConfig.defaultModel + @State private var displayShortcut = "" + var showOnlyApiSetup: Bool = false @@ -154,7 +183,23 @@ struct SettingsView: View { Form { if !showOnlyApiSetup { Section("General Settings") { - ShortcutRecorderView(shortcutText: $shortcutText) + Form { + Text(displayShortcut.isEmpty ? "Not set" : displayShortcut) + + ShortcutRecorderView() + + } + .onAppear { + // Load the raw keyCode & modifiers from UserDefaults + let rawKeyCode = UserDefaults.standard.integer(forKey: "hotKey_keyCode") + let rawModifiers = UserDefaults.standard.integer(forKey: "hotKey_modifiers") + + // Convert to proper Swift types + let keyCode = UInt16(rawKeyCode) + let flags = decodeCarbonModifiers(rawModifiers) + + displayShortcut = describeShortcut(keyCode: keyCode, flags: flags) + } Toggle("Use Gradient Theme", isOn: $useGradientTheme) } @@ -269,4 +314,82 @@ struct SettingsView: View { } } } + // Converts stored Carbon modifier bits into SwiftUI’s `NSEvent.ModifierFlags`. + private func decodeCarbonModifiers(_ rawModifiers: Int) -> NSEvent.ModifierFlags { + var flags = NSEvent.ModifierFlags() + let carbonFlags = UInt32(rawModifiers) + + if (carbonFlags & UInt32(cmdKey)) != 0 { flags.insert(.command) } + if (carbonFlags & UInt32(optionKey)) != 0 { flags.insert(.option) } + if (carbonFlags & UInt32(controlKey)) != 0 { flags.insert(.control) } + if (carbonFlags & UInt32(shiftKey)) != 0 { flags.insert(.shift) } + + return flags + } + + // Returns a human-friendly string like "⌘ =" or "⌃ D". + private func describeShortcut(keyCode: UInt16, flags: NSEvent.ModifierFlags) -> String { + var parts: [String] = [] + + if flags.contains(.command) { parts.append("⌘") } + if flags.contains(.option) { parts.append("⌥") } + if flags.contains(.control) { parts.append("⌃") } + if flags.contains(.shift) { parts.append("⇧") } + + let keyCodeInt = Int(keyCode) + + switch keyCodeInt { + case kVK_Space: + parts.append("Space") + case kVK_Return: + parts.append("Return") + case kVK_ANSI_Equal: + parts.append("=") + case kVK_ANSI_Minus: + parts.append("-") + case kVK_ANSI_LeftBracket: + parts.append("[") + case kVK_ANSI_RightBracket: + parts.append("]") + + default: + if let letter = keyCodeToLetter[keyCodeInt] { + parts.append(letter) + } else { + parts.append("(\(keyCode))") + } + } + + return parts.joined(separator: " ") + } + + // Maps the Carbon virtual key code (e.g. kVK_ANSI_D = 0x02) to the actual letter "D". + private let keyCodeToLetter: [Int: String] = [ + kVK_ANSI_A: "A", + kVK_ANSI_B: "B", + kVK_ANSI_C: "C", + kVK_ANSI_D: "D", + kVK_ANSI_E: "E", + kVK_ANSI_F: "F", + kVK_ANSI_G: "G", + kVK_ANSI_H: "H", + kVK_ANSI_I: "I", + kVK_ANSI_J: "J", + kVK_ANSI_K: "K", + kVK_ANSI_L: "L", + kVK_ANSI_M: "M", + kVK_ANSI_N: "N", + kVK_ANSI_O: "O", + kVK_ANSI_P: "P", + kVK_ANSI_Q: "Q", + kVK_ANSI_R: "R", + kVK_ANSI_S: "S", + kVK_ANSI_T: "T", + kVK_ANSI_U: "U", + kVK_ANSI_V: "V", + kVK_ANSI_W: "W", + kVK_ANSI_X: "X", + kVK_ANSI_Y: "Y", + kVK_ANSI_Z: "Z" + ] } From 0391f6c0a3f77237837b5f1d87abc0cc28b81046 Mon Sep 17 00:00:00 2001 From: Aryamirsepasi Date: Thu, 26 Dec 2024 16:59:07 +0100 Subject: [PATCH 07/10] optimized the prompts, added the chat window --- .../Commands/CustomCommand.swift | 4 +- .../Commands/CustomCommandsView.swift | 16 +- .../Commands/WritingOption.swift | 48 +-- macOS/writing-tools/UI/ResponseView.swift | 325 +++++++++++------- macOS/writing-tools/UI/ResponseWindow.swift | 2 +- 5 files changed, 239 insertions(+), 156 deletions(-) diff --git a/macOS/writing-tools/Commands/CustomCommand.swift b/macOS/writing-tools/Commands/CustomCommand.swift index e65b531..88d2e8e 100644 --- a/macOS/writing-tools/Commands/CustomCommand.swift +++ b/macOS/writing-tools/Commands/CustomCommand.swift @@ -5,12 +5,14 @@ struct CustomCommand: Codable, Identifiable, Equatable { var name: String var prompt: String var icon: String + var useResponseWindow: Bool - init(id: UUID = UUID(), name: String, prompt: String, icon: String) { + init(id: UUID = UUID(), name: String, prompt: String, icon: String, useResponseWindow: Bool = false) { self.id = id self.name = name self.prompt = prompt self.icon = icon + self.useResponseWindow = useResponseWindow } } diff --git a/macOS/writing-tools/Commands/CustomCommandsView.swift b/macOS/writing-tools/Commands/CustomCommandsView.swift index d0d26ca..a0a88c3 100644 --- a/macOS/writing-tools/Commands/CustomCommandsView.swift +++ b/macOS/writing-tools/Commands/CustomCommandsView.swift @@ -70,7 +70,7 @@ struct CustomCommandRow: View { let command: CustomCommand var onEdit: (CustomCommand) -> Void var onDelete: (CustomCommand) -> Void - + var body: some View { HStack(spacing: 12) { // Icon @@ -120,6 +120,7 @@ struct CustomCommandEditor: View { @State private var name: String = "" @State private var prompt: String = "" @State private var selectedIcon: String = "star.fill" + @State private var useResponseWindow: Bool = false @State private var showingIconPicker = false init(commandsManager: CustomCommandsManager, isPresented: Binding, editingCommand: CustomCommand? = nil) { @@ -131,6 +132,7 @@ struct CustomCommandEditor: View { _name = State(initialValue: command.name) _prompt = State(initialValue: command.prompt) _selectedIcon = State(initialValue: command.icon) + _useResponseWindow = State(initialValue: command.useResponseWindow) } } @@ -196,6 +198,14 @@ struct CustomCommandEditor: View { .stroke(Color.gray.opacity(0.2), lineWidth: 1) ) } + // Add Response Window Toggle + Toggle("Show Response in Chat Window", isOn: $useResponseWindow) + .padding(.horizontal) + + Text("When enabled, responses will appear in a chat window instead of replacing the selected text.") + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal) } .padding() } @@ -216,7 +226,8 @@ struct CustomCommandEditor: View { id: editingCommand?.id ?? UUID(), name: name, prompt: prompt, - icon: selectedIcon + icon: selectedIcon, + useResponseWindow: useResponseWindow ) if editingCommand != nil { @@ -229,6 +240,7 @@ struct CustomCommandEditor: View { } .keyboardShortcut(.return, modifiers: [.command]) .disabled(name.isEmpty || prompt.isEmpty) + .padding() } .padding() } diff --git a/macOS/writing-tools/Commands/WritingOption.swift b/macOS/writing-tools/Commands/WritingOption.swift index 4cfdf46..48c52df 100644 --- a/macOS/writing-tools/Commands/WritingOption.swift +++ b/macOS/writing-tools/Commands/WritingOption.swift @@ -14,52 +14,36 @@ enum WritingOption: String, CaseIterable, Identifiable { switch self { case .proofread: return """ - You are a grammar proofreading assistant. Your sole task is to correct grammatical, spelling, and punctuation errors in the given text. - Maintain the original text structure and writing style. Perform this task in the same language as the provided text. - Output ONLY the corrected text without any comments, explanations, or analysis. Do not include additional suggestions or formatting in your response. - """ + You are a grammar proofreading assistant. Output ONLY the corrected text without any additional comments. Maintain the original text structure and writing style. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user's text content. If the text is absolutely incompatible with this (e.g., totally random gibberish), output "ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST". + """ case .rewrite: return """ - You are a rewriting assistant. Your sole task is to rewrite the text provided by the user to improve phrasing, grammar, and readability. - Maintain the original meaning and style. Perform this task in the same language as the provided text. - Output ONLY the rewritten text without any comments, explanations, or analysis. Do not include additional suggestions or formatting in your response. - """ + You are a writing assistant. Rewrite the text provided by the user to improve phrasing. Output ONLY the rewritten text without additional comments. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user's text content. If the text is absolutely incompatible with proofreading (e.g., totally random gibberish), output "ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST". + """ case .friendly: return """ - You are a rewriting assistant. Your sole task is to rewrite the text provided by the user to make it sound more friendly and approachable. - Maintain the original meaning and structure. Perform this task in the same language as the provided text. - Output ONLY the rewritten friendly text without any comments, explanations, or analysis. Do not include additional suggestions or formatting in your response. - """ + You are a writing assistant. Rewrite the text provided by the user to be more friendly. Output ONLY the friendly text without additional comments. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user's text content. If the text is absolutely incompatible with rewriting (e.g., totally random gibberish), output "ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST". + """ case .professional: return """ - You are a rewriting assistant. Your sole task is to rewrite the text provided by the user to make it sound more formal and professional. - Maintain the original meaning and structure. Perform this task in the same language as the provided text. - Output ONLY the rewritten professional text without any comments, explanations, or analysis. Do not include additional suggestions or formatting in your response. - """ + You are a writing assistant. Rewrite the text provided by the user to sound more professional. Output ONLY the professional text without additional comments. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user's text content. If the text is absolutely incompatible with this (e.g., totally random gibberish), output "ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST". + """ case .concise: return """ - You are a rewriting assistant. Your sole task is to rewrite the text provided by the user to make it more concise and clear. - Maintain the original meaning and tone. Perform this task in the same language as the provided text. - Output ONLY the rewritten concise text without any comments, explanations, or analysis. Do not include additional suggestions or formatting in your response. - """ + You are a writing assistant. Rewrite the text provided by the user to be slightly more concise in tone, thus making it just a bit shorter. Do not change the text too much or be too reductive. Output ONLY the concise version without additional comments. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user's text content. If the text is absolutely incompatible with this (e.g., totally random gibberish), output "ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST". + """ case .summary: return """ - You are a summarization assistant. Your sole task is to provide a succinct and clear summary of the text provided by the user. - Maintain the original context and key information. Perform this task in the same language as the provided text. - Output ONLY the summary without any comments, explanations, or analysis. Do not include additional suggestions. Use Markdown formatting with line spacing between sections. - """ + You are a summarisation assistant. Provide a succinct summary of the text provided by the user. The summary should be succinct yet encompass all the key insightful points. To make it quite legible and readable, you MUST use Markdown formatting (bold, italics, underline...). You should add line spacing between your paragraphs/lines. Only if appropriate, you could also use headings (only the very small ones), lists, tables, etc. Don't be repetitive or too verbose. Output ONLY the summary without additional comments. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user's text content. If the text is absolutely incompatible with summarisation (e.g., totally random gibberish), output "ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST". + """ case .keyPoints: return """ - You are an assistant for extracting key points from text. Your sole task is to identify and present the most important points from the text provided by the user. - Maintain the original context and order of importance. Perform this task in the same language as the provided text. - Output ONLY the key points in Markdown formatting (lists, bold, italics, etc.) without any comments, explanations, or analysis. - """ + You are an assistant that extracts key points from text provided by the user. Output ONLY the key points without additional comments. You MUST use Markdown formatting (lists, bold, italics, underline, etc. as appropriate) to make it quite legible and readable. Don't be repetitive or too verbose. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user's text content. If the text is absolutely incompatible with extracting key points (e.g., totally random gibberish), output "ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST". + """ case .table: return """ - You are a text-to-table assistant. Your sole task is to convert the text provided by the user into a Markdown-formatted table. - Maintain the original context and information. Perform this task in the same language as the provided text. - Output ONLY the table without any comments, explanations, or analysis. Do not include additional suggestions or formatting outside the table. - """ + You are an assistant that converts text provided by the user into a Markdown table. Output ONLY the table without additional comments. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user's text content. If the text is completely incompatible with this with conversion, output "ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST". + """ } } diff --git a/macOS/writing-tools/UI/ResponseView.swift b/macOS/writing-tools/UI/ResponseView.swift index f41fa92..e805066 100644 --- a/macOS/writing-tools/UI/ResponseView.swift +++ b/macOS/writing-tools/UI/ResponseView.swift @@ -1,60 +1,17 @@ import SwiftUI import MarkdownUI -final class ResponseViewModel: ObservableObject { - @Published var content: String - @Published var fontSize: CGFloat = 14 - @Published var showCopyConfirmation: Bool = false - @Published var additionalPrompt: String = "" - - let selectedText: String - let option: WritingOption - - init(content: String, selectedText: String, option: WritingOption) { - self.content = content - self.selectedText = selectedText - self.option = option - } - - // Regenerate content using AI provider - func regenerateContent() async { - do { - let combinedPrompt = if !additionalPrompt.isEmpty { - """ - Original System Prompt: \(option.systemPrompt) - Additional Instructions: \(additionalPrompt) - - Apply both the original system prompt and the additional instructions to process the following text. - """ - } else { - option.systemPrompt - } - - let result = try await AppState.shared.activeProvider.processText( - systemPrompt: combinedPrompt, - userPrompt: selectedText - ) - await MainActor.run { - self.content = result - self.additionalPrompt = "" - } - } catch { - print("Error regenerating content: \(error.localizedDescription)") - } - } +struct ChatMessage: Identifiable, Equatable { + let id = UUID() + let role: String // "user" or "assistant" + let content: String + let timestamp: Date = Date() - // Copy content to clipboard - func copyContent() { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(content, forType: .string) - - // Show confirmation - showCopyConfirmation = true - - // Hide confirmation after 2 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - self.showCopyConfirmation = false - } + static func == (lhs: ChatMessage, rhs: ChatMessage) -> Bool { + lhs.id == rhs.id && + lhs.role == rhs.role && + lhs.content == rhs.content && + lhs.timestamp == rhs.timestamp } } @@ -62,7 +19,10 @@ struct ResponseView: View { @StateObject private var viewModel: ResponseViewModel @Environment(\.colorScheme) var colorScheme @AppStorage("use_gradient_theme") private var useGradientTheme = false + @State private var inputText: String = "" @State private var isRegenerating: Bool = false + @State private var scrollProxy: ScrollViewProxy? + @State private var latestMessageId: UUID? init(content: String, selectedText: String, option: WritingOption) { self._viewModel = StateObject(wrappedValue: ResponseViewModel( @@ -73,84 +33,209 @@ struct ResponseView: View { } var body: some View { - VStack(spacing: 16) { - ScrollView { - if isRegenerating { - VStack { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: Color.aiPink)) - .scaleEffect(1.2) - Text("Regenerating...") - .padding(.top, 8) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - } else { - Markdown(viewModel.content) - .font(.system(size: viewModel.fontSize)) - .textSelection(.enabled) - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - // Control bar - VStack(spacing: 12) { - // Additional Prompt Input - VStack(alignment: .leading, spacing: 4) { - Text("Follow-up question(optional):") - .font(.caption) - .foregroundColor(.secondary) - - TextField("Ask a follow-up question / describe a change", text: $viewModel.additionalPrompt) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .font(.system(size: 12)) + VStack(spacing: 0) { + // Top toolbar with controls + HStack { + Button(action: { viewModel.copyContent() }) { + Label(viewModel.showCopyConfirmation ? "Copied!" : "Copy", + systemImage: viewModel.showCopyConfirmation ? "checkmark" : "doc.on.doc") } + .animation(.easeInOut, value: viewModel.showCopyConfirmation) - HStack { - HStack(spacing: 12) { - Button(action: { - isRegenerating = true - Task { - await viewModel.regenerateContent() - isRegenerating = false - } - }) { - Label("Regenerate", systemImage: "arrow.clockwise") - .frame(minWidth: 100) - } - .disabled(isRegenerating) - - Button(action: { - viewModel.copyContent() - }) { - Label(viewModel.showCopyConfirmation ? "Copied!" : "Copy", - systemImage: viewModel.showCopyConfirmation ? "checkmark" : "doc.on.doc") - } - .animation(.easeInOut, value: viewModel.showCopyConfirmation) + Spacer() + + HStack(spacing: 8) { + Button(action: { viewModel.fontSize = max(10, viewModel.fontSize - 2) }) { + Image(systemName: "minus.magnifyingglass") } + .disabled(viewModel.fontSize <= 10) - Spacer() + Button(action: { viewModel.fontSize = 14 }) { + Image(systemName: "arrow.clockwise") + } - HStack(spacing: 8) { - Button(action: { viewModel.fontSize = max(10, viewModel.fontSize - 2) }) { - Image(systemName: "minus.magnifyingglass") - } - .disabled(viewModel.fontSize <= 10) - - Button(action: { viewModel.fontSize = 14 }) { - Image(systemName: "arrow.clockwise") + Button(action: { viewModel.fontSize = min(24, viewModel.fontSize + 2) }) { + Image(systemName: "plus.magnifyingglass") + } + .disabled(viewModel.fontSize >= 24) + } + } + .padding() + .background(Color(.windowBackgroundColor)) + + // Chat messages area + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 16) { + ForEach(viewModel.messages) { message in + ChatMessageView(message: message, fontSize: viewModel.fontSize) + .id(message.id) } - - Button(action: { viewModel.fontSize = min(24, viewModel.fontSize + 2) }) { - Image(systemName: "plus.magnifyingglass") + } + .padding() + } + .onChange(of: viewModel.messages, initial: true) { oldValue, newValue in + if let lastId = newValue.last?.id { + withAnimation { + proxy.scrollTo(lastId, anchor: .bottom) } - .disabled(viewModel.fontSize >= 24) } } } - .padding() + + // Input area + VStack(spacing: 8) { + Divider() + + HStack(spacing: 8) { + TextField("Ask a follow-up question...", text: $inputText) + .textFieldStyle(.plain) + .appleStyleTextField( + text: inputText, + isLoading: isRegenerating, + onSubmit: sendMessage + ) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + .background(Color(.windowBackgroundColor)) } .windowBackground(useGradient: useGradientTheme) } + + private func sendMessage() { + guard !inputText.isEmpty else { return } + let question = inputText + inputText = "" + isRegenerating = true + viewModel.processFollowUpQuestion(question) { + isRegenerating = false + } + } +} + +struct ChatMessageView: View { + let message: ChatMessage + let fontSize: CGFloat + + var body: some View { + HStack(alignment: .top) { + if message.role == "user" { + Spacer(minLength: 60) + } + + VStack(alignment: message.role == "user" ? .trailing : .leading, spacing: 4) { + Markdown(message.content) + .font(.system(size: fontSize)) + .textSelection(.enabled) + .padding() + .frame(maxWidth: 280, alignment: .leading) // Always left-align the text + .background(message.role == "user" ? Color.accentColor.opacity(0.1) : Color(.controlBackgroundColor)) + .cornerRadius(12) + + Text(message.timestamp.formatted(.dateTime.hour().minute())) + .font(.caption2) + .foregroundColor(.secondary) + } + + if message.role == "assistant" { + Spacer(minLength: 60) + } + } + } +} + +extension View { + func maxWidth(_ width: CGFloat) -> some View { + frame(maxWidth: width) + } +} + +// Update ResponseViewModel to handle chat messages +final class ResponseViewModel: ObservableObject { + @Published var messages: [ChatMessage] = [] + @Published var fontSize: CGFloat = 14 + @Published var showCopyConfirmation: Bool = false + + let selectedText: String + let option: WritingOption + + init(content: String, selectedText: String, option: WritingOption) { + self.selectedText = selectedText + self.option = option + + // Initialize with the first message + self.messages.append(ChatMessage( + role: "assistant", + content: content + )) + } + + func processFollowUpQuestion(_ question: String, completion: @escaping () -> Void) { + // Add user message + DispatchQueue.main.async { + self.messages.append(ChatMessage( + role: "user", + content: question + )) + } + + Task { + do { + // Build conversation history + let conversationHistory = messages.map { message in + return "\(message.role == "user" ? "User" : "Assistant"): \(message.content)" + }.joined(separator: "\n\n") + + // Create prompt with context + let contextualPrompt = """ + Previous conversation: + \(conversationHistory) + + User's new question: \(question) + + Respond to the user's question while maintaining context from the previous conversation. + """ + + let result = try await AppState.shared.activeProvider.processText( + systemPrompt: """ + You are a helpful AI assistant continuing a conversation. You have access to the entire conversation history and should maintain context when responding. + Provide clear and direct responses, maintaining the same format and style as your previous responses. + If appropriate, use Markdown formatting to make your response more readable. + Consider all previous messages when formulating your response. + """, + userPrompt: contextualPrompt + ) + + DispatchQueue.main.async { + self.messages.append(ChatMessage( + role: "assistant", + content: result + )) + completion() + } + } catch { + print("Error processing follow-up: \(error)") + completion() + } + } + } + + func clearConversation() { + messages.removeAll() + } + + func copyContent() { + // Only copy the latest AI response + if let latestAiMessage = messages.last(where: { $0.role == "assistant" }) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(latestAiMessage.content, forType: .string) + + showCopyConfirmation = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.showCopyConfirmation = false + } + } + } } diff --git a/macOS/writing-tools/UI/ResponseWindow.swift b/macOS/writing-tools/UI/ResponseWindow.swift index 6554fb9..ab65dcf 100644 --- a/macOS/writing-tools/UI/ResponseWindow.swift +++ b/macOS/writing-tools/UI/ResponseWindow.swift @@ -4,7 +4,7 @@ import SwiftUI class ResponseWindow: NSWindow { init(title: String, content: String, selectedText: String, option: WritingOption) { super.init( - contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), + contentRect: NSRect(x: 0, y: 0, width: 600, height: 500), styleMask: [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false From 85ca3b767b996137226d819622bdf42f0131d9c0 Mon Sep 17 00:00:00 2001 From: Aryamirsepasi Date: Sun, 29 Dec 2024 19:24:33 +0100 Subject: [PATCH 08/10] updated Documentation, Updated the text filed popup windows to function like windows version --- README.md | 77 +++++++++++++++++-------- macOS/README.md | 3 +- macOS/writing-tools/UI/AboutView.swift | 2 +- macOS/writing-tools/UI/PopupView.swift | 80 +++++++++++++++++++------- 4 files changed, 114 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 1cdbbae..acd0f4a 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ The macOS version is a **native Swift port**, developed by [Aryamirsepasi](https To install it: 1. Go to the [Releases](https://github.com/theJayTea/WritingTools/releases) page and download the latest `.dmg` file. -2. Open the `.dmg` file and drag the `writing-tools.app` into the Applications folder. That's it! +2. Open the `.dmg` file, also open a Finder Window, and drag the `writing-tools.app` into the Applications folder. That's it! ## 👀 Tips @@ -196,32 +196,59 @@ python pyinstaller-build-script.py ### macOS Version (by [Aryamirsepasi](https://github.com/Aryamirsepasi)) build instructions: 1. **Install Xcode** -- Ensure you have Xcode installed on your macOS system. -- Download it from the [Mac App Store](https://apps.apple.com/us/app/xcode/id497799835). + - Download and install Xcode from the App Store + - Launch Xcode once installed and complete any additional component installations -2. **Clone the Repository to your local machine** -```bash -git clone https://github.com/theJayTea/WritingTools.git -cd WritingTools - ``` - -3. **Open the Project in Xcode** -- Open Xcode. -- Select **File > Open** from the menu bar. -- Navigate to the `macOS` folder and select it. - -4. **Generate the Project File** -Run the following command to generate the `.xcodeproj` file: -```bash -swift package generate-xcodeproj -``` - -5. **Build the Project** -- Select your target device as **My Mac** in Xcode. -- Build the project by clicking the **Play** button (or pressing `Command + R`). +2. **Clone the Repository** + - Open Terminal and navigate to a directory you want the project to be in: + ```bash + git clone https://github.com/theJayTea/WritingTools.git + cd WritingTools + ``` -6. **Run the App** -- After the build is successful, the app will launch automatically. +3. **Create Xcode Project** + - Navigate to the project's macOS directory: + ```bash + cd macOS + ``` + - Create a new Xcode project: + ```bash + xcodebuild -project writing-tools.xcodeproj + ``` + +4. **Open in Xcode** + - Double-click the generated `writing-tools.xcodeproj` file + - Or open Xcode and select "Open a Project or File" + - Navigate to the `WritingTools/macOS/writing-tools.xcodeproj` file + +5. **Configure Project Settings** + - In Xcode, select the project in the navigator + - Under "Targets", select "writing-tools" + - Set the following: + - Deployment Target: macOS 14.0 + - Signing & Capabilities: Add your development team + +6. **Install Dependencies** + - In Terminal, run: + ```bash + cd macOS + swift package resolve + ``` + +7. **Build and Run** + - In Xcode, select "My Mac" as the run destination + - Click the Play button or press ⌘R to build and run + +## Troubleshooting + +If you encounter the "Could not open file" error: +1. Ensure you're opening the `.xcodeproj` file, not the folder +2. If the error persists, try: + ```bash + cd WritingTools/macOS + rm -rf writing-tools.xcodeproj + xcodebuild -project writing-tools.xcodeproj + ``` ## 🌟 Contributors diff --git a/macOS/README.md b/macOS/README.md index 7d922ff..4df2fd7 100644 --- a/macOS/README.md +++ b/macOS/README.md @@ -42,13 +42,14 @@ This guide will help you properly set up the Writing Tools macOS project in Xcod - Launch Xcode once installed and complete any additional component installations 2. **Clone the Repository** + - Open Terminal and navigate to a directory you want the project to be in: ```bash git clone https://github.com/theJayTea/WritingTools.git cd WritingTools ``` 3. **Create Xcode Project** - - Open Terminal and navigate to the project's macOS directory: + - Navigate to the project's macOS directory: ```bash cd macOS ``` diff --git a/macOS/writing-tools/UI/AboutView.swift b/macOS/writing-tools/UI/AboutView.swift index b69997d..7ef8cdb 100644 --- a/macOS/writing-tools/UI/AboutView.swift +++ b/macOS/writing-tools/UI/AboutView.swift @@ -36,7 +36,7 @@ struct AboutView: View { Divider() - Text("Version: Beta 5 (Based on Windows Port version 5.0)") + Text("Version: Beta 5 (Based on Windows Port version 6.0)") .font(.caption) Button("Check for Updates") { diff --git a/macOS/writing-tools/UI/PopupView.swift b/macOS/writing-tools/UI/PopupView.swift index 8782196..e9a0b78 100644 --- a/macOS/writing-tools/UI/PopupView.swift +++ b/macOS/writing-tools/UI/PopupView.swift @@ -110,14 +110,31 @@ struct PopupView: View { userPrompt: appState.selectedText ) - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(result, forType: .string) + if command.useResponseWindow { + // Show response in a new window + await MainActor.run { + let window = ResponseWindow( + title: command.name, + content: result, + selectedText: appState.selectedText, + option: .proofread // Using proofread as default since this is a custom command + ) + + WindowManager.shared.addResponseWindow(window) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + } + } else { + // Use inline replacement + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(result, forType: .string) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + simulatePaste() + } + } closeAction() - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - simulatePaste() - } } catch { print("Error processing custom command: \(error.localizedDescription)") } @@ -178,35 +195,56 @@ struct PopupView: View { Task { do { let systemPrompt = """ - You are a writing and coding assistant. Your sole task is to apply the user's specified changes to the provided text. - Output ONLY the modified text without any comments, explanations, or analysis. - Do not include additional suggestions or formatting in your response. + You are a writing and coding assistant. Your sole task is to respond to the user's instruction thoughtfully and comprehensively. + If the instruction is a question, provide a detailed answer. + If it's a request for help, provide clear guidance and examples where appropriate. + Use Markdown formatting to make your response more readable. """ - let userPrompt = """ - User's instruction: \(instruction) - - Text: - \(appState.selectedText) - """ + let userPrompt = appState.selectedText.isEmpty ? + instruction : + """ + User's instruction: \(instruction) + + Text: + \(appState.selectedText) + """ let result = try await appState.activeProvider.processText( systemPrompt: systemPrompt, userPrompt: userPrompt ) - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(result, forType: .string) + if appState.selectedText.isEmpty { + // Show response in a new window + await MainActor.run { + let window = ResponseWindow( + title: "AI Response", + content: result, + selectedText: instruction, + option: .proofread // Using proofread as default, but the response window will adapt based on content + ) + + WindowManager.shared.addResponseWindow(window) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + } + } else { + // For selected text, continue with the existing inline replacement behavior + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(result, forType: .string) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + simulatePaste() + } + } closeAction() - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - simulatePaste() - } } catch { print("Error processing text: \(error.localizedDescription)") } + isCustomLoading = false appState.isProcessing = false } } From 32cb8b2ce2cc33ac3b743fee5e47c7c986cb87d9 Mon Sep 17 00:00:00 2001 From: Aryamirsepasi Date: Sun, 29 Dec 2024 19:49:23 +0100 Subject: [PATCH 09/10] fixed a couple of bugs, made onboarding better --- .../AI Providers/GeminiProvider.swift | 65 +++++++++---------- .../AI Providers/OpenAIProvider.swift | 40 +++++------- macOS/writing-tools/AppState.swift | 2 +- macOS/writing-tools/UI/OnboardingView.swift | 2 + macOS/writing-tools/UI/PopupView.swift | 32 ++++----- macOS/writing-tools/UI/SettingsView.swift | 2 +- 6 files changed, 62 insertions(+), 81 deletions(-) diff --git a/macOS/writing-tools/AI Providers/GeminiProvider.swift b/macOS/writing-tools/AI Providers/GeminiProvider.swift index 563bfdf..0e25954 100644 --- a/macOS/writing-tools/AI Providers/GeminiProvider.swift +++ b/macOS/writing-tools/AI Providers/GeminiProvider.swift @@ -6,15 +6,17 @@ struct GeminiConfig: Codable { } enum GeminiModel: String, CaseIterable { - case flash8b = "gemini-1.5-flash-8b-latest" - case flash = "gemini-1.5-flash-latest" - case pro = "gemini-1.5-pro-latest" + case oneflash8b = "gemini-1.5-flash-8b-latest" + case oneflash = "gemini-1.5-flash-latest" + case onepro = "gemini-1.5-pro-latest" + case twoflash = "gemini-2.0-flash-exp" var displayName: String { switch self { - case .flash8b: return "Gemini 1.5 Flash 8B (fast)" - case .flash: return "Gemini 1.5 Flash (fast & more intelligent, recommended)" - case .pro: return "Gemini 1.5 Pro (very intelligent, but slower & lower rate limit)" + case .oneflash8b: return "Gemini 1.5 Flash 8B (fast)" + case .oneflash: return "Gemini 1.5 Flash (fast & more intelligent)" + case .onepro: return "Gemini 1.5 Pro (very intelligent, but slower & lower rate limit)" + case .twoflash: return "Gemini 2.0 Flash (extremely intelligent & fast, recommended)" } } } @@ -22,13 +24,14 @@ enum GeminiModel: String, CaseIterable { class GeminiProvider: ObservableObject, AIProvider { @Published var isProcessing = false private var config: GeminiConfig - private var currentTask: URLSessionDataTask? init(config: GeminiConfig) { self.config = config } func processText(systemPrompt: String? = "You are a helpful writing assistant.", userPrompt: String) async throws -> String { + isProcessing = true + defer { isProcessing = false } let finalPrompt = systemPrompt.map { "\($0)\n\n\(userPrompt)" } ?? userPrompt @@ -55,38 +58,30 @@ class GeminiProvider: ObservableObject, AIProvider { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: requestBody, options: .fragmentsAllowed) - do { - isProcessing = true - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Server returned an error."]) - } - - guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { - throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse JSON response."]) - } - - guard let candidates = json["candidates"] as? [[String: Any]], !candidates.isEmpty else { - throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "No candidates found in the response."]) - } - - if let content = candidates.first?["content"] as? [String: Any], - let parts = content["parts"] as? [[String: Any]], - let text = parts.first?["text"] as? String { - return text - } - - throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "No valid content in response."]) - } catch { - isProcessing = false - print("Error processing JSON: \(error.localizedDescription)") - throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Error processing text: \(error.localizedDescription)"]) + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Server returned an error."]) + } + + guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { + throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse JSON response."]) + } + + guard let candidates = json["candidates"] as? [[String: Any]], !candidates.isEmpty else { + throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "No candidates found in the response."]) + } + + if let content = candidates.first?["content"] as? [String: Any], + let parts = content["parts"] as? [[String: Any]], + let text = parts.first?["text"] as? String { + return text } + + throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "No valid content in response."]) } func cancel() { - currentTask?.cancel() isProcessing = false } } diff --git a/macOS/writing-tools/AI Providers/OpenAIProvider.swift b/macOS/writing-tools/AI Providers/OpenAIProvider.swift index 3b63648..e9fb05e 100644 --- a/macOS/writing-tools/AI Providers/OpenAIProvider.swift +++ b/macOS/writing-tools/AI Providers/OpenAIProvider.swift @@ -30,13 +30,15 @@ enum OpenAIModel: String, CaseIterable { class OpenAIProvider: ObservableObject, AIProvider { @Published var isProcessing = false private var config: OpenAIConfig - private var currentTask: URLSessionDataTask? init(config: OpenAIConfig) { self.config = config } func processText(systemPrompt: String? = "You are a helpful writing assistant.", userPrompt: String) async throws -> String { + isProcessing = true + defer { isProcessing = false } + guard !config.apiKey.isEmpty else { throw NSError(domain: "OpenAIAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "API key is missing."]) } @@ -66,32 +68,24 @@ class OpenAIProvider: ObservableObject, AIProvider { request.httpBody = try JSONSerialization.data(withJSONObject: requestBody) - do { - isProcessing = true - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - throw NSError(domain: "OpenAIAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Server returned an error."]) - } - - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let choices = json["choices"] as? [[String: Any]], - let firstChoice = choices.first, - let message = firstChoice["message"] as? [String: Any], - let content = message["content"] as? String else { - throw NSError(domain: "OpenAIAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse response."]) - } - - return content - - } catch { - isProcessing = false - throw NSError(domain: "OpenAIAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Error processing text: \(error.localizedDescription)"]) + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw NSError(domain: "OpenAIAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Server returned an error."]) } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let firstChoice = choices.first, + let message = firstChoice["message"] as? [String: Any], + let content = message["content"] as? String else { + throw NSError(domain: "OpenAIAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse response."]) + } + + return content } func cancel() { - currentTask?.cancel() isProcessing = false } } diff --git a/macOS/writing-tools/AppState.swift b/macOS/writing-tools/AppState.swift index 756d337..15b9dc0 100644 --- a/macOS/writing-tools/AppState.swift +++ b/macOS/writing-tools/AppState.swift @@ -19,7 +19,7 @@ class AppState: ObservableObject { private init() { // Initialize Gemini let geminiApiKey = UserDefaults.standard.string(forKey: "gemini_api_key")?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let geminiModelName = UserDefaults.standard.string(forKey: "gemini_model") ?? GeminiModel.flash.rawValue + let geminiModelName = UserDefaults.standard.string(forKey: "gemini_model") ?? GeminiModel.twoflash.rawValue let geminiConfig = GeminiConfig(apiKey: geminiApiKey, modelName: geminiModelName) self.geminiProvider = GeminiProvider(config: geminiConfig) diff --git a/macOS/writing-tools/UI/OnboardingView.swift b/macOS/writing-tools/UI/OnboardingView.swift index b2959d3..a1fc557 100644 --- a/macOS/writing-tools/UI/OnboardingView.swift +++ b/macOS/writing-tools/UI/OnboardingView.swift @@ -105,6 +105,8 @@ struct OnboardingView: View { VStack(alignment: .center, spacing: 10) { Text("• Improves your writing with AI") Text("• Works in any application") + Text("• Helps you write with clarity and confidence") + Text("• Support Custom Commands for anything you want") } .font(.title3) } diff --git a/macOS/writing-tools/UI/PopupView.swift b/macOS/writing-tools/UI/PopupView.swift index e9a0b78..d5aae42 100644 --- a/macOS/writing-tools/UI/PopupView.swift +++ b/macOS/writing-tools/UI/PopupView.swift @@ -215,28 +215,18 @@ struct PopupView: View { userPrompt: userPrompt ) - if appState.selectedText.isEmpty { - // Show response in a new window - await MainActor.run { - let window = ResponseWindow( - title: "AI Response", - content: result, - selectedText: instruction, - option: .proofread // Using proofread as default, but the response window will adapt based on content - ) - - WindowManager.shared.addResponseWindow(window) - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() - } - } else { - // For selected text, continue with the existing inline replacement behavior - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(result, forType: .string) + // Always show response in a new window + await MainActor.run { + let window = ResponseWindow( + title: "AI Response", + content: result, + selectedText: appState.selectedText.isEmpty ? instruction : appState.selectedText, + option: .proofread // Using proofread as default, the response window will adapt based on content + ) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - simulatePaste() - } + WindowManager.shared.addResponseWindow(window) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() } closeAction() diff --git a/macOS/writing-tools/UI/SettingsView.swift b/macOS/writing-tools/UI/SettingsView.swift index 59c3cce..a182e64 100644 --- a/macOS/writing-tools/UI/SettingsView.swift +++ b/macOS/writing-tools/UI/SettingsView.swift @@ -142,7 +142,7 @@ struct SettingsView: View { // Gemini settings @State private var geminiApiKey = UserDefaults.standard.string(forKey: "gemini_api_key") ?? "" - @State private var selectedGeminiModel = GeminiModel(rawValue: UserDefaults.standard.string(forKey: "gemini_model") ?? "gemini-1.5-flash-latest") ?? .flash + @State private var selectedGeminiModel = GeminiModel(rawValue: UserDefaults.standard.string(forKey: "gemini_model") ?? "gemini-2.0-flash-exp") ?? .twoflash // OpenAI settings @State private var openAIApiKey = UserDefaults.standard.string(forKey: "openai_api_key") ?? "" From 9b77a545b15102ef8979973b1b54c41409d2edf0 Mon Sep 17 00:00:00 2001 From: Aryamirsepasi Date: Sun, 29 Dec 2024 19:55:43 +0100 Subject: [PATCH 10/10] made the default gemini provider 1.5 again because of speed --- macOS/writing-tools/AppState.swift | 2 +- macOS/writing-tools/UI/SettingsView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/macOS/writing-tools/AppState.swift b/macOS/writing-tools/AppState.swift index 15b9dc0..4ceb6f0 100644 --- a/macOS/writing-tools/AppState.swift +++ b/macOS/writing-tools/AppState.swift @@ -19,7 +19,7 @@ class AppState: ObservableObject { private init() { // Initialize Gemini let geminiApiKey = UserDefaults.standard.string(forKey: "gemini_api_key")?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let geminiModelName = UserDefaults.standard.string(forKey: "gemini_model") ?? GeminiModel.twoflash.rawValue + let geminiModelName = UserDefaults.standard.string(forKey: "gemini_model") ?? GeminiModel.oneflash.rawValue let geminiConfig = GeminiConfig(apiKey: geminiApiKey, modelName: geminiModelName) self.geminiProvider = GeminiProvider(config: geminiConfig) diff --git a/macOS/writing-tools/UI/SettingsView.swift b/macOS/writing-tools/UI/SettingsView.swift index a182e64..d49ea58 100644 --- a/macOS/writing-tools/UI/SettingsView.swift +++ b/macOS/writing-tools/UI/SettingsView.swift @@ -142,7 +142,7 @@ struct SettingsView: View { // Gemini settings @State private var geminiApiKey = UserDefaults.standard.string(forKey: "gemini_api_key") ?? "" - @State private var selectedGeminiModel = GeminiModel(rawValue: UserDefaults.standard.string(forKey: "gemini_model") ?? "gemini-2.0-flash-exp") ?? .twoflash + @State private var selectedGeminiModel = GeminiModel(rawValue: UserDefaults.standard.string(forKey: "gemini_model") ?? "gemini-1.5-flash-latest") ?? .oneflash // OpenAI settings @State private var openAIApiKey = UserDefaults.standard.string(forKey: "openai_api_key") ?? ""