Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ pollControllers()
| File | Role |
|------|------|
| `ControllerManager.swift` | Central orchestrator — owns all sub-components, drives the 60Hz loop, resolves and executes actions |
| `HUDZoom.swift` | Shared `hud_zoom` scaling wrapper for every overlay + `HUDPanelFitter` (layout → size → position) |
| `MappingResolver.swift` | Pure translation layer — config → `Action` enum; contains all key name and modifier mappings |
| `MappingConfig.swift` | Codable config types + `ConfigLoader` (hot-reload via `DispatchSource`) |
| `InputHandler.swift` | Low-level CGEvent emission — keystrokes, text injection, mouse, scroll, media keys, input source cycling |
Expand All @@ -51,6 +52,7 @@ pollControllers()
| `ButtonIdentifier.swift` | `ButtonID` and `AxisID` enums; maps `GCControllerElement` references to canonical names |
| `ContentView.swift` | Menu bar dropdown UI — status display, permission grant, reload/quit |
| `*Overlay.swift` files | Floating `NSPanel` HUDs, each with a SwiftUI view + `@Observable` view model + controller class |
| `CustomMenuWheelView.swift` | Circular ("donut") presentation for custom menus, selected by `menu_style` / per-menu `style` |

### Config Resolution Cascade

Expand All @@ -76,7 +78,7 @@ While an overlay is visible, input is consumed before reaching the mapping pipel
3. `CustomMenuController` — blocks input while visible
4. `menu` button always opens Help (checked before any mapping)

Axis events (`pollAxes`) are suppressed entirely while any overlay is visible.
Axis-to-pointer emission (`pollAxes`) is suppressed while any overlay is visible. The raw sticks are still read in that branch and forwarded to `CustomMenuController.handleStick`, which is how a wheel-style menu is aimed; it is a no-op for every other overlay and for the list style.

### Action Types

Expand Down
126 changes: 109 additions & 17 deletions CustomMenuOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
//
// 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
Expand All @@ -19,20 +21,83 @@ final class CustomMenuViewModel {
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]
}

func moveUp() {
guard !labels.isEmpty else { return }
highlightedIndex = (highlightedIndex - 1 + labels.count) % labels.count
/// 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)
}

func moveDown() {
/// 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 }
highlightedIndex = (highlightedIndex + 1) % labels.count
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
}
}

Expand All @@ -41,9 +106,18 @@ final class CustomMenuViewModel {
struct CustomMenuView: View {
let viewModel: CustomMenuViewModel
let onSelect: (Int) -> Void
let onCancel: () -> 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)
Expand Down Expand Up @@ -142,20 +216,32 @@ final class CustomMenuController {

// MARK: - Show / Hide

func show(title: String, labels: [String], onSelect: @escaping (Int) -> Void) {
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
if let hosting = hostingView {
panel?.setContentSize(hosting.fittingSize)
// 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?.center()
panel?.makeKeyAndOrderFront(nil)
panel?.orderFrontRegardless()
}
Expand All @@ -175,11 +261,11 @@ final class CustomMenuController {
guard isVisible else { return false }

switch buttonID {
case .dpadUp:
viewModel.moveUp()
case .dpadUp, .dpadLeft:
viewModel.movePrev()
return true
case .dpadDown:
viewModel.moveDown()
case .dpadDown, .dpadRight:
viewModel.moveNext()
return true
case .a, .rt:
let index = viewModel.highlightedIndex
Expand All @@ -198,6 +284,13 @@ final class CustomMenuController {
}
}

/// 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() {
Expand All @@ -221,8 +314,7 @@ final class CustomMenuController {
let callback = self?.onSelect
self?.hide()
callback?(index)
},
onCancel: { [weak self] in self?.hide() }
}
)

let hosting = NSHostingView(rootView: view)
Expand Down
30 changes: 18 additions & 12 deletions ModeNotificationOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import Observation
@Observable
final class ModeNotificationViewModel {
var modeName: String = ""
/// Uniform HUD scale from the `hud_zoom` config key.
var zoom: CGFloat = 1.0
}

// MARK: - SwiftUI View
Expand All @@ -22,6 +24,11 @@ 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)
Expand All @@ -47,8 +54,9 @@ final class ModeNotificationController {

// MARK: - Show

func show(modeName: String) {
func show(modeName: String, zoom: CGFloat = 1.0) {
viewModel.modeName = modeName
viewModel.zoom = zoom

if panel == nil { createPanel() }

Expand Down Expand Up @@ -113,17 +121,15 @@ final class ModeNotificationController {
}

private func repositionPanel() {
guard let panel, let screen = NSScreen.main else { return }
// Flush pending layout so fittingSize reflects the new content
if let hosting = panel.contentView as? NSHostingView<ModeNotificationView> {
hosting.layoutSubtreeIfNeeded()
panel.setContentSize(hosting.fittingSize)
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))
}
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))
}
}
37 changes: 28 additions & 9 deletions PadIO/ControllerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ final class ControllerManager: ObservableObject {

/// Minimum axis deflection required to produce mouse/scroll events (eliminates stick drift).
private static let axisDeadzone: Float = 0.1
/// Deflection needed before a thumbstick re-aims the wheel menu. Deliberately much
/// larger than `axisDeadzone` (which is tuned to suppress pointer drift) so the
/// highlight does not flicker while the stick sits near centre.
private static let wheelStickDeadzone: Float = 0.4

// MARK: - Hold state machine

Expand Down Expand Up @@ -263,8 +267,13 @@ final class ControllerManager: ObservableObject {
previousButtonStates[id] = prev
holdStates[id] = holds

// Continuous axis dispatch (mouse move / scroll) — only when no overlay is visible
guard !helpOverlay.isVisible && !modePicker.isVisible && !customMenu.isVisible else { continue }
// While an overlay is up, mouse/scroll emission stays suppressed — but a
// wheel-style menu still needs the raw stick to aim with.
if helpOverlay.isVisible || modePicker.isVisible || customMenu.isVisible {
let (stickX, stickY) = dominantStick(gamepad: gamepad)
customMenu.handleStick(x: stickX, y: stickY, deadzone: Self.wheelStickDeadzone)
continue
}
pollAxes(gamepad: gamepad, heldButtons: prev)
}
}
Expand Down Expand Up @@ -326,6 +335,14 @@ final class ControllerManager: ObservableObject {
}
}

/// Returns whichever thumbstick is deflected further from centre, so either stick
/// can aim a wheel menu.
private func dominantStick(gamepad: GCExtendedGamepad) -> (x: Float, y: Float) {
let left = (gamepad.leftThumbstick.xAxis.value, gamepad.leftThumbstick.yAxis.value)
let right = (gamepad.rightThumbstick.xAxis.value, gamepad.rightThumbstick.yAxis.value)
return hypot(right.0, right.1) > hypot(left.0, left.1) ? right : left
}

/// Returns the normalised (x, y) axis values for the given axis source (-1…+1).
/// Dpad is treated as digital: produces ±1 per direction, 0 when not pressed.
private func readAxisValues(axisID: AxisID, gamepad: GCExtendedGamepad) -> (x: Float, y: Float) {
Expand Down Expand Up @@ -372,7 +389,7 @@ final class ControllerManager: ObservableObject {
guard let (profileName, profile) = mappingResolver.resolveProfile(bundleID: bundleID, config: config) else {
print("[PadIO] \(buttonID.rawValue) | no profile")
if config.debugOverlay ?? false {
debugOverlay.show(button: buttonID.rawValue, actionDescription: "no profile", postEventAccess: CGPreflightPostEventAccess())
debugOverlay.show(button: buttonID.rawValue, actionDescription: "no profile", postEventAccess: CGPreflightPostEventAccess(), zoom: config.resolvedHUDZoom)
}
return
}
Expand All @@ -391,13 +408,13 @@ final class ControllerManager: ObservableObject {
) else {
print("[PadIO] No mapping for \(buttonID.rawValue)")
if config.debugOverlay ?? false {
debugOverlay.show(button: buttonID.rawValue, actionDescription: "no mapping", postEventAccess: CGPreflightPostEventAccess())
debugOverlay.show(button: buttonID.rawValue, actionDescription: "no mapping", postEventAccess: CGPreflightPostEventAccess(), zoom: config.resolvedHUDZoom)
}
return
}

if config.debugOverlay ?? false {
debugOverlay.show(button: buttonID.rawValue, actionDescription: MappingResolver.describe(action), postEventAccess: CGPreflightPostEventAccess())
debugOverlay.show(button: buttonID.rawValue, actionDescription: MappingResolver.describe(action), postEventAccess: CGPreflightPostEventAccess(), zoom: config.resolvedHUDZoom)
}
executeAction(action, profile: profile, profileName: profileName, currentMode: modeName)
}
Expand All @@ -424,7 +441,7 @@ final class ControllerManager: ObservableObject {
config: config
)

helpOverlay.show(profileName: profileName, modeName: modeName, entries: entries) { [weak self] buttonName in
helpOverlay.show(profileName: profileName, modeName: modeName, entries: entries, zoom: config.resolvedHUDZoom) { [weak self] buttonName in
guard let self else { return }
// Re-resolve the action for the selected button and execute it
guard let profileResult = self.mappingResolver.resolveProfile(
Expand Down Expand Up @@ -476,7 +493,7 @@ final class ControllerManager: ObservableObject {
let config = configLoader.config
let modes = allModeNames(profile: profile, config: config)
guard !modes.isEmpty else { return }
modePicker.show(modes: modes, currentMode: currentMode) { [weak self] selectedMode in
modePicker.show(modes: modes, currentMode: currentMode, zoom: config.resolvedHUDZoom) { [weak self] selectedMode in
guard let self else { return }
self.profileModes[profileName] = selectedMode
self.activeModeName = selectedMode
Expand Down Expand Up @@ -512,7 +529,9 @@ final class ControllerManager: ObservableObject {
return
}
let labels = menuConfig.items.map { $0.label }
customMenu.show(title: name, labels: labels) { [weak self] index in
// Per-menu style wins over the top-level default; both fall back to the list.
let style = MenuStyle.resolve(menuConfig.style) ?? MenuStyle.resolve(config.menuStyle) ?? .list
customMenu.show(title: name, labels: labels, style: style, zoom: config.resolvedHUDZoom) { [weak self] index in
guard let self else { return }
guard menuConfig.items.indices.contains(index) else { return }
let itemAction = menuConfig.items[index].action
Expand Down Expand Up @@ -713,7 +732,7 @@ final class ControllerManager: ObservableObject {
private func switchMode(_ modeName: String, profileName: String) {
profileModes[profileName] = modeName
activeModeName = modeName
modeNotification.show(modeName: modeName)
modeNotification.show(modeName: modeName, zoom: configLoader.config.resolvedHUDZoom)
print("[PadIO] Mode changed to '\(modeName)' in profile '\(profileName)'")
}

Expand Down
Loading