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
This commit is contained in:
+7
-1
@@ -6,7 +6,7 @@ if(NOT CMAKE_GENERATOR MATCHES "^(Ninja|Xcode)$")
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
cmake_minimum_required(VERSION 3.21)
|
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)
|
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||||
|
|
||||||
@@ -93,6 +93,12 @@ target_link_libraries(xone_api PUBLIC xone_hid)
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
add_executable(xone_app
|
add_executable(xone_app
|
||||||
"src/app/main.swift"
|
"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_include_directories(xone_app PRIVATE "${CMAKE_SOURCE_DIR}/include")
|
||||||
target_compile_definitions(xone_app PRIVATE ${BASE_DEFINITIONS})
|
target_compile_definitions(xone_app PRIVATE ${BASE_DEFINITIONS})
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
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..<count {
|
||||||
|
var macBuffer = [CChar](repeating: 0, count: 18)
|
||||||
|
guard xone_controller_mac(s, Int32(i), &macBuffer, 18) == 0 else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw = xone_controller_state()
|
||||||
|
guard xone_controller_get_state(s, Int32(i), &raw) == 0 else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
list.append(ControllerInput(
|
||||||
|
id: String(cString: macBuffer),
|
||||||
|
buttons: raw.buttons,
|
||||||
|
guideDown: raw.guide_down,
|
||||||
|
gipReady: raw.gip_ready,
|
||||||
|
inputActive: raw.input_active,
|
||||||
|
triggerLeft: raw.trigger_left,
|
||||||
|
triggerRight: raw.trigger_right,
|
||||||
|
stickLeftX: raw.stick_left_x,
|
||||||
|
stickLeftY: raw.stick_left_y,
|
||||||
|
stickRightX: raw.stick_right_x,
|
||||||
|
stickRightY: raw.stick_right_y,
|
||||||
|
sequence: raw.sequence
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-304
@@ -1,9 +1,8 @@
|
|||||||
// xone_macos — macOS app entry point (SwiftUI)
|
// xone_macos: macOS app entry point (SwiftUI)
|
||||||
// TODO(phase 5): device discovery (IOKit notifications), pairing UI,
|
// TODO(phase 5): device discovery (IOKit notifications), preferences,
|
||||||
// status display, preferences, menubar icon.
|
// menubar icon.
|
||||||
|
|
||||||
import AppKit
|
import AppKit
|
||||||
import Combine
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct XoneApp: App {
|
struct XoneApp: App {
|
||||||
@@ -15,6 +14,7 @@ struct XoneApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
// Present as a regular app (foreground window) even when launched
|
// Present as a regular app (foreground window) even when launched
|
||||||
@@ -24,306 +24,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ControllerSnapshot: 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
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContentView: View {
|
|
||||||
@State private var donglePresent = false
|
|
||||||
@State private var session: OpaquePointer? = nil
|
|
||||||
// Radio state polled from the background worker each tick; a change here
|
|
||||||
// is what re-renders the debug section.
|
|
||||||
@State private var radioState = XONE_STATE_IDLE
|
|
||||||
// Connected controllers and their latest input snapshot.
|
|
||||||
@State private var controllers: [ControllerSnapshot] = []
|
|
||||||
|
|
||||||
private let timer = Timer.publish(every: 0.02, on: .main, in: .common)
|
|
||||||
.autoconnect()
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "gamecontroller.fill")
|
|
||||||
.font(.largeTitle)
|
|
||||||
Text("xone_macos")
|
|
||||||
.font(.title)
|
|
||||||
.bold()
|
|
||||||
}
|
|
||||||
|
|
||||||
statusView
|
|
||||||
|
|
||||||
if let session {
|
|
||||||
debugView(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
controllersView
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Button("Pair Controller") {
|
|
||||||
if let s = session {
|
|
||||||
xone_set_pairing(s, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(radioState != XONE_STATE_READY)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text("v\(String(cString: xone_version()))")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(24)
|
|
||||||
.frame(width: 420)
|
|
||||||
.frame(minHeight: 320)
|
|
||||||
.onReceive(timer) { _ in
|
|
||||||
let present = xone_dongle_present()
|
|
||||||
if present && session == nil {
|
|
||||||
// Probe is fast; firmware load + radio init run on a
|
|
||||||
// background thread so the UI never blocks.
|
|
||||||
if let s = xone_open() {
|
|
||||||
session = s
|
|
||||||
xone_start(s, nil)
|
|
||||||
radioState = Int(xone_state(s))
|
|
||||||
}
|
|
||||||
} else if !present, let s = session {
|
|
||||||
xone_close(s)
|
|
||||||
session = nil
|
|
||||||
} else if let s = session {
|
|
||||||
radioState = Int(xone_state(s))
|
|
||||||
refreshControllers(s)
|
|
||||||
}
|
|
||||||
donglePresent = present
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func debugView(_ session: OpaquePointer) -> 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..<count {
|
|
||||||
var macBuffer = [CChar](repeating: 0, count: 18)
|
|
||||||
guard xone_controller_mac(session, Int32(i), &macBuffer, 18) == 0 else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var state = xone_controller_state()
|
|
||||||
guard xone_controller_get_state(session, Int32(i), &state) == 0 else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
list.append(ControllerSnapshot(
|
|
||||||
id: String(cString: macBuffer),
|
|
||||||
buttons: state.buttons,
|
|
||||||
guideDown: state.guide_down,
|
|
||||||
gipReady: state.gip_ready,
|
|
||||||
inputActive: state.input_active,
|
|
||||||
triggerLeft: state.trigger_left,
|
|
||||||
triggerRight: state.trigger_right,
|
|
||||||
stickLeftX: state.stick_left_x,
|
|
||||||
stickLeftY: state.stick_left_y,
|
|
||||||
stickRightX: state.stick_right_x,
|
|
||||||
stickRightY: state.stick_right_y,
|
|
||||||
sequence: state.sequence
|
|
||||||
))
|
|
||||||
}
|
|
||||||
controllers = list
|
|
||||||
}
|
|
||||||
|
|
||||||
private var statusView: some View {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Circle()
|
|
||||||
.fill(donglePresent ? Color.green : Color.red)
|
|
||||||
.frame(width: 10, height: 10)
|
|
||||||
Text(donglePresent ? "Dongle connected" : "No dongle detected")
|
|
||||||
.font(.headline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var controllersView: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Controllers")
|
|
||||||
.font(.headline)
|
|
||||||
if controllers.isEmpty {
|
|
||||||
Text("No controllers connected.")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
ForEach(controllers) { controller in
|
|
||||||
controllerView(controller)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func controllerView(_ controller: ControllerSnapshot) -> 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
|
// Entry point: SwiftUI App provides a static main() (top-level code lives in
|
||||||
// main.swift, which CMake's Swift driver expects for executables).
|
// main.swift, which CMake's Swift driver expects for executables).
|
||||||
XoneApp.main()
|
XoneApp.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user