4 Commits
Author SHA1 Message Date
TZGyn 569e56dbba Add delete to the selection bar and drop the in-header close button
The selection bar covered copying but not the other action people reach
for with a selection. The header also carried a second close button next
to the window's own traffic light, so it was removed in favor of that.
2026-10-09 22:45:16 +08:00
TZGyn 78d56ca79b Make dark mode black instead of the system window grey
The panel is a full-height strip that sits beside your work, where the
system grey reads as a half-lit window next to a dark editor. Dark mode
is now pure black, with card and row colours in one Theme file so both
the panel background and the strip under the titlebar match.
2026-10-09 18:04:08 +08:00
TZGyn 6580a79d40 Select items by clicking the card instead of copying them
Copying was a side effect of a click, which made it impossible to build
a selection with the mouse and surprising when the intent was to pick
something up rather than copy it immediately. A click now selects (or
deselects), and copying is an explicit action from the selection bar or
the right-click menu.
2026-10-09 17:58:38 +08:00
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
6 changed files with 165 additions and 61 deletions
+25 -11
View File
@@ -27,14 +27,24 @@ 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:)`,
- `Sources/Prompt/PanelView.swift` — the panel UI. Header (section switcher, `setDone(_:ids:)`, `move(ids:to:)`) back the selection context menu, so one
search, add photos, close), item list, composer. Composer `# Name` creates/ action applies to every selected item and image files are cleaned up once.
switches sections. Click = copy; Cmd+click toggles multi-select, Shift+click Saves on every mutation.
selects a range, and a selection bar offers Copy as List / Copy as Lines; - `Sources/Prompt/Theme.swift` — the panel's appearance-aware colours. Dark
right-click = context menu (Copy, Copy as List, Copy as Lines, Done, Edit, mode is pure black rather than the system window grey (the panel is a
Open, Merge, Move To, Delete). Esc clears the selection before it hides the full-height strip beside your work, where grey reads as a half-lit window);
panel. Drop target for images/files. light mode keeps the system window colour. `Theme.background` is also handed
to the `NSPanel` so the strip under the transparent titlebar matches, and the
row colour stays slightly lighter than black so cards remain visible.
- `Sources/Prompt/PanelView.swift` — the panel UI. Header (workspace switcher,
search, add photos), item list, composer. There is no in-header close button:
the window's own close button hides the panel. Composer `# Name` creates/
switches sections. Click = select (Shift+click for a range); the selection
bar offers Copy (single item), Copy as List, Copy as Lines and Delete;
right-click = context menu acting on the whole selection (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.
- `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
`PromptPanel`), the Cmd+V image-paste monitor, and `EditWindowController` `PromptPanel`), the Cmd+V image-paste monitor, and `EditWindowController`
@@ -168,9 +178,13 @@ brew upgrade --cask prompt
dispatches `paste:` to the responder chain itself for text, and adds the 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 clipboard image to the store when there is no text. `Prompt --diag` dumps the
menu so the wiring can be checked without a keyboard. menu so the wiring can be checked without a keyboard.
- **A row is not a `Button`** because it contains a checkbox `Button` for - **A row is not a `Button`** because it contains a checkbox `Button`, and
ticking items off, and nested buttons never receive clicks. The row uses nested buttons never receive clicks. The row uses `contentShape` +
`contentShape` + `onTapGesture` for click-to-copy instead. `onTapGesture` so a click anywhere on the card selects it. Selection is
driven by the card click, the checkbox, Cmd+click (toggle) and Shift+click
(range); the context menu acts on the whole selection via `effectiveIDs(for:)`.
Copying is an explicit action (selection bar or context menu), never a side
effect of a click — that was changed deliberately.
- **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
+9 -9
View File
@@ -2,22 +2,22 @@
A native macOS app for keeping prompts and photos, in the spirit of 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. Select what you need and send it back to your clipboard.
- **Checklists by default** — every item has a checkbox, so a workspace doubles - **Click to select** — clicking a card selects it (Shift+click for a range),
as a to-do list; done items stay in place, dimmed and struck through. and the selection bar copies a single item as-is, the whole selection as a
- **Click to copy** — text notes go back as text, photos go back as images. numbered list or as plain newline-separated lines, or deletes it.
- **Text or photo** — copying sends text back as text and a photo back as an
image.
- **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.
- **Workspaces** — type `# Name` in the composer, or use "New Workspace" in - **Workspaces** — type `# Name` in the composer, or use "New Workspace" in
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 - **Right-click for actions** — Copy, Copy as List, Copy as Lines, Mark as Done,
a range), then copy the selection as a numbered list or as plain Edit, Open, Merge Notes, Move To, Delete — each acting on the whole selection.
newline-separated text, from the selection bar or the right-click menu. - **Search** — Cmd+F searches across all workspaces.
- **Merge Notes, Mark as Done, Edit, Move To** — right-click any item.
- **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
enter it on the other; no account, no login. The code is the only secret. enter it on the other; no account, no login. The code is the only secret.
- **Local & private** — everything lives in - **Local & private** — everything lives in
+4
View File
@@ -56,6 +56,10 @@ final class PanelController: NSObject {
panel.isMovableByWindowBackground = true panel.isMovableByWindowBackground = true
panel.hidesOnDeactivate = false panel.hidesOnDeactivate = false
panel.isReleasedWhenClosed = false panel.isReleasedWhenClosed = false
panel.isOpaque = true
// Match the SwiftUI content so the titlebar strip and any resize flash
// do not show the system grey through.
panel.backgroundColor = Theme.background
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.contentView = NSHostingView(rootView: PanelView(store: Store.shared)) panel.contentView = NSHostingView(rootView: PanelView(store: Store.shared))
panel.setFrameAutosaveName("PromptPanel") panel.setFrameAutosaveName("PromptPanel")
+63 -36
View File
@@ -40,6 +40,7 @@ struct PanelView: View {
composerField composerField
} }
.frame(minWidth: 300) .frame(minWidth: 300)
.background(Theme.backgroundSwiftUI)
.background(shortcutButtons) .background(shortcutButtons)
.overlay(sectionSwitcher) .overlay(sectionSwitcher)
.overlay(syncOverlay) .overlay(syncOverlay)
@@ -82,9 +83,6 @@ struct PanelView: View {
.buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)") .buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)")
Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") } Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Add photos") .buttonStyle(.plain).foregroundStyle(.secondary).help("Add photos")
Button { PanelController.shared.hide() } label: { Image(systemName: "xmark") }
.buttonStyle(.plain).foregroundStyle(.secondary)
.help("Hide the panel (Cmd+W) — Prompt keeps running so Shift-Shift capture still works. Quit from the menu bar icon or Cmd+Q.")
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -193,20 +191,20 @@ struct PanelView: View {
.padding(.vertical, 48) .padding(.vertical, 48)
} }
/// Rows are checklists by default: a checkbox to tick an item off, with the /// Rows carry a checkbox that *selects* the item, so a selection can be
/// rest of the row copying on click. The row cannot be a Button because the /// copied or deleted from the context menu. The row cannot be a Button
/// checkbox is one, and nested buttons do not receive clicks. /// 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 {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
Button { Button {
store.toggleDone(item.id) toggleSelection(item.id)
} label: { } label: {
Image(systemName: item.done ? "checkmark.circle.fill" : "circle") Image(systemName: selection.contains(item.id) ? "checkmark.circle.fill" : "circle")
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundStyle(item.done ? Color.accentColor : Color.secondary.opacity(0.55)) .foregroundStyle(selection.contains(item.id) ? Color.accentColor : Color.secondary.opacity(0.55))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help(item.done ? "Mark as not done" : "Mark as done") .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 {
@@ -242,13 +240,13 @@ struct PanelView: View {
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { handleClick(item) } .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") .help("Click to select · Shift+click for a range · right-click to copy, delete and more")
.contextMenu { contextMenu(for: item) } .contextMenu { contextMenu(for: item) }
} }
private func rowBackground(_ item: PromptItem) -> Color { private func rowBackground(_ item: PromptItem) -> Color {
if selection.contains(item.id) { return Color.accentColor.opacity(0.25) } if selection.contains(item.id) { return Color.accentColor.opacity(0.25) }
return Color.primary.opacity(0.05) return Theme.rowSwiftUI
} }
@ViewBuilder @ViewBuilder
@@ -278,43 +276,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
} }
} }
} }
@@ -553,6 +559,12 @@ struct PanelView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Spacer() Spacer()
if selection.count == 1, let id = selection.first, let item = store.item(id) {
Button("Copy") { copy(item) }
.font(.caption)
.buttonStyle(.plain)
.foregroundStyle(Color.accentColor)
}
Button("Copy as List") { copySelection(asList: true) } Button("Copy as List") { copySelection(asList: true) }
.font(.caption) .font(.caption)
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -561,6 +573,12 @@ struct PanelView: View {
.font(.caption) .font(.caption)
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(Color.accentColor) .foregroundStyle(Color.accentColor)
Button { deleteSelection() } label: {
Image(systemName: "trash")
}
.buttonStyle(.plain)
.foregroundStyle(.red)
.help("Delete selected")
Button { clearSelection() } label: { Button { clearSelection() } label: {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
} }
@@ -573,6 +591,12 @@ struct PanelView: View {
.background(Color.accentColor.opacity(0.12)) .background(Color.accentColor.opacity(0.12))
} }
private func deleteSelection() {
guard !selection.isEmpty else { return }
store.delete(ids: selection)
clearSelection()
}
private func copySelection(asList: Bool) { private func copySelection(asList: Bool) {
guard !selection.isEmpty else { return } guard !selection.isEmpty else { return }
if asList { if asList {
@@ -661,22 +685,25 @@ struct PanelView: View {
// MARK: - Actions // MARK: - Actions
/// A click anywhere on the card selects it (Shift+click selects a range).
/// Copying is then an action on the selection — from the selection bar or
/// the right-click menu — rather than the side effect of a click.
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 selection.contains(item.id) {
selection.remove(item.id)
} else {
selection.insert(item.id)
}
lastSelectedID = item.id
return
}
if modifiers.contains(.shift), let anchor = lastSelectedID, anchor != item.id { if modifiers.contains(.shift), let anchor = lastSelectedID, anchor != item.id {
selectRange(from: anchor, to: item.id) selectRange(from: anchor, to: item.id)
return return
} }
copy(item) toggleSelection(item.id)
}
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) {
+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) {
+28
View File
@@ -0,0 +1,28 @@
import AppKit
import SwiftUI
/// Appearance-aware colours for the panel.
///
/// Dark mode is pure black rather than the system window grey: the panel is a
/// full-height strip that sits beside your work, and the grey reads as a
/// half-lit window next to a dark editor or terminal.
enum Theme {
static func isDark(_ appearance: NSAppearance) -> Bool {
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
}
/// The panel's own background (also handed to the NSPanel so the area under
/// the transparent titlebar matches).
static let background = NSColor(name: nil) { appearance in
isDark(appearance) ? .black : .windowBackgroundColor
}
/// Card background, kept subtle but visible against pure black.
static let row = NSColor(name: nil) { appearance in
isDark(appearance) ? NSColor(white: 1, alpha: 0.07) : NSColor(white: 0, alpha: 0.05)
}
/// Same colours for SwiftUI.
static let backgroundSwiftUI = Color(nsColor: background)
static let rowSwiftUI = Color(nsColor: row)
}