305 lines
9.5 KiB
Swift
305 lines
9.5 KiB
Swift
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)
|
|
}
|
|
}
|