Port the remaining tabs to the macOS client

This commit is contained in:
Ian Gulliver
2026-10-17 07:26:18 -07:00
parent 9b3f775dac
commit e28c313854
15 changed files with 3648 additions and 50 deletions
+129 -1
View File
@@ -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