diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index 57daaac6..b0a68ff7 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -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" }); @@ -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(); 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(); + 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(); + 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(); + 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"); diff --git a/src/scanning/preferences.ts b/src/scanning/preferences.ts index 5212a596..c1bcd958 100644 --- a/src/scanning/preferences.ts +++ b/src/scanning/preferences.ts @@ -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 & Record>; +export type ScanPreferences = Pick + & Partial> & Record>; 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 { + 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); diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index 671fa77b..91bf5372 100644 --- a/src/settings/ScannerPreferences.tsx +++ b/src/settings/ScannerPreferences.tsx @@ -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 }) { @@ -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 = (key: K, value: ScanOptions[K] | undefined) => { if (area !== 'shared') { const overrides = { ...(settings[area] ?? {}), [key]: value }; @@ -141,10 +146,6 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl } {field('automatic', 'Automatic scanning', locked => change('automatic', value)} />)} - {area === 'keyboard' && 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 => <>
Auto scan rate
@@ -155,6 +156,17 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl )} + {field('nextScan', 'After a selection', locked => 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 => 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 => 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 => 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.' }} />)}