Without it the Shift-Shift gestures fail silently, so hiding the panel left no way to bring it back and the app looked broken. The app now says so on launch and points at Cmd+Shift+Space, which needs no permission, and --diag reports the permission state alongside the status item's placement so a lockout can be diagnosed instead of guessed at.
603 lines
22 KiB
Swift
603 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)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Shift-tap capture needs Accessibility access.")
|
|
.font(.caption)
|
|
Text("Meanwhile: Cmd+Shift+Space toggles this panel.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
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)
|
|
}
|
|
}
|