177 lines
5.0 KiB
Swift
177 lines
5.0 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]
|
||
|
|
}
|
||
|
|
|
||
|
|
@MainActor
|
||
|
|
@Observable
|
||
|
|
final class Overlays {
|
||
|
|
var alerts: [String] = []
|
||
|
|
var image: NSImage?
|
||
|
|
var menu: OverlayMenu?
|
||
|
|
@ObservationIgnored private var monitor: Any?
|
||
|
|
|
||
|
|
var isOpen: Bool {
|
||
|
|
!alerts.isEmpty || image != nil || menu != nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func showAlert(_ text: String) {
|
||
|
|
alerts.append(text)
|
||
|
|
}
|
||
|
|
|
||
|
|
func closeTop() {
|
||
|
|
if menu != nil {
|
||
|
|
menu = nil
|
||
|
|
} 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 && 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 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 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)
|
||
|
|
}
|
||
|
|
}
|