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 16683db..4df2fd7 100644 --- a/macOS/README.md +++ b/macOS/README.md @@ -1,67 +1,106 @@ -# 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** + - 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** + - 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/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/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/AppDelegate.swift b/macOS/writing-tools/AppDelegate.swift index 9471536..ad01724 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,52 +176,49 @@ 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" - - var modifiers: NSEvent.ModifierFlags = [] - var keyCode: UInt32 = 0 + // Retrieve raw code & modifiers from UserDefaults + let rawKeyCode = UserDefaults.standard.integer(forKey: "hotKey_keyCode") + let rawModifiers = UserDefaults.standard.integer(forKey: "hotKey_modifiers") - 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() } } } + // 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 +240,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { window.makeKeyAndOrderFront(nil) } + // Opens the settings window @objc private func showSettings() { settingsWindow?.close() settingsWindow = nil @@ -215,6 +264,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { settingsWindow?.makeKeyAndOrderFront(nil) } + // Opens the about window @objc private func showAbout() { aboutWindow?.close() aboutWindow = nil @@ -238,6 +288,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 +337,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 +352,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..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.flash.rawValue + let geminiModelName = UserDefaults.standard.string(forKey: "gemini_model") ?? GeminiModel.oneflash.rawValue let geminiConfig = GeminiConfig(apiKey: geminiApiKey, modelName: geminiModelName) self.geminiProvider = GeminiProvider(config: geminiConfig) @@ -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/Commands/CustomCommand.swift b/macOS/writing-tools/Commands/CustomCommand.swift new file mode 100644 index 0000000..88d2e8e --- /dev/null +++ b/macOS/writing-tools/Commands/CustomCommand.swift @@ -0,0 +1,56 @@ +import Foundation + +struct CustomCommand: Codable, Identifiable, Equatable { + let id: UUID + var name: String + var prompt: String + var icon: String + var useResponseWindow: Bool + + 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 + } +} + +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..a0a88c3 --- /dev/null +++ b/macOS/writing-tools/Commands/CustomCommandsView.swift @@ -0,0 +1,319 @@ +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, + onEdit: { editingCommand = $0 }, + onDelete: { commandsManager.deleteCommand($0) } + ) + } + } + + 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 onEdit: (CustomCommand) -> Void + var onDelete: (CustomCommand) -> Void + + var body: some View { + HStack(spacing: 12) { + // Icon + Image(systemName: command.icon) + .font(.title2) + .frame(width: 30) + + // Command Details + VStack(alignment: .leading, spacing: 4) { + Text(command.name) + .font(.headline) + Text(command.prompt) + .font(.caption) + .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) + } +} + +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 useResponseWindow: Bool = false + @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.icon) + _useResponseWindow = State(initialValue: command.useResponseWindow) + } + } + + 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) { + 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) + } + .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) + ) + } + // 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() + } + + Divider() + + // Bottom buttons + HStack { + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.escape, modifiers: []) + + Spacer() + + Button("Save") { + let command = CustomCommand( + id: editingCommand?.id ?? UUID(), + name: name, + prompt: prompt, + icon: selectedIcon, + useResponseWindow: useResponseWindow + ) + + if editingCommand != nil { + commandsManager.updateCommand(command) + } else { + commandsManager.addCommand(command) + } + + dismiss() + } + .keyboardShortcut(.return, modifiers: [.command]) + .disabled(name.isEmpty || prompt.isEmpty) + .padding() + } + .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/Commands/WritingOption.swift b/macOS/writing-tools/Commands/WritingOption.swift new file mode 100644 index 0000000..48c52df --- /dev/null +++ b/macOS/writing-tools/Commands/WritingOption.swift @@ -0,0 +1,62 @@ +enum WritingOption: String, CaseIterable, Identifiable { + case proofread = "Proofread" + case rewrite = "Rewrite" + case friendly = "Friendly" + case professional = "Professional" + case concise = "Concise" + case summary = "Summary" + case keyPoints = "Key Points" + case table = "Table" + + var id: String { rawValue } + + var systemPrompt: String { + switch self { + case .proofread: + return """ + 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 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 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 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 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 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 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 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". + """ + } + } + + var icon: String { + switch self { + case .proofread: return "magnifyingglass" + case .rewrite: return "arrow.triangle.2.circlepath" + case .friendly: return "face.smiling" + case .professional: return "briefcase" + case .concise: return "scissors" + case .summary: return "doc.text" + case .keyPoints: return "list.bullet" + case .table: return "tablecells" + } + } +} diff --git a/macOS/writing-tools/UI/AboutView.swift b/macOS/writing-tools/UI/AboutView.swift index 9f9d73e..7ef8cdb 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: Beta 5 (Based on Windows Port version 6.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/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/Custom Modifiers/LoadingModifier.swift b/macOS/writing-tools/UI/Custom Modifiers/LoadingModifier.swift new file mode 100644 index 0000000..5a1db7c --- /dev/null +++ b/macOS/writing-tools/UI/Custom Modifiers/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/OnboardingView.swift b/macOS/writing-tools/UI/OnboardingView.swift index 99f7de3..a1fc557 100644 --- a/macOS/writing-tools/UI/OnboardingView.swift +++ b/macOS/writing-tools/UI/OnboardingView.swift @@ -2,58 +2,183 @@ 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") + Text("β€’ Helps you write with clarity and confidence") + Text("β€’ Support Custom Commands for anything you want") } .font(.title3) + } + } + + private var accessibilityStep: some View { + VStack(spacing: 20) { + Text(steps[1].title) + .font(.title) + .bold() - Divider() + Text(steps[1].description) + .multilineTextAlignment(.center) - VStack(alignment: .leading, spacing: 10) { - Text("Customize your shortcut key:") - .font(.headline) - - ShortcutRecorderView(shortcutText: $shortcutText) - .frame(maxWidth: .infinity) - - Text("Theme:") + VStack(alignment: .leading, spacing: 15) { + Text("How to enable accessibility access:") .font(.headline) - Toggle("Use Gradient Theme", isOn: $useGradientTheme) + 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) - Spacer() - - Button("Next") { - saveSettingsAndContinue() + Button("Open System Settings") { + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!) } .buttonStyle(.borderedProminent) - .controlSize(.large) } - .padding() - .frame(width: 500, height: 400) - .onAppear { - isShowingSettings = false + } + + private var customizationStep: some View { + VStack(spacing: 20) { + Text(steps[2].title) + .font(.title) + .bold() + + VStack(alignment: .leading, spacing: 15) { + Text("Set your keyboard shortcut:") + .font(.headline) + + ShortcutRecorderView() + .frame(maxWidth: .infinity) + + 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) + } } } 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 f30caf7..d5aae42 100644 --- a/macOS/writing-tools/UI/PopupView.swift +++ b/macOS/writing-tools/UI/PopupView.swift @@ -4,15 +4,19 @@ 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 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 { - Spacer() + Button(action: closeAction) { Image(systemName: "xmark.circle.fill") .font(.title2) @@ -20,41 +24,57 @@ struct PopupView: View { } .buttonStyle(.plain) .padding(.top, 8) + .padding(.leading, 8) + + Spacer() + + Button(action: { showingCustomCommands = true }) { + Image(systemName: "plus.circle.fill") + .font(.title2) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .padding(.top, 8) .padding(.trailing, 8) } // Custom input with send button HStack(spacing: 8) { TextField( - appState.selectedText.isEmpty ? "Enter your instruction..." : "Describe your change...", + appState.selectedText.isEmpty ? "Describe your change..." : "Describe your change...", text: $customText ) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .onSubmit { - processCustomChange() - } - - Button(action: processCustomChange) { - Image(systemName: "paperplane.fill") - .foregroundColor(.white) - .padding(6) - .background(Color.blue) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .disabled(customText.isEmpty) + .textFieldStyle(.plain) + .appleStyleTextField( + text: customText, + isLoading: isCustomLoading, + onSubmit: processCustomChange + ) } .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) + 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) + ) } } } @@ -69,17 +89,76 @@ 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 + ) + + 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() + } catch { + print("Error processing custom command: \(error.localizedDescription)") + } + } + } + + + // 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.id) appState.isProcessing = true Task { + defer { + loadingOptions.remove(option.id) + appState.isProcessing = false + } do { let result = try await appState.activeProvider.processText( systemPrompt: option.systemPrompt, @@ -108,6 +187,7 @@ struct PopupView: View { } } + // Process custom instructions private func processCustomInstruction(_ instruction: String) { guard !instruction.isEmpty else { return } appState.isProcessing = true @@ -115,39 +195,51 @@ 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) + // 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 + ) + + WindowManager.shared.addResponseWindow(window) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + } closeAction() - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - simulatePaste() - } } catch { print("Error processing text: \(error.localizedDescription)") } + isCustomLoading = false appState.isProcessing = false } } + // Show response window for certain options private func showResponseWindow(for option: WritingOption, with result: String) { DispatchQueue.main.async { let window = ResponseWindow( @@ -165,6 +257,7 @@ struct PopupView: View { } } + // Simulate paste command private func simulatePaste() { guard let source = CGEventSource(stateID: .hidSystemState) else { return } @@ -185,6 +278,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 +291,28 @@ struct OptionButton: View { .background(Color(.controlBackgroundColor)) .cornerRadius(8) } - .buttonStyle(.plain) + .buttonStyle(LoadingButtonStyle(isLoading: isLoading)) + .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.icon) + 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 a66cd98..81df9ae 100644 --- a/macOS/writing-tools/UI/PopupWindow.swift +++ b/macOS/writing-tools/UI/PopupWindow.swift @@ -5,21 +5,37 @@ 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() 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 ) 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 +58,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 = 20 // 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 } @@ -98,7 +151,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 } @@ -127,7 +180,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 b94843c..e805066 100644 --- a/macOS/writing-tools/UI/ResponseView.swift +++ b/macOS/writing-tools/UI/ResponseView.swift @@ -1,54 +1,29 @@ import SwiftUI import MarkdownUI -final class ResponseViewModel: ObservableObject { - @Published var content: String - @Published var fontSize: CGFloat = 14 - @Published var showCopyConfirmation: Bool = false - - let selectedText: String - let option: WritingOption - - init(content: String, selectedText: String, option: WritingOption) { - self.content = content - self.selectedText = selectedText - self.option = option - } - - func regenerateContent() async { - do { - let result = try await AppState.shared.activeProvider.processText( - systemPrompt: option.systemPrompt, - userPrompt: selectedText - ) - await MainActor.run { - self.content = result - } - } 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() - 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 } } -/// Main ResponseView 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( content: content, @@ -56,48 +31,29 @@ struct ResponseView: View { option: option )) } - + var body: some View { - VStack(spacing: 16) { - ScrollView { - Markdown(viewModel.content) - .font(.system(size: viewModel.fontSize)) - .textSelection(.enabled) - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - } - + VStack(spacing: 0) { + // Top toolbar with controls HStack { - HStack(spacing: 12) { - Button(action: { - Task { - await viewModel.regenerateContent() - } - }) { - Label("Regenerate", systemImage: "arrow.clockwise") - } - - Button(action: { - viewModel.copyContent() - }) { - Label(viewModel.showCopyConfirmation ? "Copied!" : "Copy", - systemImage: viewModel.showCopyConfirmation ? "checkmark" : "doc.on.doc") - } - .animation(.easeInOut, value: viewModel.showCopyConfirmation) + 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) - + Button(action: { viewModel.fontSize = 14 }) { Image(systemName: "arrow.clockwise") } - + Button(action: { viewModel.fontSize = min(24, viewModel.fontSize + 2) }) { Image(systemName: "plus.magnifyingglass") } @@ -105,7 +61,181 @@ struct ResponseView: View { } } .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) + } + } + .padding() + } + .onChange(of: viewModel.messages, initial: true) { oldValue, newValue in + if let lastId = newValue.last?.id { + withAnimation { + proxy.scrollTo(lastId, anchor: .bottom) + } + } + } + } + + // 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 diff --git a/macOS/writing-tools/UI/SettingsView.swift b/macOS/writing-tools/UI/SettingsView.swift index 9cf9eff..d49ea58 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" ] } @@ -115,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-1.5-flash-latest") ?? .oneflash // OpenAI settings @State private var openAIApiKey = UserDefaults.standard.string(forKey: "openai_api_key") ?? "" @@ -124,17 +151,19 @@ 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 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() @@ -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) } @@ -192,7 +237,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) @@ -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" + ] } 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() diff --git a/macOS/writing-tools/WritingOption.swift b/macOS/writing-tools/WritingOption.swift deleted file mode 100644 index 4cfdf46..0000000 --- a/macOS/writing-tools/WritingOption.swift +++ /dev/null @@ -1,78 +0,0 @@ -enum WritingOption: String, CaseIterable, Identifiable { - case proofread = "Proofread" - case rewrite = "Rewrite" - case friendly = "Friendly" - case professional = "Professional" - case concise = "Concise" - case summary = "Summary" - case keyPoints = "Key Points" - case table = "Table" - - var id: String { rawValue } - - var systemPrompt: String { - 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. - """ - 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. - """ - 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. - """ - 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. - """ - 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. - """ - 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. - """ - 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. - """ - 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. - """ - } - } - - var icon: String { - switch self { - case .proofread: return "magnifyingglass" - case .rewrite: return "arrow.triangle.2.circlepath" - case .friendly: return "face.smiling" - case .professional: return "briefcase" - case .concise: return "scissors" - case .summary: return "doc.text" - case .keyPoints: return "list.bullet" - case .table: return "tablecells" - } - } -}