418e177b5c
Display the standard GIP Menu and View bits in the controller input monitor alongside the existing Guide and face-button indicators. Co-Authored-By: openai/gpt-5.6-luna: added menu button indicators
330 lines
12 KiB
Swift
330 lines
12 KiB
Swift
// xone_macos — macOS app entry point (SwiftUI)
|
|
// TODO(phase 5): device discovery (IOKit notifications), pairing UI,
|
|
// status display, preferences, menubar icon.
|
|
|
|
import AppKit
|
|
import Combine
|
|
import SwiftUI
|
|
|
|
struct XoneApp: App {
|
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
|
|
|
var body: some Scene {
|
|
WindowGroup {
|
|
ContentView()
|
|
}
|
|
}
|
|
}
|
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
// Present as a regular app (foreground window) even when launched
|
|
// directly from the build directory instead of an .app bundle.
|
|
NSApp.setActivationPolicy(.regular)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
}
|
|
|
|
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
|
|
// main.swift, which CMake's Swift driver expects for executables).
|
|
XoneApp.main()
|