-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathModeNotificationOverlay.swift
More file actions
135 lines (113 loc) · 3.89 KB
/
Copy pathModeNotificationOverlay.swift
File metadata and controls
135 lines (113 loc) · 3.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
//
// ModeNotificationOverlay.swift
// PadIO
//
// Floating HUD displayed near the top center of the screen when the active mode changes.
// Shows the new mode name and auto-dismisses after 1.5 seconds.
import AppKit
import SwiftUI
import Observation
// MARK: - View Model
@Observable
final class ModeNotificationViewModel {
var modeName: String = ""
/// Uniform HUD scale from the `hud_zoom` config key.
var zoom: CGFloat = 1.0
}
// MARK: - SwiftUI View
struct ModeNotificationView: View {
let viewModel: ModeNotificationViewModel
var body: some View {
HUDZoom(zoom: viewModel.zoom) { content }
}
@ViewBuilder
private var content: some View {
Text(viewModel.modeName)
.font(.system(size: 36, weight: .semibold, design: .monospaced))
.foregroundStyle(.primary)
.fixedSize()
.padding(.horizontal, 36)
.padding(.vertical, 20)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.separator, lineWidth: 1.5)
)
}
}
// MARK: - Controller
@MainActor
final class ModeNotificationController {
private var panel: NSPanel?
private let viewModel = ModeNotificationViewModel()
private var dismissWork: DispatchWorkItem?
// MARK: - Show
func show(modeName: String, zoom: CGFloat = 1.0) {
viewModel.modeName = modeName
viewModel.zoom = zoom
if panel == nil { createPanel() }
// Cancel any in-flight fade and snap alpha back before reshowing
dismissWork?.cancel()
panel?.alphaValue = 1
repositionPanel()
panel?.orderFrontRegardless()
// Schedule auto-dismiss with fade-out
let work = DispatchWorkItem { [weak self] in
self?.fadeOut()
}
dismissWork = work
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: work)
}
func hide() {
dismissWork?.cancel()
dismissWork = nil
panel?.alphaValue = 1
panel?.orderOut(nil)
}
private func fadeOut() {
dismissWork = nil
guard let panel else { return }
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 0.35
panel.animator().alphaValue = 0
}, completionHandler: { [weak self] in
MainActor.assumeIsolated {
self?.panel?.orderOut(nil)
self?.panel?.alphaValue = 1
}
})
}
// MARK: - Panel creation
private func createPanel() {
let styleMask: NSWindow.StyleMask = [.nonactivatingPanel, .fullSizeContentView]
let p = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 200, height: 60),
styleMask: styleMask,
backing: .buffered,
defer: false
)
p.isFloatingPanel = true
p.level = .floating
p.backgroundColor = .clear
p.isOpaque = false
p.hasShadow = true
p.hidesOnDeactivate = false
let hosting = NSHostingView(rootView: ModeNotificationView(viewModel: viewModel))
p.contentView = hosting
p.setContentSize(hosting.fittingSize)
panel = p
}
private func repositionPanel() {
guard let panel, let hosting = panel.contentView else { return }
HUDPanelFitter.fit(panel: panel, hosting: hosting) { panel in
guard let screen = NSScreen.main else { return }
let screenFrame = screen.visibleFrame
let panelSize = panel.frame.size
// Position near top center — 120pt below the menu bar
let x = screenFrame.midX - panelSize.width / 2
let y = screenFrame.maxY - panelSize.height - 120
panel.setFrameOrigin(NSPoint(x: x, y: y))
}
}
}