feat: show live controller input
Route paired controller QoS data through the existing GIP protocol layer and expose the latest gamepad report through the C API. Add a SwiftUI monitor for buttons, d-pad, triggers, and stick positions without registering a native HID device. Co-Authored-By: openai/gpt-5.6-luna: GIP bridge and live input monitor
This commit is contained in:
+148
-15
@@ -24,16 +24,31 @@ 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 (MAC strings), refreshed each tick.
|
||||
@State private var controllers: [String] = []
|
||||
// Connected controllers and their latest input snapshot.
|
||||
@State private var controllers: [ControllerSnapshot] = []
|
||||
|
||||
private let timer = Timer.publish(every: 1.0, on: .main, in: .common)
|
||||
private let timer = Timer.publish(every: 0.1, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
|
||||
var body: some View {
|
||||
@@ -138,12 +153,32 @@ struct ContentView: View {
|
||||
// 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: [String] = []
|
||||
var list: [ControllerSnapshot] = []
|
||||
for i in 0..<count {
|
||||
var buf = [CChar](repeating: 0, count: 18)
|
||||
if xone_controller_mac(session, Int32(i), &buf, 18) == 0 {
|
||||
list.append(String(cString: buf))
|
||||
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
|
||||
}
|
||||
@@ -159,24 +194,122 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private var controllersView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Controllers")
|
||||
.font(.headline)
|
||||
if controllers.isEmpty {
|
||||
Text("No controllers connected.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(controllers, id: \.self) { mac in
|
||||
HStack {
|
||||
Image(systemName: "gamecontroller")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(mac)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
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("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)
|
||||
ProgressView(value: normalized)
|
||||
.frame(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)
|
||||
ProgressView(value: Double(value), total: 1023)
|
||||
.frame(width: 70)
|
||||
Text("\(value)")
|
||||
.font(.caption2.monospacedDigit())
|
||||
.frame(width: 32, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Entry point: SwiftUI App provides a static main() (top-level code lives in
|
||||
|
||||
Reference in New Issue
Block a user