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
8 changes: 5 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { Demonstration, DemonstrationProvider } from "./help/Demonstration";
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import {
Accessibility, Bluetooth, ChevronRight, CircleHelp, Download,
Copy, Home, Keyboard, Plus, Power, RefreshCw, Save, Settings,
Copy, Home, Keyboard, MousePointer2, Plus, Power, RefreshCw, Save, Settings,
SlidersHorizontal, Smartphone, Trash2, Wrench, X,
} from "lucide-react";
import { api, type ProfileExitAction } from "./api";
import type { AppSettings, AppState, PendingPairing, SwitchProfile, UpdateState } from "./types";
import { applyLocalSettings, changedSettingKeys } from "./settings/diff";
import { SettingsView } from "./settings/SettingsView";
import { MouseSettingsView } from "./settings/MouseSettingsView";
import { updateDescription, updateInFlight, updateLiveness, updateProgress, updateStanding, type UpdateAction } from "./settings/UpdatesSection";
import { TabPanel, Tabs } from "./Tabs";
import { useSwitches, type SwitchController } from "./scanning/useSwitches";
Expand All @@ -19,7 +20,7 @@ import { areaOptions } from "./scanning/preferences";
import { SwitchesSection } from "./settings/SwitchesSection";
import { ScanningSection } from "./settings/ScanningSection";

type View = "switches" | "scanning" | "mobile" | "home" | "devices" | "profiles" | "settings" | "support";
type View = "switches" | "scanning" | "mouse" | "mobile" | "home" | "devices" | "profiles" | "settings" | "support";

const brandIconUrl = new URL("../src-tauri/icons/icon.png", import.meta.url).href;
const mobileQrUrl = new URL("./assets/mobile-download-qr.png", import.meta.url).href;
Expand Down Expand Up @@ -722,7 +723,7 @@ export function App() {

useEffect(() => { if (view === "profiles") void api.listProfiles().then(setProfiles).catch((reason) => setError(String(reason))); }, [view]);
const nav = useMemo(() => [
["home", "Home", <Home size={19} />], ["switches", "Switches", <Keyboard size={19} />], ["scanning", "Scanning", <SlidersHorizontal size={19} />], ["mobile", "Mobile", <Smartphone size={19} />], ["settings", "Settings", <Settings size={19} />],
["home", "Home", <Home size={19} />], ["switches", "Switches", <Keyboard size={19} />], ["scanning", "Scanning", <SlidersHorizontal size={19} />], ["mouse", "Mouse", <MousePointer2 size={19} />], ["mobile", "Mobile", <Smartphone size={19} />], ["settings", "Settings", <Settings size={19} />],
["support", "Help", <CircleHelp size={19} />],
] as const, []);

Expand Down Expand Up @@ -779,6 +780,7 @@ export function App() {
{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 mobileConnected={state.bluetooth === "connected"} controller={switches} suspended={pairingOpen} /></div>}
{view === "scanning" && <div className="view"><header className="page-header"><h1>Scanning</h1><p>Choose how your switches control the pointer.</p></header><ScanningSection controller={scanning} /></div>}
{view === "mouse" && <MouseSettingsView settings={settings} onChange={changeSettings} />}
{(view === "mobile" || view === "devices" || view === "profiles") && <div className="view"><header className="page-header"><div><h1>Mobile</h1></div></header><Tabs name="mobile" label="Mobile connection sections" active={view} onSelect={selectView} tabs={[{id: "mobile", label: "Connection"}, {id: "devices", label: "Paired devices"}, {id: "profiles", label: "Switch Forwarding"}]} /><TabPanel name="mobile" id={view}>
{view === "mobile" && <MobileConnection state={state} onDisconnect={() => void perform(api.disconnectAll)} />}
{view === "devices" && <DevicesView state={state} forget={(id) => void perform(() => api.forgetDevice(id))} />}
Expand Down
4 changes: 2 additions & 2 deletions src/scanning/SwitchPractice.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe("safe switch practice", () => {
fireEvent.click(screen.getByRole("button", { name: "Exit practice" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(invoke).toHaveBeenCalledWith("end_switch_practice");
expect(screen.getByRole("button", { name: "Test switches" })).toHaveFocus();
await waitFor(() => expect(screen.getByRole("button", { name: "Test switches" })).toHaveFocus());
});
it("shows disconnected remote errors rather than reporting successful input", async () => {
invoke.mockRejectedValueOnce(new Error("No remote forwarding session"));
Expand Down Expand Up @@ -62,7 +62,7 @@ describe("safe switch practice", () => {
await screen.findByRole("dialog");
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(invoke).toHaveBeenCalledWith("end_switch_practice");
expect(screen.getByRole("button", { name: "Test switches" })).toHaveFocus();
await waitFor(() => expect(screen.getByRole("button", { name: "Test switches" })).toHaveFocus());
});
it("closes after confirmed end when feedback fails, without requiring mouse assistance", async () => {
invoke.mockImplementation(async (command: string) => {
Expand Down
26 changes: 26 additions & 0 deletions src/settings/InputSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { MoreOptions } from "../ui/controls";
import type { AppSettings } from "../types";
import {
OptionGroup, SettingGroup, Toggle, dwellDelayOptions, keyRepeatDelayOptions,
repeatIntervalOptions, secondsOptions, type SettingsUpdate,
} from "./controls";

export function InputSection({ settings, update }: { settings: AppSettings; update: SettingsUpdate }) {
return <SettingGroup title="Input" description="Click after Remote pointer movement stops, or repeat held navigation keys.">
<div className="repeat-settings dwell-settings">
<Toggle label="Click when I stop" checked={settings.dwellClickEnabled} onChange={(value) => update("dwellClickEnabled", value)} />
<div className="repeat-options">
<OptionGroup<number> legend="Wait before clicking" columns="five" disabled={!settings.dwellClickEnabled} options={secondsOptions(dwellDelayOptions)} value={settings.dwellClickDelayMs} onChange={(next) => update("dwellClickDelayMs", next)}
note={{ summary: "After mobile pointer movement stops, a countdown appears and performs one left click. Move again to rearm it." }} />
</div>
</div>
<MoreOptions><div className="repeat-settings">
<Toggle label="Repeat held keys" checked={settings.keyRepeatEnabled} onChange={(value) => update("keyRepeatEnabled", value)} />
<div className="repeat-options">
<OptionGroup<number> legend="Delay before repeating" columns="four" disabled={!settings.keyRepeatEnabled} options={keyRepeatDelayOptions} value={settings.keyRepeatInitialDelayMs} onChange={(next) => update("keyRepeatInitialDelayMs", next)} />
<OptionGroup<number> legend="Key interval" columns="four" disabled={!settings.keyRepeatEnabled} options={secondsOptions(repeatIntervalOptions)} value={settings.keyRepeatIntervalMs} onChange={(next) => update("keyRepeatIntervalMs", next)}
note={{ about: "key repeat", summary: "Held navigation keys repeat, like on a keyboard.", detail: "Applies to the arrow keys, Tab, Backspace, Delete, Page Up, and Page Down." }} />
</div>
</div></MoreOptions>
</SettingGroup>;
}
11 changes: 11 additions & 0 deletions src/settings/MouseSettingsView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { AppSettings } from "../types";
import { PointerSection } from "./PointerSection";
import type { SettingsUpdate } from "./controls";

export function MouseSettingsView({ settings, onChange }: { settings: AppSettings; onChange: (next: AppSettings) => void }) {
const update: SettingsUpdate = (key, value) => onChange({ ...settings, [key]: value });
return <div className="view mouse-settings-view">
<header className="page-header"><div><h1>Mouse</h1><p>Pointer speed and repeat for Mouse scanning and Remote. Set panel scan timing under Scanning.</p></div></header>
<PointerSection settings={settings} update={update} />
</div>;
}
26 changes: 5 additions & 21 deletions src/settings/PointerSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Button, Input, MoreOptions } from "../ui/controls";
import { useEffect, useId, useState } from "react";
import type { AppSettings } from "../types";
import {
Disclosure, OptionGroup, SettingGroup, Toggle, accelerationOptions, dwellDelayOptions, keyRepeatDelayOptions,
Disclosure, OptionGroup, SettingGroup, Toggle, accelerationOptions,
movementValue, pointerSpeedOptions, repeatIntervalOptions, secondsOptions,
type SettingsUpdate,
} from "./controls";
Expand All @@ -26,7 +26,7 @@ export function PointerSection({ settings, update }: { settings: AppSettings; up
setSpeedDraft(String(next));
if (next !== settings.pointerScalePercent) update("pointerScalePercent", next);
};
return <SettingGroup title="Controls" description="">
return <SettingGroup title="Movement" description="">
<fieldset className="pointer-speed"><legend>Pointer speed <strong>{settings.pointerScalePercent}%</strong></legend><p>100% is the original speed. Higher speeds help cross the screen faster in Mouse and Remote.</p><div className="segmented compact speed-presets">
{pointerSpeedOptions.map((value) => <Button type="button" key={value} aria-label={`${value}% pointer speed`} aria-pressed={settings.pointerScalePercent === value} onClick={() => update("pointerScalePercent", value)}>{value}%</Button>)}
</div><Disclosure label={showExact ? "Hide exact speed" : "Set an exact speed"} expanded={showExact} onToggle={() => setShowExact(!showExact)} controls={exactSpeedId}>
Expand All @@ -37,29 +37,13 @@ export function PointerSection({ settings, update }: { settings: AppSettings; up
</div>
</div>
</Disclosure></fieldset>
<div className="repeat-settings dwell-settings">
<Toggle label="Click when I stop" checked={settings.dwellClickEnabled} onChange={(value) => update("dwellClickEnabled", value)} />
<div className="repeat-options">
<OptionGroup<number> legend="Wait before clicking" columns="five" disabled={!settings.dwellClickEnabled} options={secondsOptions(dwellDelayOptions)} value={settings.dwellClickDelayMs} onChange={(next) => update("dwellClickDelayMs", next)}
note={{ summary: "After mobile pointer movement stops, a countdown appears and performs one left click. Move again to rearm it." }} />
</div>
</div>
<MoreOptions> <div className="repeat-settings">
<div className="repeat-settings">
<Toggle label="Repeat mouse movement" checked={settings.mouseRepeatEnabled} onChange={(value) => update("mouseRepeatEnabled", value)} />
<div className="repeat-options">
<MoreOptions label="Repeat timing"><div className="repeat-options">
<OptionGroup<number> legend="Movement interval" columns="four" disabled={!settings.mouseRepeatEnabled} options={secondsOptions(repeatIntervalOptions)} value={settings.moveRepeatIntervalMs} onChange={(next) => update("moveRepeatIntervalMs", next)} />
<OptionGroup<number> legend="Movement acceleration" columns="four" disabled={!settings.mouseRepeatEnabled} options={accelerationOptions} value={settings.mouseRepeatAccelerationDurationMs} onChange={(next) => update("mouseRepeatAccelerationDurationMs", next)} />
<OptionGroup<number> legend="Scroll interval" columns="four" disabled={!settings.mouseRepeatEnabled} options={secondsOptions(repeatIntervalOptions)} value={settings.scrollRepeatIntervalMs} onChange={(next) => update("scrollRepeatIntervalMs", next)} />
</div>
</div>
<div className="repeat-settings">
<Toggle label="Repeat held keys" checked={settings.keyRepeatEnabled} onChange={(value) => update("keyRepeatEnabled", value)} />
<div className="repeat-options">
<OptionGroup<number> legend="Delay before repeating" columns="four" disabled={!settings.keyRepeatEnabled} options={keyRepeatDelayOptions} value={settings.keyRepeatInitialDelayMs} onChange={(next) => update("keyRepeatInitialDelayMs", next)} />
<OptionGroup<number> legend="Key interval" columns="four" disabled={!settings.keyRepeatEnabled} options={secondsOptions(repeatIntervalOptions)} value={settings.keyRepeatIntervalMs} onChange={(next) => update("keyRepeatIntervalMs", next)}
note={{ about: "key repeat", summary: "Held navigation keys repeat, like on a keyboard.", detail: "Applies to the arrow keys, Tab, Backspace, Delete, Page Up, and Page Down." }} />
</div>
</div></MoreOptions>
</div>
</MoreOptions>
</SettingGroup>;
}
2 changes: 1 addition & 1 deletion src/settings/ScannerPreferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl
<h2 ref={heading} tabIndex={-1}>{names[area]}</h2><p>{descriptions[area]} Change any value to customise it; other settings follow the shared defaults.</p>
</header>}
{area === 'shared' && <section className="scanner-areas" aria-label="Scanning modes"><div className="scanner-area-cards">{(['point', 'mouse'] as const).map(key => <Button type="button" className="scanner-area-card" data-area={key} key={key} aria-label={`Customise ${names[key].toLowerCase()}`} onClick={() => openArea(key)}><strong>{names[key]} <span aria-hidden="true">→</span></strong><span>{descriptions[key]}</span><span>{key === config.controlMode ? 'Select starts here' : 'Open with a switch or scanned control'}</span></Button>)}</div></section>}
{area === 'mouse' && <p className="setting-note">The ring stays visible while Mouse is open. Select a direction to move; with Repeat mouse movement on, the next switch press stops movement. Pointer speed and repeat controls are in Settings → Controls. Switch to Point returns to screen selection.</p>}
{area === 'mouse' && <p className="setting-note">The ring stays visible while Mouse is open. Select a direction to move; with Repeat mouse movement on, the next switch press stops movement. Pointer speed and repeat controls are under Mouse in the sidebar. Switch to Point returns to screen selection.</p>}
{area === 'point' && <>
<SettingGroup
title="Point scan"
Expand Down
8 changes: 4 additions & 4 deletions src/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { Settings } from "lucide-react";
import type { AppSettings, AppState } from "../types";
import { Tabs, TabPanel, type TabDefinition } from "../Tabs";
import { GeneralSection } from "./GeneralSection";
import { PointerSection } from "./PointerSection";
import { InputSection } from "./InputSection";
import { CursorSection } from "./CursorSection";
import { PrivacySection } from "./PrivacySection";
import { UpdatesSection, type UpdateAction } from "./UpdatesSection";


type SettingsTabId = "general" | "pointer" | "cursor" | "privacy" | "updates";
type SettingsTabId = "general" | "input" | "cursor" | "privacy" | "updates";

export function SettingsView({ state, settings, onChange, chooseTelemetry, updateAction, cancelUpdate, busy, focusUpdates, onUpdatesFocused, updateAttention, onUpdatesShown }: { state: AppState; settings: AppSettings; onChange: (next: AppSettings) => void; chooseTelemetry: (enabled: boolean) => void; updateAction: (action: UpdateAction) => void; cancelUpdate: () => void; busy: boolean; focusUpdates: boolean; onUpdatesFocused: () => void; updateAttention: string | null; onUpdatesShown: (shown: boolean) => void }) {
const updatesRef = useRef<HTMLElement>(null);
Expand All @@ -27,7 +27,7 @@ export function SettingsView({ state, settings, onChange, chooseTelemetry, updat

const tabs = useMemo<TabDefinition<SettingsTabId>[]>(() => [
{ id: "general" as const, label: "General" },
{ id: "pointer" as const, label: "Controls" },
{ id: "input" as const, label: "Input" },
...(state.capabilities.cursorOverlay ? [{ id: "cursor" as const, label: "Cursor appearance" }] : []),
{ id: "privacy" as const, label: "Privacy" },
// On the Updates tab the panel itself shows the reason, so no marker there.
Expand Down Expand Up @@ -55,7 +55,7 @@ export function SettingsView({ state, settings, onChange, chooseTelemetry, updat
<Tabs name="settings" tabs={tabs} active={active} onSelect={setActive} label="Settings sections" />
<TabPanel name="settings" id={active}>
{active === "general" && <GeneralSection settings={settings} update={update} />}
{active === "pointer" && <PointerSection settings={settings} update={update} />}
{active === "input" && <InputSection settings={settings} update={update} />}
{active === "cursor" && <CursorSection settings={settings} update={update} />}
{active === "privacy" && <PrivacySection state={state} settings={settings} update={update} chooseTelemetry={chooseTelemetry} busy={busy} />}
{active === "updates" && <UpdatesSection state={state} run={updateAction} cancel={cancelUpdate} sectionRef={updatesRef} />}
Expand Down
Loading
Loading