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
4 changes: 2 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ function SetupGuide({ state, switches, suspended, busy, error, skip, finish, acc
{step === 0 && <div className="setup-statuses">
<article><StatusIcon ok={state.accessibility === "granted"}><Accessibility size={19} /></StatusIcon><div><h3>Input access</h3><AccessibilityCopy state={state} detailed /></div>{state.accessibility === "required" && <button className="secondary" disabled={busy} onClick={() => void accessibility()}>Open Accessibility Settings</button>}</article>
</div>}
{step === 1 && <SwitchesSection controller={switches} onDraftChange={setSwitchDraft} suspended={suspended} />}
{step === 1 && <SwitchesSection androidConnected={state.bluetooth === "connected"} controller={switches} onDraftChange={setSwitchDraft} suspended={suspended} />}
{step === 2 && <div><h3>Use your switches</h3><p>Focus the application you want to use, then press and release Select to start scanning. Select chooses a point and opens the action menu.</p><p>Automatic scanning moves the highlight for you. Use Auto scan rate for grid and menu timing, and Line speed for scanning lines. Holding a switch freezes movement; release runs the action shown.</p><p>Escape disables switch control. Assign Stop scanning or Pause / resume in Switches. Holding any switch for {switches.state ? switches.state.escapeHoldMs / 1000 : 4}s disables switch control.</p><p>Open Scanning after setup to adjust movement. Android connection is optional.</p></div>}
{step === 3 && <div><h3>Choose startup behavior</h3><p>Switchify can start quietly when you sign in, ready for your switches.</p><div className="setup-choices" role="group" aria-label="Start with system choice"><button className="secondary" aria-pressed={startupChoice === true} onClick={() => setStartupChoice(true)}>Start with system</button><button className="secondary" aria-pressed={startupChoice === false} onClick={() => setStartupChoice(false)}>Start manually</button></div></div>}
{step === 4 && <div><h3>Choose whether to share diagnostics</h3><p>Optional anonymous app health and sanitized errors help improve Switchify. Typed text, commands, pairing secrets, device names, and full paths are never included.</p><div className="setup-choices" role="group" aria-label="Anonymous diagnostics choice"><button className="secondary" disabled={!state.telemetry.available} aria-pressed={diagnosticsChoice === true} onClick={() => setDiagnosticsChoice(true)}>Share diagnostics</button><button className="secondary" aria-pressed={diagnosticsChoice === false} onClick={() => setDiagnosticsChoice(false)}>Don’t share</button></div><a className="setup-privacy" href="https://switchifyapp.com/privacy" target="_blank" rel="noreferrer">Privacy policy</a></div>}
Expand Down Expand Up @@ -775,7 +775,7 @@ export function App() {
<UpdateBanner update={state.updater} openUpdates={openUpdates} />
<p id="settings-updates-notice" className="sr-only" aria-live={updateFailure ? updateLiveness(updateFailure.status) : "polite"} aria-atomic="true">{updateNotice}</p>
{view === "home" && <HomeView state={state} switches={switches} scanning={scanning} navigate={selectView} onDisconnect={() => void perform(api.disconnectAll)} onAccessibility={() => void perform(() => api.checkAccessibility(true))} />}
{view === "switches" && <div className="view"><header className="page-header"><h1>Switches</h1></header><SwitchesSection controller={switches} suspended={pairingOpen} /></div>}
{view === "switches" && <div className="view"><header className="page-header"><h1>Switches</h1></header><SwitchesSection androidConnected={state.bluetooth === "connected"} controller={switches} suspended={pairingOpen} /></div>}
{view === "scanning" && <div className="view"><header className="page-header"><h1>Scanning</h1></header><ScanningSection controller={scanning} /></div>}
{(view === "android" || view === "devices" || view === "profiles") && <div className="view"><header className="page-header"><div><h1>Android connection</h1><p>Optional Android control and switch forwarding</p></div></header><Tabs name="android" label="Android connection sections" active={view} onSelect={selectView} tabs={[{id: "android", label: "Connection"}, {id: "devices", label: "Paired devices"}, {id: "profiles", label: "Switch Forwarding"}]} /><TabPanel name="android" id={view}>
{view === "android" && <AndroidConnection state={state} onDisconnect={() => void perform(api.disconnectAll)} />}
Expand Down
34 changes: 32 additions & 2 deletions src/Switches.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ const scan = {
message: "Scanning is off.",
supported: true,
};
function Shell({ visible = true }: { visible?: boolean }) {
function Shell({ visible = true, androidConnected = false }: { visible?: boolean; androidConnected?: boolean }) {
const switches = useSwitches();
const scanning = useScanning();
return (
<>
{visible && <SwitchesSection controller={switches} />}
{visible && <SwitchesSection controller={switches} androidConnected={androidConnected} />}
<ScanningSection controller={scanning} />
</>
);
Expand Down Expand Up @@ -400,3 +400,33 @@ it("removes a remote switch and surfaces a failed remote save with retry", async
await waitFor(() => expect(remote.slots[0].pressAction).toBeNull());
expect(screen.queryByRole("heading", { name: "Remote switch 1" })).toBeNull();
});

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]
.map((pressAction) => ({ pressAction, holdActions: [] }));
render(<Shell />);
await screen.findByRole("heading", { name: "Remote switch 6" });
expect(screen.getByText(/No local switches configured/)).toBeInTheDocument();
expect(screen.getByText(/not detected physical switches/)).toBeInTheDocument();
expect(screen.getByText(/No Android device connected/)).toBeInTheDocument();
expect(screen.getAllByText(/Remote forwarding slot/)).toHaveLength(6);
expect(mocks.invoke.mock.calls.some(([command]) => command.startsWith("save_"))).toBe(false);
});
it("keeps source identity after renaming and separates connection from tested input", async () => {
const view = render(<Shell />);
await screen.findByRole("heading", { name: "Remote switch 1" });
open("Remote switch 1");
fireEvent.change(screen.getByLabelText("Name for Remote 1"), { target: { value: "Chin" } });
await waitFor(() => expect(remote.slots[0].name).toBe("Chin"));
const row = screen.getByRole("heading", { name: "Chin" }).closest("article")!;
expect(within(row).getByText("Remote forwarding slot 1")).toBeInTheDocument();
expect(within(row).getByText("Remote 1")).toBeInTheDocument();
const localRow = screen.getByRole("heading", { name: "Head switch" }).closest("article")!;
expect(within(localRow).getByText("Local keyboard input")).toBeInTheDocument();
view.rerender(<Shell androidConnected />);
expect(screen.getByText(/Android device connected. This does not confirm/)).toHaveTextContent("A connection alone does not verify a physical switch press.");
expect(screen.queryByText(/No Android device connected/)).not.toBeInTheDocument();
view.rerender(<Shell />);
expect(screen.getByText(/No Android device connected/)).toBeInTheDocument();
});
15 changes: 14 additions & 1 deletion src/settings/SwitchesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ function CaptureDialog({ name, onCancel }: { name: string; onCancel: () => void
);
}

export function SwitchesSection({ controller, onDraftChange, suspended = false }: { controller: SwitchController; onDraftChange?: (draft: boolean) => void; suspended?: boolean }) {
export function SwitchesSection({ controller, onDraftChange, suspended = false, androidConnected }: { controller: SwitchController; onDraftChange?: (draft: boolean) => void; suspended?: boolean; androidConnected?: boolean }) {
const { settings, state, pending, unsaved } = controller;
// A refused capture sets both the general error and the capture error; the
// key field already shows the latter, so the band only carries save errors.
Expand Down Expand Up @@ -556,6 +556,17 @@ export function SwitchesSection({ controller, onDraftChange, suspended = false }
title="Switches"
description="Add keyboard switches or switches forwarded from Switchify Remote, and choose what each one does."
>
<div className="switch-source-summary">
<p className="setting-note">{!state ? "Loading local switch configuration..." : settings.bindings.length === 0
? "No local switches configured. Use Add switch to learn a keyboard key."
: `${settings.bindings.length} local keyboard ${settings.bindings.length === 1 ? "switch configured" : "switches configured"}.`}</p>
<p className="setting-note">Remote assignments are numbered forwarding slots in Switchify Remote, not detected physical switches. The supplied presets can be changed or removed.</p>
<p className="setting-note" role="status">{androidConnected === true
? "Android device connected. This does not confirm that remote assignments match your switches."
: androidConnected === false
? "No Android device connected. Remote assignments are available for a future connection."
: "Android connection status is unavailable here."} A connection alone does not verify a physical switch press.</p>
</div>
<SettingNote
about="switches"
summary="Press and release a switch to run its action. Hold it to step through its hold actions instead."
Expand Down Expand Up @@ -641,6 +652,7 @@ export function SwitchesSection({ controller, onDraftChange, suspended = false }
<Keyboard size={20} aria-hidden="true" />
<div>
<h3>{name}</h3>
<p>Local keyboard input</p>
<p>{summary(binding)}</p>
</div>
<KeyBadge value={binding.key} unavailable={unavailable} />
Expand Down Expand Up @@ -708,6 +720,7 @@ export function SwitchesSection({ controller, onDraftChange, suspended = false }
<Smartphone size={20} aria-hidden="true" />
<div>
<h3>{name}</h3>
<p>Remote forwarding slot {index + 1}</p>
<p>{summary(binding)}</p>
</div>
<KeyBadge value={binding.key} unavailable={false} />
Expand Down
Loading