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 src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ fn main() {
"save_switches",
"begin_switch_capture",
"cancel_switch_capture",
"set_switch_keyboard_entry",
"get_point_scan",
"configure_point_scan",
"check_accessibility",
Expand Down
1 change: 1 addition & 0 deletions src-tauri/capabilities/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"allow-save-switches",
"allow-begin-switch-capture",
"allow-cancel-switch-capture",
"allow-set-switch-keyboard-entry",
"allow-get-point-scan",
"allow-configure-point-scan",
"allow-check-accessibility",
Expand Down
11 changes: 11 additions & 0 deletions src-tauri/permissions/autogenerated/set_switch_keyboard_entry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!

[[permission]]
identifier = "allow-set-switch-keyboard-entry"
description = "Enables the set_switch_keyboard_entry command without any pre-configured scope."
commands.allow = ["set_switch_keyboard_entry"]

[[permission]]
identifier = "deny-set-switch-keyboard-entry"
description = "Denies the set_switch_keyboard_entry command without any pre-configured scope."
commands.deny = ["set_switch_keyboard_entry"]
31 changes: 31 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,31 @@ fn cancel_switch_capture(
Ok(controller.view())
}

#[tauri::command]
fn set_switch_keyboard_entry(
window: tauri::WebviewWindow,
app: AppHandle,
active: bool,
) -> Result<switch_runtime::View, String> {
require_main(&window)?;
let switches = app.state::<switch_runtime::Controller>();
if active {
if !main_window_focused(&window)? {
return Err("Focus Switchify PC before typing with the keyboard.".into());
}
if remote_scan::active(&app) {
return Err("Stop forwarding on Remote before typing with the keyboard.".into());
}
}
switches.set_keyboard_entry(active)?;
if active {
point_scan_runtime::interrupt(&app);
}
let view = switches.view();
let _ = app.emit("switches-changed", &view);
Ok(view)
}

#[tauri::command]
fn get_point_scan(
controller: State<'_, point_scan_runtime::Controller>,
Expand All @@ -1346,6 +1371,11 @@ async fn configure_point_scan(

/// Pure environment check, safe to call every tick while scanning is off.
fn point_scan_ready(app: &AppHandle) -> Result<(), String> {
if app.state::<switch_runtime::Controller>().keyboard_entry() {
return Err(
"Typing with the keyboard. Resume switch control in Switches when finished.".into(),
);
}
let state = app.state::<AppModel>().snapshot();
if state.bluetooth == state::BluetoothState::Connected && !remote_scan::active(app) {
return Err("Local scanning pauses while Android is connected.".into());
Expand Down Expand Up @@ -1507,6 +1537,7 @@ pub fn run() {
save_switches,
begin_switch_capture,
cancel_switch_capture,
set_switch_keyboard_entry,
get_point_scan,
configure_point_scan,
get_app_state,
Expand Down
18 changes: 18 additions & 0 deletions src-tauri/src/switch_input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,24 @@ mod tests {
c
}
#[test]
fn explicit_typing_pause_drains_held_switch_and_passes_mapped_keys() {
let mut c = core();
c.mappings.insert("Enter".into(), "enter".into());
assert!(c.key("Space", true, 1));
c.stop(StopReason::Disabled);
assert!(c.key("Space", false, 2));
c.events.clear();
for key in ["Space", "Enter"] {
assert!(!c.key(key, true, 3));
assert!(!c.key(key, false, 4));
}
assert!(c.events.is_empty());
c.begin(Mode::Active, 5);
assert!(c.key("Space", true, 6));
assert!(c.key("Space", false, 7));
assert_eq!(c.events.len(), 2);
}
#[test]
fn repeat_and_unmatched_release_never_activate() {
let mut c = core();
assert!(!c.key("Space", false, 0));
Expand Down
58 changes: 58 additions & 0 deletions src-tauri/src/switch_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub struct View {
pub settings: Settings,
pub capture: CaptureState,
pub supported: bool,
pub keyboard_entry: bool,
pub error: Option<String>,
pub escape_hold_ms: u64,
pub unavailable_keys: Vec<String>,
Expand All @@ -25,6 +26,7 @@ struct Data {
settings: Settings,
capture: CaptureState,
capture_generation: u64,
keyboard_entry: bool,
error: Option<String>,
}
pub struct Controller {
Expand Down Expand Up @@ -91,6 +93,7 @@ impl Controller {
settings,
capture: CaptureState::default(),
capture_generation: 0,
keyboard_entry: false,
error,
}),
broker: Mutex::new(Capture::new()),
Expand All @@ -101,6 +104,7 @@ impl Controller {
View {
settings: d.settings.clone(),
capture: d.capture.clone(),
keyboard_entry: d.keyboard_entry,
supported: cfg!(any(target_os = "windows", target_os = "macos")),
error: d.error.clone(),
escape_hold_ms: d.settings.escape_ms(),
Expand All @@ -113,6 +117,31 @@ impl Controller {
.collect(),
}
}
pub fn keyboard_entry(&self) -> bool {
self.data
.lock()
.unwrap_or_else(|p| p.into_inner())
.keyboard_entry
}
pub fn set_keyboard_entry(&self, active: bool) -> Result<(), String> {
let mut d = self.data.lock().unwrap_or_else(|p| p.into_inner());
if active && d.capture.active {
return Err("Finish learning the switch before typing with the keyboard.".into());
}
d.keyboard_entry = active;
drop(d);
if active {
self.stop();
}
Ok(())
}
fn require_switch_control(&self) -> Result<(), String> {
if self.keyboard_entry() {
Err("Typing with the keyboard. Resume switch control in Switches when finished.".into())
} else {
Ok(())
}
}
pub fn active_generation(&self, generation: u64) -> bool {
let status = self
.broker
Expand Down Expand Up @@ -155,13 +184,15 @@ impl Controller {
Ok(self.view())
}
pub fn enable_escape(&self) -> Result<(), String> {
self.require_switch_control()?;
let mut broker = self.broker.lock().unwrap_or_else(|p| p.into_inner());
broker.stop();
broker.configure(&[], 4000).map_err(|e| e.to_string())?;
broker.enable().map_err(|e| e.to_string())?;
Ok(())
}
pub fn enable(&self, automatic: bool) -> Result<(), String> {
self.require_switch_control()?;
let view = self.view();
if let Some(e) = view.error {
return Err(e);
Expand All @@ -184,6 +215,7 @@ impl Controller {
Ok(())
}
pub fn begin_capture(&self, app: &AppHandle) -> Result<View, String> {
self.require_switch_control()?;
if let Some(e) = self.view().error {
return Err(e);
}
Expand Down Expand Up @@ -312,6 +344,32 @@ pub fn install(app: &AppHandle) {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keyboard_entry_excludes_capture_without_changing_bindings() {
// An off broker never installs a native adapter in this test.
let controller = Controller {
data: Mutex::new(Data {
settings: Settings::default(),
capture: CaptureState::default(),
capture_generation: 0,
keyboard_entry: false,
error: None,
}),
broker: Mutex::new(Capture::new()),
};
controller.set_keyboard_entry(true).unwrap();
assert!(controller.view().keyboard_entry);
assert!(controller
.enable(true)
.unwrap_err()
.contains("Resume switch control"));
assert!(controller.enable_escape().is_err());
controller.set_keyboard_entry(false).unwrap();
assert!(controller.require_switch_control().is_ok());
controller.data.lock().unwrap().capture.active = true;
assert!(controller.set_keyboard_entry(true).is_err());
assert!(!controller.keyboard_entry());
}
struct Files(std::path::PathBuf);
impl Files {
fn new() -> Self {
Expand Down
2 changes: 1 addition & 1 deletion src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe("Switchify PC shell", () => {

function localSwitches(): switchHooks.SwitchController {
const settings: switchHooks.SwitchSettings = { schemaVersion: 1, holdIntervalMs: 1000, bindings: [{ id: "space", name: "Space switch", key: "Space", pressAction: "select", holdActions: [] }] };
return { settings, state: { settings, capture: { active: false, key: null, error: null }, supported: true, error: null, escapeHoldMs: 4000, unavailableKeys: [] }, pending: 0, error: null, capturing: false, unsaved: false, update: vi.fn(), capture: vi.fn(), cancelCapture: vi.fn().mockResolvedValue(undefined), retry: vi.fn() };
return { settings, state: { settings, capture: { active: false, key: null, error: null }, supported: true, error: null, escapeHoldMs: 4000, unavailableKeys: [] }, pending: 0, error: null, capturing: false, unsaved: false, update: vi.fn(), capture: vi.fn(), cancelCapture: vi.fn().mockResolvedValue(undefined), setKeyboardEntry: vi.fn().mockResolvedValue(true), retry: vi.fn() };
}

it("shows local readiness without Bluetooth and explains Android suspension", async () => {
Expand Down
82 changes: 82 additions & 0 deletions src/Switches.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ beforeEach(() => {
mocks.listen.mockReset().mockResolvedValue(vi.fn());
mocks.invoke.mockReset().mockImplementation(async (command, args) => {
switch (command) {
case "set_switch_keyboard_entry":
current = { ...current, keyboardEntry: args.active };
return current;
case "get_switches":
return current;
case "save_switches":
Expand Down Expand Up @@ -401,6 +404,85 @@ it("removes a remote switch and surfaces a failed remote save with retry", async
expect(screen.queryByRole("heading", { name: "Remote switch 1" })).toBeNull();
});

it("pauses capture only by explicit choice, saves mapped characters and resumes after Done", async () => {
render(<Shell />);
fireEvent.click(await screen.findByRole("button", { name: "Edit Head switch" }));
const name = screen.getByRole("textbox", { name: "Name for Space" });
name.focus();
expect(mocks.invoke).not.toHaveBeenCalledWith("set_switch_keyboard_entry", expect.anything());
fireEvent.click(screen.getByRole("button", { name: "Type with keyboard" }));
await screen.findByText("Keyboard typing is on. Assigned keys type normally; switch scanning is paused.");
expect(name).toHaveFocus();
expect(screen.getByRole("button", { name: "Learn another key for Head switch" })).toBeDisabled();
fireEvent.change(name, { target: { value: "Big blue switch" } });
await waitFor(() => expect(current.settings.bindings[0].name).toBe("Big blue switch"));
fireEvent.click(screen.getByRole("button", { name: "Done" }));
await waitFor(() => expect(current.keyboardEntry).toBe(false));
});

it("releases keyboard entry when navigating away, even while entry is pending", async () => {
let finish: (() => void) | undefined;
const invoke = mocks.invoke.getMockImplementation()!;
mocks.invoke.mockImplementation((command, args) => command === "set_switch_keyboard_entry" && args.active
? new Promise((resolve) => { finish = () => { current = { ...current, keyboardEntry: true }; resolve(current); }; })
: invoke(command, args));
const view = render(<Shell />);
fireEvent.click(await screen.findByRole("button", { name: "Edit Head switch" }));
fireEvent.click(screen.getByRole("button", { name: "Type with keyboard" }));
await waitFor(() => expect(finish).toBeDefined());
view.rerender(<Shell visible={false} />);
await act(async () => finish!());
await waitFor(() => expect(current.keyboardEntry).toBe(false));
expect(mocks.invoke).toHaveBeenCalledWith("set_switch_keyboard_entry", { active: false });
});

it("keeps the name draft and normal switch control if keyboard entry is refused", async () => {
const invoke = mocks.invoke.getMockImplementation()!;
mocks.invoke.mockImplementation((command, args) => command === "set_switch_keyboard_entry" && args.active
? Promise.reject("Stop forwarding on Remote before typing with the keyboard.") : invoke(command, args));
render(<Shell />);
fireEvent.click(await screen.findByRole("button", { name: "Edit Head switch" }));
fireEvent.click(screen.getByRole("button", { name: "Type with keyboard" }));
await screen.findByText("Stop forwarding on Remote before typing with the keyboard.");
expect(screen.getByRole("textbox", { name: "Name for Space" })).toHaveValue("Head switch");
expect(screen.getByRole("button", { name: "Learn another key for Head switch" })).toBeEnabled();
});


it("preserves a newly learned key while typing and releases capture ownership on Cancel", async () => {
render(<Shell />);
fireEvent.click(await screen.findByRole("button", { name: "Add switch" }));
await screen.findByRole("button", { name: "Cancel capture" });
current = { ...current, capture: { active: false, key: "Enter", error: null } };
event(current);
fireEvent.click(await screen.findByRole("button", { name: "Type with keyboard" }));
await screen.findByRole("button", { name: "Resume switch control" });
fireEvent.change(screen.getByRole("textbox", { name: "New switch name" }), { target: { value: "Second switch" } });
expect(screen.getByText("Enter", { selector: "kbd" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Cancel new switch" }));
await waitFor(() => expect(current.keyboardEntry).toBe(false));
expect(current.settings.bindings).toHaveLength(1);
});

it("offers recovery when returning to Switches with keyboard typing still active", async () => {
current.keyboardEntry = true;
render(<Shell />);
fireEvent.click(await screen.findByRole("button", { name: "Resume switch control" }));
await waitFor(() => expect(current.keyboardEntry).toBe(false));
});


it.each([0, 1])("releases keyboard entry when the edited remote switch is removed using control %s", async (index) => {
render(<Shell />);
fireEvent.click(await screen.findByRole("button", { name: "Edit Remote switch 1" }));
fireEvent.click(screen.getByRole("button", { name: "Type with keyboard" }));
await screen.findByRole("button", { name: "Resume switch control" });
fireEvent.click(screen.getAllByRole("button", { name: "Remove Remote switch 1" })[index]);
await waitFor(() => expect(current.keyboardEntry).toBe(false));
expect(mocks.invoke).toHaveBeenCalledWith("set_switch_keyboard_entry", { active: false });
expect(screen.queryByRole("heading", { name: "Remote switch 1" })).not.toBeInTheDocument();
});

it("separates an empty local configuration from the six remote presets", async () => {
current.settings.bindings = [];
remote.slots = ["select", "next", "back", "pause", "reverse", "stop", null, null]
Expand Down
23 changes: 23 additions & 0 deletions src/scanning/useSwitches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type SwitchState = {
settings: SwitchSettings;
capture: { active: boolean; key: string | null; error: string | null };
supported: boolean;
keyboardEntry?: boolean;
error: string | null;
escapeHoldMs: number;
unavailableKeys: string[];
Expand Down Expand Up @@ -166,6 +167,27 @@ export function useSwitches() {
setError(String(e));
}
};
const setKeyboardEntry = (active: boolean): Promise<boolean> => {
if (!("__TAURI_INTERNALS__" in window)) return Promise.resolve(false);
const m = model.current;
m.pending++;
setPending(m.pending);
const operation = queue.current.then(async () => {
setError(null);
const stamp = runtime.current;
const result = await invoke<SwitchState>("set_switch_keyboard_entry", { active });
if (stamp === runtime.current) receive(result);
return true;
}).catch((reason) => {
setError(String(reason));
return false;
}).finally(() => {
m.pending--;
setPending(m.pending);
});
queue.current = operation.then(() => {});
return operation;
};
return {
state,
settings,
Expand All @@ -175,6 +197,7 @@ export function useSwitches() {
update,
capture,
cancelCapture,
setKeyboardEntry,
retry: () => save(model.current.settings, model.current.revision),
unsaved: model.current.revision !== model.current.saved,
};
Expand Down
Loading
Loading