Files
prompt/Sources/Prompt/PanelController.swift
T
TZGyn 962c205d1d Make the app reachable and quittable after the panel closes
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.
2026-10-08 10:43:42 +08:00

148 lines
5.1 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)
NSLog("Prompt: panel shown")
NotificationCenter.default.post(name: .promptPanelDidShow, object: nil)
}
func hide() {
panel?.orderOut(nil)
NSApp.deactivate()
NSLog("Prompt: panel hidden (app keeps running for the Shift-Shift hotkeys)")
}
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)
}
}