macOS client overlays, menus and notifications
This commit is contained in:
+166
-68
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user