-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCustomMenuOverlay.swift
More file actions
330 lines (287 loc) · 10.8 KB
/
Copy pathCustomMenuOverlay.swift
File metadata and controls
330 lines (287 loc) · 10.8 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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
//
// CustomMenuOverlay.swift
// PadIO
//
// Floating NSPanel HUD for user-defined menus.
// Opened via the "menu:<name>" action type. Navigate with dpad; A/RT = select; B/X/LT = close.
// Two presentations, chosen by the `menu_style` config key: a vertical list (default)
// and a circular "wheel" that can also be aimed with a thumbstick.
import AppKit
import SwiftUI
import Observation
// MARK: - View Model
@Observable
final class CustomMenuViewModel {
/// Title shown in the header (the menu name).
var title: String = ""
/// Display labels for each menu item.
var labels: [String] = []
/// Index of the currently highlighted row.
var highlightedIndex: Int = 0
/// How this menu is presented.
var style: MenuStyle = .list
/// Uniform HUD scale from the `hud_zoom` config key.
var zoom: CGFloat = 1.0
/// Wheel only — ring rotation in radians. Stays 0 until the dpad is used; stick
/// aiming deliberately leaves the ring still and moves the highlight instead.
var rotation: Double = 0
/// Beyond this many items a wheel is unreadable, so it falls back to the list.
static let maxWheelItems = 16
/// The presentation actually used, after the item-count fallback.
var effectiveStyle: MenuStyle {
style == .wheel && labels.count > Self.maxWheelItems ? .list : style
}
var highlightedLabel: String? {
guard !labels.isEmpty, labels.indices.contains(highlightedIndex) else { return nil }
return labels[highlightedIndex]
}
/// Angle at which item `index` is drawn, in radians, measured clockwise from
/// 12 o'clock. Index 0 sits at the anchor when `rotation` is 0.
func angle(for index: Int) -> Double {
guard !labels.isEmpty else { return 0 }
return 2 * .pi * Double(index) / Double(labels.count) + rotation
}
func movePrev() {
step(by: -1)
}
func moveNext() {
step(by: 1)
}
/// Steps the highlight and, in wheel mode, rotates the ring so the newly
/// highlighted item lands back on the 12 o'clock anchor.
private func step(by delta: Int) {
guard !labels.isEmpty else { return }
let count = labels.count
highlightedIndex = ((highlightedIndex + delta) % count + count) % count
guard effectiveStyle == .wheel else { return }
withAnimation(.easeOut(duration: 0.15)) {
rotation = -2 * .pi * Double(highlightedIndex) / Double(count)
}
}
/// Wheel only — highlights the item nearest the direction the stick is pointing.
/// `y` is in controller space (positive = up). Deflections below `deadzone` keep
/// the current selection so the highlight does not jitter around centre.
func aim(x: Float, y: Float, deadzone: Float) {
guard effectiveStyle == .wheel, !labels.isEmpty else { return }
guard hypot(x, y) >= deadzone else { return }
// atan2(x, y) measures clockwise from straight up, matching `angle(for:)`.
// Widen before the call: rounding a Float result can flip which of two
// near-equidistant items wins.
let stickAngle = atan2(Double(x), Double(y))
var best = highlightedIndex
var bestDelta = Double.greatestFiniteMagnitude
for index in labels.indices {
let delta = abs(Self.angularDistance(stickAngle, angle(for: index)))
if delta < bestDelta {
bestDelta = delta
best = index
}
}
highlightedIndex = best
}
/// Shortest signed distance between two angles, in radians (-pi...pi).
private static func angularDistance(_ a: Double, _ b: Double) -> Double {
var delta = (a - b).truncatingRemainder(dividingBy: 2 * .pi)
if delta > .pi { delta -= 2 * .pi }
if delta < -.pi { delta += 2 * .pi }
return delta
}
}
// MARK: - SwiftUI View
struct CustomMenuView: View {
let viewModel: CustomMenuViewModel
let onSelect: (Int) -> Void
var body: some View {
HUDZoom(zoom: viewModel.zoom) {
switch viewModel.effectiveStyle {
case .list: listContent
case .wheel: CustomMenuWheelView(viewModel: viewModel, onSelect: onSelect)
}
}
}
@ViewBuilder
private var listContent: some View {
VStack(spacing: 0) {
// Header
Text(viewModel.title)
.font(.headline)
.foregroundStyle(.secondary)
.padding(.top, 14)
.padding(.bottom, 8)
Divider()
if viewModel.labels.isEmpty {
Text("No items")
.font(.body)
.foregroundStyle(.secondary)
.padding(20)
} else {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 2) {
ForEach(Array(viewModel.labels.enumerated()), id: \.offset) { index, label in
menuRow(label: label, isHighlighted: index == viewModel.highlightedIndex)
.id(index)
.onTapGesture { onSelect(index) }
}
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
}
.onChange(of: viewModel.highlightedIndex) { _, newIndex in
withAnimation(.easeInOut(duration: 0.1)) {
proxy.scrollTo(newIndex, anchor: .center)
}
}
}
// Each row is ~36pt tall; cap at 10 visible rows.
.frame(height: min(CGFloat(viewModel.labels.count) * 36 + 12, 372))
}
Divider()
// Hint row
HStack(spacing: 16) {
hintLabel(icon: "arrowkeys", text: "Navigate")
hintLabel(icon: "a.circle", text: "Select")
hintLabel(icon: "b.circle", text: "Cancel")
}
.padding(.vertical, 8)
.font(.caption2)
.foregroundStyle(.tertiary)
}
.frame(width: 280)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(.separator, lineWidth: 0.5)
)
}
@ViewBuilder
private func menuRow(label: String, isHighlighted: Bool) -> some View {
HStack {
Text(label)
.font(.body)
.foregroundStyle(isHighlighted ? .white : .primary)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(isHighlighted ? Color.accentColor : Color.clear)
)
.contentShape(Rectangle())
}
@ViewBuilder
private func hintLabel(icon: String, text: String) -> some View {
HStack(spacing: 3) {
Image(systemName: icon)
Text(text)
}
}
}
// MARK: - Controller
/// Manages the floating custom menu NSPanel.
@MainActor
final class CustomMenuController {
private var panel: NSPanel?
private let viewModel = CustomMenuViewModel()
private var hostingView: NSHostingView<CustomMenuView>?
/// Called with the index of the selected item when the user confirms.
private var onSelect: ((Int) -> Void)?
// MARK: - Show / Hide
func show(
title: String,
labels: [String],
style: MenuStyle = .list,
zoom: CGFloat = 1.0,
onSelect: @escaping (Int) -> Void
) {
viewModel.title = title
viewModel.labels = labels
viewModel.style = style
viewModel.zoom = zoom
viewModel.highlightedIndex = 0
viewModel.rotation = 0
self.onSelect = onSelect
if viewModel.style == .wheel && viewModel.effectiveStyle == .list {
print("[PadIO] menu '\(title)' has \(labels.count) items, too many for the wheel — using the list")
}
if panel == nil { createPanel() }
// Resize to fit the updated content (item count, style or zoom may have changed)
if let panel, let hosting = hostingView {
HUDPanelFitter.fit(panel: panel, hosting: hosting) { $0.center() }
}
panel?.makeKeyAndOrderFront(nil)
panel?.orderFrontRegardless()
}
func hide() {
panel?.orderOut(nil)
}
var isVisible: Bool {
panel?.isVisible ?? false
}
// MARK: - Button handling
/// Returns `true` if the button was consumed by the overlay.
func handleButton(_ buttonID: ButtonID) -> Bool {
guard isVisible else { return false }
switch buttonID {
case .dpadUp, .dpadLeft:
viewModel.movePrev()
return true
case .dpadDown, .dpadRight:
viewModel.moveNext()
return true
case .a, .rt:
let index = viewModel.highlightedIndex
if viewModel.labels.indices.contains(index) {
let callback = onSelect
hide()
callback?(index)
}
return true
case .b, .x, .lt:
hide()
return true
default:
// Block all other input while the menu is open
return true
}
}
/// Wheel only — aims the highlight with a thumbstick. Ignored for the list style
/// and whenever the menu is hidden, so the caller can forward unconditionally.
func handleStick(x: Float, y: Float, deadzone: Float) {
guard isVisible else { return }
viewModel.aim(x: x, y: y, deadzone: deadzone)
}
// MARK: - Panel creation
private func createPanel() {
let styleMask: NSWindow.StyleMask = [.nonactivatingPanel, .fullSizeContentView]
let p = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 280, height: 100),
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 view = CustomMenuView(
viewModel: viewModel,
onSelect: { [weak self] index in
let callback = self?.onSelect
self?.hide()
callback?(index)
}
)
let hosting = NSHostingView(rootView: view)
hosting.translatesAutoresizingMaskIntoConstraints = false
p.contentView = hosting
let fittingSize = hosting.fittingSize
p.setContentSize(fittingSize)
self.hostingView = hosting
self.panel = p
}
}