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] } struct OverlayConfirm { let paragraphs: [String] let action: String let danger: Bool let anchor: CGRect? let done: (Bool) -> Void } @MainActor @Observable final class Overlays { var alerts: [String] = [] var image: NSImage? var menu: OverlayMenu? var confirm: OverlayConfirm? @ObservationIgnored private var monitor: Any? var isOpen: Bool { !alerts.isEmpty || image != nil || menu != nil || confirm != nil } func showAlert(_ text: String) { alerts.append(text) } func showConfirm(_ paragraphs: [String], action: String, danger: Bool = false, anchor: CGRect? = nil) async -> Bool { confirm?.done(false) return await withCheckedContinuation { cont in confirm = OverlayConfirm(paragraphs: paragraphs, action: action, danger: danger, anchor: anchor) { ok in cont.resume(returning: ok) } } } func answerConfirm(_ ok: Bool) { let c = confirm confirm = nil c?.done(ok) } func closeTop() { if menu != nil { menu = nil } else if confirm != nil { answerConfirm(false) } 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 && confirm != nil && (code == 36 || code == 76) { answerConfirm(true) 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 c = overlays.confirm { Color.black.opacity(0.35) .contentShape(Rectangle()) .onTapGesture { overlays.answerConfirm(false) } ConfirmPlacement(confirm: c) { ok in overlays.answerConfirm(ok) } } 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 let overlayMargin: CGFloat = 8 private struct AnchoredBox: Layout { let anchor: CGRect func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { proposal.replacingUnspecifiedDimensions() } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let width = min(520, bounds.width - overlayMargin * 2) let size = subviews[0].sizeThatFits(ProposedViewSize(width: width, height: nil)) let below = anchor.maxY + 4 let above = anchor.minY - 4 - size.height let y: CGFloat if below + size.height <= bounds.height - overlayMargin { y = below } else if above >= overlayMargin { y = above } else { y = max(overlayMargin, bounds.height - overlayMargin - size.height) } let x = min(max(overlayMargin, anchor.maxX - size.width), bounds.width - overlayMargin - size.width) subviews[0].place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + y), proposal: ProposedViewSize(width: width, height: size.height)) } } private struct ConfirmPlacement: View { let confirm: OverlayConfirm let answer: (Bool) -> Void var body: some View { GeometryReader { g in let box = ConfirmBox(confirm: confirm, maxText: max(60, g.size.height - overlayMargin * 2 - 90), answer: answer) if let anchor = confirm.anchor { AnchoredBox(anchor: anchor) { box } } else { box .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) } } } } private struct ConfirmBox: View { let confirm: OverlayConfirm let maxText: CGFloat let answer: (Bool) -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { ScrollView { VStack(alignment: .leading, spacing: 12) { ForEach(Array(confirm.paragraphs.enumerated()), id: \.offset) { _, p in Text(p) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } } .frame(maxWidth: .infinity, alignment: .leading) } .scrollBounceBehavior(.basedOnSize) .frame(maxHeight: maxText) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 8) { Spacer() Button("Cancel") { answer(false) } .buttonStyle(WebButtonStyle()) Button(confirm.action) { answer(true) } .buttonStyle(WebButtonStyle(primary: true, danger: confirm.danger)) } .padding(.top, 4) } .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) } } 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) } }