Files
prompt/Sources/Prompt/PanelController.swift
T
TZGyn c074be3378 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
2026-10-07 20:54:49 +08:00

146 lines
5.0 KiB
Swift

import SwiftUI
import AppKit
extension Notification.Name {
static let promptPanelDidShow = Notification.Name("PromptPanelDidShowNotification")
}
private final class KeyablePanel: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }
}
@MainActor
final class PanelController: NSObject {
static let shared = PanelController()
private var panel: KeyablePanel?
private var pasteMonitor: Any?
let editController = EditWindowController()
var isVisible: Bool { panel?.isVisible ?? false }
func toggle() {
isVisible ? hide() : show()
}
func show() {
let panel = panel ?? makePanel()
if UserDefaults.standard.string(forKey: "NSWindow Frame PromptPanel") == nil {
positionAtRightEdge(panel)
}
NSApp.activate(ignoringOtherApps: true)
panel.makeKeyAndOrderFront(nil)
NotificationCenter.default.post(name: .promptPanelDidShow, object: nil)
}
func hide() {
panel?.orderOut(nil)
NSApp.deactivate()
}
private func makePanel() -> KeyablePanel {
let panel = KeyablePanel(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 640),
styleMask: [.titled, .closable, .resizable, .nonactivatingPanel, .fullSizeContentView],
backing: .buffered,
defer: false
)
panel.titleVisibility = .hidden
panel.titlebarAppearsTransparent = true
panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
panel.standardWindowButton(.zoomButton)?.isHidden = true
panel.level = .floating
panel.isMovableByWindowBackground = true
panel.hidesOnDeactivate = false
panel.isReleasedWhenClosed = false
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.contentView = NSHostingView(rootView: PanelView(store: Store.shared))
panel.setFrameAutosaveName("PromptPanel")
self.panel = panel
installPasteMonitor()
return panel
}
private func positionAtRightEdge(_ panel: NSPanel) {
guard let screen = NSScreen.main?.visibleFrame else { return }
let size = NSSize(width: 360, height: screen.height - 24)
panel.setContentSize(size)
panel.setFrameOrigin(NSPoint(x: screen.maxX - size.width - 12, y: screen.minY + 12))
}
// Intercept Cmd+V when the panel is key and the clipboard holds an image
// (and no text, so normal text paste into the composer is unaffected).
private func installPasteMonitor() {
guard pasteMonitor == nil else { return }
pasteMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self, let panel = self.panel, event.window == panel else { return event }
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if modifiers == [.command], event.charactersIgnoringModifiers == "v" {
let pasteboard = NSPasteboard.general
if pasteboard.string(forType: .string) == nil,
let images = pasteboard.readObjects(forClasses: [NSImage.self], options: nil) as? [NSImage],
let image = images.first {
Task { @MainActor in Store.shared.addImage(image) }
return nil
}
}
return event
}
}
}
@MainActor
final class EditWindowController: NSObject {
private var window: NSWindow?
func edit(itemID: UUID, text: String) {
let view = EditView(
text: text,
onSave: { [weak self] newText in
Store.shared.updateText(itemID, newText: newText)
self?.window?.close()
},
onCancel: { [weak self] in self?.window?.close() }
)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
window.title = "Edit Note"
window.contentView = NSHostingView(rootView: view)
window.isReleasedWhenClosed = false
window.center()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
self.window = window
}
}
private struct EditView: View {
@State var text: String
var onSave: (String) -> Void
var onCancel: () -> Void
var body: some View {
VStack(spacing: 12) {
TextEditor(text: $text)
.font(.body)
.padding(4)
.background(Color.primary.opacity(0.05))
.cornerRadius(6)
HStack {
Spacer()
Button("Cancel", action: onCancel)
.keyboardShortcut(.cancelAction)
Button("Save") { onSave(text) }
.keyboardShortcut(.defaultAction)
.keyboardShortcut("s", modifiers: .command)
}
}
.padding(16)
}
}