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 = [] private var inside = false private var focused = false private var drafting = false private var sending: Task? 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) } } } }