diff --git a/src/gamepad.zig b/src/gamepad.zig index d715f9a..460ba1b 100644 --- a/src/gamepad.zig +++ b/src/gamepad.zig @@ -26,6 +26,35 @@ pub const TypeHint = enum(u8) { generic, }; +/// Best-guess vendor family from a human-readable device name string. +/// +/// Shared name→type classifier for any source that only has a name to go on +/// (Android `InputDevice.getName()`, raylib's `GetGamepadName`, the WebGamepad +/// `id` string). Backends with a stable USB vendor id (Linux evdev, iOS GC +/// profile) should classify from that instead — this is the name-only path. +/// +/// Matching is case-insensitive substring. A non-empty name that matches no +/// known family is `.generic`; an empty name is `.unknown`. +pub fn typeHintFromName(name: []const u8) TypeHint { + if (containsIgnoreCase(name, "xbox") or + containsIgnoreCase(name, "microsoft")) return .xbox; + if (containsIgnoreCase(name, "playstation") or + containsIgnoreCase(name, "dualsense") or + containsIgnoreCase(name, "dualshock") or + containsIgnoreCase(name, "sony") or + containsIgnoreCase(name, "wireless controller")) return .playstation; + if (containsIgnoreCase(name, "nintendo") or + containsIgnoreCase(name, "switch") or + containsIgnoreCase(name, "joy-con") or + containsIgnoreCase(name, "pro controller")) return .nintendo; + if (name.len > 0) return .generic; + return .unknown; +} + +fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool { + return std.ascii.indexOfIgnoreCase(haystack, needle) != null; +} + /// What kind of physical device produced the event. Distinguishes a real /// game controller from a TV / set-top "d-pad remote" (Android TV, tvOS), /// which should usually be treated differently by menus. @@ -161,6 +190,19 @@ test "setName truncates to NAME_CAPACITY and stays NUL-terminated" { try std.testing.expectEqual(@as(u8, 0), ev.name[NAME_CAPACITY]); // sentinel intact } +test "typeHintFromName classifies known vendor families (name-only path)" { + try std.testing.expectEqual(TypeHint.xbox, typeHintFromName("Xbox Wireless Controller")); + try std.testing.expectEqual(TypeHint.xbox, typeHintFromName("XBOX 360 For Windows")); + try std.testing.expectEqual(TypeHint.xbox, typeHintFromName("Microsoft X-Box pad")); + try std.testing.expectEqual(TypeHint.playstation, typeHintFromName("Sony DualSense Wireless Controller")); + try std.testing.expectEqual(TypeHint.playstation, typeHintFromName("PLAYSTATION(R)3 Controller")); + try std.testing.expectEqual(TypeHint.playstation, typeHintFromName("Wireless Controller")); + try std.testing.expectEqual(TypeHint.nintendo, typeHintFromName("Nintendo Switch Pro Controller")); + try std.testing.expectEqual(TypeHint.nintendo, typeHintFromName("Joy-Con (L)")); + try std.testing.expectEqual(TypeHint.generic, typeHintFromName("Generic USB Joystick")); + try std.testing.expectEqual(TypeHint.unknown, typeHintFromName("")); +} + test "disconnected constructor" { const ev = GamepadEvent.disconnected(3); try std.testing.expectEqual(GamepadEvent.Kind.disconnected, ev.kind); diff --git a/src/gamepad_source/android.zig b/src/gamepad_source/android.zig index 10e1f8e..ebe3370 100644 --- a/src/gamepad_source/android.zig +++ b/src/gamepad_source/android.zig @@ -236,6 +236,11 @@ export fn labelle_android_on_device_added( ev.setName(name); ev.guid = descriptorGuid(descriptor); ev.source_class = classifySources(sources); + // Derive the vendor family from the name (Android exposes no stable USB + // vendor id here), matching the desktop/web name-only path so the HUD shows + // e.g. `Pad 9: Xbox Wireless Controller [xbox]` instead of `[unknown]` + // (labelle-assembler#270). Uses the buffered name so truncation is honored. + ev.type_hint = source.typeHintFromName(ev.nameSlice()); ring.push(ev); } @@ -343,6 +348,18 @@ test "EventRing overflow drops oldest" { try std.testing.expectEqual(@as(u32, 5), out[0].slot); } +test "device-added uses the shared name classifier for type_hint (#270)" { + // The `labelle_android_on_device_added` body early-returns off Android, so we + // exercise the same name→type_hint derivation it performs (`setName` then + // `typeHintFromName(nameSlice())`) to lock in the wiring that drives the HUD. + var ev = GamepadEvent{ .kind = .connected, .slot = 9 }; + ev.setName("Xbox Wireless Controller"); + ev.type_hint = source.typeHintFromName(ev.nameSlice()); + try std.testing.expectEqual(source.GamepadEvent.Kind.connected, ev.kind); + try std.testing.expectEqualStrings("Xbox Wireless Controller", ev.nameSlice()); + try std.testing.expectEqual(source.TypeHint.xbox, ev.type_hint); +} + test "pollEvents returns 0 on host (no Android activity)" { var buf: [8]GamepadEvent = undefined; try std.testing.expectEqual(@as(usize, 0), Source.pollEvents(&buf)); diff --git a/src/gamepad_source/root.zig b/src/gamepad_source/root.zig index 1543bf1..ce8c95e 100644 --- a/src/gamepad_source/root.zig +++ b/src/gamepad_source/root.zig @@ -43,6 +43,14 @@ pub const GamepadDescription = gamepad.GamepadDescription; // into `../gamepad.zig`. Without it `android.zig` fails to compile for the // Android target (labelle-core#23). pub const SourceClass = gamepad.SourceClass; +/// Re-exported alongside the name classifier so per-OS source files can name +/// the result type through `@import("root.zig")`. +pub const TypeHint = gamepad.TypeHint; +/// Re-exported so per-OS source files can derive a `TypeHint` from a device +/// name (the name-only classification path) through `@import("root.zig")` +/// without reaching back into `../gamepad.zig`. Shared so Android, raylib and +/// the web source classify identical name strings the same way (#270). +pub const typeHintFromName = gamepad.typeHintFromName; /// Comptime OS/abi dispatch. Each branch maps to exactly one owned file so /// parallel Wave-1 work never collides. Android is detected via abi (there