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 accent: Color let entries: [MenuEntry] } struct ModalButton { let label: String let value: String var primary = false var danger = false var help = "" } struct OverlayModal { let content: AnyView let buttons: [ModalButton] let anchor: CGRect? let inPane: Bool let canSubmit: @MainActor () -> Bool let done: (String) -> Void } struct OverlayAlert: Identifiable { let id = UUID() let content: AnyView } enum LeftTab { case lead, status, server, settings } @MainActor @Observable final class Overlays { var alerts: [OverlayAlert] = [] var image: NSImage? var menu: OverlayMenu? { didSet { menuHover = false } } var modal: OverlayModal? var switcher: SwitcherModel? var leftTab = LeftTab.lead var leadFocus = 0 @ObservationIgnored var pickerFrame: CGRect = .zero @ObservationIgnored var menuHover = false var paneFrame: CGRect = .zero @ObservationIgnored weak var window: NSWindow? @ObservationIgnored private var monitor: Any? @ObservationIgnored private var mouseMonitor: Any? var isOpen: Bool { !alerts.isEmpty || image != nil || menu != nil || modal != nil || switcher != nil } var dialogOpen: Bool { !alerts.isEmpty || modal != nil } func showAlert(_ text: String) { menu = nil alerts.append(OverlayAlert(content: AnyView(ModalText(text: text)))) } func showAlert(_ content: some View) { menu = nil alerts.append(OverlayAlert(content: AnyView(content))) } func showModal(_ content: some View, _ buttons: [ModalButton], anchor: CGRect? = nil, inPane: Bool = false, canSubmit: @escaping @MainActor () -> Bool = { true }) async -> String { modal?.done("cancel") menu = nil return await withCheckedContinuation { cont in modal = OverlayModal(content: AnyView(content), buttons: buttons, anchor: anchor, inPane: inPane, canSubmit: canSubmit) { value in cont.resume(returning: value) } } } func showConfirm(_ paragraphs: [String], action: String, danger: Bool = false, anchor: CGRect? = nil) async -> Bool { let content = VStack(alignment: .leading, spacing: 12) { ForEach(Array(paragraphs.enumerated()), id: \.offset) { _, p in ModalText(text: p) } } return await showModal(content, [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: action, value: "ok", primary: true, danger: danger)], anchor: anchor) == "ok" } func showChoice(_ content: some View, _ choices: [ModalButton], anchor: CGRect? = nil) async -> String { await showModal(content, [ModalButton(label: "Cancel", value: "cancel")] + choices, anchor: anchor) } func answerModal(_ value: String) { let m = modal modal = nil m?.done(value) } func openSwitcher(_ store: Store) { if switcher != nil { return } logFocus("open switcher") switcher = SwitcherModel(store: store, overlays: self, anchor: pickerFrame) } func closeTop() { if !alerts.isEmpty { alerts.removeLast() } else if modal != nil { answerModal("cancel") } else if image != nil { image = nil } else if let switcher { switcher.escape() } else if menu != nil { menu = nil } } func install(_ ctrl: @escaping @MainActor (String) -> Bool) { if monitor != nil { return } monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in let win = event.window.map(ObjectIdentifier.init) let code = event.keyCode let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) let chars = event.charactersIgnoringModifiers ?? "" let taken = MainActor.assumeIsolated { guard let self, let window = self.window, win == ObjectIdentifier(window) else { return false } return self.key(code, flags, chars, ctrl) } return taken ? nil : event } mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] event in let win = event.window.map(ObjectIdentifier.init) MainActor.assumeIsolated { if let self, let window = self.window, win == ObjectIdentifier(window), self.menu != nil, !self.menuHover { self.menu = nil } } return event } } private func key(_ code: UInt16, _ flags: NSEvent.ModifierFlags, _ chars: String, _ ctrl: @MainActor (String) -> Bool) -> Bool { if !dialogOpen && flags.contains(.control) && !flags.contains(.command) && goKeys.contains(chars) { return ctrl(chars) } if !isOpen { return false } if code == 53 { closeTop() return true } let enter = code == 36 || code == 76 let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false if !alerts.isEmpty { if enter { alerts.removeLast() return true } return false } if let modal { if enter && !marked { if modal.canSubmit(), let last = modal.buttons.last { answerModal(last.value) } return true } return false } if image == nil, !marked, let switcher { if code == 125 { switcher.move(1) return true } if code == 126 { switcher.move(-1) return true } if enter { switcher.pick() return true } } return false } func uninstall() { if let monitor { NSEvent.removeMonitor(monitor) } if let mouseMonitor { NSEvent.removeMonitor(mouseMonitor) } monitor = nil mouseMonitor = nil } } @MainActor func logFocus(_ what: String) { let key = NSApp.keyWindow let main = NSApp.mainWindow let responder = key?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" log.notice("focus debug: \(what, privacy: .public) active=\(NSApp.isActive) key=\(key?.title ?? "nil", privacy: .public) main=\(main?.title ?? "nil", privacy: .public) mainIsKey=\(main?.isKeyWindow ?? false) responder=\(responder, privacy: .public)") } struct ModalText: View { let text: String var body: some View { Text(text) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } } struct OverlayHost: View { @Environment(Overlays.self) private var overlays var body: some View { ZStack(alignment: .topLeading) { if let switcher = overlays.switcher { Color.black.opacity(0.35) .contentShape(Rectangle()) .onTapGesture { switcher.close() } SwitcherPlacement(model: switcher) } 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 menu = overlays.menu { 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: overlayShadow, radius: 14, y: 8) .environment(\.paneAccent, menu.accent) .onHover { inside in overlays.menuHover = inside } .fixedSize() .offset(x: menu.anchor.minX, y: menu.anchor.maxY + 4) } if let m = overlays.modal { Color.black.opacity(0.35) .contentShape(Rectangle()) ModalPlacement(modal: m, pane: overlays.paneFrame) { value in overlays.answerModal(value) } } if let alert = overlays.alerts.last { Color.black.opacity(0.35) .contentShape(Rectangle()) GeometryReader { g in AlertBox(alert: alert, maxText: max(60, g.size.height - 48 - 90)) { overlays.alerts.removeLast() } .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 } } } 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 PaneCenteredBox: Layout { let pane: 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, pane.width - 48) let size = subviews[0].sizeThatFits(ProposedViewSize(width: width, height: nil)) let x = pane.midX - size.width / 2 let y = min(max(overlayMargin, pane.midY - size.height / 2), bounds.height - overlayMargin - size.height) subviews[0].place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + y), proposal: ProposedViewSize(width: width, height: size.height)) } } private struct ModalPlacement: View { let modal: OverlayModal let pane: CGRect let answer: (String) -> Void var body: some View { GeometryReader { g in let box = ModalBox(modal: modal, maxText: max(60, g.size.height - overlayMargin * 2 - 90), answer: answer) if let anchor = modal.anchor { AnchoredBox(anchor: anchor) { box } } else if modal.inPane { PaneCenteredBox(pane: pane) { box } } else { box .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) } } } } private struct ModalBox: View { let modal: OverlayModal let maxText: CGFloat let answer: (String) -> Void var body: some View { let ok = modal.canSubmit() VStack(alignment: .leading, spacing: 12) { ScrollView { modal.content .frame(maxWidth: .infinity, alignment: .leading) } .scrollBounceBehavior(.basedOnSize) .frame(maxHeight: maxText) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 8) { Spacer() ForEach(Array(modal.buttons.enumerated()), id: \.offset) { _, b in Button(b.label) { answer(b.value) } .buttonStyle(WebButtonStyle(primary: b.primary, danger: b.danger)) .disabled(b.value != "cancel" && !ok) .help(b.help) } } .padding(.top, 4) } .padding(16) .frame(maxWidth: 520) .background(Palette.raised, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border)) .shadow(color: overlayShadow, radius: 14, y: 8) } } private struct AlertBox: View { let alert: OverlayAlert let maxText: CGFloat let ok: () -> Void var body: some View { VStack(alignment: .leading, spacing: 16) { ScrollView { alert.content .frame(maxWidth: .infinity, alignment: .leading) } .scrollBounceBehavior(.basedOnSize) .frame(maxHeight: maxText) .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: overlayShadow, radius: 14, y: 8) .padding(24) } }