2 Commits
Author SHA1 Message Date
TZGyn c7b4ed32f2 Make the row checkbox select items instead of ticking them off
The checkbox was marking items done, but the intent was to gather items
and act on them: tick several, then right-click to copy, copy as a list,
move, merge or delete them all at once. Done is still available from the
same menu, it is just no longer what the checkbox does.
2026-10-09 17:54:54 +08:00
TZGyn 9f0c0f8113 Make pasting reliable and turn items into checkboxes
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.
2026-10-09 17:49:06 +08:00
6 changed files with 165 additions and 104 deletions
+26 -13
View File
@@ -27,13 +27,17 @@ clicking an item copies it back to the clipboard.
`Images/` next to it, cached in an `NSCache`. Owns all mutations (add, `Images/` next to it, cached in an `NSCache`. Owns all mutations (add,
toggle done, edit, delete, move, merge, clear done) and clipboard writes toggle done, edit, delete, move, merge, clear done) and clipboard writes
(`copy`, `copyAsList` for numbered output, `copyAsLines` for plain newlines; (`copy`, `copyAsList` for numbered output, `copyAsLines` for plain newlines;
both text modes skip image items). Saves on every mutation. both text modes skip image items). Batch mutations (`delete(ids:)`,
`setDone(_:ids:)`, `move(ids:to:)`) back the selection context menu, so one
action applies to every selected item and image files are cleaned up once.
Saves on every mutation.
- `Sources/Prompt/PanelView.swift` — the panel UI. Header (section switcher, - `Sources/Prompt/PanelView.swift` — the panel UI. Header (section switcher,
search, add photos, close), item list, composer. Composer `# Name` creates/ search, add photos, close), item list, composer. Composer `# Name` creates/
switches sections. Click = copy; Cmd+click toggles multi-select, Shift+click switches sections. Click = copy; the row checkbox selects, Cmd+click toggles
selects a range, and a selection bar offers Copy as List / Copy as Lines; multi-select, Shift+click selects a range, and a selection bar offers Copy as
right-click = context menu (Copy, Copy as List, Copy as Lines, Done, Edit, List / Copy as Lines; right-click = context menu acting on the selection
Open, Merge, Move To, Delete). Esc clears the selection before it hides the (Copy, Copy as List, Copy as Lines, Done, Edit, Open, Merge, Move To,
Delete). Esc clears the selection before it hides the
panel. Drop target for images/files. panel. Drop target for images/files.
- `Sources/Prompt/PanelController.swift` — owns the floating `NSPanel` - `Sources/Prompt/PanelController.swift` — owns the floating `NSPanel`
(`.nonactivatingPanel`, `.floating`, right-edge docked, frame autosaved as (`.nonactivatingPanel`, `.floating`, right-edge docked, frame autosaved as
@@ -156,14 +160,23 @@ brew upgrade --cask prompt
## Gotchas ## Gotchas
- **Setting `NSApp.mainMenu` replaces SwiftUI's default menu.** SwiftUI installs - **Never assign `NSApp.mainMenu`.** SwiftUI installs a menu whose Edit items
a menu containing the standard Edit items, and AppKit routes Cmd+C/V/X/A/Z are how AppKit routes Cmd+C/V/X/A/Z; replacing it with a hand-built menu
through those items — so a hand-built menu with only Hide/Quit silently silently breaks pasting, copying and select-all in the composer (this shipped
breaks pasting, copying and select-all in the composer. `setupMainMenu()` once and had to be reverted). `installMenuItems()` only *augments* the
therefore rebuilds an Edit menu with `undo:`/`redo:`/`cut:`/`copy:`/`paste:`/ existing menu: it adds Hide Panel (Cmd+W) and removes SwiftUI's Settings item,
`selectAll:` (nil targets, so they travel the responder chain to the field whose empty scene would otherwise shadow the in-panel Cmd+, sync panel.
editor). `Prompt --diag` dumps the menu so this can be checked without a - **Cmd+V is handled by the paste monitor, not the menu.** The panel is a
keyboard. `.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
selecting the item, and nested buttons never receive clicks. The row uses
`contentShape` + `onTapGesture` for click-to-copy instead. Selection is
driven by the checkbox, Cmd+click (toggle) and Shift+click (range); the
context menu acts on the whole selection via `effectiveIDs(for:)`.
- **Never bind bare keys as `.keyboardShortcut` in the panel** (Space, Delete, - **Never bind bare keys as `.keyboardShortcut` in the panel** (Space, Delete,
plain Cmd+C). SwiftUI key equivalents are matched before text input and plain Cmd+C). SwiftUI key equivalents are matched before text input and
would steal characters from the composer TextField. Only Cmd-modified would steal characters from the composer TextField. Only Cmd-modified
+8 -3
View File
@@ -4,6 +4,9 @@ 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 [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. to your work. Click anything to copy it back to your clipboard.
- **Checkbox to select** — every item has a checkbox; tick several and act on
them at once (copy, delete, move, merge). Clicking the row itself still
copies that item.
- **Click to copy** — text notes go back as text, photos go back as images. - **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**. - **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. - **Photos** — paste (Cmd+V), drag & drop, or add via the photo button.
@@ -11,9 +14,11 @@ to your work. Click anything to copy it back to your clipboard.
the Cmd+K switcher (which opens a name modal); Cmd+K switches, and the the Cmd+K switcher (which opens a name modal); Cmd+K switches, and the
up/down arrows in the switcher reorder them. (The code and the saved JSON up/down arrows in the switcher reorder them. (The code and the saved JSON
still call these `sections` so existing data keeps working.) still call these `sections` so existing data keeps working.)
- **Copy as List / Copy as Lines** — Cmd+click to multi-select (Shift+click for - **Copy as List / Copy as Lines** — select with the checkboxes (or Cmd+click,
a range), then copy the selection as a numbered list or as plain or Shift+click for a range), then copy the selection as a numbered list or as
newline-separated text, from the selection bar or the right-click menu. plain newline-separated text, from the selection bar, the right-click menu, or
Shift+Cmd+C / Option+Cmd+C. The same menu deletes, moves or merges the
selection.
- **Merge Notes, Mark as Done, Edit, Move To** — right-click any item. - **Merge Notes, Mark as Done, Edit, Move To** — right-click any item.
- **Search** — Cmd+F searches across all sections. - **Search** — Cmd+F searches across all sections.
- **Sync (optional)** — generate an 8-digit code (`1234-5678`) on one Mac and - **Sync (optional)** — generate an 8-digit code (`1234-5678`) on one Mac and
+18 -36
View File
@@ -32,7 +32,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// which locks the user out once the panel is hidden. // which locks the user out once the panel is hidden.
if CommandLine.arguments.contains("--diag") { if CommandLine.arguments.contains("--diag") {
setupStatusItem() setupStatusItem()
setupMainMenu() installMenuItems()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.writeDiagnostics() self.writeDiagnostics()
NSApp.terminate(nil) NSApp.terminate(nil)
@@ -60,9 +60,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
HotkeyManager.shared.start() HotkeyManager.shared.start()
setupStatusItem() setupStatusItem()
setupMainMenu()
promptForAccessibilityIfNeeded() 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 // 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). // icon and the menu bar icon can be hard to spot on a full bar).
Task { @MainActor in Task { @MainActor in
@@ -111,44 +113,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return .terminateNow return .terminateNow
} }
/// Accessory apps have no visible menu bar, but the main menu still /// Adds "Hide Panel" to the menu SwiftUI already installed. Do NOT replace
/// services key equivalents — this is what makes Cmd+Q and Cmd+W work /// `NSApp.mainMenu`: SwiftUI's menu carries the Edit items that AppKit
/// while the panel has focus. /// 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,
/// The Edit menu is not decoration: AppKit routes Cmd+C/V/X/A/Z through /// whose empty scene would otherwise shadow the in-panel Cmd+, sync panel.
/// these items, so a minimal main menu silently breaks pasting and private func installMenuItems() {
/// selecting in the composer. guard let appMenu = NSApp.mainMenu?.items.first?.submenu else { return }
private func setupMainMenu() {
let mainMenu = NSMenu()
let appMenuItem = NSMenuItem() if let settings = appMenu.items.firstIndex(where: { $0.keyEquivalent == "," && $0.keyEquivalentModifierMask == [.command] }) {
mainMenu.addItem(appMenuItem) appMenu.removeItem(at: settings)
let appMenu = NSMenu(title: "Prompt") }
appMenuItem.submenu = appMenu guard !appMenu.items.contains(where: { $0.action == #selector(hidePanel) }) else { return }
let hide = NSMenuItem(title: "Hide Panel", action: #selector(hidePanel), keyEquivalent: "w") let hide = NSMenuItem(title: "Hide Panel", action: #selector(hidePanel), keyEquivalent: "w")
hide.target = self hide.target = self
appMenu.addItem(hide) let quitIndex = appMenu.items.firstIndex { $0.action == #selector(NSApplication.terminate(_:)) } ?? appMenu.items.count
appMenu.addItem(.separator()) appMenu.insertItem(.separator(), at: quitIndex)
let quit = NSMenuItem(title: "Quit Prompt", action: #selector(quit), keyEquivalent: "q") appMenu.insertItem(hide, at: quitIndex + 1)
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
} }
@MainActor @objc private func hidePanel() { @MainActor @objc private func hidePanel() {
+16 -10
View File
@@ -71,21 +71,27 @@ final class PanelController: NSObject {
panel.setFrameOrigin(NSPoint(x: screen.maxX - size.width - 12, y: screen.minY + 12)) 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 // Cmd+V is handled here rather than left to a menu key equivalent. The
// (and no text, so normal text paste into the composer is unaffected). // 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() { private func installPasteMonitor() {
guard pasteMonitor == nil else { return } guard pasteMonitor == nil else { return }
pasteMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in pasteMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self, let panel = self.panel, event.window == panel else { return event } guard let self, let panel = self.panel, event.window == panel else { return event }
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if modifiers == [.command], event.charactersIgnoringModifiers == "v" { guard modifiers == [.command], event.charactersIgnoringModifiers == "v" else { return event }
let pasteboard = NSPasteboard.general
if pasteboard.string(forType: .string) == nil, let pasteboard = NSPasteboard.general
let images = pasteboard.readObjects(forClasses: [NSImage.self], options: nil) as? [NSImage], if pasteboard.string(forType: .string) != nil {
let image = images.first { NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil)
Task { @MainActor in Store.shared.addImage(image) } return 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 return event
} }
+61 -37
View File
@@ -193,8 +193,21 @@ struct PanelView: View {
.padding(.vertical, 48) .padding(.vertical, 48)
} }
/// Rows carry a checkbox that *selects* the item, so a selection can be
/// copied or deleted from the context menu. 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 { private func row(for item: PromptItem, sectionName: String) -> some View {
Button { handleClick(item) } label: { HStack(alignment: .top, spacing: 8) {
Button {
toggleSelection(item.id)
} label: {
Image(systemName: selection.contains(item.id) ? "checkmark.circle.fill" : "circle")
.font(.system(size: 15))
.foregroundStyle(selection.contains(item.id) ? Color.accentColor : Color.secondary.opacity(0.55))
}
.buttonStyle(.plain)
.help(selection.contains(item.id) ? "Deselect" : "Select for copy or delete")
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
if !sectionName.isEmpty { if !sectionName.isEmpty {
Text(sectionName.uppercased()) Text(sectionName.uppercased())
@@ -208,29 +221,28 @@ struct PanelView: View {
Text(sourceApp) Text(sourceApp)
} }
Text(item.createdAt, style: .time) Text(item.createdAt, style: .time)
if item.done {
Image(systemName: "checkmark")
}
} }
.font(.caption2) .font(.caption2)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
} }
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(rowBackground(item)) }
.cornerRadius(8) .padding(10)
.overlay(alignment: .topTrailing) { .frame(maxWidth: .infinity, alignment: .leading)
if copiedID == item.id { .background(rowBackground(item))
Label("Copied", systemImage: "checkmark.circle.fill") .cornerRadius(8)
.font(.caption2) .overlay(alignment: .topTrailing) {
.foregroundStyle(.green) if copiedID == item.id {
.padding(6) Label("Copied", systemImage: "checkmark.circle.fill")
.transition(.opacity) .font(.caption2)
} .foregroundStyle(.green)
.padding(6)
.transition(.opacity)
} }
} }
.buttonStyle(.plain) .contentShape(Rectangle())
.help("Click to copy · Cmd+click to select several · Shift+click for a range · right-click for more") .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) } .contextMenu { contextMenu(for: item) }
} }
@@ -266,43 +278,51 @@ struct PanelView: View {
} }
private func contextMenu(for item: PromptItem) -> some View { private func contextMenu(for item: PromptItem) -> some View {
Group { let ids = effectiveIDs(for: item)
let multiple = ids.count > 1
return Group {
Button("Copy") { copy(item) } Button("Copy") { copy(item) }
Button("Copy as List") { Button(multiple ? "Copy \(ids.count) as List" : "Copy as List") {
store.copyAsList(ids: effectiveIDs(for: item)) store.copyAsList(ids: ids)
flashCopied(item.id) flashCopied(item.id)
} }
Button("Copy as Lines") { Button(multiple ? "Copy \(ids.count) as Lines" : "Copy as Lines") {
store.copyAsLines(ids: effectiveIDs(for: item)) store.copyAsLines(ids: ids)
flashCopied(item.id) flashCopied(item.id)
} }
Divider() Divider()
Button(item.done ? "Mark as Not Done" : "Mark as Done") { store.toggleDone(item.id) } Button(item.done ? "Mark as Not Done" : "Mark as Done") {
if case .text(let text) = item.content { store.setDone(!item.done, ids: ids)
}
if case .text(let text) = item.content, !multiple {
Button("Edit…") { Button("Edit…") {
PanelController.shared.editController.edit(itemID: item.id, text: text) PanelController.shared.editController.edit(itemID: item.id, text: text)
} }
} }
if case .image(let filename) = item.content { if case .image(let filename) = item.content, !multiple {
Button("Open") { Button("Open") {
NSWorkspace.shared.open(store.imageURL(for: filename)) NSWorkspace.shared.open(store.imageURL(for: filename))
} }
} }
if selection.count > 1, selection.contains(item.id) { if multiple {
Button("Merge Notes") { Button("Merge Notes") {
store.merge(ids: selection) store.merge(ids: ids)
clearSelection() clearSelection()
} }
} }
Menu("Move To") { Menu("Move To") {
ForEach(store.sections.filter { $0.id != store.sectionID(of: item.id) }) { section in ForEach(store.sections.filter { $0.id != store.sectionID(of: item.id) }) { section in
Button(section.name) { store.move(item.id, to: section.id) } Button(section.name) {
store.move(ids: ids, to: section.id)
if multiple { clearSelection() }
}
} }
} }
Divider() Divider()
Button("Delete", role: .destructive) { Button(multiple ? "Delete \(ids.count) Items" : "Delete", role: .destructive) {
selection.remove(item.id) store.delete(ids: ids)
store.delete(item.id) selection.subtract(ids)
lastSelectedID = nil
} }
} }
} }
@@ -652,12 +672,7 @@ struct PanelView: View {
private func handleClick(_ item: PromptItem) { private func handleClick(_ item: PromptItem) {
let modifiers = NSApp.currentEvent?.modifierFlags ?? [] let modifiers = NSApp.currentEvent?.modifierFlags ?? []
if modifiers.contains(.command) { if modifiers.contains(.command) {
if selection.contains(item.id) { toggleSelection(item.id)
selection.remove(item.id)
} else {
selection.insert(item.id)
}
lastSelectedID = item.id
return return
} }
if modifiers.contains(.shift), let anchor = lastSelectedID, anchor != item.id { if modifiers.contains(.shift), let anchor = lastSelectedID, anchor != item.id {
@@ -667,6 +682,15 @@ struct PanelView: View {
copy(item) copy(item)
} }
private func toggleSelection(_ id: UUID) {
if selection.contains(id) {
selection.remove(id)
} else {
selection.insert(id)
}
lastSelectedID = id
}
private func selectRange(from anchor: UUID, to target: UUID) { private func selectRange(from anchor: UUID, to target: UUID) {
let ids = displayedItems.map(\.item.id) let ids = displayedItems.map(\.item.id)
guard let start = ids.firstIndex(of: anchor), let end = ids.firstIndex(of: target) else { return } guard let start = ids.firstIndex(of: anchor), let end = ids.firstIndex(of: target) else { return }
+36 -5
View File
@@ -190,18 +190,49 @@ final class Store: ObservableObject {
} }
func delete(_ id: UUID) { func delete(_ id: UUID) {
delete(ids: [id])
}
/// Deletes every item in `ids` in one pass, cleaning up their image files.
func delete(ids: Set<UUID>) {
guard !ids.isEmpty else { return }
var removedAny = false
for sectionIndex in sections.indices { for sectionIndex in sections.indices {
if let itemIndex = sections[sectionIndex].items.firstIndex(where: { $0.id == id }) { for item in sections[sectionIndex].items where ids.contains(item.id) {
let item = sections[sectionIndex].items[itemIndex] removedAny = true
if case .image(let filename) = item.content { if case .image(let filename) = item.content {
try? FileManager.default.removeItem(at: imagesDirectory.appendingPathComponent(filename)) try? FileManager.default.removeItem(at: imagesDirectory.appendingPathComponent(filename))
imageCache.removeObject(forKey: filename as NSString) imageCache.removeObject(forKey: filename as NSString)
} }
sections[sectionIndex].items.remove(at: itemIndex) }
save() sections[sectionIndex].items.removeAll { ids.contains($0.id) }
return }
if removedAny { save() }
}
func setDone(_ done: Bool, ids: Set<UUID>) {
guard !ids.isEmpty else { return }
for sectionIndex in sections.indices {
for itemIndex in sections[sectionIndex].items.indices
where ids.contains(sections[sectionIndex].items[itemIndex].id) {
sections[sectionIndex].items[itemIndex].done = done
} }
} }
save()
}
/// Moves every item in `ids` into `targetSectionID`, keeping their order.
func move(ids: Set<UUID>, to targetSectionID: UUID) {
guard !ids.isEmpty,
let targetIndex = sections.firstIndex(where: { $0.id == targetSectionID }) else { return }
var moved: [PromptItem] = []
for sectionIndex in sections.indices where sectionIndex != targetIndex {
moved.append(contentsOf: sections[sectionIndex].items.filter { ids.contains($0.id) })
sections[sectionIndex].items.removeAll { ids.contains($0.id) }
}
guard !moved.isEmpty else { return }
sections[targetIndex].items.append(contentsOf: moved)
save()
} }
func move(_ id: UUID, to targetSectionID: UUID) { func move(_ id: UUID, to targetSectionID: UUID) {