Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ members = [
"crates/canvas-svg-napi",
"crates/audio-context-napi",
"crates/canvas-media-napi",
"crates/canvas-gamepad-napi",
"crates/canvas-svg-ios",
"crates/canvas-svg-android",
]
Expand Down
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ windows-audio: $(addsuffix _audio_napi,$(ARCHS_WINDOWS))

windows-media: $(addsuffix _media_napi,$(ARCHS_WINDOWS))

windows-gamepad: $(addsuffix _gamepad_napi,$(ARCHS_WINDOWS))

ios-svg: GENERATE_IOS_SVG

visionos-svg: GENERATE_VISIONOS_SVG
Expand Down Expand Up @@ -104,6 +106,10 @@ $(addsuffix _audio_napi,$(ARCHS_WINDOWS)): %_audio_napi:
$(addsuffix _media_napi,$(ARCHS_WINDOWS)): %_media_napi:
./tools/scripts/build-napi.sh $* release-napi canvas-media-napi

.PHONY: windows-gamepad $(addsuffix _gamepad_napi,$(ARCHS_WINDOWS))
$(addsuffix _gamepad_napi,$(ARCHS_WINDOWS)): %_gamepad_napi:
./tools/scripts/build-napi.sh $* release-napi canvas-gamepad-napi

.PHONY: GENERATE_ANDROID
GENERATE_ANDROID: $(ARCHS_ANDROID)

Expand Down
5 changes: 3 additions & 2 deletions apps/demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
"@nativescript/canvas": "file:../../packages/canvas",
"@nativescript/canvas-babylon": "file:../../packages/canvas-babylon",
"@nativescript/canvas-chartjs": "file:../../packages/canvas-chartjs",
"@nativescript/canvas-gamepad": "file:../../packages/canvas-gamepad",
"@nativescript/canvas-media": "file:../../packages/canvas-media",
"@nativescript/canvas-phaser": "file:../../packages/canvas-phaser",
"@nativescript/canvas-phaser-ce": "file:../../packages/canvas-phaser-ce",
"@nativescript/canvas-pixi": "file:../../packages/canvas-pixi",
"@nativescript/canvas-polyfill": "file:../../packages/canvas-polyfill",
"@nativescript/canvas-svg": "file:../../packages/canvas-svg",
"@nativescript/canvas-three": "file:../../packages/canvas-three",
"@nativescript/core": "https://pkg.pr.new/@nativescript/core@11272",
"@nativescript/core": "https://pkg.pr.new/@nativescript/core@54125e1",
"@nativescript/font-manager": "file:../../node_modules/@nativescript/font-manager",
"@pixi-essentials/svg": "file:../../node_modules/@pixi-essentials/svg",
"babylonjs": "file:../../node_modules/babylonjs",
Expand All @@ -37,7 +38,7 @@
"@nativescript/devtools": "~0.0.1",
"@nativescript/ios": "9.0.4-next.2026-08-13-31742124335",
"@nativescript/visionos": "~9.0.1",
"@nativescript/webpack": "https://pkg.pr.new/@nativescript/webpack@11272",
"@nativescript/webpack": "https://pkg.pr.new/@nativescript/webpack@54125e1",
"@nativescript/windows": "beta",
"typescript": "~5.6.0"
}
Expand Down
1 change: 1 addition & 0 deletions apps/demo/src/main-page.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<Button text="canvas" tap="{{ viewDemo }}" class="btn btn-primary view-demo"/>
<Button text="canvas-babylon" tap="{{ viewDemo }}" class="btn btn-primary view-demo"/>
<Button text="canvas-media" tap="{{ viewDemo }}" class="btn btn-primary view-demo"/>
<Button text="canvas-gamepad" tap="{{ viewDemo }}" class="btn btn-primary view-demo"/>
<Button text="canvas-phaser" tap="{{ viewDemo }}" class="btn btn-primary view-demo"/>
<Button text="canvas-phaser-ce" tap="{{ viewDemo }}" class="btn btn-primary view-demo"/>
<Button text="canvas-pixi" tap="{{ viewDemo }}" class="btn btn-primary view-demo"/>
Expand Down
9 changes: 9 additions & 0 deletions apps/demo/src/plugin-demos/canvas-gamepad.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { EventData, Page } from '@nativescript/core';
import { DemoSharedCanvasGamepad } from '@demo/shared';

export function navigatingTo(args: EventData) {
const page = <Page>args.object;
page.bindingContext = new DemoModel();
}

export class DemoModel extends DemoSharedCanvasGamepad {}
9 changes: 9 additions & 0 deletions apps/demo/src/plugin-demos/canvas-gamepad.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Page xmlns="http://schemas.nativescript.org/tns.xsd" xmlns:canvas="@nativescript/canvas" navigatingTo="navigatingTo" class="page">
<Page.actionBar>
<ActionBar title="canvas-gamepad" icon="" class="action-bar">
</ActionBar>
</Page.actionBar>
<GridLayout unloaded="{{ unloaded }}" iosOverflowSafeArea="false">
<canvas:Canvas iosOverflowSafeArea="false" loaded="{{ canvasLoaded }}" style="width:100%;height:100%;" />
</GridLayout>
</Page>
1 change: 1 addition & 0 deletions apps/demo/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@nativescript/canvas-pixi": ["../../packages/canvas-pixi/index.ts"],
"@nativescript/canvas-three": ["../../packages/canvas-three/index.ts"],
"@nativescript/canvas-media": ["../../packages/canvas-media/index.ts"],
"@nativescript/canvas-gamepad": ["../../packages/canvas-gamepad/index.ts"],
"@nativescript/*": ["../../packages/*"],
"@nativescript/audio-context": ["../../packages/audio-context/index.d.ts"]
}
Expand Down
29 changes: 29 additions & 0 deletions crates/canvas-gamepad-napi/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[package]
name = "canvas-gamepad-napi"
version = "0.1.0"
edition = "2021"
publish = false
description = "Node-API binding for @nativescript/canvas-gamepad on Windows: Windows.Gaming.Input readings for navigator.getGamepads()."

[lib]
crate-type = ["cdylib"]

[features]
default = ["napi8"]
napi8 = ["napi/napi8"]
# Hosts whose Node-API lacks type tags (NativeScript Windows on JavaScriptCore).
napi7 = ["napi/napi7"]

[dependencies]
napi = { version = "3.13.0", default-features = false, features = ["dyn-symbols"] }
napi-derive = "3.6.9"

[target.'cfg(target_os = "windows")'.dependencies]
windows = { workspace = true, features = [
"Foundation",
"Foundation_Collections",
"Gaming_Input",
] }

[build-dependencies]
napi-build = "2.5.0"
3 changes: 3 additions & 0 deletions crates/canvas-gamepad-napi/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fn main() {
napi_build::setup();
}
16 changes: 16 additions & 0 deletions crates/canvas-gamepad-napi/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "@nativescript/canvas-gamepad-napi",
"private": true,
"version": "0.1.0",
"description": "Node-API build of the canvas-gamepad native module (Windows.Gaming.Input poller for Windows).",
"napi": {
"binaryName": "canvasgamepad",
"targets": [
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc"
]
},
"engines": {
"node": ">=20"
}
}
211 changes: 211 additions & 0 deletions crates/canvas-gamepad-napi/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
#![cfg(target_os = "windows")]
#![deny(clippy::all)]

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use napi::bindgen_prelude::{Float32Array, Function, ObjectFinalize};
use napi::threadsafe_function::{ThreadsafeFunctionCallMode, UnknownReturnValue};
use napi::{Env, Result};
use napi_derive::napi;
use windows::Foundation::EventHandler;
use windows::Gaming::Input::{Gamepad, GamepadButtons, RawGameController};

// Keep in sync with packages/canvas-gamepad/common.ts.
const MAX_GAMEPADS: usize = 4;
const MAX_AXES: usize = 8;
const SLOT_CONNECTED: usize = 0;
const SLOT_SEQUENCE: usize = 1;
const SLOT_MAPPING: usize = 2;
const SLOT_AXIS_COUNT: usize = 3;
const SLOT_BUTTON_COUNT: usize = 4;
const SLOT_AXES: usize = 5;
const SLOT_BUTTONS: usize = SLOT_AXES + MAX_AXES;
const SLOT_STRIDE: usize = 80;
const BUTTON_PRESSED: u32 = 1;
const BUTTON_TOUCHED: u32 = 2;
const MAPPING_STANDARD: f32 = 1.0;
const STANDARD_AXES: f32 = 4.0;
// Windows.Gaming.Input.Gamepad has no guide button.
const STANDARD_BUTTONS: f32 = 16.0;
// Float32 holds integers exactly up to 2^24.
const SEQUENCE_WRAP: f32 = 16_777_216.0;
const TRIGGER_PRESSED: f32 = 0.1;
const MICROSOFT_VENDOR: u16 = 0x045e;

const DIGITAL: [(usize, GamepadButtons); 14] = [
(0, GamepadButtons::A),
(1, GamepadButtons::B),
(2, GamepadButtons::X),
(3, GamepadButtons::Y),
(4, GamepadButtons::LeftShoulder),
(5, GamepadButtons::RightShoulder),
(8, GamepadButtons::View),
(9, GamepadButtons::Menu),
(10, GamepadButtons::LeftThumbstick),
(11, GamepadButtons::RightThumbstick),
(12, GamepadButtons::DPadUp),
(13, GamepadButtons::DPadDown),
(14, GamepadButtons::DPadLeft),
(15, GamepadButtons::DPadRight),
];

fn write_button(slot: &mut [f32], index: usize, value: f32, pressed: bool) {
let offset = SLOT_BUTTONS + index * 2;
let touched = pressed || value > 0.0;
slot[offset] = value;
slot[offset + 1] = ((if pressed { BUTTON_PRESSED } else { 0 }) | (if touched { BUTTON_TOUCHED } else { 0 })) as f32;
}

type ChangeCallback<'a> = Function<'a, (), UnknownReturnValue>;

#[napi(js_name = "NSCGamepadPoller", custom_finalize)]
pub struct NSCGamepadPoller {
slots: [Option<Gamepad>; MAX_GAMEPADS],
ids: [String; MAX_GAMEPADS],
dirty: Arc<AtomicBool>,
tokens: Option<(i64, i64)>,
}

impl ObjectFinalize for NSCGamepadPoller {
fn finalize(mut self, _: Env) -> Result<()> {
self.close();
Ok(())
}
}

#[napi]
impl NSCGamepadPoller {
#[napi(constructor, ts_args_type = "onConnectionChanged: () => void")]
pub fn new(on_change: ChangeCallback) -> Result<Self> {
let dirty = Arc::new(AtomicBool::new(true));
let tsfn = on_change.build_threadsafe_function::<()>().weak::<true>().build()?;
let tsfn = Arc::new(tsfn);

let handler = {
let dirty = Arc::clone(&dirty);
let tsfn = Arc::clone(&tsfn);
EventHandler::<Gamepad>::new(move |_, _| {
dirty.store(true, Ordering::Release);
tsfn.call((), ThreadsafeFunctionCallMode::NonBlocking);
Ok(())
})
};
let tokens = match (Gamepad::GamepadAdded(&handler), Gamepad::GamepadRemoved(&handler)) {
(Ok(added), Ok(removed)) => Some((added, removed)),
_ => None,
};

Ok(Self {
slots: Default::default(),
ids: Default::default(),
dirty,
tokens,
})
}

#[napi]
pub fn poll(&mut self, mut buffer: Float32Array) -> u32 {
// SAFETY: the JS caller owns the array and is blocked for the duration of the call.
let buffer: &mut [f32] = unsafe { buffer.as_mut() };
if buffer.len() < MAX_GAMEPADS * SLOT_STRIDE {
return 0;
}
let changed = if self.dirty.swap(false, Ordering::AcqRel) {
self.sync(buffer)
} else {
0
};

for (index, pad) in self.slots.iter().enumerate() {
let Some(pad) = pad else { continue };
let Ok(reading) = pad.GetCurrentReading() else { continue };
let slot = &mut buffer[index * SLOT_STRIDE..(index + 1) * SLOT_STRIDE];

let axes = &mut slot[SLOT_AXES..SLOT_AXES + 4];
axes[0] = reading.LeftThumbstickX as f32;
axes[1] = -reading.LeftThumbstickY as f32;
axes[2] = reading.RightThumbstickX as f32;
axes[3] = -reading.RightThumbstickY as f32;

for (button, flag) in DIGITAL {
let pressed = reading.Buttons.contains(flag);
write_button(slot, button, if pressed { 1.0 } else { 0.0 }, pressed);
}
let left = reading.LeftTrigger as f32;
let right = reading.RightTrigger as f32;
write_button(slot, 6, left, left > TRIGGER_PRESSED);
write_button(slot, 7, right, right > TRIGGER_PRESSED);

slot[SLOT_SEQUENCE] = (slot[SLOT_SEQUENCE] + 1.0) % SEQUENCE_WRAP;
}
changed
}

#[napi]
pub fn id(&self, index: u32) -> String {
self.ids.get(index as usize).cloned().unwrap_or_default()
}

#[napi]
pub fn close(&mut self) {
if let Some((added, removed)) = self.tokens.take() {
let _ = Gamepad::RemoveGamepadAdded(added);
let _ = Gamepad::RemoveGamepadRemoved(removed);
}
self.slots = Default::default();
}
}

impl NSCGamepadPoller {
fn sync(&mut self, buffer: &mut [f32]) -> u32 {
let current: Vec<Gamepad> = Gamepad::Gamepads().map(|list| list.into_iter().collect()).unwrap_or_default();
let mut changed = 0u32;

for index in 0..MAX_GAMEPADS {
let gone = matches!(&self.slots[index], Some(pad) if !current.contains(pad));
if gone {
self.slots[index] = None;
self.ids[index].clear();
buffer[index * SLOT_STRIDE..(index + 1) * SLOT_STRIDE].fill(0.0);
changed |= 1 << index;
}
}

for pad in current {
if self.slots.iter().any(|slot| slot.as_ref() == Some(&pad)) {
continue;
}
let Some(index) = self.slots.iter().position(Option::is_none) else { break };
let slot = &mut buffer[index * SLOT_STRIDE..(index + 1) * SLOT_STRIDE];
slot.fill(0.0);
slot[SLOT_CONNECTED] = 1.0;
slot[SLOT_MAPPING] = MAPPING_STANDARD;
slot[SLOT_AXIS_COUNT] = STANDARD_AXES;
slot[SLOT_BUTTON_COUNT] = STANDARD_BUTTONS;
self.ids[index] = describe(&pad);
self.slots[index] = Some(pad);
changed |= 1 << index;
}
changed
}
}

fn describe(pad: &Gamepad) -> String {
match RawGameController::FromGameController(pad) {
Ok(raw) => {
let name = raw.DisplayName().map(|n| n.to_string()).unwrap_or_default();
let vendor = raw.HardwareVendorId().unwrap_or(0);
let product = raw.HardwareProductId().unwrap_or(0);
// Over Bluetooth Windows names Xbox pads by their HID class; Chrome reports the product name.
let generic = name.is_empty() || name.starts_with("HID-compliant");
let name = match (generic, vendor) {
(true, MICROSOFT_VENDOR) => "Xbox Wireless Controller".to_string(),
(true, _) if name.is_empty() => "Xbox Controller".to_string(),
_ => name,
};
format!("{name} (STANDARD GAMEPAD Vendor: {vendor:04x} Product: {product:04x})")
}
Err(_) => "Xbox Controller (STANDARD GAMEPAD)".to_string(),
}
}
2 changes: 1 addition & 1 deletion packages/canvas-babylon/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-babylon",
"version": "3.0.0-alpha.15",
"version": "3.0.0-alpha.16",
"description": "",
"main": "index",
"typings": "index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/canvas-chartjs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-chartjs",
"version": "3.0.0-alpha.15",
"version": "3.0.0-alpha.16",
"description": "ChartJS",
"main": "index",
"typings": "index.d.ts",
Expand Down
18 changes: 18 additions & 0 deletions packages/canvas-gamepad/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": ["../../.eslintrc.json"],
"ignorePatterns": ["!**/*", "node_modules/**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}
Loading
Loading