adds quick question UI for mac catalyst target
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import AppKit
|
||||
import Carbon
|
||||
|
||||
// AppKit is unavailable to Catalyst targets. This native bundle uses only
|
||||
// public AppKit/Carbon APIs and communicates with the client through Foundation.
|
||||
@MainActor
|
||||
@objc(SybilMacQuickQuestionPlugin)
|
||||
final class MacQuickQuestionPlugin: NSObject, SybilQuickQuestionPanelBridging, NSWindowDelegate {
|
||||
private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
|
||||
private var panel: QuickQuestionPanel?
|
||||
private var panelView: QuickQuestionPanelView?
|
||||
private var hotKey: EventHotKeyRef?
|
||||
private var eventHandler: EventHandlerRef?
|
||||
private var outsideClickMonitor: Any?
|
||||
private var hotKeyState = SybilQuickQuestionHotKeyState()
|
||||
private var isDismissing = false
|
||||
private var shortcutAvailable = false
|
||||
private var windowlessActivity: NSObjectProtocol?
|
||||
private var latestState = SybilQuickQuestionPanelState()
|
||||
private var previousApplication: NSRunningApplication?
|
||||
|
||||
required override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
var isVisible: Bool { panel?.isVisible == true }
|
||||
|
||||
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
|
||||
self.delegate = delegate
|
||||
if windowlessActivity == nil {
|
||||
// Keep the hotkey service alive with no windows. This option does
|
||||
// not prevent sleep/App Nap or intercept an explicit Quit command.
|
||||
windowlessActivity = ProcessInfo.processInfo.beginActivity(
|
||||
options: .automaticTerminationDisabled,
|
||||
reason: "Quick Question global shortcut"
|
||||
)
|
||||
}
|
||||
guard eventHandler == nil else { return noErr }
|
||||
var eventTypes = [
|
||||
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
|
||||
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
|
||||
]
|
||||
let context = Unmanaged.passUnretained(self).toOpaque()
|
||||
let handlerStatus = InstallEventHandler(
|
||||
GetApplicationEventTarget(),
|
||||
{ _, event, context in
|
||||
guard let event, let context else { return OSStatus(eventNotHandledErr) }
|
||||
var identifier = EventHotKeyID()
|
||||
let status = GetEventParameter(
|
||||
event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID),
|
||||
nil, MemoryLayout<EventHotKeyID>.size, nil, &identifier
|
||||
)
|
||||
guard status == noErr, identifier.signature == 0x53595151, identifier.id == 1 else {
|
||||
return OSStatus(eventNotHandledErr)
|
||||
}
|
||||
let isKeyDown = GetEventKind(event) == UInt32(kEventHotKeyPressed)
|
||||
let plugin = Unmanaged<MacQuickQuestionPlugin>.fromOpaque(context).takeUnretainedValue()
|
||||
// Application event handlers run on the main event loop.
|
||||
MainActor.assumeIsolated {
|
||||
if plugin.hotKeyState.shouldToggle(isKeyDown: isKeyDown) {
|
||||
plugin.delegate?.quickQuestionToggleRequested()
|
||||
}
|
||||
}
|
||||
return noErr
|
||||
},
|
||||
eventTypes.count, &eventTypes, context, &eventHandler
|
||||
)
|
||||
guard handlerStatus == noErr else { return handlerStatus }
|
||||
let status = RegisterEventHotKey(
|
||||
UInt32(kVK_Space), UInt32(optionKey),
|
||||
EventHotKeyID(signature: 0x53595151, id: 1),
|
||||
GetApplicationEventTarget(), 0, &hotKey
|
||||
)
|
||||
shortcutAvailable = status == noErr
|
||||
return status
|
||||
}
|
||||
|
||||
func updateState(_ data: Data) {
|
||||
guard let state = try? JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data) else { return }
|
||||
// Updating a background request must not create a non-hosting window.
|
||||
// The controller creates/orders the panel only after UIKit is foreground.
|
||||
latestState = state
|
||||
panelView?.update(state, shortcutAvailable: shortcutAvailable)
|
||||
if isVisible { resizePanel() }
|
||||
}
|
||||
|
||||
func show() {
|
||||
createPanelIfNeeded()
|
||||
guard let panel, let panelView else { return }
|
||||
panelView.update(latestState, shortcutAvailable: shortcutAvailable)
|
||||
let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) } ?? NSScreen.main
|
||||
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
|
||||
let width = min(680, visibleFrame.width - 48)
|
||||
panel.setContentSize(NSSize(width: width, height: 240))
|
||||
panelView.layoutSubtreeIfNeeded()
|
||||
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
|
||||
let top = visibleFrame.maxY - min(visibleFrame.height * 0.18, 160)
|
||||
let y = max(visibleFrame.minY + 24, top - height)
|
||||
panel.setFrame(NSRect(x: visibleFrame.midX - width / 2, y: y, width: width, height: height), display: true)
|
||||
// The controller has verified UIKit foreground readiness. A
|
||||
// nonactivating panel can then take focus without raising a workspace.
|
||||
panel.makeKeyAndOrderFront(nil)
|
||||
panel.makeFirstResponder(panelView.promptView)
|
||||
panelView.preparePromptForPresentation()
|
||||
installOutsideClickMonitor()
|
||||
}
|
||||
|
||||
func hide() {
|
||||
dismissPanel(restoringPreviousApplication: true)
|
||||
}
|
||||
|
||||
func activateForQuickQuestion() {
|
||||
if let frontmost = NSWorkspace.shared.frontmostApplication,
|
||||
frontmost != NSRunningApplication.current {
|
||||
previousApplication = frontmost
|
||||
}
|
||||
// Do not order a window here. Catalyst must finish its own foreground
|
||||
// transition before the controller calls show().
|
||||
NSApp.activate()
|
||||
}
|
||||
|
||||
func activateMainWindow() {
|
||||
// An explicit Open in Chat/Open Sybil action keeps focus in Sybil.
|
||||
previousApplication = nil
|
||||
NSApp.activate()
|
||||
NSApp.windows.first { $0 !== panel && $0.canBecomeMain }?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
private func dismissPanel(restoringPreviousApplication: Bool) {
|
||||
guard !isDismissing else { return }
|
||||
let wasVisible = isVisible
|
||||
isDismissing = true
|
||||
if wasVisible { panel?.orderOut(nil) }
|
||||
removeOutsideClickMonitor()
|
||||
isDismissing = false
|
||||
let applicationToRestore = previousApplication
|
||||
previousApplication = nil
|
||||
if restoringPreviousApplication, NSApp.isActive,
|
||||
let applicationToRestore, !applicationToRestore.isTerminated {
|
||||
NSApp.yieldActivation(to: applicationToRestore)
|
||||
applicationToRestore.activate(options: [])
|
||||
}
|
||||
if wasVisible { delegate?.quickQuestionPanelDismissed() }
|
||||
}
|
||||
|
||||
func stop() {
|
||||
previousApplication = nil
|
||||
hide()
|
||||
removeOutsideClickMonitor()
|
||||
if let hotKey { UnregisterEventHotKey(hotKey) }
|
||||
if let eventHandler { RemoveEventHandler(eventHandler) }
|
||||
hotKey = nil
|
||||
eventHandler = nil
|
||||
hotKeyState = SybilQuickQuestionHotKeyState()
|
||||
if let windowlessActivity {
|
||||
ProcessInfo.processInfo.endActivity(windowlessActivity)
|
||||
}
|
||||
windowlessActivity = nil
|
||||
delegate = nil
|
||||
}
|
||||
|
||||
func windowDidResignKey(_ notification: Notification) {
|
||||
// Focus is already moving to a window/app the user chose.
|
||||
dismissPanel(restoringPreviousApplication: false)
|
||||
}
|
||||
|
||||
private func createPanelIfNeeded() {
|
||||
guard panel == nil else { return }
|
||||
let panel = QuickQuestionPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 680, height: 240),
|
||||
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false
|
||||
)
|
||||
panel.title = "Quick Question"
|
||||
panel.level = .floating
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]
|
||||
panel.isFloatingPanel = true
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.becomesKeyOnlyIfNeeded = false
|
||||
panel.worksWhenModal = true
|
||||
panel.isReleasedWhenClosed = false
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = true
|
||||
panel.isMovableByWindowBackground = true
|
||||
panel.appearance = NSAppearance(named: .darkAqua)
|
||||
panel.delegate = self
|
||||
panel.onDismiss = { [weak self] in self?.hide() }
|
||||
let view = QuickQuestionPanelView()
|
||||
view.delegate = delegate
|
||||
view.onDismiss = { [weak self] in self?.hide() }
|
||||
panel.contentView = view
|
||||
panelView = view
|
||||
self.panel = panel
|
||||
}
|
||||
|
||||
private func resizePanel() {
|
||||
guard let panel, let panelView else { return }
|
||||
panelView.layoutSubtreeIfNeeded()
|
||||
let visibleFrame = panel.screen?.visibleFrame ?? panel.frame
|
||||
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
|
||||
guard abs(height - panel.frame.height) > 1 else { return }
|
||||
var frame = panel.frame
|
||||
frame.origin.y = max(visibleFrame.minY + 24, frame.maxY - height)
|
||||
frame.size.height = height
|
||||
panel.setFrame(frame, display: true)
|
||||
}
|
||||
|
||||
private func installOutsideClickMonitor() {
|
||||
guard outsideClickMonitor == nil else { return }
|
||||
// Mouse-only monitoring does not require Accessibility or Input
|
||||
// Monitoring permission. No global keyboard event tap is installed.
|
||||
outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in self?.hide() }
|
||||
}
|
||||
}
|
||||
|
||||
private func removeOutsideClickMonitor() {
|
||||
if let outsideClickMonitor { NSEvent.removeMonitor(outsideClickMonitor) }
|
||||
outsideClickMonitor = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class QuickQuestionPanel: NSPanel {
|
||||
var onDismiss: (() -> Void)?
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { false }
|
||||
override func cancelOperation(_ sender: Any?) { onDismiss?() }
|
||||
}
|
||||
Reference in New Issue
Block a user