macOS client overlays, menus and notifications

This commit is contained in:
Ian Gulliver
2026-10-17 08:24:22 -07:00
parent b43cffab6d
commit 38d56139c3
17 changed files with 1297 additions and 101 deletions
+166 -68
View File
@@ -13,86 +13,163 @@ struct OverlayMenu {
let entries: [MenuEntry]
}
struct OverlayConfirm {
let paragraphs: [String]
let action: String
let danger: Bool
struct ModalButton {
let label: String
let value: String
var primary = false
var danger = false
var help = ""
}
struct OverlayModal {
let content: AnyView
let buttons: [ModalButton]
let anchor: CGRect?
let done: (Bool) -> Void
let canSubmit: @MainActor () -> Bool
let done: (String) -> Void
}
struct OverlayAlert: Identifiable {
let id = UUID()
let content: AnyView
}
enum LeftTab {
case lead, status, settings
}
@MainActor
@Observable
final class Overlays {
var alerts: [String] = []
var alerts: [OverlayAlert] = []
var image: NSImage?
var menu: OverlayMenu?
var confirm: OverlayConfirm?
var modal: OverlayModal?
var switcher: SwitcherModel?
var leftTab = LeftTab.lead
var leadFocus = 0
@ObservationIgnored var pickerFrame: CGRect = .zero
@ObservationIgnored var mergeFrame: CGRect?
@ObservationIgnored private var monitor: Any?
var isOpen: Bool {
!alerts.isEmpty || image != nil || menu != nil || confirm != nil
!alerts.isEmpty || image != nil || menu != nil || modal != nil || switcher != nil
}
func showAlert(_ text: String) {
alerts.append(text)
alerts.append(OverlayAlert(content: AnyView(ModalText(text: text))))
}
func showConfirm(_ paragraphs: [String], action: String, danger: Bool = false, anchor: CGRect? = nil) async -> Bool {
confirm?.done(false)
func showAlert(_ content: some View) {
alerts.append(OverlayAlert(content: AnyView(content)))
}
func showModal(_ content: some View, _ buttons: [ModalButton], anchor: CGRect? = nil, canSubmit: @escaping @MainActor () -> Bool = { true }) async -> String {
modal?.done("cancel")
return await withCheckedContinuation { cont in
confirm = OverlayConfirm(paragraphs: paragraphs, action: action, danger: danger, anchor: anchor) { ok in
cont.resume(returning: ok)
modal = OverlayModal(content: AnyView(content), buttons: buttons, anchor: anchor, canSubmit: canSubmit) { value in
cont.resume(returning: value)
}
}
}
func answerConfirm(_ ok: Bool) {
let c = confirm
confirm = nil
c?.done(ok)
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)
}
}
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)
}
func openSwitcher(_ store: Store) {
if switcher != nil {
return
}
switcher = SwitcherModel(store: store, overlays: self, anchor: pickerFrame)
}
func closeTop() {
if menu != nil {
menu = nil
} else if confirm != nil {
answerConfirm(false)
if !alerts.isEmpty {
alerts.removeFirst()
} else if modal != nil {
answerModal("cancel")
} else if image != nil {
image = nil
} else if !alerts.isEmpty {
alerts.removeFirst()
} else if let switcher {
switcher.escape()
} else if menu != nil {
menu = nil
}
}
func install() {
func install(_ ctrl: @escaping @MainActor (String) -> Bool) {
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 ?? ""
let taken = MainActor.assumeIsolated {
self?.key(code) ?? false
self?.key(code, flags, chars, ctrl) ?? false
}
return taken ? nil : event
}
}
private func key(_ code: UInt16) -> Bool {
private func key(_ code: UInt16, _ flags: NSEvent.ModifierFlags, _ chars: String, _ ctrl: @MainActor (String) -> Bool) -> Bool {
if !isOpen {
if flags.contains(.control) && !flags.contains(.command) && goKeys.contains(chars) {
return ctrl(chars)
}
return false
}
if code == 53 {
closeTop()
return true
}
if menu == nil && confirm != nil && (code == 36 || code == 76) {
answerConfirm(true)
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
}
if menu == nil && image == nil && !alerts.isEmpty && (code == 36 || code == 76) {
alerts.removeFirst()
return true
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
}
}
return false
}
@@ -105,6 +182,17 @@ final class Overlays {
}
}
struct ModalText: View {
let text: String
var body: some View {
Text(text)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct OverlayHost: View {
@Environment(Overlays.self) private var overlays
@@ -131,6 +219,14 @@ struct OverlayHost: View {
.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)
}
if let image = overlays.image {
Color.black.opacity(0.6)
.onTapGesture {
@@ -146,23 +242,25 @@ struct OverlayHost: View {
overlays.image = nil
}
}
if let c = overlays.confirm {
if let m = overlays.modal {
Color.black.opacity(0.35)
.contentShape(Rectangle())
.onTapGesture {
overlays.answerConfirm(false)
overlays.answerModal("cancel")
}
ConfirmPlacement(confirm: c) { ok in
overlays.answerConfirm(ok)
ModalPlacement(modal: m) { value in
overlays.answerModal(value)
}
}
if let text = overlays.alerts.first {
if let alert = overlays.alerts.first {
Color.black.opacity(0.35)
.contentShape(Rectangle())
AlertBox(text: text) {
overlays.alerts.removeFirst()
GeometryReader { g in
AlertBox(alert: alert, maxText: max(60, g.size.height - 48 - 90)) {
overlays.alerts.removeFirst()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
@@ -191,7 +289,7 @@ private struct MenuRow: View {
}
}
private let overlayMargin: CGFloat = 8
let overlayMargin: CGFloat = 8
private struct AnchoredBox: Layout {
let anchor: CGRect
@@ -218,14 +316,14 @@ private struct AnchoredBox: Layout {
}
}
private struct ConfirmPlacement: View {
let confirm: OverlayConfirm
let answer: (Bool) -> Void
private struct ModalPlacement: View {
let modal: OverlayModal
let answer: (String) -> 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 {
let box = ModalBox(modal: modal, maxText: max(60, g.size.height - overlayMargin * 2 - 90), answer: answer)
if let anchor = modal.anchor {
AnchoredBox(anchor: anchor) {
box
}
@@ -238,36 +336,31 @@ private struct ConfirmPlacement: View {
}
}
private struct ConfirmBox: View {
let confirm: OverlayConfirm
private struct ModalBox: View {
let modal: OverlayModal
let maxText: CGFloat
let answer: (Bool) -> Void
let answer: (String) -> Void
var body: some View {
let ok = modal.canSubmit()
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)
modal.content
.frame(maxWidth: .infinity, alignment: .leading)
}
.scrollBounceBehavior(.basedOnSize)
.frame(maxHeight: maxText)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
Spacer()
Button("Cancel") {
answer(false)
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)
}
.buttonStyle(WebButtonStyle())
Button(confirm.action) {
answer(true)
}
.buttonStyle(WebButtonStyle(primary: true, danger: confirm.danger))
}
.padding(.top, 4)
}
@@ -280,14 +373,19 @@ private struct ConfirmBox: View {
}
private struct AlertBox: View {
let text: String
let alert: OverlayAlert
let maxText: CGFloat
let ok: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(text)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
ScrollView {
alert.content
.frame(maxWidth: .infinity, alignment: .leading)
}
.scrollBounceBehavior(.basedOnSize)
.frame(maxHeight: maxText)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Button("OK", action: ok)