Initial commit: a Copper-style panel for prompts and photos
Keeps text prompts and photos in a floating always-on-top panel and
copies them back to the clipboard on click. Double-Shift captures the
selection from any app; sections, search, copy-as-list, merge, and
local JSON persistence mirror shadcn's Copper workflow.
💘 Generated with Crush
Assisted-by: Crush:kimi-k3
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct PanelView: View {
|
||||
@ObservedObject var store: Store
|
||||
|
||||
@State private var composer = ""
|
||||
@State private var search = ""
|
||||
@State private var showSearch = false
|
||||
@State private var showSectionSwitcher = false
|
||||
@State private var selection = Set<UUID>()
|
||||
@State private var copiedID: UUID?
|
||||
@State private var accessibilityGranted = AXIsProcessTrusted()
|
||||
|
||||
private enum Field: Hashable { case composer, search }
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
if !accessibilityGranted {
|
||||
permissionBanner
|
||||
}
|
||||
if showSearch {
|
||||
searchField
|
||||
}
|
||||
Divider()
|
||||
content
|
||||
Divider()
|
||||
composerField
|
||||
}
|
||||
.frame(minWidth: 300)
|
||||
.background(shortcutButtons)
|
||||
.overlay(sectionSwitcher)
|
||||
.onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in
|
||||
focusedField = .composer
|
||||
accessibilityGranted = AXIsProcessTrusted()
|
||||
}
|
||||
.onAppear { focusedField = .composer }
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.15)) { showSectionSwitcher.toggle() }
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(store.activeSection.name.uppercased())
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.tracking(1)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Switch section (Cmd+K)")
|
||||
|
||||
Spacer()
|
||||
|
||||
Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") }
|
||||
.buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)")
|
||||
Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") }
|
||||
.buttonStyle(.plain).foregroundStyle(.secondary).help("Add photos")
|
||||
Button { PanelController.shared.hide() } label: { Image(systemName: "xmark") }
|
||||
.buttonStyle(.plain).foregroundStyle(.secondary).help("Close (Esc)")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private var permissionBanner: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
Text("Shift-tap capture needs Accessibility access.")
|
||||
.font(.caption)
|
||||
Spacer()
|
||||
Button("Grant…") {
|
||||
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
||||
_ = AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.yellow.opacity(0.1))
|
||||
}
|
||||
|
||||
private var searchField: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||||
TextField("Search all sections", text: $search)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($focusedField, equals: .search)
|
||||
.onExitCommand {
|
||||
search = ""
|
||||
showSearch = false
|
||||
focusedField = .composer
|
||||
}
|
||||
if !search.isEmpty {
|
||||
Button { search = "" } label: { Image(systemName: "xmark.circle.fill") }
|
||||
.buttonStyle(.plain).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
private var displayedItems: [(section: String, item: PromptItem)] {
|
||||
if search.isEmpty {
|
||||
return store.activeSection.items.map { ("", $0) }
|
||||
}
|
||||
let query = search.lowercased()
|
||||
return store.sections.flatMap { section in
|
||||
section.items.compactMap { item in
|
||||
switch item.content {
|
||||
case .text(let text) where text.lowercased().contains(query):
|
||||
return (section.name, item)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 6) {
|
||||
if displayedItems.isEmpty {
|
||||
emptyState
|
||||
}
|
||||
ForEach(displayedItems, id: \.item.id) { entry in
|
||||
row(for: entry.item, sectionName: entry.section)
|
||||
.id(entry.item.id)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.onChange(of: store.activeSection.items.count) { _ in
|
||||
if let last = store.activeSection.items.last {
|
||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(.quaternary)
|
||||
Text(search.isEmpty ? "Nothing here yet" : "No matches")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
if search.isEmpty {
|
||||
Text("Select text anywhere and tap Shift twice,\nor drop photos into this panel.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 48)
|
||||
}
|
||||
|
||||
private func row(for item: PromptItem, sectionName: String) -> some View {
|
||||
Button { handleClick(item) } label: {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if !sectionName.isEmpty {
|
||||
Text(sectionName.uppercased())
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
itemContent(item)
|
||||
HStack(spacing: 6) {
|
||||
if let sourceApp = item.sourceApp {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu { contextMenu(for: item) }
|
||||
}
|
||||
|
||||
private func rowBackground(_ item: PromptItem) -> Color {
|
||||
if selection.contains(item.id) { return Color.accentColor.opacity(0.25) }
|
||||
return Color.primary.opacity(0.05)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func itemContent(_ item: PromptItem) -> some View {
|
||||
switch item.content {
|
||||
case .text(let text):
|
||||
Text(markdown(text))
|
||||
.font(.callout)
|
||||
.strikethrough(item.done)
|
||||
.opacity(item.done ? 0.45 : 1)
|
||||
.lineLimit(8)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .image(let filename):
|
||||
if let image = store.image(for: filename) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 160)
|
||||
.cornerRadius(6)
|
||||
.opacity(item.done ? 0.4 : 1)
|
||||
} else {
|
||||
Label("Missing image", systemImage: "photo")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func contextMenu(for item: PromptItem) -> some View {
|
||||
Group {
|
||||
Button("Copy") { copy(item) }
|
||||
Button("Copy as List") {
|
||||
store.copyAsList(ids: effectiveIDs(for: item))
|
||||
flashCopied(item)
|
||||
}
|
||||
Divider()
|
||||
Button(item.done ? "Mark as Not Done" : "Mark as Done") { store.toggleDone(item.id) }
|
||||
if case .text(let text) = item.content {
|
||||
Button("Edit…") {
|
||||
PanelController.shared.editController.edit(itemID: item.id, text: text)
|
||||
}
|
||||
}
|
||||
if case .image(let filename) = item.content {
|
||||
Button("Open") {
|
||||
NSWorkspace.shared.open(store.imageURL(for: filename))
|
||||
}
|
||||
}
|
||||
if selection.count > 1, selection.contains(item.id) {
|
||||
Button("Merge Notes") {
|
||||
store.merge(ids: selection)
|
||||
selection.removeAll()
|
||||
}
|
||||
}
|
||||
Menu("Move To") {
|
||||
ForEach(store.sections.filter { $0.id != store.sectionID(of: item.id) }) { section in
|
||||
Button(section.name) { store.move(item.id, to: section.id) }
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) {
|
||||
selection.remove(item.id)
|
||||
store.delete(item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section switcher
|
||||
|
||||
@ViewBuilder
|
||||
private var sectionSwitcher: some View {
|
||||
if showSectionSwitcher {
|
||||
VStack(spacing: 0) {
|
||||
Color.black.opacity(0.001)
|
||||
.onTapGesture { showSectionSwitcher = false }
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
VStack(spacing: 0) {
|
||||
ForEach(store.sections) { section in
|
||||
Button {
|
||||
store.activeSectionID = section.id
|
||||
showSectionSwitcher = false
|
||||
} label: {
|
||||
HStack {
|
||||
Text(section.name)
|
||||
.font(.callout)
|
||||
Spacer()
|
||||
Text("\(section.items.count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
if section.id == store.activeSection.id {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
composer = "# "
|
||||
showSectionSwitcher = false
|
||||
focusedField = .composer
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus")
|
||||
Text("New Section")
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.background(.regularMaterial)
|
||||
.cornerRadius(10)
|
||||
.shadow(radius: 12)
|
||||
.padding(.horizontal, 24)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.padding(.top, 44)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Composer
|
||||
|
||||
private var composerField: some View {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Add a note or prompt (\(store.activeSection.name))", text: $composer)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($focusedField, equals: .composer)
|
||||
.onSubmit(submit)
|
||||
.onExitCommand { PanelController.shared.hide() }
|
||||
if !composer.isEmpty {
|
||||
Button(action: submit) { Image(systemName: "arrow.up.circle.fill") }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
let text = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
if text.hasPrefix("#") {
|
||||
let name = String(text.dropFirst()).trimmingCharacters(in: .whitespaces)
|
||||
if !name.isEmpty { store.createSection(named: name) }
|
||||
} else {
|
||||
store.addText(text)
|
||||
}
|
||||
composer = ""
|
||||
}
|
||||
|
||||
// MARK: - Shortcuts
|
||||
|
||||
// Only Cmd-modified shortcuts live here: bare keys (Space, Delete, Cmd+C)
|
||||
// would be swallowed as key equivalents and break typing in the composer.
|
||||
private var shortcutButtons: some View {
|
||||
VStack {
|
||||
Button("") { toggleSearch() }
|
||||
.keyboardShortcut("f", modifiers: .command)
|
||||
Button("") {
|
||||
withAnimation(.easeInOut(duration: 0.15)) { showSectionSwitcher.toggle() }
|
||||
}
|
||||
.keyboardShortcut("k", modifiers: .command)
|
||||
Button("") { focusedField = .composer }
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
Button("") {
|
||||
guard !selection.isEmpty else { return }
|
||||
store.copyAsList(ids: selection)
|
||||
if let id = selection.first { flashCopied(PromptItem(id: id, content: .text(""))) }
|
||||
}
|
||||
.keyboardShortcut("c", modifiers: [.command, .shift])
|
||||
}
|
||||
.opacity(0)
|
||||
.frame(width: 0, height: 0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func handleClick(_ item: PromptItem) {
|
||||
if NSApp.currentEvent?.modifierFlags.contains(.command) == true {
|
||||
if selection.contains(item.id) {
|
||||
selection.remove(item.id)
|
||||
} else {
|
||||
selection.insert(item.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
copy(item)
|
||||
}
|
||||
|
||||
private func copy(_ item: PromptItem) {
|
||||
store.copy(item)
|
||||
flashCopied(item)
|
||||
}
|
||||
|
||||
private func flashCopied(_ item: PromptItem) {
|
||||
withAnimation { copiedID = item.id }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
||||
if copiedID == item.id {
|
||||
withAnimation { copiedID = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func effectiveIDs(for item: PromptItem) -> Set<UUID> {
|
||||
if selection.contains(item.id), selection.count > 1 { return selection }
|
||||
return [item.id]
|
||||
}
|
||||
|
||||
private func toggleSearch() {
|
||||
showSearch.toggle()
|
||||
if showSearch {
|
||||
focusedField = .search
|
||||
} else {
|
||||
search = ""
|
||||
focusedField = .composer
|
||||
}
|
||||
}
|
||||
|
||||
private func addPhotos() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.image]
|
||||
panel.allowsMultipleSelection = true
|
||||
guard panel.runModal() == .OK else { return }
|
||||
for url in panel.urls {
|
||||
if let image = NSImage(contentsOf: url) {
|
||||
store.addImage(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDrop(_ providers: [NSItemProvider]) -> Bool {
|
||||
var handled = false
|
||||
for provider in providers {
|
||||
if provider.canLoadObject(ofClass: NSImage.self) {
|
||||
handled = true
|
||||
_ = provider.loadObject(ofClass: NSImage.self) { object, _ in
|
||||
if let image = object as? NSImage {
|
||||
Task { @MainActor in Store.shared.addImage(image) }
|
||||
}
|
||||
}
|
||||
} else if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
|
||||
handled = true
|
||||
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in
|
||||
guard let data = item as? Data,
|
||||
let url = URL(dataRepresentation: data, relativeTo: nil),
|
||||
let image = NSImage(contentsOf: url) else { return }
|
||||
Task { @MainActor in Store.shared.addImage(image) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return handled
|
||||
}
|
||||
|
||||
private func markdown(_ string: String) -> AttributedString {
|
||||
let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
return (try? AttributedString(markdown: string, options: options)) ?? AttributedString(string)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user