Files

461 lines
15 KiB
Swift

import AppKit
import SwiftUI
struct MenuEntry: Identifiable {
let id: String
let label: String
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, notes, workflows, 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)
}
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
if code == 53 {
if isOpen {
closeTop()
return true
}
return !marked && !terminalFocused()
}
if !isOpen {
return false
}
let enter = code == 36 || code == 76
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 && !flags.contains(.shift) {
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) {
Text(entry.label)
.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)
}
}