Skip to content

Commit 12820f7

Browse files
authored
Merge pull request #188 from theJayTea/macOS-v4_4
macOS v4
2 parents 501388f + 9aa0e66 commit 12820f7

32 files changed

+3458
-980
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.1
1+
4.0

macOS/writing-tools.xcodeproj/project.pbxproj

Lines changed: 104 additions & 303 deletions
Large diffs are not rendered by default.

macOS/writing-tools/App/AppDelegate.swift

Lines changed: 230 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,148 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
6060

6161
}
6262

63+
// Register the main popup shortcut
6364
KeyboardShortcuts.onKeyUp(for: .showPopup) { [weak self] in
6465
if !AppSettings.shared.hotkeysPaused {
6566
self?.showPopup()
6667
} else {
6768
NSLog("Hotkeys are paused")
6869
}
6970
}
71+
72+
// Set up command-specific shortcuts
73+
setupCommandShortcuts()
74+
75+
// Register for command changes to update shortcuts
76+
NotificationCenter.default.addObserver(
77+
self,
78+
selector: #selector(setupCommandShortcuts),
79+
name: NSNotification.Name("CommandsChanged"),
80+
object: nil
81+
)
82+
}
83+
84+
// Setup and register all command shortcuts
85+
@objc private func setupCommandShortcuts() {
86+
// Only reset shortcuts for commands that should not have shortcuts
87+
for command in appState.commandManager.commands.filter({ !$0.hasShortcut }) {
88+
KeyboardShortcuts.reset(.commandShortcut(for: command.id))
89+
}
90+
91+
// Register handlers for commands with shortcuts enabled
92+
for command in appState.commandManager.commands.filter({ $0.hasShortcut }) {
93+
KeyboardShortcuts.onKeyUp(for: .commandShortcut(for: command.id)) { [weak self] in
94+
guard let self = self, !AppSettings.shared.hotkeysPaused else { return }
95+
96+
// Execute the command directly
97+
self.executeCommandDirectly(command)
98+
}
99+
}
100+
}
101+
102+
// Executes a command without showing the popup
103+
private func executeCommandDirectly(_ command: CommandModel) {
104+
// Cancel any ongoing processing first
105+
appState.activeProvider.cancel()
106+
107+
DispatchQueue.main.async { [weak self] in
108+
guard let self = self else { return }
109+
110+
// Save the current app so we can return to it
111+
if let currentFrontmostApp = NSWorkspace.shared.frontmostApplication {
112+
self.appState.previousApplication = currentFrontmostApp
113+
}
114+
115+
let generalPasteboard = NSPasteboard.general
116+
117+
// Get initial pasteboard content to restore later
118+
let oldContents = generalPasteboard.string(forType: .string)
119+
120+
// Clear and perform copy command to get selected text
121+
generalPasteboard.clearContents()
122+
let source = CGEventSource(stateID: .hidSystemState)
123+
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x08, keyDown: true)
124+
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x08, keyDown: false)
125+
keyDown?.flags = .maskCommand
126+
keyUp?.flags = .maskCommand
127+
keyDown?.post(tap: .cghidEventTap)
128+
keyUp?.post(tap: .cghidEventTap)
129+
130+
// Wait for copy operation to complete
131+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
132+
guard let self = self else { return }
133+
134+
// Get the selected text
135+
let selectedText = generalPasteboard.string(forType: .string) ?? ""
136+
137+
// Restore original clipboard contents
138+
generalPasteboard.clearContents()
139+
if let oldContents = oldContents {
140+
generalPasteboard.setString(oldContents, forType: .string)
141+
}
142+
143+
// Skip if no text is selected
144+
guard !selectedText.isEmpty else {
145+
NSLog("No text selected for command: \(command.name)")
146+
return
147+
}
148+
149+
// Store the selected text in app state
150+
self.appState.selectedText = selectedText
151+
152+
// Process the command
153+
Task {
154+
await self.processCommandWithUI(command)
155+
}
156+
}
157+
}
158+
}
159+
160+
// Process a command with appropriate UI feedback
161+
private func processCommandWithUI(_ command: CommandModel) async {
162+
// Set processing flag to prevent duplicate operations
163+
if appState.isProcessing {
164+
return
165+
}
166+
167+
appState.isProcessing = true
168+
169+
do {
170+
// Process the text with the AI provider
171+
let result = try await appState.activeProvider.processText(
172+
systemPrompt: command.prompt,
173+
userPrompt: appState.selectedText,
174+
images: [],
175+
streaming: false
176+
)
177+
178+
// Handle the result
179+
await MainActor.run {
180+
if command.useResponseWindow {
181+
// Show in response window
182+
let window = ResponseWindow(
183+
title: command.name,
184+
content: result,
185+
selectedText: appState.selectedText,
186+
option: nil
187+
)
188+
189+
NSApp.activate(ignoringOtherApps: true)
190+
WindowManager.shared.addResponseWindow(window)
191+
window.makeKeyAndOrderFront(nil)
192+
window.orderFrontRegardless()
193+
} else {
194+
// Replace text directly
195+
appState.replaceSelectedText(with: result)
196+
}
197+
}
198+
} catch {
199+
print("Error processing command \(command.name): \(error.localizedDescription)")
200+
}
201+
202+
await MainActor.run {
203+
appState.isProcessing = false
204+
}
70205
}
71206

72207
// Called when app is about to close - performs cleanup
@@ -227,85 +362,112 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
227362
}
228363
}
229364

230-
// Shows the main popup window when shortcut is triggered
231365
@MainActor private func showPopup() {
232-
appState.activeProvider.cancel()
233-
234-
DispatchQueue.main.async { [weak self] in
235-
guard let self = self else { return }
236-
237-
if let currentFrontmostApp = NSWorkspace.shared.frontmostApplication {
238-
self.appState.previousApplication = currentFrontmostApp
239-
}
240-
241-
self.closePopupWindow()
242-
243-
let generalPasteboard = NSPasteboard.general
244-
245-
// Get initial pasteboard content
246-
let oldContents = generalPasteboard.string(forType: .string)
247-
248-
// Prioritized image types (in order of preference)
249-
let supportedImageTypes = [
250-
NSPasteboard.PasteboardType("public.png"),
251-
NSPasteboard.PasteboardType("public.jpeg"),
252-
NSPasteboard.PasteboardType("public.tiff"),
253-
NSPasteboard.PasteboardType("com.compuserve.gif"),
254-
NSPasteboard.PasteboardType("public.image")
255-
]
256-
var foundImage: Data? = nil
366+
appState.activeProvider.cancel()
257367

258-
// Try to find the first available image in order of preference
259-
for type in supportedImageTypes {
260-
if let data = generalPasteboard.data(forType: type) {
261-
foundImage = data
262-
NSLog("Selected image type: \(type)")
263-
break // Take only the first matching format
264-
}
265-
}
266-
267-
// Clear and perform copy command
268-
generalPasteboard.clearContents()
269-
let source = CGEventSource(stateID: .hidSystemState)
270-
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x08, keyDown: true)
271-
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x08, keyDown: false)
272-
keyDown?.flags = .maskCommand
273-
keyUp?.flags = .maskCommand
274-
keyDown?.post(tap: .cghidEventTap)
275-
keyUp?.post(tap: .cghidEventTap)
276-
277-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
368+
DispatchQueue.main.async { [weak self] in
278369
guard let self = self else { return }
279-
let selectedText = generalPasteboard.string(forType: .string) ?? ""
280-
281-
// Update app state with found image if any
282-
self.appState.selectedImages = foundImage.map { [$0] } ?? []
283370

284-
generalPasteboard.clearContents()
285-
if let oldContents = oldContents {
286-
generalPasteboard.setString(oldContents, forType: .string)
371+
if let currentFrontmostApp = NSWorkspace.shared.frontmostApplication {
372+
self.appState.previousApplication = currentFrontmostApp
287373
}
288374

289-
let window = PopupWindow(appState: self.appState)
290-
window.delegate = self
375+
self.closePopupWindow()
291376

292-
self.appState.selectedText = selectedText
293-
self.popupWindow = window
377+
let generalPasteboard = NSPasteboard.general
378+
let oldContents = generalPasteboard.string(forType: .string)
294379

295-
// Set appropriate window size based on content
296-
if !selectedText.isEmpty || !self.appState.selectedImages.isEmpty {
297-
window.setContentSize(NSSize(width: 400, height: 400))
298-
} else {
299-
window.setContentSize(NSSize(width: 400, height: 100))
300-
}
380+
// Clear and perform copy command to get current selection
381+
generalPasteboard.clearContents()
382+
let source = CGEventSource(stateID: .hidSystemState)
383+
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x08, keyDown: true)
384+
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x08, keyDown: false)
385+
keyDown?.flags = .maskCommand
386+
keyUp?.flags = .maskCommand
387+
keyDown?.post(tap: .cghidEventTap)
388+
keyUp?.post(tap: .cghidEventTap)
301389

302-
window.positionNearMouse()
303-
NSApp.activate(ignoringOtherApps: true)
304-
window.makeKeyAndOrderFront(nil)
305-
window.orderFrontRegardless()
390+
// Wait for the copy operation to complete, then process the pasteboard
391+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
392+
guard let self = self else { return }
393+
394+
var foundImages: [Data] = []
395+
var selectedText = ""
396+
397+
// First check for file URLs (for Finder selections)
398+
let classes = [NSURL.self]
399+
let options: [NSPasteboard.ReadingOptionKey: Any] = [
400+
.urlReadingFileURLsOnly: true,
401+
.urlReadingContentsConformToTypes: [
402+
"public.image",
403+
"public.png",
404+
"public.jpeg",
405+
"public.tiff",
406+
"com.compuserve.gif"
407+
]
408+
]
409+
410+
if let urls = generalPasteboard.readObjects(forClasses: classes, options: options) as? [URL] {
411+
for url in urls {
412+
if let imageData = try? Data(contentsOf: url) {
413+
if NSImage(data: imageData) != nil {
414+
foundImages.append(imageData)
415+
NSLog("Loaded image data from file: \(url.lastPathComponent)")
416+
}
417+
}
418+
}
419+
}
420+
421+
// If no file URLs found, check for direct image data
422+
if foundImages.isEmpty {
423+
let supportedImageTypes = [
424+
NSPasteboard.PasteboardType("public.png"),
425+
NSPasteboard.PasteboardType("public.jpeg"),
426+
NSPasteboard.PasteboardType("public.tiff"),
427+
NSPasteboard.PasteboardType("com.compuserve.gif"),
428+
NSPasteboard.PasteboardType("public.image")
429+
]
430+
431+
for type in supportedImageTypes {
432+
if let data = generalPasteboard.data(forType: type) {
433+
foundImages.append(data)
434+
NSLog("Found direct image data of type: \(type)")
435+
break
436+
}
437+
}
438+
}
439+
440+
// Get any text content
441+
selectedText = generalPasteboard.string(forType: .string) ?? ""
442+
443+
// Restore original pasteboard contents
444+
generalPasteboard.clearContents()
445+
if let oldContents = oldContents {
446+
generalPasteboard.setString(oldContents, forType: .string)
447+
}
448+
449+
// Update app state and show popup
450+
self.appState.selectedImages = foundImages
451+
self.appState.selectedText = selectedText
452+
453+
let window = PopupWindow(appState: self.appState)
454+
window.delegate = self
455+
self.popupWindow = window
456+
457+
// Set window size based on content
458+
if !selectedText.isEmpty || !foundImages.isEmpty {
459+
window.setContentSize(NSSize(width: 400, height: 400))
460+
} else {
461+
window.setContentSize(NSSize(width: 400, height: 100))
462+
}
463+
464+
window.positionNearMouse()
465+
NSApp.activate(ignoringOtherApps: true)
466+
window.makeKeyAndOrderFront(nil)
467+
window.orderFrontRegardless()
468+
}
306469
}
307470
}
308-
}
309471

310472
// Closes and cleans up the popup window
311473
private func closePopupWindow() {

macOS/writing-tools/App/AppSettings.swift

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@ class AppSettings: ObservableObject {
88

99
// MARK: - Published Settings
1010
@Published var themeStyle: String {
11-
didSet {
12-
defaults.set(themeStyle, forKey: "theme_style")
13-
// Also update the useGradientTheme flag for backward compatibility
11+
didSet {
12+
defaults.set(themeStyle, forKey: "theme_style")
1413
useGradientTheme = (themeStyle != "standard")
1514
}
1615
}
@@ -23,6 +22,10 @@ class AppSettings: ObservableObject {
2322
didSet { defaults.set(geminiModel.rawValue, forKey: "gemini_model") }
2423
}
2524

25+
@Published var geminiCustomModel: String {
26+
didSet { defaults.set(geminiCustomModel, forKey: "gemini_custom_model") }
27+
}
28+
2629
@Published var openAIApiKey: String {
2730
didSet { defaults.set(openAIApiKey, forKey: "openai_api_key") }
2831
}
@@ -96,6 +99,9 @@ class AppSettings: ObservableObject {
9699
didSet { defaults.set(ollamaKeepAlive, forKey: "ollama_keep_alive") }
97100
}
98101

102+
@Published var ollamaImageMode: OllamaImageMode {
103+
didSet { defaults.set(ollamaImageMode.rawValue, forKey: "ollama_image_mode") }
104+
}
99105

100106
// MARK: - Init
101107
private init() {
@@ -109,6 +115,8 @@ class AppSettings: ObservableObject {
109115
let geminiModelStr = defaults.string(forKey: "gemini_model") ?? GeminiModel.twoflash.rawValue
110116
self.geminiModel = GeminiModel(rawValue: geminiModelStr) ?? .twoflash
111117

118+
self.geminiCustomModel = defaults.string(forKey: "gemini_custom_model") ?? ""
119+
112120
self.openAIApiKey = defaults.string(forKey: "openai_api_key") ?? ""
113121
self.openAIBaseURL = defaults.string(forKey: "openai_base_url") ?? OpenAIConfig.defaultBaseURL
114122
self.openAIModel = defaults.string(forKey: "openai_model") ?? OpenAIConfig.defaultModel
@@ -132,6 +140,9 @@ class AppSettings: ObservableObject {
132140
self.hotKeyCode = defaults.integer(forKey: "hotKey_keyCode")
133141
self.hotKeyModifiers = defaults.integer(forKey: "hotKey_modifiers")
134142
self.hotkeysPaused = defaults.bool(forKey: "hotkeys_paused")
143+
144+
let ollamaImageModeRaw = defaults.string(forKey: "ollama_image_mode") ?? OllamaImageMode.ocr.rawValue
145+
self.ollamaImageMode = OllamaImageMode(rawValue: ollamaImageModeRaw) ?? .ocr
135146
}
136147

137148
// MARK: - Convenience

0 commit comments

Comments
 (0)