2026-10-16 23:30:57 -07:00
|
|
|
import AppKit
|
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
struct MenuEntry: Identifiable {
|
|
|
|
|
let id: String
|
|
|
|
|
let label: String
|
|
|
|
|
let badge: TabBadge
|
|
|
|
|
let action: () -> Void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct OverlayMenu {
|
|
|
|
|
let anchor: CGRect
|
2026-10-17 10:54:06 -07:00
|
|
|
let accent: Color
|
2026-10-16 23:30:57 -07:00
|
|
|
let entries: [MenuEntry]
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 08:24:22 -07:00
|
|
|
struct ModalButton {
|
|
|
|
|
let label: String
|
|
|
|
|
let value: String
|
|
|
|
|
var primary = false
|
|
|
|
|
var danger = false
|
|
|
|
|
var help = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct OverlayModal {
|
|
|
|
|
let content: AnyView
|
|
|
|
|
let buttons: [ModalButton]
|
2026-10-17 07:26:18 -07:00
|
|
|
let anchor: CGRect?
|
2026-10-17 08:51:59 -07:00
|
|
|
let inPane: Bool
|
2026-10-17 08:24:22 -07:00
|
|
|
let canSubmit: @MainActor () -> Bool
|
|
|
|
|
let done: (String) -> Void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct OverlayAlert: Identifiable {
|
|
|
|
|
let id = UUID()
|
|
|
|
|
let content: AnyView
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enum LeftTab {
|
2026-10-18 16:14:03 -07:00
|
|
|
case lead, status, server, settings
|
2026-10-17 07:26:18 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-16 23:30:57 -07:00
|
|
|
@MainActor
|
|
|
|
|
@Observable
|
|
|
|
|
final class Overlays {
|
2026-10-17 08:24:22 -07:00
|
|
|
var alerts: [OverlayAlert] = []
|
2026-10-16 23:30:57 -07:00
|
|
|
var image: NSImage?
|
2026-10-17 10:54:06 -07:00
|
|
|
var menu: OverlayMenu? {
|
|
|
|
|
didSet {
|
|
|
|
|
menuHover = false
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-17 08:24:22 -07:00
|
|
|
var modal: OverlayModal?
|
|
|
|
|
var switcher: SwitcherModel?
|
|
|
|
|
var leftTab = LeftTab.lead
|
|
|
|
|
var leadFocus = 0
|
|
|
|
|
@ObservationIgnored var pickerFrame: CGRect = .zero
|
2026-10-17 10:54:06 -07:00
|
|
|
@ObservationIgnored var menuHover = false
|
2026-10-17 08:51:59 -07:00
|
|
|
var paneFrame: CGRect = .zero
|
2026-10-16 23:30:57 -07:00
|
|
|
@ObservationIgnored private var monitor: Any?
|
2026-10-17 10:54:06 -07:00
|
|
|
@ObservationIgnored private var mouseMonitor: Any?
|
2026-10-16 23:30:57 -07:00
|
|
|
|
|
|
|
|
var isOpen: Bool {
|
2026-10-17 08:24:22 -07:00
|
|
|
!alerts.isEmpty || image != nil || menu != nil || modal != nil || switcher != nil
|
2026-10-16 23:30:57 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-17 10:54:06 -07:00
|
|
|
var dialogOpen: Bool {
|
|
|
|
|
!alerts.isEmpty || modal != nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 23:30:57 -07:00
|
|
|
func showAlert(_ text: String) {
|
2026-10-17 10:54:06 -07:00
|
|
|
menu = nil
|
2026-10-17 08:24:22 -07:00
|
|
|
alerts.append(OverlayAlert(content: AnyView(ModalText(text: text))))
|
2026-10-16 23:30:57 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-17 08:24:22 -07:00
|
|
|
func showAlert(_ content: some View) {
|
2026-10-17 10:54:06 -07:00
|
|
|
menu = nil
|
2026-10-17 08:24:22 -07:00
|
|
|
alerts.append(OverlayAlert(content: AnyView(content)))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 08:51:59 -07:00
|
|
|
func showModal(_ content: some View, _ buttons: [ModalButton], anchor: CGRect? = nil, inPane: Bool = false, canSubmit: @escaping @MainActor () -> Bool = { true }) async -> String {
|
2026-10-17 08:24:22 -07:00
|
|
|
modal?.done("cancel")
|
2026-10-17 10:54:06 -07:00
|
|
|
menu = nil
|
2026-10-17 07:26:18 -07:00
|
|
|
return await withCheckedContinuation { cont in
|
2026-10-17 08:51:59 -07:00
|
|
|
modal = OverlayModal(content: AnyView(content), buttons: buttons, anchor: anchor, inPane: inPane, canSubmit: canSubmit) { value in
|
2026-10-17 08:24:22 -07:00
|
|
|
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)
|
2026-10-17 07:26:18 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-17 08:24:22 -07:00
|
|
|
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)
|
2026-10-17 07:26:18 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-17 08:24:22 -07:00
|
|
|
func openSwitcher(_ store: Store) {
|
|
|
|
|
if switcher != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-17 10:06:12 -07:00
|
|
|
logFocus("open switcher")
|
2026-10-17 08:24:22 -07:00
|
|
|
switcher = SwitcherModel(store: store, overlays: self, anchor: pickerFrame)
|
2026-10-17 07:26:18 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-16 23:30:57 -07:00
|
|
|
func closeTop() {
|
2026-10-17 08:24:22 -07:00
|
|
|
if !alerts.isEmpty {
|
2026-10-17 10:54:06 -07:00
|
|
|
alerts.removeLast()
|
2026-10-17 08:24:22 -07:00
|
|
|
} else if modal != nil {
|
|
|
|
|
answerModal("cancel")
|
2026-10-16 23:30:57 -07:00
|
|
|
} else if image != nil {
|
|
|
|
|
image = nil
|
2026-10-17 08:24:22 -07:00
|
|
|
} else if let switcher {
|
|
|
|
|
switcher.escape()
|
|
|
|
|
} else if menu != nil {
|
|
|
|
|
menu = nil
|
2026-10-16 23:30:57 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 08:24:22 -07:00
|
|
|
func install(_ ctrl: @escaping @MainActor (String) -> Bool) {
|
2026-10-16 23:30:57 -07:00
|
|
|
if monitor != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
|
|
|
let code = event.keyCode
|
2026-10-17 08:24:22 -07:00
|
|
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
|
|
|
let chars = event.charactersIgnoringModifiers ?? ""
|
2026-10-16 23:30:57 -07:00
|
|
|
let taken = MainActor.assumeIsolated {
|
2026-10-17 08:24:22 -07:00
|
|
|
self?.key(code, flags, chars, ctrl) ?? false
|
2026-10-16 23:30:57 -07:00
|
|
|
}
|
|
|
|
|
return taken ? nil : event
|
|
|
|
|
}
|
2026-10-17 10:54:06 -07:00
|
|
|
mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] event in
|
|
|
|
|
MainActor.assumeIsolated {
|
|
|
|
|
if let self, self.menu != nil, !self.menuHover {
|
|
|
|
|
self.menu = nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return event
|
|
|
|
|
}
|
2026-10-16 23:30:57 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-17 08:24:22 -07:00
|
|
|
private func key(_ code: UInt16, _ flags: NSEvent.ModifierFlags, _ chars: String, _ ctrl: @MainActor (String) -> Bool) -> Bool {
|
2026-10-17 10:54:06 -07:00
|
|
|
if !dialogOpen && flags.contains(.control) && !flags.contains(.command) && goKeys.contains(chars) {
|
|
|
|
|
return ctrl(chars)
|
|
|
|
|
}
|
2026-10-16 23:30:57 -07:00
|
|
|
if !isOpen {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if code == 53 {
|
|
|
|
|
closeTop()
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-10-17 08:24:22 -07:00
|
|
|
let enter = code == 36 || code == 76
|
|
|
|
|
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
|
|
|
|
|
if !alerts.isEmpty {
|
|
|
|
|
if enter {
|
2026-10-17 10:54:06 -07:00
|
|
|
alerts.removeLast()
|
2026-10-17 08:24:22 -07:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
2026-10-17 07:26:18 -07:00
|
|
|
}
|
2026-10-17 08:24:22 -07:00
|
|
|
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
|
|
|
|
|
}
|
2026-10-16 23:30:57 -07:00
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func uninstall() {
|
|
|
|
|
if let monitor {
|
|
|
|
|
NSEvent.removeMonitor(monitor)
|
|
|
|
|
}
|
2026-10-17 10:54:06 -07:00
|
|
|
if let mouseMonitor {
|
|
|
|
|
NSEvent.removeMonitor(mouseMonitor)
|
|
|
|
|
}
|
2026-10-16 23:30:57 -07:00
|
|
|
monitor = nil
|
2026-10-17 10:54:06 -07:00
|
|
|
mouseMonitor = nil
|
2026-10-16 23:30:57 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 10:06:12 -07:00
|
|
|
@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)")
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 08:24:22 -07:00
|
|
|
struct ModalText: View {
|
|
|
|
|
let text: String
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
Text(text)
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 23:30:57 -07:00
|
|
|
struct OverlayHost: View {
|
|
|
|
|
@Environment(Overlays.self) private var overlays
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
ZStack(alignment: .topLeading) {
|
2026-10-17 08:24:22 -07:00
|
|
|
if let switcher = overlays.switcher {
|
|
|
|
|
Color.black.opacity(0.35)
|
|
|
|
|
.contentShape(Rectangle())
|
|
|
|
|
.onTapGesture {
|
|
|
|
|
switcher.close()
|
|
|
|
|
}
|
|
|
|
|
SwitcherPlacement(model: switcher)
|
|
|
|
|
}
|
2026-10-16 23:30:57 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-17 10:54:06 -07:00
|
|
|
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)
|
|
|
|
|
}
|
2026-10-17 08:24:22 -07:00
|
|
|
if let m = overlays.modal {
|
2026-10-17 07:26:18 -07:00
|
|
|
Color.black.opacity(0.35)
|
|
|
|
|
.contentShape(Rectangle())
|
2026-10-17 08:51:59 -07:00
|
|
|
ModalPlacement(modal: m, pane: overlays.paneFrame) { value in
|
2026-10-17 08:24:22 -07:00
|
|
|
overlays.answerModal(value)
|
2026-10-17 07:26:18 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-17 10:54:06 -07:00
|
|
|
if let alert = overlays.alerts.last {
|
2026-10-16 23:30:57 -07:00
|
|
|
Color.black.opacity(0.35)
|
|
|
|
|
.contentShape(Rectangle())
|
2026-10-17 08:24:22 -07:00
|
|
|
GeometryReader { g in
|
|
|
|
|
AlertBox(alert: alert, maxText: max(60, g.size.height - 48 - 90)) {
|
2026-10-17 10:54:06 -07:00
|
|
|
overlays.alerts.removeLast()
|
2026-10-17 08:24:22 -07:00
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
2026-10-16 23:30:57 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.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 }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 08:24:22 -07:00
|
|
|
let overlayMargin: CGFloat = 8
|
2026-10-17 07:26:18 -07:00
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 08:51:59 -07:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 08:24:22 -07:00
|
|
|
private struct ModalPlacement: View {
|
|
|
|
|
let modal: OverlayModal
|
2026-10-17 08:51:59 -07:00
|
|
|
let pane: CGRect
|
2026-10-17 08:24:22 -07:00
|
|
|
let answer: (String) -> Void
|
2026-10-17 07:26:18 -07:00
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
GeometryReader { g in
|
2026-10-17 08:24:22 -07:00
|
|
|
let box = ModalBox(modal: modal, maxText: max(60, g.size.height - overlayMargin * 2 - 90), answer: answer)
|
|
|
|
|
if let anchor = modal.anchor {
|
2026-10-17 07:26:18 -07:00
|
|
|
AnchoredBox(anchor: anchor) {
|
|
|
|
|
box
|
|
|
|
|
}
|
2026-10-17 08:51:59 -07:00
|
|
|
} else if modal.inPane {
|
|
|
|
|
PaneCenteredBox(pane: pane) {
|
|
|
|
|
box
|
|
|
|
|
}
|
2026-10-17 07:26:18 -07:00
|
|
|
} else {
|
|
|
|
|
box
|
|
|
|
|
.padding(24)
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 08:24:22 -07:00
|
|
|
private struct ModalBox: View {
|
|
|
|
|
let modal: OverlayModal
|
2026-10-17 07:26:18 -07:00
|
|
|
let maxText: CGFloat
|
2026-10-17 08:24:22 -07:00
|
|
|
let answer: (String) -> Void
|
2026-10-17 07:26:18 -07:00
|
|
|
|
|
|
|
|
var body: some View {
|
2026-10-17 08:24:22 -07:00
|
|
|
let ok = modal.canSubmit()
|
2026-10-17 07:26:18 -07:00
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
ScrollView {
|
2026-10-17 08:24:22 -07:00
|
|
|
modal.content
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
2026-10-17 07:26:18 -07:00
|
|
|
}
|
|
|
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
|
|
|
.frame(maxHeight: maxText)
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
Spacer()
|
2026-10-17 08:24:22 -07:00
|
|
|
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)
|
2026-10-17 07:26:18 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.padding(.top, 4)
|
|
|
|
|
}
|
|
|
|
|
.padding(16)
|
|
|
|
|
.frame(maxWidth: 520)
|
|
|
|
|
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
|
|
|
|
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
|
2026-10-17 10:54:06 -07:00
|
|
|
.shadow(color: overlayShadow, radius: 14, y: 8)
|
2026-10-17 07:26:18 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 23:30:57 -07:00
|
|
|
private struct AlertBox: View {
|
2026-10-17 08:24:22 -07:00
|
|
|
let alert: OverlayAlert
|
|
|
|
|
let maxText: CGFloat
|
2026-10-16 23:30:57 -07:00
|
|
|
let ok: () -> Void
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 16) {
|
2026-10-17 08:24:22 -07:00
|
|
|
ScrollView {
|
|
|
|
|
alert.content
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
}
|
|
|
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
|
|
|
.frame(maxHeight: maxText)
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
2026-10-16 23:30:57 -07:00
|
|
|
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))
|
2026-10-17 10:54:06 -07:00
|
|
|
.shadow(color: overlayShadow, radius: 14, y: 8)
|
2026-10-16 23:30:57 -07:00
|
|
|
.padding(24)
|
|
|
|
|
}
|
|
|
|
|
}
|