From 4931bf5c18835ab5eb33aef574a7e43cfcbc768b Mon Sep 17 00:00:00 2001 From: portersky Date: Sat, 29 Aug 2026 14:47:41 +0200 Subject: [PATCH] feat: redesign UI with gamepad-style input monitor Split the single-file Swift app into model, store, and view files and rework the layout. The dongle card shows a status pill with an adaptive info grid; controllers render as cards in an adaptive grid that fills wider windows; each card uses a gamepad-style visualization (D-pad, face buttons, stick pads, triggers) instead of flat indicator rows. The window is now resizable and content reflows instead of staying a fixed 420pt column. Co-Authored-By: qwen3.8-27b@q3_k_xl: redesigned app UI and structure --- CMakeLists.txt | 8 +- src/app/ContentView.swift | 63 +++++++ src/app/ControllerCardView.swift | 66 +++++++ src/app/DongleCardView.swift | 98 ++++++++++ src/app/GamepadViews.swift | 137 ++++++++++++++ src/app/XoneModels.swift | 99 ++++++++++ src/app/XoneStore.swift | 105 +++++++++++ src/app/main.swift | 308 +------------------------------ 8 files changed, 579 insertions(+), 305 deletions(-) create mode 100644 src/app/ContentView.swift create mode 100644 src/app/ControllerCardView.swift create mode 100644 src/app/DongleCardView.swift create mode 100644 src/app/GamepadViews.swift create mode 100644 src/app/XoneModels.swift create mode 100644 src/app/XoneStore.swift diff --git a/CMakeLists.txt b/CMakeLists.txt index 714df8a..26b794b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,7 +6,7 @@ if(NOT CMAKE_GENERATOR MATCHES "^(Ninja|Xcode)$") endif() cmake_minimum_required(VERSION 3.21) -project(xone_macos VERSION 0.1.20 LANGUAGES CXX Swift) +project(xone_macos VERSION 0.1.21 LANGUAGES CXX Swift) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) @@ -93,6 +93,12 @@ target_link_libraries(xone_api PUBLIC xone_hid) # ============================================================================== add_executable(xone_app "src/app/main.swift" + "src/app/XoneModels.swift" + "src/app/XoneStore.swift" + "src/app/ContentView.swift" + "src/app/DongleCardView.swift" + "src/app/ControllerCardView.swift" + "src/app/GamepadViews.swift" ) target_include_directories(xone_app PRIVATE "${CMAKE_SOURCE_DIR}/include") target_compile_definitions(xone_app PRIVATE ${BASE_DEFINITIONS}) diff --git a/src/app/ContentView.swift b/src/app/ContentView.swift new file mode 100644 index 0000000..b605681 --- /dev/null +++ b/src/app/ContentView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +struct ContentView: View { + @StateObject private var store = XoneStore() + + // Cards flow into extra columns as the window is widened, so dead space + // is used instead of stretching a single column. + private let cardColumns = [GridItem(.adaptive(minimum: 320), spacing: 16)] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header + DongleCardView(store: store) + controllersSection + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(minWidth: 480, idealWidth: 560, maxWidth: .infinity, + minHeight: 420, idealHeight: 640, maxHeight: .infinity) + } + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: "gamecontroller.fill") + .font(.title) + Text("xone_macos") + .font(.title2.bold()) + Spacer() + Text("v\(String(cString: xone_version()))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var controllersSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Controllers") + .font(.headline) + if store.controllers.isEmpty { + emptyState + } else { + LazyVGrid(columns: cardColumns, alignment: .leading, spacing: 16) { + ForEach(store.controllers) { controller in + ControllerCardView(controller: controller) + } + } + } + } + } + + private var emptyState: some View { + Text(store.donglePresent + ? "No controllers connected. Use Pair Controller to add one." + : "Plug in the dongle and pair a controller.") + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 60) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} diff --git a/src/app/ControllerCardView.swift b/src/app/ControllerCardView.swift new file mode 100644 index 0000000..2b79c77 --- /dev/null +++ b/src/app/ControllerCardView.swift @@ -0,0 +1,66 @@ +import SwiftUI + +struct ControllerCardView: View { + let controller: ControllerInput + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + HStack(spacing: 28) { + DPadView(controller: controller) + FaceButtonsView(controller: controller) + } + .frame(maxWidth: .infinity) + sticksAndTriggers + systemButtons + footer + } + .padding(14) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + private var header: some View { + HStack(spacing: 8) { + Circle() + .fill(controller.gipReady ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text(controller.id) + .font(.system(.callout, design: .monospaced)) + Spacer() + Text(controller.gipReady ? "ready" : "connecting") + .font(.caption.weight(.medium)) + .foregroundStyle(controller.gipReady ? .green : .orange) + } + } + + private var sticksAndTriggers: some View { + HStack(alignment: .top, spacing: 12) { + StickPadView(label: "L", x: controller.stickLeftX, y: controller.stickLeftY) + Spacer() + TriggerBarView(label: "LT", value: controller.triggerLeft) + TriggerBarView(label: "RT", value: controller.triggerRight) + Spacer() + StickPadView(label: "R", x: controller.stickRightX, y: controller.stickRightY) + } + } + + private var systemButtons: some View { + HStack(spacing: 6) { + ButtonChip(button: .guide, pressed: controller.guideDown) + ButtonChip(button: .start, pressed: controller.isPressed(.start)) + ButtonChip(button: .select, pressed: controller.isPressed(.select)) + Spacer() + ButtonChip(button: .leftBumper, pressed: controller.isPressed(.leftBumper)) + ButtonChip(button: .rightBumper, pressed: controller.isPressed(.rightBumper)) + ButtonChip(button: .leftStick, pressed: controller.isPressed(.leftStick)) + ButtonChip(button: .rightStick, pressed: controller.isPressed(.rightStick)) + } + } + + private var footer: some View { + Text(controller.inputActive ? "input #\(controller.sequence)" : "waiting for input") + .font(.caption) + .foregroundStyle(.secondary) + } +} diff --git a/src/app/DongleCardView.swift b/src/app/DongleCardView.swift new file mode 100644 index 0000000..0b97536 --- /dev/null +++ b/src/app/DongleCardView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct DongleCardView: View { + @ObservedObject var store: XoneStore + + private let infoColumns = [GridItem(.adaptive(minimum: 150), spacing: 8)] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + StatePill(state: store.state, active: store.donglePresent) + if !store.errorText.isEmpty { + Text(store.errorText) + .font(.callout) + .foregroundStyle(.red) + .lineLimit(2) + } + Spacer() + pairingButton + } + + if store.donglePresent { + LazyVGrid(columns: infoColumns, alignment: .leading, spacing: 8) { + InfoChip(label: "PID", value: String(format: "0x%04X", store.pid)) + InfoChip(label: "Chip ID", value: String(format: "0x%04X", store.chipID)) + InfoChip(label: "MAC", value: store.macAddress) + if store.state == .ready { + InfoChip(label: "Firmware", value: firmwareText) + } + } + } else { + Text("No dongle detected. Plug in the Xbox Wireless Dongle.") + .font(.callout) + .foregroundStyle(.secondary) + } + } + .padding(14) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + private var pairingButton: some View { + Button(store.pairingActive ? "Stop Pairing" : "Pair Controller") { + store.togglePairing() + } + .disabled(!store.donglePresent || store.state != .ready) + } + + private var firmwareText: String { + store.firmwareBuild.isEmpty ? "not loaded" : store.firmwareBuild + } +} + +struct StatePill: View { + let state: DongleState + let active: Bool + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + Text(active ? state.label : "no dongle") + .font(.callout.weight(.medium)) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(color.opacity(0.15)) + .clipShape(Capsule()) + } + + private var color: Color { + guard active else { return .gray } + switch state { + case .idle: return .gray + case .starting: return .orange + case .ready: return .green + case .error: return .red + } + } +} + +struct InfoChip: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label.uppercased()) + .font(.caption2) + .foregroundStyle(.secondary) + Text(value) + .font(.system(.callout, design: .monospaced)) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/src/app/GamepadViews.swift b/src/app/GamepadViews.swift new file mode 100644 index 0000000..2ed969a --- /dev/null +++ b/src/app/GamepadViews.swift @@ -0,0 +1,137 @@ +import SwiftUI + +// Gamepad-style visualization of a controller input snapshot: D-pad and face +// buttons on top, stick pads and triggers below, mirroring the physical +// layout of an Xbox controller. + +struct DPadView: View { + let controller: ControllerInput + + var body: some View { + VStack(spacing: 3) { + cell(.dPadUp) + HStack(spacing: 3) { + cell(.dPadLeft) + RoundedRectangle(cornerRadius: 5) + .fill(Color.secondary.opacity(0.28)) + .frame(width: 30, height: 26) + cell(.dPadRight) + } + cell(.dPadDown) + } + } + + private func cell(_ button: XboxButton) -> some View { + let pressed = controller.isPressed(button) + return RoundedRectangle(cornerRadius: 5) + .fill(pressed ? Color.green : Color.secondary.opacity(0.18)) + .frame(width: 30, height: 26) + .overlay( + Image(systemName: button.dPadSymbol ?? "") + .font(.system(size: 9)) + .foregroundStyle(pressed ? .white : .secondary) + ) + } +} + +struct FaceButtonsView: View { + let controller: ControllerInput + + var body: some View { + VStack(spacing: 4) { + faceButton(.y) + HStack(spacing: 10) { + faceButton(.x) + faceButton(.b) + } + faceButton(.a) + } + } + + private func faceButton(_ button: XboxButton) -> some View { + let pressed = controller.isPressed(button) + return Text(button.label) + .font(.callout.weight(.semibold)) + .frame(width: 32, height: 32) + .background(Circle().fill(pressed ? Color.green : Color.secondary.opacity(0.18))) + .foregroundStyle(pressed ? .white : .primary) + } +} + +struct StickPadView: View { + let label: String + let x: Int16 + let y: Int16 + + private let diameter: CGFloat = 64 + + var body: some View { + VStack(spacing: 4) { + ZStack { + Circle() + .fill(Color.secondary.opacity(0.15)) + Circle() + .stroke(Color.secondary.opacity(0.35), lineWidth: 1) + .padding(6) + Circle() + .fill(Color.accentColor) + .frame(width: 12, height: 12) + .offset(x: offset.width, y: offset.height) + } + .frame(width: diameter, height: diameter) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + // Map the signed 16-bit stick values onto the pad, keeping the dot inside + // the ring. + private var offset: CGSize { + let travel = diameter / 2 - 10 + return CGSize( + width: CGFloat(Double(x) / 32768.0 * travel), + height: CGFloat(Double(y) / 32768.0 * travel) + ) + } +} + +struct TriggerBarView: View { + let label: String + let value: UInt16 + + private let barWidth: CGFloat = 56 + + var body: some View { + VStack(spacing: 4) { + ZStack(alignment: .leading) { + Color.secondary.opacity(0.18) + Color.accentColor + .frame(width: barWidth * fraction, alignment: .leading) + } + .frame(width: barWidth, height: 6) + .clipShape(RoundedRectangle(cornerRadius: 3)) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private var fraction: CGFloat { + min(max(CGFloat(Double(value) / 1023.0), 0), 1) + } +} + +struct ButtonChip: View { + let button: XboxButton + let pressed: Bool + + var body: some View { + Text(button.label) + .font(.caption2.weight(.medium)) + .frame(minWidth: 30, minHeight: 20) + .background(pressed ? Color.green : Color.secondary.opacity(0.15)) + .foregroundStyle(pressed ? .white : .primary) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } +} diff --git a/src/app/XoneModels.swift b/src/app/XoneModels.swift new file mode 100644 index 0000000..deb015f --- /dev/null +++ b/src/app/XoneModels.swift @@ -0,0 +1,99 @@ +import Foundation + +// Session states reported by xone_state(). Raw values match the C enum. +enum DongleState: Int { + case idle = 0 + case starting + case ready + case error + + var label: String { + switch self { + case .idle: return "idle" + case .starting: return "starting" + case .ready: return "ready" + case .error: return "error" + } + } +} + +// Snapshot of the latest standard Xbox controller input report. Stick values +// are signed 16-bit; trigger values are unsigned 10-bit. +struct ControllerInput: Identifiable { + let id: String + let buttons: UInt16 + let guideDown: Bool + let gipReady: Bool + let inputActive: Bool + let triggerLeft: UInt16 + let triggerRight: UInt16 + let stickLeftX: Int16 + let stickLeftY: Int16 + let stickRightX: Int16 + let stickRightY: Int16 + let sequence: UInt32 + + func isPressed(_ button: XboxButton) -> Bool { + (buttons & button.mask) != 0 + } +} + +// Standard GIP gamepad buttons and their bit masks. Guide is a separate flag +// in the C API, so its mask is zero. +enum XboxButton { + case a, b, x, y + case dPadUp, dPadDown, dPadLeft, dPadRight + case leftBumper, rightBumper, leftStick, rightStick + case guide, start, select + + var mask: UInt16 { + switch self { + case .a: return 0x0010 + case .b: return 0x0020 + case .x: return 0x0040 + case .y: return 0x0080 + case .dPadUp: return 0x0100 + case .dPadDown: return 0x0200 + case .dPadLeft: return 0x0400 + case .dPadRight: return 0x0800 + case .leftBumper: return 0x1000 + case .rightBumper: return 0x2000 + case .leftStick: return 0x4000 + case .rightStick: return 0x8000 + case .start: return 0x0004 + case .select: return 0x0008 + case .guide: return 0 + } + } + + var label: String { + switch self { + case .a: return "A" + case .b: return "B" + case .x: return "X" + case .y: return "Y" + case .dPadUp: return "Up" + case .dPadDown: return "Down" + case .dPadLeft: return "Left" + case .dPadRight: return "Right" + case .leftBumper: return "LB" + case .rightBumper: return "RB" + case .leftStick: return "LS" + case .rightStick: return "RS" + case .guide: return "Guide" + case .start: return "Start" + case .select: return "Select" + } + } + + // SF Symbol for the D-pad cells; nil for every other button. + var dPadSymbol: String? { + switch self { + case .dPadUp: return "arrowtriangle.up.fill" + case .dPadDown: return "arrowtriangle.down.fill" + case .dPadLeft: return "arrowtriangle.left.fill" + case .dPadRight: return "arrowtriangle.right.fill" + default: return nil + } + } +} diff --git a/src/app/XoneStore.swift b/src/app/XoneStore.swift new file mode 100644 index 0000000..8b4f149 --- /dev/null +++ b/src/app/XoneStore.swift @@ -0,0 +1,105 @@ +import AppKit +import Combine +import Foundation + +// Observable model that owns the C API session and polls it on a timer. +// Views render the published state; none of them touch the C API directly. +final class XoneStore: ObservableObject { + @Published private(set) var donglePresent = false + @Published private(set) var state: DongleState = .idle + @Published private(set) var pid: UInt16 = 0 + @Published private(set) var chipID: UInt16 = 0 + @Published private(set) var macAddress = "" + @Published private(set) var firmwareBuild = "" + @Published private(set) var errorText = "" + @Published private(set) var controllers: [ControllerInput] = [] + @Published private(set) var pairingActive = false + + private var session: OpaquePointer? + private var cancellables = Set() + + init() { + // Probe is fast; firmware load + radio init run on a background + // thread in the C layer so the UI never blocks. + Timer.publish(every: 0.02, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in self?.poll() } + .store(in: &cancellables) + } + + func togglePairing() { + guard let s = session, state == .ready else { return } + pairingActive.toggle() + xone_set_pairing(s, pairingActive) + } + + private func poll() { + let present = xone_dongle_present() + donglePresent = present + + if present && session == nil { + if let s = xone_open() { + session = s + xone_start(s, nil) + } + } else if !present { + closeSession() + } + + guard let s = session else { return } + refresh(from: s) + } + + private func closeSession() { + if let s = session { + xone_close(s) + session = nil + } + pairingActive = false + controllers = [] + errorText = "" + firmwareBuild = "" + } + + private func refresh(from s: OpaquePointer) { + state = DongleState(rawValue: Int(xone_state(s))) ?? .idle + pid = xone_pid(s) + chipID = xone_chip_id(s) + macAddress = String(cString: xone_mac_address(s)) + firmwareBuild = String(cString: xone_firmware_build(s)) + errorText = state == .error ? String(cString: xone_error(s)) : "" + controllers = readControllers(s) + } + + private func readControllers(_ s: OpaquePointer) -> [ControllerInput] { + let count = Int(xone_controller_count(s)) + var list: [ControllerInput] = [] + for i in 0.. some View { - VStack(alignment: .leading, spacing: 4) { - Text("Debug") - .font(.headline) - debugRow("PID", String(format: "0x%04X", xone_pid(session))) - debugRow("Chip ID", String(format: "0x%04X", xone_chip_id(session))) - debugRow("MAC", String(cString: xone_mac_address(session))) - debugRow("State", stateText(radioState)) - if radioState == XONE_STATE_READY { - debugRow("Firmware", firmwareBuildText(session)) - } else if radioState == XONE_STATE_ERROR { - debugRow("Error", String(cString: xone_error(session))) - } - } - } - - private func debugRow(_ label: String, _ value: String) -> some View { - HStack { - Text(label) - .foregroundStyle(.secondary) - Spacer() - Text(value) - .font(.system(.body, design: .monospaced)) - } - } - - private func stateText(_ state: Int) -> String { - switch state { - case XONE_STATE_IDLE: return "idle" - case XONE_STATE_STARTING: return "starting..." - case XONE_STATE_READY: return "ready" - case XONE_STATE_ERROR: return "error" - default: return "unknown" - } - } - - private func firmwareBuildText(_ session: OpaquePointer) -> String { - let build = String(cString: xone_firmware_build(session)) - return build.isEmpty ? "not loaded" : build - } - - // Query the C API for connected controllers and store their MACs. - private func refreshControllers(_ session: OpaquePointer) { - let count = Int(xone_controller_count(session)) - var list: [ControllerSnapshot] = [] - for i in 0.. some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Image(systemName: "gamecontroller") - .foregroundStyle(.secondary) - Text(controller.id) - .font(.system(.body, design: .monospaced)) - Spacer() - Text(controller.gipReady ? "ready" : "connecting") - .foregroundStyle(controller.gipReady ? .green : .orange) - } - - HStack(spacing: 4) { - buttonIndicator("Guide", pressed: controller.guideDown) - buttonIndicator("Start", pressed: isPressed(controller, 0x0004)) - buttonIndicator("Select", pressed: isPressed(controller, 0x0008)) - buttonIndicator("A", pressed: isPressed(controller, 0x0010)) - buttonIndicator("B", pressed: isPressed(controller, 0x0020)) - buttonIndicator("X", pressed: isPressed(controller, 0x0040)) - buttonIndicator("Y", pressed: isPressed(controller, 0x0080)) - } - - HStack(spacing: 4) { - buttonIndicator("LB", pressed: isPressed(controller, 0x1000)) - buttonIndicator("RB", pressed: isPressed(controller, 0x2000)) - buttonIndicator("LS", pressed: isPressed(controller, 0x4000)) - buttonIndicator("RS", pressed: isPressed(controller, 0x8000)) - buttonIndicator("↑", pressed: isPressed(controller, 0x0100)) - buttonIndicator("↓", pressed: isPressed(controller, 0x0200)) - buttonIndicator("←", pressed: isPressed(controller, 0x0400)) - buttonIndicator("→", pressed: isPressed(controller, 0x0800)) - } - - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Left stick") - .foregroundStyle(.secondary) - signedAxis("X", controller.stickLeftX) - signedAxis("Y", controller.stickLeftY) - } - VStack(alignment: .leading, spacing: 4) { - Text("Right stick") - .foregroundStyle(.secondary) - signedAxis("X", controller.stickRightX) - signedAxis("Y", controller.stickRightY) - } - } - - HStack(spacing: 12) { - triggerAxis("Left trigger", controller.triggerLeft) - triggerAxis("Right trigger", controller.triggerRight) - } - - Text(controller.inputActive - ? "Input #\(controller.sequence)" - : "Waiting for input") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(10) - .background(Color.secondary.opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - - private func isPressed(_ controller: ControllerSnapshot, _ mask: UInt16) -> Bool { - (controller.buttons & mask) != 0 - } - - private func buttonIndicator(_ title: String, pressed: Bool) -> some View { - Text(title) - .font(.caption2) - .frame(minWidth: 28, minHeight: 22) - .padding(.horizontal, 2) - .background(pressed ? Color.green : Color.secondary.opacity(0.18)) - .foregroundStyle(pressed ? .white : .primary) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } - - private func signedAxis(_ label: String, _ value: Int16) -> some View { - let normalized = Double(Int(value) + 32768) / 65535.0 - return HStack(spacing: 4) { - Text(label) - .font(.caption2) - .frame(width: 12, alignment: .leading) - valueBar(normalized, width: 82) - Text("\(value)") - .font(.caption2.monospacedDigit()) - .frame(width: 42, alignment: .trailing) - } - } - - private func triggerAxis(_ label: String, _ value: UInt16) -> some View { - HStack(spacing: 4) { - Text(label) - .font(.caption2) - .frame(width: 76, alignment: .leading) - valueBar(Double(value) / 1023.0, width: 70) - Text("\(value)") - .font(.caption2.monospacedDigit()) - .frame(width: 32, alignment: .trailing) - } - } - - private func valueBar(_ fraction: Double, width: CGFloat) -> some View { - let clamped = min(max(fraction, 0), 1) - return ZStack(alignment: .leading) { - Color.secondary.opacity(0.18) - .frame(width: width, height: 6) - Color.accentColor - .frame(width: width * CGFloat(clamped), height: 6) - } - .frame(width: width, height: 6) - .clipShape(RoundedRectangle(cornerRadius: 3)) - } -} - // Entry point: SwiftUI App provides a static main() (top-level code lives in // main.swift, which CMake's Swift driver expects for executables). XoneApp.main()