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() @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 { 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) } }