2026-10-16 23:30:57 -07:00
|
|
|
import AppKit
|
2026-10-17 09:33:27 -07:00
|
|
|
import ByreAPI
|
2026-10-16 23:30:57 -07:00
|
|
|
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
|
2026-10-17 08:49:44 -07:00
|
|
|
private(set) var drafting = false
|
2026-10-16 23:30:57 -07:00
|
|
|
private var sending: Task<Void, Never>?
|
2026-10-18 00:31:46 -07:00
|
|
|
private var observers: [NotificationCenter.ObservationToken] = []
|
2026-10-16 23:30:57 -07:00
|
|
|
private var responder: NSKeyValueObservation?
|
|
|
|
|
private var clicks: Any?
|
|
|
|
|
|
|
|
|
|
init(api: API) {
|
|
|
|
|
self.api = api
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func attach(_ window: NSWindow) {
|
|
|
|
|
if self.window === window {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-18 09:20:01 -07:00
|
|
|
log.notice("pane focus: attach window=\(ObjectIdentifier(window).debugDescription, privacy: .public) title=\(window.title, privacy: .public)")
|
2026-10-16 23:30:57 -07:00
|
|
|
detach()
|
|
|
|
|
self.window = window
|
|
|
|
|
let center = NotificationCenter.default
|
2026-10-18 00:31:46 -07:00
|
|
|
observers = [
|
|
|
|
|
center.addObserver(of: window, for: .didBecomeKey) { [weak self] _ in
|
|
|
|
|
self?.changed(NSWindow.didBecomeKeyNotification)
|
|
|
|
|
},
|
|
|
|
|
center.addObserver(of: window, for: .didResignKey) { [weak self] _ in
|
|
|
|
|
self?.changed(NSWindow.didResignKeyNotification)
|
|
|
|
|
},
|
|
|
|
|
center.addObserver(of: NSApplication.self, for: .didBecomeActive) { [weak self] _ in
|
|
|
|
|
self?.changed(NSApplication.didBecomeActiveNotification)
|
|
|
|
|
},
|
|
|
|
|
center.addObserver(of: NSApplication.self, for: .didResignActive) { [weak self] _ in
|
|
|
|
|
self?.changed(NSApplication.didResignActiveNotification)
|
|
|
|
|
},
|
|
|
|
|
]
|
2026-10-16 23:30:57 -07:00
|
|
|
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() {
|
2026-10-18 09:20:01 -07:00
|
|
|
log.notice("pane focus: detach window=\(self.window.map { ObjectIdentifier($0).debugDescription } ?? "nil", privacy: .public) observers=\(self.observers.count)")
|
2026-10-16 23:30:57 -07:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 00:31:46 -07:00
|
|
|
private func changed(_ name: Notification.Name) {
|
|
|
|
|
logFocus(name.rawValue)
|
|
|
|
|
update()
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 23:30:57 -07:00
|
|
|
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)
|
2026-10-18 09:20:01 -07:00
|
|
|
log.notice("pane focus: responder \(String(describing: type(of: view)), privacy: .public) point=\(p.debugDescription, privacy: .public) frame=\(self.paneFrame.debugDescription, privacy: .public) inside=\(self.inside)")
|
2026-10-16 23:30:57 -07:00
|
|
|
}
|
|
|
|
|
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))
|
2026-10-18 09:20:01 -07:00
|
|
|
log.notice("pane focus: click point=\(p.debugDescription, privacy: .public) flipped=\(content.isFlipped) frame=\(self.paneFrame.debugDescription, privacy: .public) inside=\(self.inside)")
|
2026-10-16 23:30:57 -07:00
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
|
|
|
self?.responderChanged()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func update() {
|
|
|
|
|
let now = (window?.isKeyWindow ?? false) && NSApp.isActive && inside
|
|
|
|
|
let draft = !drafts.isEmpty
|
2026-10-18 09:20:01 -07:00
|
|
|
let attached = window.map { ObjectIdentifier($0).debugDescription } ?? "nil"
|
|
|
|
|
let keyWindow = NSApp.keyWindow.map { ObjectIdentifier($0).debugDescription } ?? "nil"
|
|
|
|
|
log.notice("pane focus: update window=\(attached, privacy: .public) keyWindow=\(keyWindow, privacy: .public) key=\(self.window?.isKeyWindow ?? false) active=\(NSApp.isActive) inside=\(self.inside) now=\(now) draft=\(draft) focused=\(self.focused) drafting=\(self.drafting)")
|
2026-10-16 23:30:57 -07:00
|
|
|
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
|
2026-10-18 09:20:01 -07:00
|
|
|
log.notice("pane focus: sent focused=\(body.focused) drafting=\(body.drafting)")
|
2026-10-16 23:30:57 -07:00
|
|
|
} 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 {
|
2026-10-17 09:57:28 -07:00
|
|
|
if let w = unsafe view.window {
|
2026-10-16 23:30:57 -07:00
|
|
|
found(w)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return view
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func updateNSView(_ nsView: NSView, context: Context) {
|
|
|
|
|
DispatchQueue.main.async {
|
2026-10-17 09:57:28 -07:00
|
|
|
if let w = unsafe nsView.window {
|
2026-10-16 23:30:57 -07:00
|
|
|
found(w)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|