From 9f0c0f8113e9e844481b38f5d0c02cfb6da4cbdc Mon Sep 17 00:00:00 2001 From: TZGyn Date: Fri, 9 Oct 2026 17:49:06 +0800 Subject: [PATCH] Make pasting reliable and turn items into checkboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pasting still failed because the panel is a nonactivating panel: it can be key while the app is not active, and menu key equivalents are not delivered reliably in that state. Cmd+V is now dispatched to the responder chain directly, and the app no longer replaces SwiftUI's main menu — it only adds Hide Panel to it, so the Edit items that AppKit routes text shortcuts through stay intact. Rows also gained a checkbox so a workspace works as a checklist without turning the row into a button, which would swallow the checkbox's clicks. --- AGENTS.md | 23 +++++++----- README.md | 2 ++ Sources/Prompt/App.swift | 54 ++++++++++------------------ Sources/Prompt/PanelController.swift | 26 ++++++++------ Sources/Prompt/PanelView.swift | 46 +++++++++++++++--------- 5 files changed, 80 insertions(+), 71 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a390f1f..cb37ea1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,14 +156,21 @@ brew upgrade --cask prompt ## Gotchas -- **Setting `NSApp.mainMenu` replaces SwiftUI's default menu.** SwiftUI installs - a menu containing the standard Edit items, and AppKit routes Cmd+C/V/X/A/Z - through those items — so a hand-built menu with only Hide/Quit silently - breaks pasting, copying and select-all in the composer. `setupMainMenu()` - therefore rebuilds an Edit menu with `undo:`/`redo:`/`cut:`/`copy:`/`paste:`/ - `selectAll:` (nil targets, so they travel the responder chain to the field - editor). `Prompt --diag` dumps the menu so this can be checked without a - keyboard. +- **Never assign `NSApp.mainMenu`.** SwiftUI installs a menu whose Edit items + are how AppKit routes Cmd+C/V/X/A/Z; replacing it with a hand-built menu + silently breaks pasting, copying and select-all in the composer (this shipped + once and had to be reverted). `installMenuItems()` only *augments* the + existing menu: it adds Hide Panel (Cmd+W) and removes SwiftUI's Settings item, + whose empty scene would otherwise shadow the in-panel Cmd+, sync panel. +- **Cmd+V is handled by the paste monitor, not the menu.** The panel is a + `.nonactivatingPanel`, so it can be key while the app is not active, and menu + key equivalents are not reliably delivered in that state. The monitor + dispatches `paste:` to the responder chain itself for text, and adds the + clipboard image to the store when there is no text. `Prompt --diag` dumps the + menu so the wiring can be checked without a keyboard. +- **A row is not a `Button`** because it contains a checkbox `Button` for + ticking items off, and nested buttons never receive clicks. The row uses + `contentShape` + `onTapGesture` for click-to-copy instead. - **Never bind bare keys as `.keyboardShortcut` in the panel** (Space, Delete, plain Cmd+C). SwiftUI key equivalents are matched before text input and would steal characters from the composer TextField. Only Cmd-modified diff --git a/README.md b/README.md index 20084c5..ebb5fc9 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ A native macOS app for keeping prompts and photos, in the spirit of [Copper](https://shadcn.com/copper) by shadcn: a floating panel that sits next to your work. Click anything to copy it back to your clipboard. +- **Checklists by default** — every item has a checkbox, so a workspace doubles + as a to-do list; done items stay in place, dimmed and struck through. - **Click to copy** — text notes go back as text, photos go back as images. - **Capture from anywhere** — select text in any app and tap **Left Shift twice**. - **Photos** — paste (Cmd+V), drag & drop, or add via the photo button. diff --git a/Sources/Prompt/App.swift b/Sources/Prompt/App.swift index 0b1b5be..f8ec1b9 100644 --- a/Sources/Prompt/App.swift +++ b/Sources/Prompt/App.swift @@ -32,7 +32,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // which locks the user out once the panel is hidden. if CommandLine.arguments.contains("--diag") { setupStatusItem() - setupMainMenu() + installMenuItems() DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.writeDiagnostics() NSApp.terminate(nil) @@ -60,9 +60,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { HotkeyManager.shared.start() setupStatusItem() - setupMainMenu() promptForAccessibilityIfNeeded() + // Let SwiftUI finish installing its main menu, then augment it. + DispatchQueue.main.async { self.installMenuItems() } + // Show the panel on launch so the app is discoverable (it has no Dock // icon and the menu bar icon can be hard to spot on a full bar). Task { @MainActor in @@ -111,44 +113,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return .terminateNow } - /// Accessory apps have no visible menu bar, but the main menu still - /// services key equivalents — this is what makes Cmd+Q and Cmd+W work - /// while the panel has focus. - /// - /// The Edit menu is not decoration: AppKit routes Cmd+C/V/X/A/Z through - /// these items, so a minimal main menu silently breaks pasting and - /// selecting in the composer. - private func setupMainMenu() { - let mainMenu = NSMenu() + /// Adds "Hide Panel" to the menu SwiftUI already installed. Do NOT replace + /// `NSApp.mainMenu`: SwiftUI's menu carries the Edit items that AppKit + /// routes Cmd+C/V/X/A/Z through, so replacing it breaks text editing in the + /// composer. This only inserts one item and drops SwiftUI's Settings item, + /// whose empty scene would otherwise shadow the in-panel Cmd+, sync panel. + private func installMenuItems() { + guard let appMenu = NSApp.mainMenu?.items.first?.submenu else { return } - let appMenuItem = NSMenuItem() - mainMenu.addItem(appMenuItem) - let appMenu = NSMenu(title: "Prompt") - appMenuItem.submenu = appMenu + if let settings = appMenu.items.firstIndex(where: { $0.keyEquivalent == "," && $0.keyEquivalentModifierMask == [.command] }) { + appMenu.removeItem(at: settings) + } + guard !appMenu.items.contains(where: { $0.action == #selector(hidePanel) }) else { return } let hide = NSMenuItem(title: "Hide Panel", action: #selector(hidePanel), keyEquivalent: "w") hide.target = self - appMenu.addItem(hide) - appMenu.addItem(.separator()) - let quit = NSMenuItem(title: "Quit Prompt", action: #selector(quit), keyEquivalent: "q") - quit.target = self - appMenu.addItem(quit) - - let editMenuItem = NSMenuItem() - mainMenu.addItem(editMenuItem) - let editMenu = NSMenu(title: "Edit") - editMenuItem.submenu = editMenu - - editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") - let redo = editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "z") - redo.keyEquivalentModifierMask = [.command, .shift] - editMenu.addItem(.separator()) - editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") - editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") - editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") - editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") - - NSApp.mainMenu = mainMenu + let quitIndex = appMenu.items.firstIndex { $0.action == #selector(NSApplication.terminate(_:)) } ?? appMenu.items.count + appMenu.insertItem(.separator(), at: quitIndex) + appMenu.insertItem(hide, at: quitIndex + 1) } @MainActor @objc private func hidePanel() { diff --git a/Sources/Prompt/PanelController.swift b/Sources/Prompt/PanelController.swift index a5e4a4e..69b2f66 100644 --- a/Sources/Prompt/PanelController.swift +++ b/Sources/Prompt/PanelController.swift @@ -71,21 +71,27 @@ final class PanelController: NSObject { panel.setFrameOrigin(NSPoint(x: screen.maxX - size.width - 12, y: screen.minY + 12)) } - // Intercept Cmd+V when the panel is key and the clipboard holds an image - // (and no text, so normal text paste into the composer is unaffected). + // Cmd+V is handled here rather than left to a menu key equivalent. The + // panel is a nonactivating panel, so it can be key while the app is not + // active, and menu key equivalents are not reliably delivered in that + // state — which is what silently broke pasting. Dispatching paste: to the + // responder chain ourselves removes that dependency. private func installPasteMonitor() { guard pasteMonitor == nil else { return } pasteMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self, let panel = self.panel, event.window == panel else { return event } let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if modifiers == [.command], event.charactersIgnoringModifiers == "v" { - let pasteboard = NSPasteboard.general - if pasteboard.string(forType: .string) == nil, - let images = pasteboard.readObjects(forClasses: [NSImage.self], options: nil) as? [NSImage], - let image = images.first { - Task { @MainActor in Store.shared.addImage(image) } - return nil - } + guard modifiers == [.command], event.charactersIgnoringModifiers == "v" else { return event } + + let pasteboard = NSPasteboard.general + if pasteboard.string(forType: .string) != nil { + NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil) + return nil + } + if let images = pasteboard.readObjects(forClasses: [NSImage.self], options: nil) as? [NSImage], + let image = images.first { + Task { @MainActor in Store.shared.addImage(image) } + return nil } return event } diff --git a/Sources/Prompt/PanelView.swift b/Sources/Prompt/PanelView.swift index 05fc662..44ef165 100644 --- a/Sources/Prompt/PanelView.swift +++ b/Sources/Prompt/PanelView.swift @@ -193,8 +193,21 @@ struct PanelView: View { .padding(.vertical, 48) } + /// Rows are checklists by default: a checkbox to tick an item off, with the + /// rest of the row copying on click. The row cannot be a Button because the + /// checkbox is one, and nested buttons do not receive clicks. private func row(for item: PromptItem, sectionName: String) -> some View { - Button { handleClick(item) } label: { + HStack(alignment: .top, spacing: 8) { + Button { + store.toggleDone(item.id) + } label: { + Image(systemName: item.done ? "checkmark.circle.fill" : "circle") + .font(.system(size: 15)) + .foregroundStyle(item.done ? Color.accentColor : Color.secondary.opacity(0.55)) + } + .buttonStyle(.plain) + .help(item.done ? "Mark as not done" : "Mark as done") + VStack(alignment: .leading, spacing: 6) { if !sectionName.isEmpty { Text(sectionName.uppercased()) @@ -208,29 +221,28 @@ struct PanelView: View { Text(sourceApp) } Text(item.createdAt, style: .time) - if item.done { - Image(systemName: "checkmark") - } } .font(.caption2) .foregroundStyle(.tertiary) } - .padding(10) .frame(maxWidth: .infinity, alignment: .leading) - .background(rowBackground(item)) - .cornerRadius(8) - .overlay(alignment: .topTrailing) { - if copiedID == item.id { - Label("Copied", systemImage: "checkmark.circle.fill") - .font(.caption2) - .foregroundStyle(.green) - .padding(6) - .transition(.opacity) - } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground(item)) + .cornerRadius(8) + .overlay(alignment: .topTrailing) { + if copiedID == item.id { + Label("Copied", systemImage: "checkmark.circle.fill") + .font(.caption2) + .foregroundStyle(.green) + .padding(6) + .transition(.opacity) } } - .buttonStyle(.plain) - .help("Click to copy · Cmd+click to select several · Shift+click for a range · right-click for more") + .contentShape(Rectangle()) + .onTapGesture { handleClick(item) } + .help("Click to copy · checkbox to tick off · Cmd+click to select several · Shift+click for a range · right-click for more") .contextMenu { contextMenu(for: item) } }