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
10 changes: 6 additions & 4 deletions site/src/content/docs/get-started/add-a-switch.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
---
title: Add a switch
description: Connect a jack or USB switch, let Switchify PC learn its key, and choose what a press and a hold do.
description: Connect a jack or USB switch, let Switchify PC learn its key or gamepad button, and choose what a press and a hold do.
---

Switchify PC sees a switch as a key press. Anything that sends a key when you press it will work: a switch interface with a 3.5 mm socket, a USB switch, or a keyboard key.
Switchify PC sees a switch as a key press or a gamepad button press. Anything that sends a key or a button when you press it will work: a switch interface with a 3.5 mm socket, a USB switch, a keyboard key, or a game controller.

## Connect the switch

**A jack switch.** Check which connector and socket your switch interface requires, push the plug fully into the interface, and connect the interface to the computer.

**A USB switch.** Connect it to a USB port. Plugging it in does not configure anything by itself; you still add it below.

**Choose the keyboard mode on your interface.** Most interfaces have a mode setting. Mouse-click and gamepad modes cannot be learned, so Switchify PC sees nothing when you press. In keyboard mode most interfaces send Space or Enter, and Switchify PC learns those. If your interface can be programmed, set it to F13 or higher (F13 to F20 on a Mac). Nothing else on the computer types those keys, so they never clash. F12 is reserved on Windows and cannot be used.
**Choose a keyboard or gamepad mode on your interface.** Most interfaces have a mode setting. Mouse-click modes cannot be learned, so Switchify PC sees nothing when you press. In keyboard mode most interfaces send Space or Enter, and Switchify PC learns those. If your interface can be programmed, set it to F13 or higher (F13 to F20 on a Mac). Nothing else on the computer types those keys, so they never clash. F12 is reserved on Windows and cannot be used.

**Gamepad mode.** Interfaces such as the Smartbox Joycable, the Pretorian USB Switch and the Crick USB Switch Box can send gamepad buttons instead of keys, and so can a game controller, such as the Xbox Adaptive Controller on Windows. Switchify PC learns buttons 1 to 8 and shows them as *Gamepad 1* to *Gamepad 8*. Button 1 on any connected controller counts as the same switch. Gamepad buttons never type anything, so they cannot clash with the keyboard. Sticks, triggers and the direction pad are not switches.

## Learn the key

1. Choose **Switches** in the sidebar, then **Add switch**.
2. Press and release your switch once. The key it sends appears as a badge.
2. Press and release your switch once. The key or gamepad button it sends appears as a badge.

![The learning dialog: Press and release your switch, with a Cancel capture button.](../../../assets/screenshots/add-switch-waiting.png)

Expand Down
3 changes: 2 additions & 1 deletion site/src/content/docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ Switchify PC's Help page has **Setup** and **Troubleshooting** tabs with the sam

## Nothing happens when I press the switch

- Check the mode on your switch interface. Mouse-click and gamepad modes cannot be learned; choose the keyboard mode.
- Check the mode on your switch interface. Mouse-click modes cannot be learned; choose a keyboard or gamepad mode.
- In gamepad mode only buttons 1 to 8 are learned. If the interface is set to a higher button, a stick or a direction, choose a lower button or the keyboard mode.
- Make sure you have added the switch in Settings and it has an action. Automatic scanning needs Select; manual scanning also needs Next and Previous.
- If your interface can be programmed, set it to F13 or higher (F13 to F20 on a Mac). F12 is reserved on Windows.
- On Windows, if a switch was unplugged while held, plug it back in (or use the same key on another keyboard), then press and release it once. That press clears the stuck state and does nothing else; press Select to start again.
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ objc2-core-graphics = { version = "0.3.2", default-features = false, features =
objc2-foundation = { version = "0.3.2", features = ["NSArray", "NSGeometry", "NSHost", "NSNotification", "NSObject", "NSOperation", "NSString", "NSThread", "NSURL", "block2"] }

[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Security", "Win32_System_JobObjects", "Win32_System_SystemInformation", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Security", "Win32_System_JobObjects", "Win32_System_SystemInformation", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_Devices_HumanInterfaceDevice"] }
windows = { version = "0.62.2", features = [
"Devices_Bluetooth",
"Devices_Bluetooth_GenericAttributeProfile",
Expand Down
59 changes: 54 additions & 5 deletions src-tauri/src/portable_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use serde_json::Value;
use std::collections::HashSet;
use tauri::{AppHandle, Manager};

pub const SCHEMA_VERSION: u32 = 5;
pub const SCHEMA_VERSION: u32 = 6;
const MAX_CUSTOM_PROFILES: usize = 32;

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand Down Expand Up @@ -195,10 +195,11 @@ pub fn parse(mut value: Value, schema_version: u32) -> Result<Document, ParseErr
return Err(ParseError::Newer(schema_version));
}
match schema_version {
1 => value = upgrade_v4(upgrade_v3(upgrade_v2(upgrade_v1(value)?)?)?)?,
2 => value = upgrade_v4(upgrade_v3(upgrade_v2(value)?)?)?,
3 => value = upgrade_v4(upgrade_v3(value)?)?,
4 => value = upgrade_v4(value)?,
1 => value = upgrade_v5(upgrade_v4(upgrade_v3(upgrade_v2(upgrade_v1(value)?)?)?)?)?,
2 => value = upgrade_v5(upgrade_v4(upgrade_v3(upgrade_v2(value)?)?)?)?,
3 => value = upgrade_v5(upgrade_v4(upgrade_v3(value)?)?)?,
4 => value = upgrade_v5(upgrade_v4(value)?)?,
5 => value = upgrade_v5(value)?,
SCHEMA_VERSION => {}
_ => {
return Err(ParseError::Invalid(format!(
Expand Down Expand Up @@ -292,6 +293,17 @@ fn upgrade_v4(mut value: Value) -> Result<Value, ParseError> {
.filter(|preferences| !preferences.contains_key("panelTheme"))
.ok_or_else(|| ParseError::Invalid("it does not match version 4 settings".into()))?
.insert("panelTheme".into(), Value::from("system"));
value["schemaVersion"] = Value::from(5);
Ok(value)
}

/// Version 5 documents have the same shape, but a version 5 install rejects
/// gamepad buttons as switch keys. The bump makes such an install report
/// that it must update instead of refusing the synced switches.
fn upgrade_v5(mut value: Value) -> Result<Value, ParseError> {
if value.get("schemaVersion") != Some(&Value::from(5)) {
return Err(ParseError::Invalid("version mismatch".into()));
}
value["schemaVersion"] = Value::from(SCHEMA_VERSION);
Ok(value)
}
Expand Down Expand Up @@ -665,6 +677,33 @@ mod tests {
written_by(serde_json::to_value(local().document()).unwrap(), 1)
}

#[test]
fn gamepad_switch_keys_sync_as_version_six() {
// Version 5 installs reject these keys, which is why the schema moved
// to 6: they see a newer document and ask for an update instead.
let local = local();
let mut cloud = local.clone();
cloud.switches.bindings = vec![
binding("pad", "Gamepad1", Action::Select),
binding("next", "Gamepad8", Action::Next),
];
let value = serde_json::to_value(cloud.document()).unwrap();
assert_eq!(value["schemaVersion"], json!(6));
assert_eq!(value["switches"]["bindings"][0]["key"], json!("Gamepad1"));
let parsed = parse(value, SCHEMA_VERSION).unwrap();
let switches = plan(&local, parsed).unwrap().switches.unwrap();
assert_eq!(switches, cloud.switches);
// Button 9 is not a switch, so a document naming it is refused.
let mut bad = cloud.clone();
bad.switches.bindings[0].key = "Gamepad9".into();
let parsed = parse(
serde_json::to_value(bad.document()).unwrap(),
SCHEMA_VERSION,
)
.unwrap();
assert!(plan(&local, parsed).is_err());
}

#[test]
fn version_four_documents_read_with_panels_matching_the_system() {
use crate::scan_preferences::PanelTheme;
Expand Down Expand Up @@ -716,6 +755,16 @@ mod tests {
PanelTheme::System
);

// A version 5 document climbs unchanged: nothing was added in 6.
let version_five = written_by(serde_json::to_value(cloud.document()).unwrap(), 5);
let parsed = parse(version_five.clone(), 5).unwrap();
assert_eq!(parsed.schema_version, SCHEMA_VERSION);
assert_eq!(parsed.switches, cloud.switches);
assert!(matches!(
parse(version_five, SCHEMA_VERSION),
Err(ParseError::Invalid(_))
));

// A chosen theme syncs, and an unknown one is refused rather than reset.
let mut chosen = serde_json::to_value(cloud.document()).unwrap();
chosen["pointScan"]["scanPreferences"]["panelTheme"] = json!("dark");
Expand Down
78 changes: 78 additions & 0 deletions src-tauri/src/switch_input/gamepad.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//! Buttons 1 to 8 across every connected controller, so button 1 on any of
//! them is one switch. A button is down while any controller holds it.
use super::GAMEPAD_BUTTONS;
use std::collections::HashMap;

#[derive(Default)]
pub(super) struct Gamepads {
devices: HashMap<isize, u8>,
held: u8,
}
impl Gamepads {
/// A whole-state report: bit `n - 1` of `buttons` is button `n`.
/// Returns the button edges it produces, as (button, pressed).
pub fn report(&mut self, device: isize, buttons: u8) -> Vec<(u8, bool)> {
if buttons == 0 {
self.devices.remove(&device);
} else {
self.devices.insert(device, buttons);
}
self.settle()
}
/// One button of one controller changed.
#[cfg_attr(target_os = "windows", allow(dead_code))]
pub fn button(&mut self, device: isize, button: u8, pressed: bool) -> Vec<(u8, bool)> {
if !(1..=GAMEPAD_BUTTONS).contains(&button) {
return Vec::new();
}
let bit = 1 << (button - 1);
let current = self.devices.get(&device).copied().unwrap_or(0);
let buttons = if pressed {
current | bit
} else {
current & !bit
};
self.report(device, buttons)
}
/// A controller went away: its held buttons are released.
pub fn removed(&mut self, device: isize) -> Vec<(u8, bool)> {
self.devices.remove(&device);
self.settle()
}
fn settle(&mut self) -> Vec<(u8, bool)> {
let held = self.devices.values().fold(0u8, |acc, bits| acc | bits);
let changed = held ^ self.held;
self.held = held;
(1..=GAMEPAD_BUTTONS)
.filter(|button| changed & (1 << (button - 1)) != 0)
.map(|button| (button, held & (1 << (button - 1)) != 0))
.collect()
}
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn controllers_merge_and_release_on_removal() {
let mut pads = Gamepads::default();
assert_eq!(pads.report(1, 0b0000_0001), vec![(1, true)]);
// A second controller holding the same button changes nothing.
assert_eq!(pads.report(2, 0b0000_0001), vec![]);
assert_eq!(pads.report(2, 0b1000_0001), vec![(8, true)]);
assert_eq!(pads.report(1, 0), vec![]);
assert_eq!(pads.removed(2), vec![(1, false), (8, false)]);
assert_eq!(pads.removed(2), vec![]);
}
#[test]
fn single_button_changes_build_the_same_state() {
let mut pads = Gamepads::default();
assert_eq!(pads.button(7, 2, true), vec![(2, true)]);
assert_eq!(pads.button(7, 2, true), vec![]);
assert_eq!(pads.button(7, 5, true), vec![(5, true)]);
assert_eq!(pads.button(7, 2, false), vec![(2, false)]);
assert_eq!(pads.button(7, 9, true), vec![]);
assert_eq!(pads.button(7, 0, true), vec![]);
assert_eq!(pads.removed(7), vec![(5, false)]);
}
}
56 changes: 55 additions & 1 deletion src-tauri/src/switch_input/keys.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/// Canonical keyboard names used by embedded capture and persisted mappings.
/// Canonical key names used by embedded capture and persisted mappings:
/// keyboard keys and the first eight numbered buttons of any game controller.
pub fn normalize(code: &str) -> Option<String> {
let value = match code {
"Return" => "Enter",
Expand Down Expand Up @@ -34,9 +35,62 @@ pub fn normalize(code: &str) -> Option<String> {
.strip_prefix('F')
.and_then(|n| n.parse::<u8>().ok())
.is_some_and(|n| (1..=24).contains(&n) && value == format!("F{n}"))
|| gamepad_button(value).is_some()
{
Some(value.into())
} else {
None
}
}

/// How many numbered controller buttons can be switches. Switch interfaces in
/// gamepad mode send buttons 1 to 4; eight covers every box found so far.
pub const GAMEPAD_BUTTONS: u8 = 8;

/// The key name for controller button `button` (1-based).
pub fn gamepad_name(button: u8) -> String {
format!("Gamepad{button}")
}

/// How a key reads in a message: keyboard keys by name, controller buttons
/// as "Gamepad 1" to "Gamepad 8", matching the badges in Settings.
pub fn display_name(code: &str) -> String {
match gamepad_button(code) {
Some(n) => format!("Gamepad {n}"),
None => code.to_string(),
}
}

/// The 1-based button number of a gamepad key name, if it is one.
pub fn gamepad_button(name: &str) -> Option<u8> {
let n = name.strip_prefix("Gamepad")?.parse::<u8>().ok()?;
((1..=GAMEPAD_BUTTONS).contains(&n) && name == gamepad_name(n)).then_some(n)
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gamepad_buttons_one_to_eight_are_keys_and_nothing_else_is() {
for n in 1..=GAMEPAD_BUTTONS {
let name = gamepad_name(n);
assert_eq!(normalize(&name).as_deref(), Some(name.as_str()));
assert_eq!(gamepad_button(&name), Some(n));
}
for bad in [
"Gamepad0",
"Gamepad9",
"Gamepad01",
"Gamepad",
"gamepad1",
"Gamepad1 ",
] {
assert_eq!(normalize(bad), None, "{bad}");
assert_eq!(gamepad_button(bad), None, "{bad}");
}
assert_eq!(display_name("Gamepad3"), "Gamepad 3");
assert_eq!(display_name("Space"), "Space");
assert_eq!(normalize("Return").as_deref(), Some("Enter"));
assert_eq!(normalize("F24").as_deref(), Some("F24"));
}
}
4 changes: 4 additions & 0 deletions src-tauri/src/switch_input/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ impl Capture {
},
|| {
driver.ready(pressed_keys());
// Controller buttons share this run loop. Without HID
// access the keyboard tap still runs on its own.
let gamepads = super::macos_gamepad::Monitor::start(driver.clone());
if tx.send(Ok(())).is_err() {
return;
}
Expand All @@ -146,6 +149,7 @@ impl Capture {
}
driver.tick();
}
drop(gamepads);
},
);
if installed.is_err() {
Expand Down
Loading
Loading