Cmd+, is where macOS users look for settings, and it makes the sync
panel reachable without hunting for the header icon.
💘 Generated with Crush
Assisted-by: Crush:kimi-k3
597 lines
22 KiB
Swift
597 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("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) {
|
|
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)
|
|
}
|
|
}
|