Files
byre/mac/Sources/Byre/Dialogs.swift
T

434 lines
14 KiB
Swift
Raw Normal View History

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
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]
2026-10-17 07:26:18 -07:00
let anchor: CGRect?
2026-10-17 08:51:59 -07:00
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, settings
2026-10-17 07:26:18 -07:00
}
2026-10-16 23:30:57 -07:00
@MainActor
@Observable
final class Overlays {
var alerts: [OverlayAlert] = []
2026-10-16 23:30:57 -07:00
var image: NSImage?
var menu: OverlayMenu?
var modal: OverlayModal?
var switcher: SwitcherModel?
var leftTab = LeftTab.lead
var leadFocus = 0
@ObservationIgnored var pickerFrame: CGRect = .zero
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?
var isOpen: Bool {
!alerts.isEmpty || image != nil || menu != nil || modal != nil || switcher != nil
2026-10-16 23:30:57 -07:00
}
func showAlert(_ text: String) {
alerts.append(OverlayAlert(content: AnyView(ModalText(text: text))))
2026-10-16 23:30:57 -07:00
}
func showAlert(_ content: some View) {
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 {
modal?.done("cancel")
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
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
}
}
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
}
func openSwitcher(_ store: Store) {
if switcher != nil {
return
}
2026-10-17 10:06:12 -07:00
logFocus("open switcher")
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() {
if !alerts.isEmpty {
alerts.removeFirst()
} else if modal != nil {
answerModal("cancel")
2026-10-16 23:30:57 -07:00
} else if image != nil {
image = nil
} else if let switcher {
switcher.escape()
} else if menu != nil {
menu = nil
2026-10-16 23:30:57 -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
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
let chars = event.charactersIgnoringModifiers ?? ""
2026-10-16 23:30:57 -07:00
let taken = MainActor.assumeIsolated {
self?.key(code, flags, chars, ctrl) ?? false
2026-10-16 23:30:57 -07:00
}
return taken ? nil : event
}
}
private func key(_ code: UInt16, _ flags: NSEvent.ModifierFlags, _ chars: String, _ ctrl: @MainActor (String) -> Bool) -> Bool {
2026-10-16 23:30:57 -07:00
if !isOpen {
if flags.contains(.control) && !flags.contains(.command) && goKeys.contains(chars) {
return ctrl(chars)
}
2026-10-16 23:30:57 -07:00
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.removeFirst()
return true
}
return false
2026-10-17 07:26:18 -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)
}
monitor = nil
}
}
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)")
}
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) {
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 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
}
}
if let m = overlays.modal {
2026-10-17 07:26:18 -07:00
Color.black.opacity(0.35)
.contentShape(Rectangle())
.onTapGesture {
overlays.answerModal("cancel")
2026-10-17 07:26:18 -07:00
}
2026-10-17 08:51:59 -07:00
ModalPlacement(modal: m, pane: overlays.paneFrame) { value in
overlays.answerModal(value)
2026-10-17 07:26:18 -07:00
}
}
if let alert = overlays.alerts.first {
2026-10-16 23:30:57 -07:00
Color.black.opacity(0.35)
.contentShape(Rectangle())
GeometryReader { g in
AlertBox(alert: alert, maxText: max(60, g.size.height - 48 - 90)) {
overlays.alerts.removeFirst()
}
.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 }
}
}
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))
}
}
private struct ModalPlacement: View {
let modal: OverlayModal
2026-10-17 08:51:59 -07:00
let pane: CGRect
let answer: (String) -> Void
2026-10-17 07:26:18 -07:00
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 {
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)
}
}
}
}
private struct ModalBox: View {
let modal: OverlayModal
2026-10-17 07:26:18 -07:00
let maxText: CGFloat
let answer: (String) -> Void
2026-10-17 07:26:18 -07:00
var body: some View {
let ok = modal.canSubmit()
2026-10-17 07:26:18 -07:00
VStack(alignment: .leading, spacing: 12) {
ScrollView {
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()
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))
.shadow(color: .black.opacity(0.18), radius: 14, y: 8)
}
}
2026-10-16 23:30:57 -07:00
private struct AlertBox: View {
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) {
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))
.shadow(color: .black.opacity(0.18), radius: 14, y: 8)
.padding(24)
}
}