Closing the panel only hid it, and launching the app again did nothing because the running instance ignored the reopen, so it looked like the app refused to exit. Reopening now shows the panel, and the main menu makes Cmd+Q and Cmd+W work while the panel has focus. Termination also tears down the event tap, hotkeys and sync timers instead of leaving them live.
598 lines
22 KiB
Swift
598 lines
22 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
import UniformTypeIdentifiers
|
|
|
|
struct PanelView: View {
|
|
@ObservedObject var store: Store
|
|
@ObservedObject private var sync = SyncService.shared
|
|
|
|
@State private var composer = ""
|
|
@State private var search = ""
|
|
@State private var showSearch = false
|
|
@State private var showSectionSwitcher = false
|
|
@State private var showSync = false
|
|
@State private var joinCode = ""
|
|
@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)
|
|
.overlay(syncOverlay)
|
|
.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 { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } } label: {
|
|
Image(systemName: "arrow.triangle.2.circlepath")
|
|
.foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor)
|
|
}
|
|
.buttonStyle(.plain).help("Sync")
|
|
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("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(.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) {
|
|
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: - Sync
|
|
|
|
@ViewBuilder
|
|
private var syncOverlay: some View {
|
|
if showSync {
|
|
VStack(spacing: 0) {
|
|
Color.black.opacity(0.001)
|
|
.onTapGesture { showSync = false }
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text("Sync").font(.headline)
|
|
Spacer()
|
|
if sync.isSyncing { ProgressView().controlSize(.small) }
|
|
}
|
|
TextField("Server URL", text: $sync.serverURL)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.callout)
|
|
if let code = sync.code {
|
|
Divider()
|
|
Text("Your code").font(.caption).foregroundStyle(.secondary)
|
|
HStack {
|
|
Text(code)
|
|
.font(.system(size: 20, weight: .medium, design: .monospaced))
|
|
.textSelection(.enabled)
|
|
Spacer()
|
|
Button {
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(code, forType: .string)
|
|
} label: {
|
|
Image(systemName: "doc.on.doc")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Copy code")
|
|
}
|
|
Text("Enter this code on your other Mac to sync.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
statusLine
|
|
Divider()
|
|
Button("Disconnect", role: .destructive) { sync.disconnect() }
|
|
.font(.callout)
|
|
} else {
|
|
Divider()
|
|
Button("Generate Code") { sync.generateCode() }
|
|
.disabled(sync.isSyncing)
|
|
Text("Creates a new sync group and uploads what's here.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Divider()
|
|
Text("Or join an existing group").font(.caption).foregroundStyle(.secondary)
|
|
HStack {
|
|
TextField("1234-5678", text: $joinCode)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.system(.callout, design: .monospaced))
|
|
.onExitCommand { showSync = false }
|
|
Button("Join") {
|
|
sync.join(rawCode: joinCode)
|
|
joinCode = ""
|
|
}
|
|
.disabled(sync.isSyncing || joinCode.isEmpty)
|
|
}
|
|
Text("Joining replaces what's on this Mac with the group's notes.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if let error = sync.lastError {
|
|
Text(error)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
.padding(14)
|
|
.background(.regularMaterial)
|
|
.cornerRadius(10)
|
|
.shadow(radius: 12)
|
|
.padding(.horizontal, 20)
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.padding(.top, 44)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statusLine: some View {
|
|
if sync.isSyncing {
|
|
Text("Syncing…").font(.caption).foregroundStyle(.secondary)
|
|
} else if let last = sync.lastSyncedAt {
|
|
Text("Last synced \(last, style: .relative) ago")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Text("Not synced yet").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
// 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("") {
|
|
withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() }
|
|
}
|
|
.keyboardShortcut(",", 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)
|
|
}
|
|
}
|