From 87ea57a13a4e0a11cf18e5692a33fe69c3ecff03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Gr=C3=A9goire?= Date: Tue, 18 Aug 2026 11:37:29 -0400 Subject: [PATCH 1/2] add circular menu style and hud zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom menus can now be drawn as a circular "wheel" instead of the vertical list, and every HUD scales with a single config key. menu_style sets the default presentation ("list" or "wheel", with "donut" and "radial" as aliases); an individual menu overrides it by switching from the bare-array form to { "style": ..., "items": [...] }. The array form stays valid and inherits the global default. Unknown style strings warn and fall back to the list rather than throwing, so a typo cannot blank the config on hot-reload. The wheel offers two ways to drive it at once: either thumbstick aims, leaving the ring still while the highlight follows the stick, and dpad left/right rotates the ring against a fixed marker at 12 o'clock. Selection and cancellation keep the same buttons as the list. Aiming needs analog input inside an overlay, which did not previously exist: pollControllers dropped all axis input whenever an overlay was visible. That branch now forwards the dominant thumbstick to the menu before returning, with its own larger deadzone (0.4) so the highlight does not jitter near centre. Mouse and scroll emission stays suppressed exactly as before. hud_zoom scales all five overlays. HUDZoom leans on a small Layout rather than scaleEffect alone: scaleEffect is render-only and leaves the reported size untouched, so the panels — which size themselves from NSHostingView.fittingSize — would stay unscaled and clip their contents. Reporting zoom x the ideal size is synchronous and correct on the first layout pass. At zoom 1 the content is returned untouched, so the default appearance is unchanged. Also fixes two pre-existing sizing bugs surfaced by the zoom work: CustomMenuController.show sized its panel without a layout pass, and HelpController.show never re-fitted at all. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 4 +- CustomMenuOverlay.swift | 126 ++++++++++++++++++++++++---- ModeNotificationOverlay.swift | 30 ++++--- PadIO/ControllerManager.swift | 37 +++++++-- PadIO/CustomMenuWheelView.swift | 143 ++++++++++++++++++++++++++++++++ PadIO/DebugInputOverlay.swift | 28 ++++--- PadIO/HUDZoom.swift | 75 +++++++++++++++++ PadIO/HelpOverlay.swift | 18 +++- PadIO/MappingConfig.swift | 84 +++++++++++++++++-- PadIO/ModePickerOverlay.swift | 17 ++-- README.md | 2 +- TO_VERIFY.md | 15 ++++ config.json | 13 ++- docs/configuration/index.md | 11 +++ docs/configuration/menus.md | 58 ++++++++++++- docs/example-config.md | 1 + docs/huds.md | 19 +++++ docs/index.md | 2 +- 18 files changed, 614 insertions(+), 69 deletions(-) create mode 100644 PadIO/CustomMenuWheelView.swift create mode 100644 PadIO/HUDZoom.swift create mode 100644 TO_VERIFY.md diff --git a/CLAUDE.md b/CLAUDE.md index f1b9238..7c4516d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | @@ -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 @@ -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 diff --git a/CustomMenuOverlay.swift b/CustomMenuOverlay.swift index 15bf290..869f684 100644 --- a/CustomMenuOverlay.swift +++ b/CustomMenuOverlay.swift @@ -4,6 +4,8 @@ // // Floating NSPanel HUD for user-defined menus. // Opened via the "menu:" 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 @@ -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 } } @@ -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) @@ -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() } @@ -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 @@ -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() { @@ -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) diff --git a/ModeNotificationOverlay.swift b/ModeNotificationOverlay.swift index f9b154f..76ad599 100644 --- a/ModeNotificationOverlay.swift +++ b/ModeNotificationOverlay.swift @@ -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 @@ -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) @@ -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() } @@ -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 { - 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)) } } diff --git a/PadIO/ControllerManager.swift b/PadIO/ControllerManager.swift index c7afa87..6e7812b 100644 --- a/PadIO/ControllerManager.swift +++ b/PadIO/ControllerManager.swift @@ -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 @@ -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) } } @@ -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) { @@ -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 } @@ -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) } @@ -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( @@ -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 @@ -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 @@ -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)'") } diff --git a/PadIO/CustomMenuWheelView.swift b/PadIO/CustomMenuWheelView.swift new file mode 100644 index 0000000..f29387d --- /dev/null +++ b/PadIO/CustomMenuWheelView.swift @@ -0,0 +1,143 @@ +// +// CustomMenuWheelView.swift +// PadIO +// +// Circular ("donut") presentation for custom menus, selected with `"style": "wheel"`. +// Items sit on a ring around a hub that spells out the current selection. +// +// Two ways to drive it, both live at once: +// - a thumbstick aims: the ring stays still and the highlight follows the stick +// - dpad left/right rotates: the ring turns and the highlight stays on the +// 12 o'clock anchor marker + +import SwiftUI + +struct CustomMenuWheelView: View { + let viewModel: CustomMenuViewModel + let onSelect: (Int) -> Void + + // MARK: - Geometry + + /// Widest a single item chip may draw before its label truncates. + private static let chipMaxWidth: CGFloat = 120 + /// Vertical room one chip occupies on the ring, used to keep chips from colliding. + private static let chipPitch: CGFloat = 36 + /// Breathing room between the outermost chip edge and the panel edge. + private static let outerPadding: CGFloat = 20 + + /// Ring radius. Grows with the item count so chips never overlap, with a floor that + /// leaves the hub enough room to sit clear of the chips at 3 and 9 o'clock. + private var radius: CGFloat { + let needed = CGFloat(viewModel.labels.count) * Self.chipPitch / (2 * .pi) + 60 + return max(165, needed) + } + + /// The panel is square: the ring plus half a chip on each side, plus padding. + private var side: CGFloat { + 2 * radius + Self.chipMaxWidth + 2 * Self.outerPadding + } + + /// Clear space inside the ring, available to the hub. The inset keeps the hub from + /// butting up against the chips on either side. + private var hubWidth: CGFloat { + 2 * (radius - Self.chipMaxWidth / 2) - 40 + } + + // MARK: - Body + + var body: some View { + ZStack { + Circle() + .fill(.regularMaterial) + .overlay(Circle().strokeBorder(.separator, lineWidth: 0.5)) + + // Faint guide showing the path the items travel along. + Circle() + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.35), lineWidth: 0.5) + .frame(width: radius * 2, height: radius * 2) + + anchorMarker + hub + + ForEach(Array(viewModel.labels.enumerated()), id: \.offset) { index, label in + chip(label: label, isHighlighted: index == viewModel.highlightedIndex) + .position(position(for: index)) + .onTapGesture { onSelect(index) } + } + } + .frame(width: side, height: side) + } + + // MARK: - Pieces + + /// Centre of item `index`, in the square's coordinate space. + private func position(for index: Int) -> CGPoint { + let angle = viewModel.angle(for: index) + let centre = side / 2 + // Angles run clockwise from 12 o'clock; the view's y axis points down. + return CGPoint( + x: centre + radius * CGFloat(sin(angle)), + y: centre - radius * CGFloat(cos(angle)) + ) + } + + /// Fixed selection slot at 12 o'clock — where dpad rotation brings items. + private var anchorMarker: some View { + Image(systemName: "arrowtriangle.down.fill") + .font(.caption2) + .foregroundStyle(.tertiary) + .position( + x: side / 2, + y: side / 2 - radius - Self.chipPitch / 2 - 6 + ) + } + + /// Centre of the donut — menu name over the full label of the current selection. + private var hub: some View { + VStack(spacing: 6) { + Text(viewModel.title) + .font(.caption) + .foregroundStyle(.secondary) + + Text(viewModel.highlightedLabel ?? "No items") + .font(.headline) + .multilineTextAlignment(.center) + .foregroundStyle(.primary) + + HStack(spacing: 10) { + hintLabel(icon: "l.joystick", text: "Aim") + hintLabel(icon: "arrow.left.and.right", text: "Rotate") + hintLabel(icon: "a.circle", text: "Select") + } + .font(.caption2) + .foregroundStyle(.tertiary) + .padding(.top, 2) + } + .frame(width: hubWidth) + } + + private func chip(label: String, isHighlighted: Bool) -> some View { + Text(label) + .font(.body) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(isHighlighted ? .white : .primary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .frame(maxWidth: Self.chipMaxWidth) + .background( + Capsule().fill(isHighlighted ? Color.accentColor : Color.clear) + ) + .overlay( + Capsule().strokeBorder(.separator, lineWidth: isHighlighted ? 0 : 0.5) + ) + .contentShape(Capsule()) + } + + private func hintLabel(icon: String, text: String) -> some View { + HStack(spacing: 3) { + Image(systemName: icon) + Text(text) + } + } +} diff --git a/PadIO/DebugInputOverlay.swift b/PadIO/DebugInputOverlay.swift index 5a035be..917f0a5 100644 --- a/PadIO/DebugInputOverlay.swift +++ b/PadIO/DebugInputOverlay.swift @@ -15,6 +15,8 @@ import Observation final class DebugInputViewModel { var buttonName: String = "" var actionDescription: String = "" + /// Uniform HUD scale from the `hud_zoom` config key. + var zoom: CGFloat = 1.0 } // MARK: - SwiftUI View @@ -23,6 +25,11 @@ struct DebugInputView: View { let viewModel: DebugInputViewModel var body: some View { + HUDZoom(zoom: viewModel.zoom) { content } + } + + @ViewBuilder + private var content: some View { VStack(alignment: .leading, spacing: 9) { Text(viewModel.buttonName) .font(.system(size: 51, weight: .bold, design: .monospaced)) @@ -55,9 +62,10 @@ final class DebugInputController { // MARK: - Show - func show(button: String, actionDescription: String, postEventAccess: Bool = true) { + func show(button: String, actionDescription: String, postEventAccess: Bool = true, zoom: CGFloat = 1.0) { viewModel.buttonName = button viewModel.actionDescription = actionDescription + viewModel.zoom = zoom if panel == nil { createPanel() } @@ -122,16 +130,14 @@ final class DebugInputController { } 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 { - 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 + let x = screenFrame.midX - panelSize.width / 2 + let y = screenFrame.minY + 120 // 120pt above the Dock/bottom edge + panel.setFrameOrigin(NSPoint(x: x, y: y)) } - let screenFrame = screen.visibleFrame - let panelSize = panel.frame.size - let x = screenFrame.midX - panelSize.width / 2 - let y = screenFrame.minY + 120 // 120pt above the Dock/bottom edge - panel.setFrameOrigin(NSPoint(x: x, y: y)) } } diff --git a/PadIO/HUDZoom.swift b/PadIO/HUDZoom.swift new file mode 100644 index 0000000..3c67f52 --- /dev/null +++ b/PadIO/HUDZoom.swift @@ -0,0 +1,75 @@ +// +// HUDZoom.swift +// PadIO +// +// Shared uniform scaling for the floating HUD panels. +// Driven by the top-level `hud_zoom` config key. + +import AppKit +import SwiftUI + +/// Scales a HUD's content uniformly by `zoom`. +/// +/// At `zoom == 1` the content is returned untouched, so the default appearance is +/// byte-for-byte unchanged. +/// +/// Above that, `ScaledLayout` does the work. It has to: `.scaleEffect` is a render-only +/// transform that leaves the reported layout size alone, and every overlay controller +/// sizes its `NSPanel` from `NSHostingView.fittingSize`. Scaling without correcting the +/// reported size leaves the panel at its unscaled dimensions and clips the content. +struct HUDZoom: View { + let zoom: CGFloat + @ViewBuilder let content: Content + + var body: some View { + if zoom == 1 { + content + } else { + ScaledLayout(zoom: zoom) { + content.scaleEffect(zoom, anchor: .center) + } + } + } +} + +/// Reports `zoom`× its subview's ideal size, while placing the subview at its natural +/// size so a `.scaleEffect` on it fills the enlarged bounds exactly. +/// +/// This is deliberately synchronous — it is correct on the very first layout pass, so +/// a panel never has to be measured, shown, and then resized. +private struct ScaledLayout: Layout { + let zoom: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let natural = naturalSize(of: subviews) + return CGSize(width: natural.width * zoom, height: natural.height * zoom) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + guard let subview = subviews.first else { return } + let natural = naturalSize(of: subviews) + subview.place( + at: CGPoint(x: bounds.midX, y: bounds.midY), + anchor: .center, + proposal: ProposedViewSize(natural) + ) + } + + private func naturalSize(of subviews: Subviews) -> CGSize { + subviews.first?.sizeThatFits(.unspecified) ?? .zero + } +} + +/// Sizes a HUD panel to its SwiftUI content and positions it. +@MainActor +enum HUDPanelFitter { + /// Lays out `hosting`, resizes `panel` to fit it, then applies `position`. + /// + /// The layout pass matters: `fittingSize` is stale until SwiftUI has re-laid-out for + /// whatever content (or zoom) was just assigned. + static func fit(panel: NSPanel, hosting: NSView, position: (NSPanel) -> Void) { + hosting.layoutSubtreeIfNeeded() + panel.setContentSize(hosting.fittingSize) + position(panel) + } +} diff --git a/PadIO/HelpOverlay.swift b/PadIO/HelpOverlay.swift index 0d89433..b07c68f 100644 --- a/PadIO/HelpOverlay.swift +++ b/PadIO/HelpOverlay.swift @@ -19,6 +19,8 @@ final class HelpViewModel { var entries: [(button: String, action: String)] = [] /// Currently highlighted row index for dpad scrolling. var highlightedIndex: Int = 0 + /// Uniform HUD scale from the `hud_zoom` config key. + var zoom: CGFloat = 1.0 func scrollUp() { guard !entries.isEmpty else { return } @@ -39,6 +41,11 @@ struct HelpView: View { let onClose: () -> Void var body: some View { + HUDZoom(zoom: viewModel.zoom) { content } + } + + @ViewBuilder + private var content: some View { VStack(spacing: 0) { // Header VStack(spacing: 2) { @@ -144,11 +151,13 @@ struct HelpView: View { final class HelpController { private var panel: NSPanel? private let viewModel = HelpViewModel() + private var hostingView: NSHostingView? private var onTrigger: ((String) -> Void)? // MARK: - Show / Hide - func show(profileName: String, modeName: String, entries: [(button: String, action: String)], onTrigger: @escaping (String) -> Void) { + func show(profileName: String, modeName: String, entries: [(button: String, action: String)], zoom: CGFloat = 1.0, onTrigger: @escaping (String) -> Void) { + viewModel.zoom = zoom viewModel.profileName = profileName viewModel.modeName = modeName viewModel.entries = entries @@ -157,7 +166,11 @@ final class HelpController { if panel == nil { createPanel() } - panel?.center() + // Resize to fit the updated content (entry count or zoom may have changed) + if let panel, let hosting = hostingView { + HUDPanelFitter.fit(panel: panel, hosting: hosting) { $0.center() } + } + panel?.makeKeyAndOrderFront(nil) panel?.orderFrontRegardless() } @@ -226,6 +239,7 @@ final class HelpController { let fittingSize = hosting.fittingSize p.setContentSize(fittingSize) + hostingView = hosting panel = p } } diff --git a/PadIO/MappingConfig.swift b/PadIO/MappingConfig.swift index 9dd75f2..c323776 100644 --- a/PadIO/MappingConfig.swift +++ b/PadIO/MappingConfig.swift @@ -19,23 +19,76 @@ struct MenuItemConfig: Codable, Sendable { let action: ActionConfig } +/// How a custom menu is presented on screen. +/// Stored in the config as a raw string and resolved via `resolve(_:)` at the point of use, +/// matching how every other string discriminant in this config is handled. +enum MenuStyle: String, Sendable { + /// Vertical scrolling list — the default. + case list + /// Circular "donut" — items arranged on a ring, aimed with a thumbstick. + case wheel + + /// Maps a raw config string onto a style. + /// Returns `nil` for a missing value (so the caller can fall back to a wider default) + /// and, for an unrecognised value, warns and returns `nil` rather than throwing — + /// a typo must not blank the whole config on hot-reload. + static func resolve(_ raw: String?) -> MenuStyle? { + guard let raw, !raw.isEmpty else { return nil } + switch raw.lowercased() { + case "list": return .list + case "wheel", "donut", "radial": return .wheel + default: + print("[PadIO] unknown menu style '\(raw)', using default") + return nil + } + } +} + /// A named custom menu — an ordered list of label/action pairs. -/// Stored as a JSON array for clean config authoring. +/// +/// Accepts two JSON forms: +/// - a bare array of items: `"git": [ { "label": ..., "action": ... } ]` +/// - an object with an explicit style: `"git": { "style": "wheel", "items": [ ... ] }` +/// +/// The array form is the original syntax and stays valid; it inherits the top-level `menu_style`. struct MenuConfig: Codable, Sendable { let items: [MenuItemConfig] + /// Per-menu style override. `nil` means "inherit the top-level `menu_style`". + let style: String? - init(items: [MenuItemConfig]) { + init(items: [MenuItemConfig], style: String? = nil) { self.items = items + self.style = style + } + + enum CodingKeys: String, CodingKey { + case items + case style } init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - items = try container.decode([MenuItemConfig].self) + // Try the bare-array form first, then fall back to the keyed object form. + if let container = try? decoder.singleValueContainer(), + let array = try? container.decode([MenuItemConfig].self) { + items = array + style = nil + return + } + let container = try decoder.container(keyedBy: CodingKeys.self) + items = try container.decode([MenuItemConfig].self, forKey: .items) + style = try container.decodeIfPresent(String.self, forKey: .style) } func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(items) + // Round-trip to whichever form the menu was authored in. + guard let style else { + var container = encoder.singleValueContainer() + try container.encode(items) + return + } + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(items, forKey: .items) + try container.encode(style, forKey: .style) } } @@ -101,6 +154,12 @@ struct MappingConfig: Codable, Sendable { /// that are context-driven). Hidden modes stay reachable via setMode, external context, /// and default_mode; only the picker listing is filtered. var hiddenModes: [String] + /// Default presentation for custom menus ("list" or "wheel"). Defaults to "list" when omitted. + /// An individual menu can override this with its own `style` key. + var menuStyle: String? + /// Uniform scale factor for every floating HUD (help, mode picker, custom menu, + /// mode notification, debug overlay). Defaults to 1.0; clamped to 0.75–3.0 on use. + var hudZoom: Double? enum CodingKeys: String, CodingKey { case triggerThreshold = "trigger_threshold" @@ -112,9 +171,11 @@ struct MappingConfig: Codable, Sendable { case aliases case sharedModes = "shared_modes" case hiddenModes = "hidden_modes" + case menuStyle = "menu_style" + case hudZoom = "hud_zoom" } - init(triggerThreshold: Double?, debugOverlay: Bool?, global: [String: ActionConfig], profiles: [String: ProfileConfig], menus: [String: MenuConfig], haptics: HapticsConfig? = nil, aliases: [String: ActionConfig]? = nil, sharedModes: [String: ModeConfig]? = nil, hiddenModes: [String] = []) { + init(triggerThreshold: Double?, debugOverlay: Bool?, global: [String: ActionConfig], profiles: [String: ProfileConfig], menus: [String: MenuConfig], haptics: HapticsConfig? = nil, aliases: [String: ActionConfig]? = nil, sharedModes: [String: ModeConfig]? = nil, hiddenModes: [String] = [], menuStyle: String? = nil, hudZoom: Double? = nil) { self.triggerThreshold = triggerThreshold self.debugOverlay = debugOverlay self.global = global @@ -124,6 +185,8 @@ struct MappingConfig: Codable, Sendable { self.aliases = aliases self.sharedModes = sharedModes self.hiddenModes = hiddenModes + self.menuStyle = menuStyle + self.hudZoom = hudZoom } init(from decoder: Decoder) throws { @@ -137,6 +200,13 @@ struct MappingConfig: Codable, Sendable { aliases = try container.decodeIfPresent([String: ActionConfig].self, forKey: .aliases) sharedModes = try container.decodeIfPresent([String: ModeConfig].self, forKey: .sharedModes) hiddenModes = try container.decodeIfPresent([String].self, forKey: .hiddenModes) ?? [] + menuStyle = try container.decodeIfPresent(String.self, forKey: .menuStyle) + hudZoom = try container.decodeIfPresent(Double.self, forKey: .hudZoom) + } + + /// `hud_zoom` clamped to a sane range, with the 1.0 default applied. + var resolvedHUDZoom: CGFloat { + CGFloat(min(max(hudZoom ?? 1.0, 0.75), 3.0)) } static let empty = MappingConfig(triggerThreshold: nil, debugOverlay: nil, global: [:], profiles: [:], menus: [:]) diff --git a/PadIO/ModePickerOverlay.swift b/PadIO/ModePickerOverlay.swift index 6ef7b73..65e6af4 100644 --- a/PadIO/ModePickerOverlay.swift +++ b/PadIO/ModePickerOverlay.swift @@ -18,6 +18,8 @@ final class ModePickerViewModel { var highlightedIndex: Int = 0 /// The mode that is already active (shown with a checkmark). var activeMode: String = "" + /// Uniform HUD scale from the `hud_zoom` config key. + var zoom: CGFloat = 1.0 var highlightedMode: String? { guard !modes.isEmpty, modes.indices.contains(highlightedIndex) else { return nil } @@ -43,6 +45,11 @@ struct ModePickerView: View { let onCancel: () -> Void var body: some View { + HUDZoom(zoom: viewModel.zoom) { content } + } + + @ViewBuilder + private var content: some View { VStack(spacing: 0) { Text("Select Mode") .font(.headline) @@ -144,8 +151,9 @@ final class ModePickerController { // MARK: - Show / Hide - func show(modes: [String], currentMode: String?, onSelect: @escaping (String) -> Void) { + func show(modes: [String], currentMode: String?, zoom: CGFloat = 1.0, onSelect: @escaping (String) -> Void) { // Update view model + viewModel.zoom = zoom viewModel.modes = modes viewModel.activeMode = currentMode ?? "" viewModel.highlightedIndex = modes.firstIndex(of: currentMode ?? "") ?? 0 @@ -155,12 +163,11 @@ final class ModePickerController { createPanel() } - // Resize to fit the updated content (mode count may have changed) - if let hosting = hostingView { - panel?.setContentSize(hosting.fittingSize) + // Resize to fit the updated content (mode count 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() } diff --git a/README.md b/README.md index d182c99..4194e03 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ If you're looking for a friendlier GUI-based controller remapper, search "Game C - **Per-app profiles** — automatic profile switching based on the frontmost application - **Modes** — multiple binding sets per profile, switchable via picker, cycling, or direct jump - **Automatic modes** — let an external program pick the mode, e.g. the app in your terminal's focused pane ([herdr](https://herdr.dev/) plugin [available](https://github.com/vgreg/herdr-padio)) -- **Custom menus** — define popup menus with labeled items that trigger any action +- **Custom menus** — define popup menus with labeled items that trigger any action, as a list or a circular wheel you aim with the stick - **Haptic feedback** — rumble on system beep, notifications, or on-demand from any binding - **Media keys** — play/pause, track skip, volume, brightness (no Accessibility permission needed) - **Hot-reload** — save the config file and changes take effect instantly diff --git a/TO_VERIFY.md b/TO_VERIFY.md new file mode 100644 index 0000000..1fb0d3d --- /dev/null +++ b/TO_VERIFY.md @@ -0,0 +1,15 @@ +# To verify + +Items needing manual verification with a physical controller. Remove entries once checked. + +## Wheel menu style + `hud_zoom` + +Verified automatically already: config decoding (both menu forms, aliases, bad-value fallback, zoom clamping), the wheel's aim/rotation math, wheel and list layout rendered offscreen, Xcode build, docs build, and that the app launches without crashing. + +Still needs a controller and a live panel: + +- [ ] **Stick aiming** — open a wheel menu, sweep either thumbstick, confirm the highlight follows the item under the stick and the ring stays still. Returning the stick to centre should keep the last selection, not reset it. +- [ ] **Dpad rotation** — dpad left/right rotates the ring and the highlight stays on the marker at 12 o'clock. Mixing the two (aim with the stick, then press dpad) should rotate the currently aimed item up to the anchor. +- [ ] **Confirm / cancel** — A and RT execute the highlighted item; B, X and LT close without executing. +- [ ] **Live panel sizing at zoom** — set `"hud_zoom"` to 1.5 and 2.5 and open each of the five HUDs (help, mode picker, custom menu, mode notification, debug overlay). Confirm each is scaled, not clipped, and still on screen. The screen-edge-anchored ones (mode notification, debug overlay) are the likely failure cases. Panel sizing was verified offscreen via `ImageRenderer`, not in a live `NSPanel`. +- [ ] **No pointer drift while aiming** — with a stick bound to `mouse_move`, confirm aiming the wheel does not also move the cursor underneath. diff --git a/config.json b/config.json index 53c8170..dddf3e3 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,7 @@ { "trigger_threshold": 0.5, "debug_overlay": true, + "hud_zoom": 1.0, "aliases": { "tmux_leader": { "type": "keystroke", "key": "a", "modifiers": ["ctrl"] } }, @@ -131,6 +132,16 @@ { "label": "git push", "action": { "type": "keystroke", "key": "`git push\n`" } }, { "label": "git stash", "action": { "type": "keystroke", "key": "`git stash\n`" } }, { "label": "git stash pop", "action": { "type": "keystroke", "key": "`git stash pop\n`" } } - ] + ], + "window": { + "style": "wheel", + "items": [ + { "label": "Left half", "action": { "type": "keystroke", "key": "left", "modifiers": ["ctrl", "opt"] } }, + { "label": "Right half", "action": { "type": "keystroke", "key": "right", "modifiers": ["ctrl", "opt"] } }, + { "label": "Top half", "action": { "type": "keystroke", "key": "up", "modifiers": ["ctrl", "opt"] } }, + { "label": "Maximize", "action": { "type": "keystroke", "key": "f", "modifiers": ["ctrl", "opt"] } }, + { "label": "Centre", "action": { "type": "keystroke", "key": "c", "modifiers": ["ctrl", "opt"] } } + ] + } } } diff --git a/docs/configuration/index.md b/docs/configuration/index.md index da4b1cb..47afb91 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -10,6 +10,11 @@ If the file does not exist, PadIO runs with no bindings (controller input is sil { "trigger_threshold": 0.5, "debug_overlay": false, + "menu_style": "list", + "hud_zoom": 1.0, + "aliases": { }, + "shared_modes": { }, + "hidden_modes": [ ], "global": { }, "profiles": { }, "menus": { }, @@ -21,6 +26,11 @@ If the file does not exist, PadIO runs with no bindings (controller input is sil |---------------------|---------|---------|-------------| | `trigger_threshold` | number | `0.5` | Analog trigger press threshold (0–1). Values above this are treated as pressed. | | `debug_overlay` | boolean | `false` | Show a floating HUD on every button press displaying the button name and resolved action. Set to `true` during development. | +| `menu_style` | string | `"list"` | Default presentation for custom menus — `"list"` or `"wheel"`. Individual menus can override it (see [Custom Menus](menus.md)). | +| `hud_zoom` | number | `1.0` | Uniform scale for every HUD. Clamped to 0.75–3.0 (see [HUDs](../huds.md)). | +| `aliases` | object | omitted | Reusable action definitions, referenced by `{ "type": "alias", "name": "" }`. | +| `shared_modes` | object | omitted | Modes available to every profile by name (see [Profiles & Modes](profiles.md)). | +| `hidden_modes` | array | `[]` | Mode names hidden from the mode picker across all profiles. They stay reachable via `mode:`, context modes, and `default_mode`. | | `global` | object | `{}` | Button bindings applied to all profiles. These take priority over everything else. | | `profiles` | object | `{}` | Named profiles, each applying to a set of apps. | | `menus` | object | `{}` | Named custom menus (see [Custom Menus](menus.md)). | @@ -32,3 +42,4 @@ If the file does not exist, PadIO runs with no bindings (controller input is sil - **`profiles`** — per-app binding sets. Each profile has its own modes. See [Profiles & Modes](profiles.md). - **`menus`** — popup menus that can be opened from any binding. See [Custom Menus](menus.md). - **`haptics`** — rumble triggers for system events (beep, notifications). See [Haptics](haptics.md). +- **`menu_style` / `hud_zoom`** — appearance of the floating overlays. `hud_zoom` scales every HUD at once, which is useful when driving PadIO from across the room. diff --git a/docs/configuration/menus.md b/docs/configuration/menus.md index 27a5e02..cdfa2fb 100644 --- a/docs/configuration/menus.md +++ b/docs/configuration/menus.md @@ -1,6 +1,6 @@ # Custom Menus -Named menus are defined at the top level of the config under `"menus"`. Each menu is an array of `{ label, action }` pairs and is opened via the `menu:` action type. +Named menus are defined at the top level of the config under `"menus"`. Each menu is a list of `{ label, action }` pairs and is opened via the `menu:` action type. ## Defining a menu @@ -24,12 +24,66 @@ Open it from any binding: The legacy syntax `"type": "menu:git"` is also supported. +## Menu styles + +A menu is drawn either as a vertical **list** (the default) or as a circular **wheel**. + +Set the default for every menu at the top level of the config: + +```json +"menu_style": "wheel" +``` + +Accepted values are `"list"` and `"wheel"` (`"donut"` and `"radial"` are accepted as aliases for `"wheel"`). An unrecognised value falls back to `"list"` and logs a warning rather than rejecting the config. + +To override the style for one menu, write that menu as an object with `style` and `items` instead of a bare array: + +```json +"menus": { + "git": [ + { "label": "git status", "action": { "type": "keystroke", "key": "`git status\n`" } } + ], + + "window": { + "style": "wheel", + "items": [ + { "label": "Left half", "action": { "type": "keystroke", "key": "left", "modifiers": ["ctrl", "opt"] } }, + { "label": "Right half", "action": { "type": "keystroke", "key": "right", "modifiers": ["ctrl", "opt"] } }, + { "label": "Maximize", "action": { "type": "keystroke", "key": "up", "modifiers": ["ctrl", "opt"] } }, + { "label": "Centre", "action": { "type": "keystroke", "key": "c", "modifiers": ["ctrl", "opt"] } } + ] + } +} +``` + +Both forms can be mixed freely in one config. A menu written as a bare array inherits the top-level `menu_style`. + +!!! note + The wheel is meant for short menus. A menu with more than 16 items is drawn as a list regardless of its style, and logs a warning. + ## Navigation -- **dpad up/down** — move highlight +### List style + +- **dpad up/down** (or **left/right**) — move highlight - **A** or **RT** — select item and execute its action - **B**, **X**, or **LT** — cancel and close +### Wheel style + +Items sit on a ring around a hub that spells out the current selection. There are two ways to drive it, both live at the same time: + +- **either thumbstick** — aim. The ring stays still and the highlight jumps to whichever item the stick points at. Letting the stick return to centre keeps the current selection. +- **dpad left/right** (or **up/down**) — rotate. The ring turns and the highlight stays on the fixed marker at the 12 o'clock position. +- **A** or **RT** — select item and execute its action +- **B**, **X**, or **LT** — cancel and close + +Selection and cancellation use the same buttons in both styles. + +## Sizing + +Menus scale with the top-level `hud_zoom` setting along with every other HUD. See [HUDs](../huds.md). + ## Nested menus Menu item actions can be any action type, including another `menu:` for nested menus. diff --git a/docs/example-config.md b/docs/example-config.md index 751932d..7ca7e32 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -6,6 +6,7 @@ A complete annotated config demonstrating profiles, modes, sequences, custom men { "trigger_threshold": 0.5, "debug_overlay": false, + "hud_zoom": 1.0, "aliases": { "tmux_leader": { "type": "keystroke", "key": "a", "modifiers": ["ctrl"] } }, diff --git a/docs/huds.md b/docs/huds.md index 0b2b099..e12b6a6 100644 --- a/docs/huds.md +++ b/docs/huds.md @@ -11,6 +11,15 @@ Press the **menu (≡)** button at any time to open a floating overlay showing a The Help HUD takes priority over all other button processing while visible. +## Custom menu + +Opened with a `menu:` action, this overlay lists the items of a [custom menu](configuration/menus.md) and executes the one you pick. It comes in two styles, chosen with `menu_style` (globally) or a per-menu `style` key: + +- **List** (default) — a vertical list, navigated with the dpad. +- **Wheel** — a circular "donut" with the items on a ring around a hub showing the current selection. Aim it with either thumbstick, or rotate the ring with dpad left/right against a fixed marker at 12 o'clock. + +Like the Help HUD, the custom menu consumes all button input while it is visible. Pointer and scroll emission from the sticks is suspended too, so a stick can aim the wheel without moving the mouse underneath. + ## Mode notification When a mode switch occurs via `prev_mode`, `next_mode`, or `mode:`, a small overlay briefly appears at the top of the screen displaying the new mode name. It auto-dismisses after 1.5 seconds. @@ -25,3 +34,13 @@ When `"debug_overlay": true` is set in the config, a small pill-shaped HUD appea The overlay auto-dismisses after 2 seconds. A new press resets the timer immediately. Set `"debug_overlay": false` (or omit the field) for production use. + +## Sizing + +The top-level `hud_zoom` key scales every HUD on this page at once: + +```json +"hud_zoom": 1.5 +``` + +The default is `1.0`; values are clamped to the range 0.75–3.0. This is worth turning up when the controller is being used from across the room rather than at the desk. diff --git a/docs/index.md b/docs/index.md index 1ec1ce8..0cffa41 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,7 +16,7 @@ If you're looking for a friendlier GUI-based controller remapper, search "Game C - **Mouse & scroll** — map sticks/dpad to cursor movement and scroll wheel with speed modifiers - **Per-app profiles** — automatic profile switching based on the frontmost application - **Modes** — multiple binding sets per profile, switchable via picker, cycling, or direct jump -- **Custom menus** — define popup menus with labeled items that trigger any action +- **Custom menus** — define popup menus with labeled items that trigger any action, as a list or a circular wheel you aim with the stick - **Haptic feedback** — rumble on system beep, notifications, or on-demand from any binding - **Media keys** — play/pause, track skip, volume, brightness (no Accessibility permission needed) - **Hot-reload** — save the config file and changes take effect instantly From 90397a9b3303229977299f81a4cd8be98858599a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Gr=C3=A9goire?= Date: Tue, 18 Aug 2026 11:55:59 -0400 Subject: [PATCH 2/2] document menu_style and hud_zoom in the remaining pages The example config had picked up hud_zoom but not menu_style or the object-form menu, so it no longer matched the sample config.json it mirrors. Add both, plus notes explaining that the bare-array and object menu forms can be mixed. The menu action reference described how to open a menu but never pointed at the page defining one; link it. Add a feature bullet for HUD scaling, which was otherwise only discoverable in the config reference. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + docs/configuration/actions.md | 2 ++ docs/example-config.md | 17 +++++++++++++++-- docs/index.md | 1 + 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4194e03..98a4f85 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ If you're looking for a friendlier GUI-based controller remapper, search "Game C - **Media keys** — play/pause, track skip, volume, brightness (no Accessibility permission needed) - **Hot-reload** — save the config file and changes take effect instantly - **Help HUD** — press the menu button anytime to see all current bindings +- **Scalable HUDs** — one `hud_zoom` setting scales every overlay, for driving PadIO from across the room - **Debug overlay** — optional HUD showing every button press and its resolved action ## Installation diff --git a/docs/configuration/actions.md b/docs/configuration/actions.md index 869772b..d767910 100644 --- a/docs/configuration/actions.md +++ b/docs/configuration/actions.md @@ -105,6 +105,8 @@ Open a named custom menu overlay (defined in the top-level `menus` object). See The legacy syntax `"type": "menu:git"` is still supported for backward compatibility. +The menu itself — its items, and whether it is drawn as a list or a circular wheel — is defined in the top-level `menus` object. See [Custom Menus](menus.md). + ## `alias` Reference a reusable action defined in the top-level `aliases` object. diff --git a/docs/example-config.md b/docs/example-config.md index 7ca7e32..3a44f31 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -7,6 +7,7 @@ A complete annotated config demonstrating profiles, modes, sequences, custom men "trigger_threshold": 0.5, "debug_overlay": false, "hud_zoom": 1.0, + "menu_style": "list", "aliases": { "tmux_leader": { "type": "keystroke", "key": "a", "modifiers": ["ctrl"] } }, @@ -137,7 +138,17 @@ A complete annotated config demonstrating profiles, modes, sequences, custom men { "label": "git push", "action": { "type": "keystroke", "key": "`git push\n`" } }, { "label": "git stash", "action": { "type": "keystroke", "key": "`git stash\n`" } }, { "label": "git stash pop", "action": { "type": "keystroke", "key": "`git stash pop\n`" } } - ] + ], + "window": { + "style": "wheel", + "items": [ + { "label": "Left half", "action": { "type": "keystroke", "key": "left", "modifiers": ["ctrl", "opt"] } }, + { "label": "Right half", "action": { "type": "keystroke", "key": "right", "modifiers": ["ctrl", "opt"] } }, + { "label": "Top half", "action": { "type": "keystroke", "key": "up", "modifiers": ["ctrl", "opt"] } }, + { "label": "Maximize", "action": { "type": "keystroke", "key": "f", "modifiers": ["ctrl", "opt"] } }, + { "label": "Centre", "action": { "type": "keystroke", "key": "c", "modifiers": ["ctrl", "opt"] } } + ] + } } } ``` @@ -157,4 +168,6 @@ This config: - **tmux mode**: prefix sequences (ctrl-a + key) for pane navigation - **agent mode**: return, escape, and `continue` as injected text — reached only via `context_modes` - **Automatic modes**: `context_modes` switches mode from an external token (see [Automatic Modes](configuration/automatic-modes.md)), and `hidden_modes` keeps `agent` out of the picker since it is never chosen by hand -- **Git menu**: quick terminal commands accessible via Y button in shell mode +- **Git menu**: quick terminal commands accessible via Y button in shell mode, written as a bare array so it inherits the top-level `menu_style` +- **Window menu**: the object form, overriding the default to draw as a circular wheel — both forms can be mixed freely (see [Custom Menus](configuration/menus.md)) +- **Appearance**: `menu_style` sets the default menu presentation and `hud_zoom` scales every HUD at once (see [HUDs](huds.md)) diff --git a/docs/index.md b/docs/index.md index 0cffa41..16b416b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,6 +21,7 @@ If you're looking for a friendlier GUI-based controller remapper, search "Game C - **Media keys** — play/pause, track skip, volume, brightness (no Accessibility permission needed) - **Hot-reload** — save the config file and changes take effect instantly - **Help HUD** — press the menu button anytime to see all current bindings +- **Scalable HUDs** — one `hud_zoom` setting scales every overlay, for driving PadIO from across the room - **Debug overlay** — optional HUD showing every button press and its resolved action ## Quick install