Files
byre/mac/Sources/Byre/PaneFocus.swift
T

201 lines
5.7 KiB
Swift
Raw Normal View History

2026-10-16 23:30:57 -07:00
import AppKit
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
}
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() {
for o in observers {
NotificationCenter.default.removeObserver(o)
}
observers = []
responder = nil
if let clicks {
NSEvent.removeMonitor(clicks)
}
clicks = nil
window = nil
}
var windowKey: Bool {
(window?.isKeyWindow ?? false) && NSApp.isActive
}
2026-10-16 23:30:57 -07:00
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, !view.visibleRect.isEmpty else {
2026-10-16 23:30:57 -07:00
update()
return
}
if let p = contentPoint(view.visibleRect, in: view) {
2026-10-16 23:30:57 -07:00
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) -> WindowReaderView {
WindowReaderView(found: found)
2026-10-16 23:30:57 -07:00
}
func updateNSView(_ nsView: WindowReaderView, context: Context) {}
}
final class WindowReaderView: NSView {
private let found: (NSWindow) -> Void
init(found: @escaping (NSWindow) -> Void) {
self.found = found
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if let w = unsafe window {
found(w)
2026-10-16 23:30:57 -07:00
}
}
}