Port the remaining tabs to the macOS client
This commit is contained in:
@@ -13,25 +13,51 @@ struct OverlayMenu {
|
||||
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
|
||||
!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 {
|
||||
@@ -60,6 +86,10 @@ final class Overlays {
|
||||
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
|
||||
@@ -116,6 +146,16 @@ struct OverlayHost: View {
|
||||
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())
|
||||
@@ -151,6 +191,94 @@ private struct MenuRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user