macOS client panes and transcript

This commit is contained in:
Ian Gulliver
2026-10-16 23:30:57 -07:00
parent 14d7ad4631
commit 00cd1ed958
23 changed files with 5059 additions and 739 deletions
+176
View File
@@ -0,0 +1,176 @@
import AppKit
import SwiftUI
struct MenuEntry: Identifiable {
let id: String
let label: String
let badge: TabBadge
let action: () -> Void
}
struct OverlayMenu {
let anchor: CGRect
let entries: [MenuEntry]
}
@MainActor
@Observable
final class Overlays {
var alerts: [String] = []
var image: NSImage?
var menu: OverlayMenu?
@ObservationIgnored private var monitor: Any?
var isOpen: Bool {
!alerts.isEmpty || image != nil || menu != nil
}
func showAlert(_ text: String) {
alerts.append(text)
}
func closeTop() {
if menu != nil {
menu = nil
} else if image != nil {
image = nil
} else if !alerts.isEmpty {
alerts.removeFirst()
}
}
func install() {
if monitor != nil {
return
}
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
let code = event.keyCode
let taken = MainActor.assumeIsolated {
self?.key(code) ?? false
}
return taken ? nil : event
}
}
private func key(_ code: UInt16) -> Bool {
if !isOpen {
return false
}
if code == 53 {
closeTop()
return true
}
if menu == nil && image == nil && !alerts.isEmpty && (code == 36 || code == 76) {
alerts.removeFirst()
return true
}
return false
}
func uninstall() {
if let monitor {
NSEvent.removeMonitor(monitor)
}
monitor = nil
}
}
struct OverlayHost: View {
@Environment(Overlays.self) private var overlays
var body: some View {
ZStack(alignment: .topLeading) {
if let menu = overlays.menu {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
overlays.menu = nil
}
VStack(alignment: .leading, spacing: 0) {
ForEach(menu.entries) { entry in
MenuRow(entry: entry) {
overlays.menu = nil
entry.action()
}
}
}
.padding(4)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
.shadow(color: .black.opacity(0.18), radius: 14, y: 8)
.fixedSize()
.offset(x: menu.anchor.minX, y: menu.anchor.maxY + 4)
}
if let image = overlays.image {
Color.black.opacity(0.6)
.onTapGesture {
overlays.image = nil
}
Image(nsImage: image)
.resizable()
.scaledToFit()
.frame(maxWidth: image.size.width, maxHeight: image.size.height)
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture {
overlays.image = nil
}
}
if let text = overlays.alerts.first {
Color.black.opacity(0.35)
.contentShape(Rectangle())
AlertBox(text: text) {
overlays.alerts.removeFirst()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
private struct MenuRow: View {
let entry: MenuEntry
let action: () -> Void
@ViewState private var hover = false
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
Text(entry.label)
TabBadgeView(badge: entry.badge)
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.background(hover ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 4))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hover = $0 }
}
}
private struct AlertBox: View {
let text: String
let ok: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(text)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Button("OK", action: ok)
.buttonStyle(WebButtonStyle(primary: true))
}
}
.padding(16)
.frame(maxWidth: 520)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
.shadow(color: .black.opacity(0.18), radius: 14, y: 8)
.padding(24)
}
}