macOS client panes and transcript
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class PaneFocus {
|
||||
private let api: API
|
||||
private weak var window: NSWindow?
|
||||
private var paneFrame: CGRect = .zero
|
||||
private var drafts: Set<String> = []
|
||||
private var inside = false
|
||||
private var focused = false
|
||||
private var drafting = false
|
||||
private var sending: Task<Void, Never>?
|
||||
private var observers: [any NSObjectProtocol] = []
|
||||
private var responder: NSKeyValueObservation?
|
||||
private var clicks: Any?
|
||||
|
||||
init(api: API) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
func attach(_ window: NSWindow) {
|
||||
if self.window === window {
|
||||
return
|
||||
}
|
||||
detach()
|
||||
self.window = window
|
||||
let center = NotificationCenter.default
|
||||
for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification] {
|
||||
observers.append(center.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.update()
|
||||
}
|
||||
})
|
||||
}
|
||||
for name in [NSApplication.didBecomeActiveNotification, NSApplication.didResignActiveNotification] {
|
||||
observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.update()
|
||||
}
|
||||
})
|
||||
}
|
||||
responder = window.observe(\.firstResponder, options: [.new]) { [weak self] _, _ in
|
||||
Task { @MainActor in
|
||||
self?.responderChanged()
|
||||
}
|
||||
}
|
||||
clicks = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
|
||||
let win = event.window.map(ObjectIdentifier.init)
|
||||
let point = event.locationInWindow
|
||||
MainActor.assumeIsolated {
|
||||
if let self, let window = self.window, win == ObjectIdentifier(window) {
|
||||
self.clicked(point)
|
||||
}
|
||||
}
|
||||
return event
|
||||
}
|
||||
responderChanged()
|
||||
}
|
||||
|
||||
func detach() {
|
||||
for o in observers {
|
||||
NotificationCenter.default.removeObserver(o)
|
||||
}
|
||||
observers = []
|
||||
responder = nil
|
||||
if let clicks {
|
||||
NSEvent.removeMonitor(clicks)
|
||||
}
|
||||
clicks = nil
|
||||
window = nil
|
||||
}
|
||||
|
||||
func setFrame(_ frame: CGRect) {
|
||||
paneFrame = frame
|
||||
}
|
||||
|
||||
func setDraft(_ id: String, _ nonEmpty: Bool) {
|
||||
if nonEmpty {
|
||||
drafts.insert(id)
|
||||
} else {
|
||||
drafts.remove(id)
|
||||
}
|
||||
update()
|
||||
}
|
||||
|
||||
func reconnected() {
|
||||
if focused {
|
||||
send()
|
||||
}
|
||||
}
|
||||
|
||||
func leave() async {
|
||||
detach()
|
||||
if !focused {
|
||||
return
|
||||
}
|
||||
focused = false
|
||||
await sending?.value
|
||||
do {
|
||||
_ = try await api.client.setPaneFocus(body: .json(.init(focused: false, drafting: drafting))).noContent
|
||||
} catch {
|
||||
log.error("set pane focus: \(errorText(error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func contentPoint(_ rect: CGRect, in view: NSView) -> CGPoint? {
|
||||
guard let content = window?.contentView else {
|
||||
return nil
|
||||
}
|
||||
let r = content.convert(rect, from: view)
|
||||
return CGPoint(x: r.midX, y: content.isFlipped ? r.midY : content.bounds.height - r.midY)
|
||||
}
|
||||
|
||||
private func responderChanged() {
|
||||
guard let view = window?.firstResponder as? NSView, view !== window?.contentView else {
|
||||
update()
|
||||
return
|
||||
}
|
||||
if let p = contentPoint(view.bounds, in: view) {
|
||||
inside = paneFrame.contains(p)
|
||||
}
|
||||
update()
|
||||
}
|
||||
|
||||
private func clicked(_ location: CGPoint) {
|
||||
guard let content = window?.contentView else {
|
||||
return
|
||||
}
|
||||
let p = content.convert(location, from: nil)
|
||||
inside = paneFrame.contains(CGPoint(x: p.x, y: content.isFlipped ? p.y : content.bounds.height - p.y))
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.responderChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private func update() {
|
||||
let now = (window?.isKeyWindow ?? false) && NSApp.isActive && inside
|
||||
let draft = !drafts.isEmpty
|
||||
if now == focused && draft == drafting {
|
||||
return
|
||||
}
|
||||
focused = now
|
||||
drafting = draft
|
||||
send()
|
||||
}
|
||||
|
||||
private func send() {
|
||||
let body = Components.Schemas.PaneFocus(focused: focused, drafting: drafting)
|
||||
let previous = sending
|
||||
sending = Task { [api] in
|
||||
await previous?.value
|
||||
do {
|
||||
_ = try await api.client.setPaneFocus(body: .json(body)).noContent
|
||||
} catch {
|
||||
log.error("set pane focus: \(errorText(error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WindowReader: NSViewRepresentable {
|
||||
let found: (NSWindow) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
DispatchQueue.main.async {
|
||||
if let w = view.window {
|
||||
found(w)
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
DispatchQueue.main.async {
|
||||
if let w = nsView.window {
|
||||
found(w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user