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
81 changes: 77 additions & 4 deletions src/PointScan.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
defaultPointScanConfig,
type PointScanState,
} from "./scanning/useScanning";
import { defaultScanPreferences } from "./scanning/preferences";
import { ScanningSection } from "./settings/ScanningSection";
function more() {
const button = screen.getByRole("button", { name: "More options" });
Expand Down Expand Up @@ -391,16 +392,88 @@ it("saves the shared panel pointer-avoidance choice from the mouse and keyboard
expect(screen.getByRole("checkbox", { name: "Move away from the pointer" })).toBeChecked();
});

it("saves the keyboard after-typing choice and preserves it while manual", async () => {
it("shows what each scanner does after a selection and saves a choice for one", async () => {
mocks.invoke.mockImplementation(async (command, args) => command === "get_point_scan" ? initial : { ...initial, config: args.config });
render(<PointScan />);
await screen.findByText(initial.message);
expect(screen.queryByText("After typing")).not.toBeInTheDocument();
const pressed = (name: string) => screen.getByRole("button", { name }).getAttribute("aria-pressed") === "true";
more();
expect(screen.getAllByRole("button", { name: "Usual for each scanner" }).map(button => button.getAttribute("aria-pressed"))).toEqual(["true", "true"]);
fireEvent.click(screen.getByRole("button", { name: "Customise menus" }));
more();
expect(screen.queryByRole("button", { name: "Usual for each scanner" })).not.toBeInTheDocument();
expect([pressed("Keep scanning"), pressed("Where I selected")]).toEqual([true, true]);
fireEvent.click(screen.getByRole("button", { name: /Back to scanning settings/ }));
fireEvent.click(screen.getByRole("button", { name: "Customise point scanning" }));
more();
expect(pressed("Wait for Select")).toBe(true);
expect(screen.queryByText("Start again from")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Keep scanning" }));
await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { config: { ...defaultPointScanConfig, scanPreferences: { ...defaultScanPreferences, point: { nextScan: "automatic" } } } }));
fireEvent.click(screen.getByRole("button", { name: "Use default for after a selection" }));
await waitFor(() => expect(pressed("Wait for Select")).toBe(true));
fireEvent.click(screen.getByRole("button", { name: /Back to scanning settings/ }));
fireEvent.click(screen.getByRole("button", { name: "Customise mouse scanning" }));
more();
expect([pressed("Keep scanning"), pressed("The beginning")]).toEqual([true, true]);
fireEvent.click(screen.getByRole("button", { name: "Where I selected" }));
await waitFor(() => expect(mocks.invoke.mock.lastCall?.[1].config.scanPreferences.mouse).toEqual({ startFrom: "selection" }));
});

it("saves a choice for every scanner and shows it in each", async () => {
mocks.invoke.mockImplementation(async (command, args) => command === "get_point_scan"
? { ...initial, config: { ...initial.config, automatic: false, keyboardWaitAfterTyping: true } } : { ...initial, config: args.config });
render(<PointScan />);
await screen.findByText(initial.message);
more();
// Point scanning and scanners with their own setting still use it.
expect(screen.getByRole("button", { name: "Keep scanning" })).toBeEnabled();
expect(screen.getByText(/The keyboard waits, as saved earlier/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Keep scanning" }));
await waitFor(() => expect(mocks.invoke.mock.lastCall?.[1].config.scanPreferences.nextScan).toBe("automatic"));
expect(screen.queryByText(/The keyboard waits, as saved earlier/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Where I selected" }));
await waitFor(() => expect(mocks.invoke.mock.lastCall?.[1].config.scanPreferences.startFrom).toBe("selection"));
fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" }));
more();
expect(screen.getByRole("button", { name: "Keep scanning" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Keep scanning" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Where I selected" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Where I selected" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "The beginning" }));
await waitFor(() => expect(mocks.invoke.mock.lastCall?.[1].config.scanPreferences.keyboard).toEqual({ startFrom: "beginning" }));
fireEvent.click(screen.getByRole("button", { name: "Reset scanning overrides" }));
await waitFor(() => expect(mocks.invoke.mock.lastCall?.[1].config.scanPreferences.keyboard).toEqual({}));
expect(screen.getByRole("button", { name: "Where I selected" })).toHaveAttribute("aria-pressed", "true");
});

it("shows a keyboard set back to standard as it will behave", async () => {
const saved = { ...initial, config: { ...initial.config, keyboardWaitAfterTyping: true, scanPreferences: { ...defaultScanPreferences, keyboard: { nextScan: "standard" as const } } } };
mocks.invoke.mockImplementation(async (command, args) => command === "get_point_scan" ? saved : { ...saved, config: args.config });
render(<PointScan />);
await screen.findByText(initial.message);
more();
fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" }));
expect(screen.getByRole("button", { name: "Continue scanning" })).toHaveAttribute("aria-pressed", "true");
more();
expect(screen.getByRole("button", { name: "Keep scanning" })).toHaveAttribute("aria-pressed", "true");
});

it("keeps a saved wait after typing as the keyboard's usual behaviour", async () => {
const saved = { ...initial, config: { ...initial.config, keyboardWaitAfterTyping: true } };
mocks.invoke.mockImplementation(async (command, args) => command === "get_point_scan" ? saved : { ...saved, config: args.config });
render(<PointScan />);
await screen.findByText(initial.message);
more();
fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" }));
more();
expect(screen.queryByRole("group", { name: "After typing" })).not.toBeInTheDocument();
expect(screen.getByText(/saved earlier as After typing/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Wait for Select" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "Keep scanning" }));
await waitFor(() => expect(mocks.invoke.mock.lastCall?.[1].config.scanPreferences.keyboard).toEqual({ nextScan: "automatic" }));
expect(screen.getByRole("button", { name: "Keep scanning" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "Wait for Select" }));
await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { config: { ...defaultPointScanConfig, keyboardWaitAfterTyping: true } }));
await waitFor(() => expect(mocks.invoke.mock.lastCall?.[1].config.scanPreferences.keyboard).toEqual({ nextScan: "wait" }));
fireEvent.click(screen.getByRole("checkbox", { name: "Automatic scanning" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Wait for Select" })).toBeDisabled());
expect(screen.getByRole("button", { name: "Wait for Select" })).toHaveAttribute("aria-pressed", "true");
Expand Down
21 changes: 19 additions & 2 deletions src/scanning/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,33 @@ export type ScanOptions = {
pattern: 'grouped' | 'linear';
color: ScannerColor;
thickness: 'thin' | 'standard' | 'thick';
/** `standard` is what each scanner has always done. */
nextScan: 'standard' | 'automatic' | 'wait';
startFrom: 'standard' | 'beginning' | 'selection';
};
export type ScanPreferences = Pick<ScanOptions, 'direction' | 'passLimit' | 'pattern' | 'thickness'> & Record<ScanArea, Partial<ScanOptions>>;
export type ScanPreferences = Pick<ScanOptions, 'direction' | 'passLimit' | 'pattern' | 'thickness'>
& Partial<Pick<ScanOptions, 'nextScan' | 'startFrom'>> & Record<ScanArea, Partial<ScanOptions>>;
export const defaultScanPreferences: ScanPreferences = {
direction: 'forward', passLimit: 3, pattern: 'grouped', thickness: 'standard',
point: {}, menu: {}, keyboard: {}, mouse: {},
};
/** Wait after typing, saved before the choice existed for every scanner, is still in effect. */
export function savedKeyboardWait(config: PointScanConfig) {
const settings = config.scanPreferences ?? defaultScanPreferences;
return config.keyboardWaitAfterTyping && settings.keyboard?.nextScan == null && (settings.nextScan ?? 'standard') === 'standard';
}
/** What a scanner does after a selection when nothing has been chosen for it. */
export function usualAfterSelection(config: PointScanConfig, area: ScanArea): Pick<ScanOptions, 'nextScan' | 'startFrom'> {
return {
nextScan: area === 'point' || (area === 'keyboard' && savedKeyboardWait(config)) ? 'wait' : 'automatic',
startFrom: area === 'menu' ? 'selection' : 'beginning',
};
}
export function sharedOptions(config: PointScanConfig): ScanOptions {
const settings = config.scanPreferences ?? defaultScanPreferences;
return { automatic: config.automatic, intervalMs: config.blockIntervalMs, color: config.scannerColor,
direction: settings.direction, passLimit: settings.passLimit, pattern: settings.pattern, thickness: settings.thickness };
direction: settings.direction, passLimit: settings.passLimit, pattern: settings.pattern, thickness: settings.thickness,
nextScan: settings.nextScan ?? 'standard', startFrom: settings.startFrom ?? 'standard' };
}
export function areaOptions(config: PointScanConfig, area: ScanArea): ScanOptions {
const shared = sharedOptions(config);
Expand Down
22 changes: 17 additions & 5 deletions src/settings/ScannerPreferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Button, Input, Select, MoreOptions } from "../ui/controls";
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { ChevronRight } from 'lucide-react';
import type { ScanningController, ScannerColor, PointScanConfig } from '../scanning/useScanning';
import { areaOptions, sharedOptions, defaultScanPreferences, type ScanArea, type ScanOptions } from '../scanning/preferences';
import { areaOptions, sharedOptions, defaultScanPreferences, usualAfterSelection, savedKeyboardWait, type ScanArea, type ScanOptions } from '../scanning/preferences';
import { SettingGroup, Toggle, OptionGroup } from './controls';

export function ScannerPreferences({ controller }: { controller: ScanningController }) {
Expand Down Expand Up @@ -45,6 +45,11 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl
const settings = config.scanPreferences ?? defaultScanPreferences;
const shared = sharedOptions(config);
const effective = area === 'shared' ? shared : areaOptions(config, area);
// A scanner shows what it will do, so its usual behaviour stands in for "standard".
const usual = area === 'shared' ? null : usualAfterSelection(config, area);
const nextScan = effective.nextScan === 'standard' && usual ? usual.nextScan : effective.nextScan;
const startFrom = effective.startFrom === 'standard' && usual ? usual.startFrom : effective.startFrom;
const usualOption = area === 'shared' ? [{ value: 'standard' as const, label: 'Usual for each scanner' }] : [];
const change = <K extends keyof ScanOptions>(key: K, value: ScanOptions[K] | undefined) => {
if (area !== 'shared') {
const overrides = { ...(settings[area] ?? {}), [key]: value };
Expand Down Expand Up @@ -141,10 +146,6 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl
</SettingGroup>}
<SettingGroup title="Movement" description="">
{field('automatic', 'Automatic scanning', locked => <Toggle label="Automatic scanning" checked={effective.automatic} disabled={locked} onChange={value => change('automatic', value)} />)}
{area === 'keyboard' && <OptionGroup<'continue' | 'wait'> legend="After typing" disabled={disabled || !effective.automatic} value={config.keyboardWaitAfterTyping ? 'wait' : 'continue'}
options={[{ value: 'continue', label: 'Continue scanning' }, { value: 'wait', label: 'Wait for Select' }]}
onChange={value => update('keyboardWaitAfterTyping', value === 'wait')}
note={{ summary: effective.automatic ? 'After typing a key or suggestion, wait for Select before scanning again.' : 'Used in automatic keyboard scanning. Your choice is kept while scanning manually.' }} />}
{field('intervalMs', 'Auto scan rate', locked => <>
<div className="exact-speed"><span>Auto scan rate</span><div className="scan-rate-stepper">
<Button type="button" aria-label="Decrease auto scan interval by 0.1 seconds" disabled={locked || !effective.automatic || effective.intervalMs <= 100} onClick={() => change('intervalMs', Math.max(100, effective.intervalMs - 100))}>−</Button>
Expand All @@ -155,6 +156,17 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl
</>)}
</SettingGroup>
<MoreOptions>
{field('nextScan', 'After a selection', locked => <OptionGroup<ScanOptions['nextScan']> legend="After a selection" columns={area === 'shared' ? 'three' : undefined} disabled={locked || (area !== 'shared' && area !== 'point' && !effective.automatic)} value={nextScan}
options={[...usualOption, { value: 'automatic', label: 'Keep scanning' }, { value: 'wait', label: 'Wait for Select' }]} onChange={value => change('nextScan', value)}
note={{ summary: area === 'point' ? 'After a click, the next point scan starts by itself or waits for Select. Scanning still stops at the pass limit.'
: area !== 'shared' && !effective.automatic ? 'Used in automatic scanning. Your choice is kept while scanning manually.'
: area === 'shared' ? 'After a selection that does something, such as typing a key or clicking, scanning starts again by itself or waits for Select. Usually, scanning waits after a click and starts again after anything else.' + (savedKeyboardWait(config) ? ' The keyboard waits, as saved earlier.' : '') + ' Waiting applies to scanners that scan automatically.'
: 'After a selection that does something, such as typing a key or scrolling, scanning starts again by itself or waits for Select.' + (area === 'keyboard' && savedKeyboardWait(config) ? ' Waiting is the default here because it was saved earlier as After typing.' : '') }} />)}
{area !== 'point' && field('startFrom', 'Start again from', locked => <OptionGroup<ScanOptions['startFrom']> legend="Start again from" columns={area === 'shared' ? 'three' : undefined} disabled={locked} value={startFrom}
options={[...usualOption, { value: 'beginning', label: 'The beginning' }, { value: 'selection', label: 'Where I selected' }]} onChange={value => change('startFrom', value)}
note={{ summary: area === 'shared' ? 'Where scanning starts after a selection that does something. Usually, menus stay where you selected, and the keyboard and mouse panel return to the beginning. Point scanning always starts from the beginning.'
: area === 'keyboard' ? 'Where scanning starts after typing a key. After a suggestion, or when changing page, it starts from the beginning.'
: 'Where scanning starts after a selection that does something. Opening another page or menu starts from its beginning.' }} />)}
{field('direction', 'Initial direction', locked => <OptionGroup<ScanOptions['direction']> legend="Initial direction" disabled={locked} value={effective.direction} options={[{ value: 'forward', label: 'Forward' }, { value: 'reverse', label: 'Reverse' }]} onChange={value => change('direction', value)} />)}
{field('passLimit', 'Pass limit', locked => <OptionGroup<number> legend="Pass limit" disabled={locked} value={effective.passLimit} options={[1, 2, 3, 5, 0].map(value => ({ value, label: value ? `${value} ${value === 1 ? 'pass' : 'passes'}` : 'Unlimited' }))} onChange={value => change('passLimit', value)}
note={{ summary: 'After this many automatic passes, scanning waits for Select. Unlimited keeps scanning until you pause or stop.' }} />)}
Expand Down
Loading