diff --git a/docs/architecture/09-settings-ui.md b/docs/architecture/09-settings-ui.md index 6bda425..4abea2d 100644 --- a/docs/architecture/09-settings-ui.md +++ b/docs/architecture/09-settings-ui.md @@ -158,6 +158,16 @@ switches on `row.type`: | `profiles` | `prefs/ProfilePicker.svelte` | A dropdown of the profiles plus New; a trash per profile in the open list (none when one is left, none on Default). Dialogs: `prefs/NewProfileDialog.svelte`, delete confirm via `Prompt` | | `button`, `about` | `SettingRow`, `controls/About.svelte` | Actions (open ini, export diagnostics) and the logo hero | +**Search** (#317, `ui/src/search/`): `search.js` is a pure, unit-tested ranker. Every labelled row is +scored best field first (label exact > word-prefix > substring/fuzzy, then the row's `keywords`, then +its description, then card caption and tab name); each query word must match something, fuzzy (one typo +for 4-7 letters, two for 8+, same first letter, a few spelling folds like ight/ite), and the scores add. +The result is ONE ranked list, not grouped by tab. `Results.svelte` renders each hit with the page's own +`SettingRow` (tab name beside the label), so a result is edited in place with the same session/persist +behaviour and clicking it goes nowhere; advanced rows always show there, a row gated by `showIf` shows +dimmed. Every labelled schema row carries `keywords` (synonyms, never displayed; a test enforces it). The +Tray menu's item lists are not schema rows, so they are never searchable; its Performance switch is. + Visual tokens (colours, radii, the aurora banner) live in `ui/src/design/tokens.css`, taken from `docs/design/settings-2026-10/FINAL-v10-grey.html`; the reference render is `FINAL-reference.png` in the same folder. diff --git a/src/version.h b/src/version.h index 7b876e2..56f4180 100644 --- a/src/version.h +++ b/src/version.h @@ -3,8 +3,8 @@ #pragma once #define WIND_VER_MAJOR 0 -#define WIND_VER_MINOR 20 -#define WIND_VER_PATCH 2 +#define WIND_VER_MINOR 21 +#define WIND_VER_PATCH 0 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.20.2" +#define WIND_VERSION_STR "0.21.0" diff --git a/ui/src/Settings.svelte b/ui/src/Settings.svelte index bcd6e16..423cfcb 100644 --- a/ui/src/Settings.svelte +++ b/ui/src/Settings.svelte @@ -38,7 +38,6 @@ let loaded = $state(false); let main = $state(); let maximized = $state(false); - let revealKey = $state(''); // an advanced row opened by a search result while the switch is off const top = groups.filter((g) => !g.bottom); const bottom = groups.filter((g) => g.bottom); // below the sidebar divider @@ -233,16 +232,13 @@ const searchInput = () => document.querySelector('.side .search input'); function onSearch(q) { query = q; } function clearSearch() { query = ''; const i = searchInput(); if (i) i.value = ''; } - async function jump(hit) { - clearSearch(); - revealKey = hit.adv ? hit.key : ''; - await select(hit.groupId); - const row = main && main.querySelector('[data-key="' + hit.key + '"]'); - if (row) { - row.scrollIntoView({ block: 'center' }); - const f = row.querySelector('button, input, select, [tabindex]'); - if (f) f.focus({ preventScroll: true }); - } + // A result's control is the real one; a row hidden on its page by showIf (it only applies under another + // setting) shows dimmed, like the rest of the app shows a gated control. + const isDisabled = (r) => !visible(r); + // Enter or Down in the search box moves into the best result's control (Tab then walks the rest). + function focusFirstResult() { + const c = main && main.querySelector('.res .hit button, .res .hit input, .res .hit select, .res .hit [tabindex="0"]'); + if (c) c.focus(); } function onKeydown(e) { const mod = e.ctrlKey && !e.altKey && !e.shiftKey; @@ -250,7 +246,7 @@ if (e.key === 'Escape' && (searching || e.target === searchInput())) { e.preventDefault(); clearSearch(); if (main) main.focus({ preventScroll: true }); return; } - if (e.key === 'Enter' && e.target === searchInput() && results.length) { e.preventDefault(); jump(results[0].rows[0]); return; } + if ((e.key === 'Enter' || e.key === 'ArrowDown') && e.target === searchInput() && results.length) { e.preventDefault(); focusFirstResult(); return; } if (mod && e.key.toLowerCase() === 'q') { e.preventDefault(); requestQuit(); } } @@ -268,10 +264,9 @@ else if (a === 'quitWind') requestQuit(); } const visible = (r) => !r.showIf || String(values[r.showIf.key]) === String(r.showIf.eq); - // Advanced rows show only while the global switch is on, with one exception: the row a search result - // opened (revealKey) shows on its own and turns nothing on. + // Advanced rows show on their page only while the global switch is on; search results show them always. const advOn = $derived(Number(values.showAdvanced) === 1); - const shown = (r) => visible(r) && (!r.adv || advOn || r.key === revealKey); + const shown = (r) => visible(r) && (!r.adv || advOn); const extra = $derived({ mpoNeedsRestart, runningModel, version: VERSION, onRepo: openRepo, onAction, pick: pickExe, @@ -291,12 +286,12 @@ windowControl('minimize')} onMaximize={() => windowControl('maximize')} onClose={requestClose} />
{ revealKey = ''; clearSearch(); select(id); }} /> + onSelect={(id) => { clearSearch(); select(id); }} />
{#key searching ? '?search' : activeId}
{#if searching} - + {:else} {#each group.cards as card, i (group.id + i)} diff --git a/ui/src/controls/Preview.svelte b/ui/src/controls/Preview.svelte index aafb21d..fabb073 100644 --- a/ui/src/controls/Preview.svelte +++ b/ui/src/controls/Preview.svelte @@ -23,7 +23,7 @@ const visible = (r) => !r.showIf || String(values[r.showIf.key]) === String(r.showIf.eq); const set = (key, val) => { values[key] = val; calls.push([key, val]); }; const extra = { - runningModel: 'hybrid', mpoNeedsRestart: false, version: '0.20.2', + runningModel: 'hybrid', mpoNeedsRestart: false, version: '0.21.0', onRestart: () => calls.push('restart'), onAction: (a) => calls.push(['action', a]), onRepo: () => calls.push('repo'), pick: async () => 'RDR2.exe', diff --git a/ui/src/controls/SettingRow.svelte b/ui/src/controls/SettingRow.svelte index f60901a..e420c84 100644 --- a/ui/src/controls/SettingRow.svelte +++ b/ui/src/controls/SettingRow.svelte @@ -6,6 +6,7 @@ // onChange onChange(val) for the row's own key // onSet onSet(key, val) for a sibling key (the extra-key switches) // live live(patch) for keybind captures (written immediately by the page) + // tab optional quiet tab name shown beside the label (search results) // extra { mpoNeedsRestart, runningModel, onRestart, // profiles: {names, active, onSwitch, onNew, onDelete}, version, onRepo, onAction(name) } import Toggle from './Toggle.svelte'; @@ -20,7 +21,7 @@ import ThemePicker from '../prefs/ThemePicker.svelte'; import ProfilePicker from '../prefs/ProfilePicker.svelte'; import About from './About.svelte'; - let { row, value = undefined, values = {}, onChange = () => {}, onSet = () => {}, live = () => {}, extra = {}, disabled = false } = $props(); + let { row, value = undefined, values = {}, onChange = () => {}, onSet = () => {}, live = () => {}, extra = {}, disabled = false, tab = '' } = $props(); // Ids so every control is named by its row: label (and the live value for value-bearing ones). const rid = $derived('row-' + String(row.key).replace(/[^A-Za-z0-9_-]/g, '')); @@ -35,7 +36,7 @@ {:else}
- {#if row.label}
{row.label}
{/if} + {#if row.label}
{row.label}
{#if tab}{tab}{/if}
{/if} {#if row.desc}
{row.desc}
{/if}
@@ -91,6 +92,8 @@ .row.wide { grid-template-columns: minmax(0, 1fr); gap: 10px; padding-bottom: 12px; } /* the control sits under the text, full width */ .row.wide .ctl { justify-content: flex-start; min-width: 0; } .meta { min-width: 0; } + .lrow { display: flex; align-items: baseline; gap: 10px; } + .tab { font: 11px var(--s); letter-spacing: .02em; color: var(--fg3); opacity: .8; } .label { font: 500 13.5px var(--s); color: var(--fg); } .desc { margin-top: 1px; font: 12.5px var(--s); color: var(--fg3); } .ctl { display: flex; align-items: center; justify-content: flex-end; gap: 6px; } diff --git a/ui/src/search/Results.svelte b/ui/src/search/Results.svelte index 72ae936..88c3162 100644 --- a/ui/src/search/Results.svelte +++ b/ui/src/search/Results.svelte @@ -1,37 +1,31 @@

Search

-

{total === 0 ? 'No settings match "' + query + '".' : total + (total === 1 ? ' setting' : ' settings') + ' match "' + query + '". Press Enter to open the first, Esc to clear.'}

- {#each results as g (g.id)} -
{g.label}
+

{results.length === 0 ? 'No settings match "' + query + '".' : results.length + (results.length === 1 ? ' setting' : ' settings') + ' match "' + query + '", best first. Change them here, Esc clears.'}

+ {#if results.length}
- {#each g.rows as r (r.key)} - + {#each results as r (r.key)} +
+ onChange(r.key, v)} onSet={onSet} /> +
{/each}
- {/each} + {/if}
diff --git a/ui/src/search/search.js b/ui/src/search/search.js index fcc6bbc..46ba83d 100644 --- a/ui/src/search/search.js +++ b/ui/src/search/search.js @@ -1,26 +1,143 @@ -// Settings search (pure). Matches rows across all groups, hidden (showIf) and advanced rows included (the -// word "advanced" also matches them; a hit carries adv so the page can show a hidden one on its own). -// A row matches when every query word is a word-prefix of its label, description, card caption or -// group label (case-insensitive). Returns [{ id, label, rows: [{ key, label, desc, caption, groupId, adv }] }] -// in schema order, dropping groups with no match. +// Settings search (pure, no DOM). Ranks every searchable row against a query, best match first. +// +// Each row is scored from its own text, best field first: label (exact > word-prefix > substring > +// fuzzy), then its meta keywords (synonyms, never displayed), then its description, then its card +// caption and tab name. Every query word must match something (fuzzy allowed) and the per-word scores +// add up; a whole-label match and a whole-phrase keyword match add a bonus. Matching ignores case, +// accents and punctuation. Typo tolerance (same first letter): edit distance 1 for words of 4-7 letters, 2 for 8+ (none +// below 4), a typo'd prefix counts too, and a few spelling folds (ight/ite, ph/f) catch phonetic +// spellings such as "nite". Advanced rows also match the word "advanced". +// +// search(groups, query) -> flat array, best first: +// { key, row, label, desc, caption, groupId, groupLabel, adv, score } +// Rows with no label (the About hero) are not searchable. The Tray menu's item lists are not schema +// rows (they are drawn by src/tray), so they never appear; its "Performance in the tray" switch is a +// normal row and does. -const words = (s) => String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); +export const words = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '') + .toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); + +// Optimal string alignment distance (insert, delete, substitute, adjacent swap), early exit past max. +export function editDistance(a, b, max = 2) { + if (a === b) return 0; + if (Math.abs(a.length - b.length) > max) return max + 1; + let prev2 = null, prev = Array.from({ length: b.length + 1 }, (_, j) => j); + for (let i = 1; i <= a.length; i++) { + const cur = [i]; + let rowMin = i; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + let v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost); + if (prev2 && i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) v = Math.min(v, prev2[j - 2] + 1); + cur[j] = v; + if (v < rowMin) rowMin = v; + } + if (rowMin > max) return max + 1; + prev2 = prev; prev = cur; + } + return prev[b.length]; +} + +// How many typos a query word may carry. +export const budget = (w) => (w.length >= 8 ? 2 : w.length >= 4 ? 1 : 0); +// Spelling folds for the fuzzy comparison only. +const fold = (w) => w.replace(/ight/g, 'ite').replace(/ph/g, 'f'); + +// Score of one query word against one list of target words, as a 0..1 strength (0 = no match): +// exact 1, word-prefix .8, substring .6, fuzzy (whole word or typo'd prefix) .45. +export function wordStrength(q, targets, fuzzy = true) { + let best = 0; + const b = fuzzy ? budget(q) : 0, fq = fold(q); + for (const t of targets) { + let s = 0; + if (t === q) s = 1; + else if (t.startsWith(q)) s = 0.8; + else if (q.length >= 3 && t.includes(q)) s = 0.6; + else if (b > 0 && t[0] === q[0] && !STOPWORDS.has(t)) { // fuzzy needs the same first letter: far fewer false hits + const ft = fold(t); + if (editDistance(q, t, b) <= b || editDistance(fq, ft, b) <= b) s = 0.45; + else if (q.length >= 5 && t.length > q.length && editDistance(q, t.slice(0, q.length), b) <= b) s = 0.4; + } + if (s > best) best = s; + if (best === 1) break; + } + return best; +} + +// Field weights: what a full-strength hit in that field is worth. +const W = { label: 100, kw: 40, desc: 18, cap: 8 }; + +const cache = new WeakMap(); // row -> its normalized fields (rows are static schema objects) +function fieldsOf(row, card, group) { + let f = cache.get(row); + if (f) return f; + const label = words(row.label); + f = { + label, + compact: label.join(''), + labelNorm: label.join(' '), + kw: (row.keywords || []).flatMap(words), + kwPhrases: (row.keywords || []).map((k) => words(k).join(' ')), + desc: words(row.desc), + cap: [...words(card.caption), ...words(group.label), ...(row.adv ? ['advanced'] : [])], + }; + cache.set(row, f); + return f; +} + +// Common short words carry no meaning of their own: dropped from a query that has other words. +export const STOPWORDS = new Set(['in', 'the', 'to', 'of', 'a', 'an', 'with', 'for', 'on', 'and', 'or', 'is', 'at', 'these', 'this', 'that', 'those', 'from', 'your']); +export const meaningful = (q) => { const m = q.filter((w) => !STOPWORDS.has(w)); return m.length ? m : q; }; + +// Score one row for the query words (0 = no match). +export function scoreRow(f, all) { + const q = meaningful(all); + let total = 0; + // Stopwords never need to match; an exact label hit only breaks ties ("zoom in" -> Zoom in over Zoom out). + if (q.length < all.length) for (const w of all) if (STOPWORDS.has(w) && f.label.includes(w)) total += 5; + for (const w of q) { + let best = 0; + const l = wordStrength(w, f.label) * W.label; + best = Math.max(best, l); + if (w.length >= 4 && f.compact.includes(w)) best = Math.max(best, 0.55 * W.label); // "zoomin" for "Zoom-in" + if (best < W.label) { + best = Math.max(best, wordStrength(w, f.kw) * W.kw); + // Free text (description, caption, tab name) matches exactly/by prefix/substring only: fuzzy hits there + // pair unrelated words that merely look alike (tray/trac, theme/these). + best = Math.max(best, wordStrength(w, f.desc, false) * W.desc); + best = Math.max(best, wordStrength(w, f.cap, false) * W.cap); + } + if (best === 0) return 0; // every word must match something + total += best; + } + // Tie-breaks among equal matches: a label that opens with the query beats one that merely contains it, + // and shorter labels beat longer ones. + if (f.label[0] && f.label[0].startsWith(q[0])) total += 10; + total -= f.label.length * 0.5; + const phrase = all.join(' '); + if (f.labelNorm === phrase) total += 400; + else if (f.labelNorm.startsWith(phrase)) total += 60; + else if (all.length > 1 && f.labelNorm.includes(phrase)) total += 30; + if (all.length > 1 && f.kwPhrases.some((k) => k.includes(phrase))) total += 25; + return total; +} export function search(groups, query) { const q = words(query); if (!q.length) return []; - const out = []; + const hits = []; + let order = 0; for (const g of groups) { - const rows = []; for (const card of g.cards || []) { for (const r of card.rows || []) { if (!r.label) continue; - const hay = [...words(r.label), ...words(r.desc), ...words(card.caption), ...words(g.label), ...(r.adv ? ['advanced'] : [])]; - if (q.every((w) => hay.some((h) => h.startsWith(w)))) - rows.push({ key: r.key, label: r.label, desc: r.desc || '', caption: card.caption || '', groupId: g.id, adv: !!r.adv }); + const score = scoreRow(fieldsOf(r, card, g), q); + if (score > 0) + hits.push({ key: r.key, row: r, label: r.label, desc: r.desc || '', caption: card.caption || '', + groupId: g.id, groupLabel: g.label, adv: !!r.adv, score, order: order++ }); + else order++; } } - if (rows.length) out.push({ id: g.id, label: g.label, rows }); } - return out; + return hits.sort((a, b) => b.score - a.score || a.order - b.order); } diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index c629af4..8141462 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -13,6 +13,9 @@ // { key, eq } to hide it unless another setting has that value. A keybind row may carry `max` (how // many bindings it takes, default 1) and `onKey` (the on/off switch key of an extra key). // +// Every labelled row carries `keywords`: synonyms and related words people might type (never displayed, +// searched after the label, see src/search/search.js). Add them to any new row; tests/search.spec.js checks it. +// // Copy rules: plain language, no toggle labels starting with "Enable", no description that restates // its label, no symbols or product names. @@ -28,28 +31,28 @@ export const groups = [ // Two bindings per direction (max 2). Each ini slot holds one part: a key, a mouse button or a // wheel direction (button codes 6 / 7), with its modifiers. Either slot works alone, both fire // the same action (the core OR-combines them). - { key: '__zoomIn', type: 'keybind', max: 2, label: 'Zoom in', + { key: '__zoomIn', type: 'keybind', max: 2, label: 'Zoom in', keywords: ['magnify', 'enlarge', 'bigger', 'closer', 'hotkey', 'shortcut', 'key', 'keybind', 'binding', 'button', 'mouse wheel', 'wheel', 'scroll', 'increase'], desc: 'Hold or scroll to magnify the view.', buttonKey: 'zoomInButton', vkKey: 'zoomInVk', modsKey: 'zoomInMods', buttonModsKey: 'zoomInButtonMods', buttonKey2: 'zoomInButton2', vkKey2: 'zoomInVk2', modsKey2: 'zoomInMods2', buttonModsKey2: 'zoomInButton2Mods' }, - { key: '__zoomOut', type: 'keybind', max: 2, label: 'Zoom out', desc: 'Hold or scroll to zoom back out.', + { key: '__zoomOut', type: 'keybind', max: 2, label: 'Zoom out', keywords: ['shrink', 'smaller', 'reduce', 'back out', 'hotkey', 'shortcut', 'key', 'keybind', 'binding', 'button', 'mouse wheel', 'wheel', 'scroll', 'decrease'], desc: 'Hold or scroll to zoom back out.', buttonKey: 'zoomOutButton', vkKey: 'zoomOutVk', modsKey: 'zoomOutMods', buttonModsKey: 'zoomOutButtonMods', buttonKey2: 'zoomOutButton2', vkKey2: 'zoomOutVk2', modsKey2: 'zoomOutMods2', buttonModsKey2: 'zoomOutButton2Mods' }, // Keyboard-hook suspension (#156): trades key-interception for smooth panning, per app. - { key: 'noSwallowApps', type: 'applist', adv: true, label: 'Share zoom keys with these apps', + { key: 'noSwallowApps', type: 'applist', adv: true, label: 'Share zoom keys with these apps', keywords: ['exclude', 'exception', 'passthrough', 'pass through', 'allow', 'whitelist', 'block', 'swallow', 'conflict', 'game', 'program', 'exe', 'hook', 'keys'], desc: 'Zoom keys also reach these apps.', def: '' }, ] }, { caption: 'Extra keys', rows: [ // Keyboard panning (#287): unbound by default (#307). The arrow keys are fixed and drawn by the // control itself; only the modifiers (one or two, required) are chosen. The switch (panKeysOn) // frees the keys without losing the binding. - { key: '__pan', type: 'keybind', label: 'Pan with the arrow keys', + { key: '__pan', type: 'keybind', label: 'Pan with the arrow keys', keywords: ['arrows', 'arrow keys', 'move', 'scroll', 'keyboard', 'navigate', 'shift view', 'left right up down', 'hotkey', 'shortcut', 'modifier'], desc: 'Hold the modifiers and press an arrow key to move the view.', panArrows: true, onKey: 'panKeysOn', panKeys: ['panLeftVk', 'panUpVk', 'panRightVk', 'panDownVk', 'panLeftMods', 'panUpMods', 'panRightMods', 'panDownMods'] }, - { key: '__hideCursor', type: 'keybind', label: 'Hide pointer', desc: 'Hides or shows the pointer while zoomed.', + { key: '__hideCursor', type: 'keybind', label: 'Hide pointer', keywords: ['hide mouse', 'hide cursor', 'invisible', 'pointer visibility', 'toggle', 'hotkey', 'shortcut', 'key', 'mouse cursor'], desc: 'Hides or shows the pointer while zoomed.', vkKey: 'hideCursorVk', modsKey: 'hideCursorMods', onKey: 'hideCursorOn' }, - { key: '__cursorLock', type: 'keybind', label: 'Inspect mode', desc: 'Freezes the pointer so you can look around.', + { key: '__cursorLock', type: 'keybind', label: 'Inspect mode', keywords: ['freeze', 'frozen', 'crosshair', 'tooltip', 'hover', 'look around', 'reticle', 'lock cursor', 'cursor lock', 'hotkey', 'shortcut', 'key'], desc: 'Freezes the pointer so you can look around.', vkKey: 'cursorLockVk', modsKey: 'cursorLockMods', onKey: 'cursorLockOn' }, ] }, ] }, @@ -58,18 +61,18 @@ export const groups = [ desc: 'How far and how fast to zoom.', cards: [ { caption: 'Level and speed', rows: [ - { key: 'maxLevel', type: 'slider', label: 'Max zoom', desc: 'The highest zoom level.', min: 2, max: 50, step: 1, def: 12.0, unit: 'times' }, - { key: 'zoomInSpeed', type: 'slider', label: 'Zoom-in speed', desc: 'How fast the view zooms in.', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, - { key: 'zoomOutSpeed', type: 'slider', label: 'Zoom-out speed', desc: 'How fast the view zooms out.', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, - { key: 'zoomEaseOutMs', type: 'slider', label: 'Release glide', desc: 'How long the zoom coasts after you let go.', min: 0, max: 300, step: 5, def: 45, unit: 'ms' }, + { key: 'maxLevel', type: 'slider', label: 'Max zoom', keywords: ['maximum', 'limit', 'highest', 'magnification', 'level', 'cap', 'zoom level', 'factor', 'times', 'upper'], desc: 'The highest zoom level.', min: 2, max: 50, step: 1, def: 12.0, unit: 'times' }, + { key: 'zoomInSpeed', type: 'slider', label: 'Zoom-in speed', keywords: ['faster', 'slower', 'rate', 'magnify speed', 'zoom rate', 'acceleration', 'velocity', 'how fast'], desc: 'How fast the view zooms in.', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, + { key: 'zoomOutSpeed', type: 'slider', label: 'Zoom-out speed', keywords: ['faster', 'slower', 'rate', 'zoom rate', 'velocity', 'how fast'], desc: 'How fast the view zooms out.', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, + { key: 'zoomEaseOutMs', type: 'slider', label: 'Release glide', keywords: ['inertia', 'coast', 'momentum', 'ease out', 'easing', 'smooth stop', 'slow down', 'deceleration', 'stop', 'let go', 'release', 'delay', 'milliseconds'], desc: 'How long the zoom coasts after you let go.', min: 0, max: 300, step: 5, def: 45, unit: 'ms' }, ] }, { caption: 'Easing', rows: [ // Smooth zoom is always on (core default 1); its shape sliders are advanced. - { key: 'smoothZoomAccel', type: 'slider', adv: true, label: 'Soft start', desc: 'Softens the start of each zoom.', min: 1, max: 8, step: 0.5, def: 3.0 }, - { key: 'smoothZoomRamp', type: 'slider', adv: true, label: 'Soft start length', desc: 'How long the soft start lasts.', min: 0.1, max: 3, step: 0.1, def: 0.6, unit: 'seconds' }, + { key: 'smoothZoomAccel', type: 'slider', adv: true, label: 'Soft start', keywords: ['ease', 'ease in', 'acceleration', 'gentle', 'smooth', 'smoothing', 'ramp', 'curve', 'animation', 'start'], desc: 'Softens the start of each zoom.', min: 1, max: 8, step: 0.5, def: 3.0 }, + { key: 'smoothZoomRamp', type: 'slider', adv: true, label: 'Soft start length', keywords: ['ease', 'duration', 'time', 'ramp', 'seconds', 'how long', 'smooth', 'smoothing', 'animation', 'curve'], desc: 'How long the soft start lasts.', min: 0.1, max: 3, step: 0.1, def: 0.6, unit: 'seconds' }, ] }, { caption: 'Engine', rows: [ - { key: 'model', type: 'engine', adv: true, label: 'Engine', desc: 'How the screen is magnified.', + { key: 'model', type: 'engine', adv: true, label: 'Engine', keywords: ['renderer', 'render', 'mode', 'transform', 'auto', 'hybrid', 'model', 'backend', 'method', 'magnifier', 'gpu', 'capture', 'dwm', 'restart', 'compositor'], desc: 'How the screen is magnified.', options: ['hybrid', 'render', 'transform'], optionLabels: { hybrid: 'Auto', render: 'Render', transform: 'Transform' }, def: 'hybrid' }, @@ -77,15 +80,15 @@ export const groups = [ // behaves as before. They only apply when the engine above is Auto, so showIf hides them // when a single engine is pinned. ("Never use Render for" left Settings in #318; the // renderExclude ini key and the core's own detection stay.) - { key: 'engineGame', type: 'select', adv: true, label: 'Engine for games', desc: 'Full-screen and borderless games.', + { key: 'engineGame', type: 'select', adv: true, label: 'Engine for games', keywords: ['renderer', 'render', 'transform', 'auto', 'fullscreen', 'borderless', 'video games', 'gaming', 'mode', 'per window', 'per app'], desc: 'Full-screen and borderless games.', options: engineOpts, optionLabels: engineLabels, def: 'auto', showIf: hybrid }, - { key: 'engineAcrylic', type: 'select', adv: true, label: 'Engine for blurred windows', + { key: 'engineAcrylic', type: 'select', adv: true, label: 'Engine for blurred windows', keywords: ['renderer', 'render', 'transform', 'auto', 'acrylic', 'mica', 'transparent', 'translucent', 'glass', 'blur', 'mode', 'per window'], desc: 'Windows with see-through, blurred backgrounds.', options: engineOpts, optionLabels: engineLabels, def: 'auto', showIf: hybrid }, - { key: 'engineDesktop', type: 'select', adv: true, label: 'Engine for the desktop', + { key: 'engineDesktop', type: 'select', adv: true, label: 'Engine for the desktop', keywords: ['renderer', 'render', 'transform', 'auto', 'taskbar', 'explorer', 'file windows', 'shell', 'mode', 'per window'], desc: 'The desktop, the taskbar and file windows.', options: engineOpts, optionLabels: engineLabels, def: 'auto', showIf: hybrid }, - { key: 'engineOther', type: 'select', adv: true, label: 'Engine for other windows', desc: 'All other windows.', + { key: 'engineOther', type: 'select', adv: true, label: 'Engine for other windows', keywords: ['renderer', 'render', 'transform', 'auto', 'apps', 'programs', 'rest', 'everything else', 'default', 'mode', 'per window'], desc: 'All other windows.', options: engineOpts, optionLabels: engineLabels, def: 'auto', showIf: hybrid }, ] }, ] }, @@ -94,30 +97,30 @@ export const groups = [ desc: 'How the view follows the mouse and typing.', cards: [ { caption: 'Speed', rows: [ - { key: 'panSpeed', type: 'slider', label: 'Arrow key speed', desc: 'How fast the arrow keys move the view.', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, - { key: 'cursorSensitivity', type: 'slider', label: 'Mouse speed', desc: 'How fast the view follows the mouse.', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, - { key: 'cursorSmoothing', type: 'slider', adv: true, label: 'Pan smoothing', desc: 'Adds gentle inertia when the view moves.', min: 0, max: 0.95, step: 0.05, def: 0.4 }, + { key: 'panSpeed', type: 'slider', label: 'Arrow key speed', keywords: ['pan', 'pan speed', 'arrows', 'move', 'scroll', 'keyboard', 'how fast', 'velocity', 'shift view', 'rate'], desc: 'How fast the arrow keys move the view.', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, + { key: 'cursorSensitivity', type: 'slider', label: 'Mouse speed', keywords: ['sensitivity', 'pointer speed', 'cursor speed', 'dpi', 'pan', 'follow', 'tracking speed', 'how fast', 'velocity', 'mouse sensitivity', 'rate'], desc: 'How fast the view follows the mouse.', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, + { key: 'cursorSmoothing', type: 'slider', adv: true, label: 'Pan smoothing', keywords: ['inertia', 'smooth', 'smoothing', 'glide', 'momentum', 'lag', 'delay', 'damping', 'gentle', 'filter', 'mouse'], desc: 'Adds gentle inertia when the view moves.', min: 0, max: 0.95, step: 0.05, def: 0.4 }, ] }, { caption: 'Pointer', rows: [ - { key: 'mouseAlign', type: 'select', label: 'Pointer position', desc: 'Where the mouse pointer sits while the view moves.', options: ['0', '1'], optionLabels: { '0': 'Centred', '1': 'Within the edges' }, def: '0' }, - { key: 'mouseMarginPct', type: 'slider', label: 'Edge margin', desc: 'How near the edge the pointer gets before the view moves.', min: 0, max: 30, step: 1, def: 0, unit: '%', + { key: 'mouseAlign', type: 'select', label: 'Pointer position', keywords: ['mouse position', 'mouse', 'cursor', 'pointer', 'where', 'centred', 'centered', 'center', 'centre', 'middle', 'edge', 'edges', 'within the edges', 'align', 'alignment', 'lock to center', 'follow mode', 'placement'], desc: 'Where the mouse pointer sits while the view moves.', options: ['0', '1'], optionLabels: { '0': 'Centred', '1': 'Within the edges' }, def: '0' }, + { key: 'mouseMarginPct', type: 'slider', label: 'Edge margin', keywords: ['edge', 'edges', 'border', 'padding', 'distance', 'boundary', 'margin', 'pointer', 'mouse', 'percent', 'dead zone', 'how near'], desc: 'How near the edge the pointer gets before the view moves.', min: 0, max: 30, step: 1, def: 0, unit: '%', showIf: { key: 'mouseAlign', eq: '1' } }, // High resolution cursor (#227) + MPO, ONE option (#242). Ini key stays txSamplingMode // (0 nearest / 1 smooth); the MPO half lives in HKLM and is staged by the page. The two are // safety-coupled: crisp with MPO enabled is the NVIDIA 16-bit overflow TDR combo (#148), so // turning high-res OFF also stages MPO-disable and turning it ON stages MPO re-enable, both // atomic at the Windows restart (the core holds the boot state's look until then). - { key: 'txSamplingMode', type: 'highres', label: 'High resolution cursor', + { key: 'txSamplingMode', type: 'highres', label: 'High resolution cursor', keywords: ['sharp', 'crisp', 'smooth', 'blurry', 'pixelated', 'hidpi', 'sampling', 'quality', 'nearest', 'mpo', 'overlay', 'cursor', 'pointer', 'resolution', '4k', 'antialiasing', 'restart', 'registry'], desc: 'A sharper pointer and image at high zoom.', def: 0 }, // Zoom lock detection (#221): games like DOOM pin the mouse to the screen centre, which would // pin the zoom view there too. Listed apps get the view UNLOCKED from the pointer. - { key: 'lockApps', type: 'applist', adv: true, label: 'Mouse-locked games', + { key: 'lockApps', type: 'applist', adv: true, label: 'Mouse-locked games', keywords: ['lock', 'locked', 'fps', 'shooter', 'first person', 'mouselook', 'mouse look', 'camera', 'game', 'exe', 'program', 'unlock', 'exception', 'apps', 'pinned', 'centre'], desc: 'The view follows mouse movement in these games.', def: '' }, ] }, { caption: 'Typing and focus', rows: [ - { key: 'trackCaret', type: 'toggle', label: 'Follow the text cursor', desc: 'The view moves to where you type.', def: 1 }, - { key: 'trackFocus', type: 'toggle', label: 'Follow keyboard focus', desc: 'The view moves to the selected control.', def: 0 }, - { key: 'trackAlign', type: 'select', label: 'Text cursor position', desc: 'Where the text cursor and focus sit in the view.', options: ['0', '1'], optionLabels: { '0': 'Centred', '1': 'Within the edges' }, def: '0' }, + { key: 'trackCaret', type: 'toggle', label: 'Follow the text cursor', keywords: ['caret', 'typing', 'type', 'text', 'keyboard', 'insertion point', 'track', 'tracking', 'editor', 'notepad', 'word', 'input', 'write', 'follow'], desc: 'The view moves to where you type.', def: 1 }, + { key: 'trackFocus', type: 'toggle', label: 'Follow keyboard focus', keywords: ['focus', 'tab', 'tab key', 'navigate', 'selected', 'control', 'button', 'accessibility', 'keyboard', 'track', 'tracking', 'highlight', 'follow'], desc: 'The view moves to the selected control.', def: 0 }, + { key: 'trackAlign', type: 'select', label: 'Text cursor position', keywords: ['mouse position', 'caret', 'typing', 'where', 'centred', 'centered', 'center', 'centre', 'middle', 'edge', 'edges', 'within the edges', 'align', 'alignment', 'focus', 'placement', 'mouse', 'cursor'], desc: 'Where the text cursor and focus sit in the view.', options: ['0', '1'], optionLabels: { '0': 'Centred', '1': 'Within the edges' }, def: '0' }, ] }, ] }, @@ -126,8 +129,8 @@ export const groups = [ desc: 'Warmth and brightness of the screen.', cards: [ { caption: 'Screen light', rows: [ - { key: 'colorWarmPct', type: 'slider', label: 'Warmth', desc: 'Makes the screen warmer.', min: 0, max: 100, step: 5, def: 0, unit: '%' }, - { key: 'colorDimPct', type: 'slider', label: 'Brightness', desc: 'Dims the whole screen.', min: 1, max: 100, step: 1, def: 100, unit: '%' }, + { key: 'colorWarmPct', type: 'slider', label: 'Warmth', keywords: ['night light', 'blue light', 'yellow', 'orange', 'warm', 'colour temperature', 'color temperature', 'temperature', 'eye strain', 'tint', 'filter', 'evening', 'sleep', 'amber', 'red', 'colour', 'color', 'reduce blue', 'kelvin'], desc: 'Makes the screen warmer.', min: 0, max: 100, step: 5, def: 0, unit: '%' }, + { key: 'colorDimPct', type: 'slider', label: 'Brightness', keywords: ['dim', 'dark', 'darker', 'dimmer', 'light', 'luminance', 'backlight', 'night', 'screen', 'intensity', 'contrast', 'colour', 'color', 'lower', 'reduce'], desc: 'Dims the whole screen.', min: 1, max: 100, step: 1, def: 100, unit: '%' }, ] }, ] }, @@ -136,16 +139,16 @@ export const groups = [ cards: [ { caption: 'General', rows: [ // Global UI-only key (uiPalette): the built-in theme, shared by Settings and the tray menu. One row of mini window cards, right-aligned like the other controls. - { key: 'uiPalette', type: 'palette', label: 'Theme', desc: 'Set your preferred look.', def: 'grey' }, - { key: '__profiles', type: 'profiles', label: 'Profile', desc: 'A saved set of all settings.' }, + { key: 'uiPalette', type: 'palette', label: 'Theme', keywords: ['palette', 'colour', 'color', 'colours', 'colors', 'appearance', 'look', 'dark', 'grey', 'gray', 'ember', 'ocean', 'high contrast', 'hicon', 'skin', 'style', 'accent', 'ui', 'interface'], desc: 'Set your preferred look.', def: 'grey' }, + { key: '__profiles', type: 'profiles', label: 'Profile', keywords: ['preset', 'config', 'configuration', 'save', 'saved', 'profiles', 'switch', 'load', 'new', 'create', 'delete', 'rename', 'duplicate', 'set of settings', 'game profile', 'scheme', 'layout'], desc: 'A saved set of all settings.' }, // Global UI-only key: shows the advanced rows of every page. - { key: 'showAdvanced', type: 'toggle', label: 'Show advanced settings', desc: 'Shows extra options on every page.', def: 0 }, + { key: 'showAdvanced', type: 'toggle', label: 'Show advanced settings', keywords: ['expert', 'extra', 'more', 'hidden', 'options', 'all settings', 'power user', 'developer', 'reveal', 'show all', 'additional'], desc: 'Shows extra options on every page.', def: 0 }, ] }, { caption: 'Troubleshooting', rows: [ // Always visible: these are not advanced settings. - { key: 'diagnostics', type: 'toggle', label: 'Frame time logging', desc: 'Writes frame times to a log file.', def: 0 }, - { key: '__diagnostics', type: 'button', label: 'Export diagnostics', desc: 'Saves a report to share when asking for help.', btn: 'Export', action: 'exportDiagnostics' }, - { key: '__openIni', type: 'button', label: 'Open settings file', desc: 'Opens the settings file in a text editor.', btn: 'Open', action: 'openIni' }, + { key: 'diagnostics', type: 'toggle', label: 'Frame time logging', keywords: ['log', 'logging', 'fps', 'performance', 'diagnostics', 'debug', 'stutter', 'profiling', 'telemetry', 'trace', 'measure', 'benchmark', 'file'], desc: 'Writes frame times to a log file.', def: 0 }, + { key: '__diagnostics', type: 'button', label: 'Export diagnostics', keywords: ['report', 'support', 'help', 'log', 'logs', 'zip', 'bug', 'troubleshoot', 'problem', 'debug', 'crash', 'save report', 'share'], desc: 'Saves a report to share when asking for help.', btn: 'Export', action: 'exportDiagnostics' }, + { key: '__openIni', type: 'button', label: 'Open settings file', keywords: ['ini', 'magnifier.ini', 'edit', 'config', 'text editor', 'notepad', 'manual', 'raw', 'configuration', 'file'], desc: 'Opens the settings file in a text editor.', btn: 'Open', action: 'openIni' }, ] }, ] }, @@ -155,7 +158,7 @@ export const groups = [ desc: 'Choose what the tray menu shows, and in what order.', cards: [ { caption: '', rows: [ - { key: 'trayPerf', type: 'toggle', label: 'Performance in the tray', desc: 'Zoom, fps and a frame-time graph.', def: 1 }, + { key: 'trayPerf', type: 'toggle', label: 'Performance in the tray', keywords: ['tray', 'menu', 'fps', 'frame time', 'graph', 'stats', 'statistics', 'zoom level', 'monitor', 'flyout', 'notification area', 'system tray', 'show', 'hide'], desc: 'Zoom, fps and a frame-time graph.', def: 1 }, ] }, ] }, diff --git a/ui/tests/search.spec.js b/ui/tests/search.spec.js index 9699149..6361a54 100644 --- a/ui/tests/search.spec.js +++ b/ui/tests/search.spec.js @@ -1,19 +1,24 @@ +// Settings search (#317): the pure ranker/fuzzy matcher, then the results page against the mock bridge +// (ranked list, the real controls edited in place, advanced rows, tray lists never indexed). import { test, expect } from '@playwright/test'; import { readFileSync } from 'node:fs'; -import { groups } from '../src/settings-schema.js'; -import { search } from '../src/search/search.js'; +import { groups, allRows } from '../src/settings-schema.js'; +import { search, editDistance, words } from '../src/search/search.js'; test.beforeEach(async ({ page }) => { await page.addInitScript(() => { window.__skipSplash = true; + window.__msgs = []; const listeners = new Set(); const send = (data) => listeners.forEach((fn) => fn({ data })); - const live = { maxLevel: '12', model: 'hybrid', uiTheme: 'dark' }; + window.__live = { maxLevel: '12', model: 'hybrid', uiTheme: 'dark' }; window.chrome = { webview: { addEventListener: (_e, fn) => listeners.add(fn), postMessage: (msg) => { - if (msg.type === 'getConfig') send({ type: 'config', values: { ...live }, saved: { ...live }, + window.__msgs.push(msg); + if (msg.type === 'getConfig') send({ type: 'config', values: { ...window.__live }, saved: { ...window.__live }, profiles: { names: ['Default'], active: 'Default' } }); + else if (msg.type === 'setConfig') window.__live[msg.key] = msg.value; else if (msg.type === 'mpoState') send({ type: 'mpoState', disabled: false, bootKnown: true, atBoot: false }); }, } }; @@ -21,97 +26,271 @@ test.beforeEach(async ({ page }) => { }); const input = (page) => page.getByLabel('Search settings'); +const keys = (q) => search(groups, q).map((h) => h.key); +const rank = (q, key) => keys(q).indexOf(key); -test('search is case-insensitive, word-prefix and covers label, description, caption and group', () => { +// ---- the pure matcher ------------------------------------------------------------------------- + +test('words() ignores case, accents and punctuation; editDistance counts swaps as one', () => { + expect(words('Zoom-in SPEED!')).toEqual(['zoom', 'in', 'speed']); + expect(words('Café')).toEqual(['cafe']); + expect(editDistance('centre', 'center')).toBe(1); + expect(editDistance('abc', 'abc')).toBe(0); + expect(editDistance('warmth', 'brightness', 2)).toBeGreaterThan(2); +}); + +test('empty and unmatched queries return nothing', () => { expect(search(groups, '')).toEqual([]); - const keys = (q) => search(groups, q).flatMap((g) => g.rows.map((r) => r.key)); - expect(keys('MAX ZO')).toContain('maxLevel'); - expect(keys('zoom-in speed')).toContain('zoomInSpeed'); + expect(search(groups, ' ,, ')).toEqual([]); expect(keys('xyzzy')).toEqual([]); - // Mid-word fragments do not match (word-prefix only). - expect(keys('axzoom')).toEqual([]); - // Group label and card caption reach their rows. - expect(search(groups, 'preferences').map((g) => g.id)).toContain('prefs'); - expect(keys('typing')).toContain('trackCaret'); - // The single pan row is found by both words (#303). - expect(keys('pan')).toContain('__pan'); - expect(keys('arrow')).toContain('__pan'); - // Advanced rows are searchable (hidden-by-showIf ones too) and say so: the page shows a hit on its own. - const engine = search(groups, 'engine').flatMap((g) => g.rows); - expect(engine.find((r) => r.key === 'model').adv).toBe(true); - expect(engine.find((r) => r.key === 'engineGame').adv).toBe(true); - expect(search(groups, 'maximum').flatMap((g) => g.rows).every((r) => !r.adv)).toBe(true); + expect(keys('zoom xyzzy')).toEqual([]); // every word must match +}); + +test('stopwords never outrank content words and fuzzy skips free text', () => { + const zi = search(groups, 'zoom in').map((h) => h.label); + expect(zi[0]).toBe('Zoom in'); + expect(zi.indexOf('Zoom out')).toBeGreaterThan(-1); + expect(zi.indexOf('Zoom out')).toBeLessThan(zi.indexOf('Performance in the tray')); + expect(search(groups, 'tray').map((h) => h.label)).toEqual(['Performance in the tray']); + expect(search(groups, 'theme').map((h) => h.label)).toEqual(['Theme']); +}); + +test('results are one flat list ranked best first, with the tab name on each hit', () => { + const hits = search(groups, 'zoom'); + expect(hits.length).toBeGreaterThan(5); + for (let i = 1; i < hits.length; i++) expect(hits[i - 1].score).toBeGreaterThanOrEqual(hits[i].score); + expect(hits[0].groupLabel).toBeTruthy(); + expect(hits[0].row.key).toBe(hits[0].key); + // A label hit beats a description-only or tab-name-only hit. + expect(rank('zoom', 'maxLevel')).toBeLessThan(rank('zoom', 'trackCaret') === -1 ? 999 : rank('zoom', 'trackCaret')); +}); + +test('ranking tiers: exact label > label prefix > label substring > keyword > description > caption', () => { + // Exact whole label first. + expect(keys('engine')[0]).toBe('model'); + expect(keys('warmth')[0]).toBe('colorWarmPct'); + expect(keys('zoom in')[0]).toBe('__zoomIn'); + // Label word-prefix beats a keyword-only hit ("glide" is a label word, "coast" only a keyword). + expect(keys('glide')).toContain('zoomEaseOutMs'); + const labelPrefix = search(groups, 'brigh')[0]; + expect(labelPrefix.key).toBe('colorDimPct'); + // Label hit beats keyword hit: "speed" is in several labels, "velocity" only a keyword of them. + expect(rank('speed', 'zoomInSpeed')).toBeLessThan(rank('speed', 'zoomEaseOutMs') === -1 ? 999 : rank('speed', 'zoomEaseOutMs')); + // Keyword beats description: "caret" is a keyword of the text cursor rows. + const caret = keys('caret'); + expect(caret.slice(0, 2).sort()).toEqual(['trackAlign', 'trackCaret']); + // Description-only hit ranks below a label hit: "pointer" is a label word of Pointer position and + // High resolution cursor's description. + expect(rank('pointer', 'mouseAlign')).toBeLessThan(rank('pointer', 'txSamplingMode')); + // Card caption and tab name still find rows, last. + expect(keys('troubleshooting')).toEqual(expect.arrayContaining(['diagnostics', '__diagnostics', '__openIni'])); + expect(keys('preferences')).toContain('uiPalette'); +}); + +test('multi-word queries: every word matches something, scores add up', () => { + expect(keys('mouse speed')[0]).toBe('cursorSensitivity'); + expect(keys('speed mouse')[0]).toBe('cursorSensitivity'); + expect(keys('soft start length')[0]).toBe('smoothZoomRamp'); + expect(keys('zoom wheel')).toEqual(expect.arrayContaining(['__zoomIn', '__zoomOut'])); +}); + +test('typo tolerance: distance 1 for 4-7 letters, 2 for 8+, prefixes, substrings, phonetic spellings', () => { + expect(keys('warmht')).toContain('colorWarmPct'); // transposition, 6 letters + expect(keys('brigthness')[0]).toBe('colorDimPct'); // 10 letters + expect(keys('brihtness')[0]).toBe('colorDimPct'); // dropped letter + expect(keys('inertai')).toEqual(expect.arrayContaining(['zoomEaseOutMs'])); + expect(keys('smothing')).toContain('cursorSmoothing'); + expect(keys('sensitivty')).toContain('cursorSensitivity'); // keyword, 10 letters, one missing + expect(keys('nite light')).toEqual(expect.arrayContaining(['colorWarmPct'])); + expect(keys('nite light')[0]).toBe('colorWarmPct'); + expect(keys('mouze')).toContain('cursorSensitivity'); + expect(keys('MAX ZO')).toContain('maxLevel'); // case-insensitive prefix + expect(keys('zoomin')).toContain('__zoomIn'); // joined form of "Zoom-in" + expect(keys('xom')).toEqual([]); // 3 letters: no typo budget +}); + +test('meta keywords reach rows without their label words, incl. the centred-cursor example', () => { + for (const q of ['mouse position', 'center', 'centre', 'centered', 'centred', 'edge', 'edges']) { + expect(keys(q)).toEqual(expect.arrayContaining(['mouseAlign', 'trackAlign'])); + } + expect(keys('mouse position').slice(0, 2).sort()).toEqual(['mouseAlign', 'trackAlign']); + expect(keys('magnify')).toEqual(expect.arrayContaining(['__zoomIn'])); + expect(keys('hotkey')).toEqual(expect.arrayContaining(['__zoomIn', '__zoomOut'])); + expect(keys('night light')[0]).toBe('colorWarmPct'); + expect(keys('blue light')[0]).toBe('colorWarmPct'); + expect(keys('dim')).toContain('colorDimPct'); + expect(keys('renderer')).toEqual(expect.arrayContaining(['model', 'engineGame'])); + expect(keys('freeze')).toContain('__cursorLock'); + expect(keys('crosshair')).toContain('__cursorLock'); + expect(keys('arrows')).toContain('__pan'); + expect(keys('acceleration')).toContain('smoothZoomAccel'); + expect(keys('momentum')).toEqual(expect.arrayContaining(['zoomEaseOutMs'])); + expect(keys('preset')).toContain('__profiles'); +}); + +test('every searchable row has meta keywords, and no keyword is empty or has an em-dash', () => { + const rows = allRows.filter((r) => r.label); + expect(rows.length).toBeGreaterThan(30); + for (const r of rows) { + expect(Array.isArray(r.keywords) && r.keywords.length >= 3, r.key + ' needs keywords').toBe(true); + for (const k of r.keywords) expect(words(k).length, r.key + ' keyword ' + k).toBeGreaterThan(0); + } +}); + +test('advanced rows are searchable and say so; removed and hero rows are not indexed', () => { + const engine = search(groups, 'engine'); + expect(engine.find((h) => h.key === 'model').adv).toBe(true); + expect(engine.find((h) => h.key === 'engineGame').adv).toBe(true); + expect(search(groups, 'maximum').every((h) => !h.adv)).toBe(true); expect(keys('advanced')).toEqual(expect.arrayContaining(['model', 'noSwallowApps', 'lockApps', 'smoothZoomAccel'])); expect(keys('advanced')).not.toContain('maxLevel'); - // The removed row is not in the index. expect(keys('render')).not.toContain('renderExclude'); + expect(keys('logo')).not.toContain('__about'); +}); + +test('Tray menu: only its normal switch row is indexed, never the item lists', () => { + expect(keys('performance')).toContain('trayPerf'); + const trayRows = groups.find((g) => g.id === 'tray').cards.flatMap((c) => c.rows); + expect(trayRows.map((r) => r.key)).toEqual(['trayPerf']); + // The lists are drawn by the custom page, so no key of theirs is a schema row. + for (const k of ['trayTools', 'traySliders', 'trayToggles', 'trayOrder', 'keepEdges']) expect(allRows.map((r) => r.key)).not.toContain(k); + for (const q of ['slider', 'toggle', 'quick', 'segment', 'drag', 'reorder', 'keep cursor centred']) + expect(keys(q).filter((k) => k.startsWith('tray') && k !== 'trayPerf')).toEqual([]); }); test('search files contain no em-dash', () => { - for (const f of ['../src/search/search.js', '../src/search/Results.svelte']) + for (const f of ['../src/search/search.js', '../src/search/Results.svelte', '../src/settings-schema.js']) expect(readFileSync(new URL(f, import.meta.url), 'utf8')).not.toContain(String.fromCharCode(0x2014)); }); -test('typing shows grouped results, Enter jumps to the first, Esc clears', async ({ page }) => { +// ---- the results page -------------------------------------------------------------------------- + +test('typing shows one ranked list of real rows with a quiet tab name each', async ({ page }) => { await page.goto('/'); await expect(page.locator('h1')).toHaveText('Hotkeys'); - await input(page).fill('cursor'); - await expect(page.locator('.res')).toBeVisible(); - await expect(page.locator('.res .gl').first()).toBeVisible(); - const first = await page.locator('.res .hit').first().getAttribute('data-hit'); - await input(page).press('Enter'); - await expect(page.locator('.res')).toHaveCount(0); - await expect(page.locator('[data-key="' + first + '"]')).toBeVisible(); - await input(page).fill('speed'); + await input(page).fill('zoom'); await expect(page.locator('.res')).toBeVisible(); - await input(page).press('Escape'); - await expect(page.locator('.res')).toHaveCount(0); - await expect(input(page)).toHaveValue(''); - await expect(page.locator('h1')).toBeVisible(); + await expect(page.locator('.res .gl')).toHaveCount(0); // no per-tab grouping + const shown = await page.locator('.res .hit').evaluateAll((els) => els.map((e) => e.getAttribute('data-hit'))); + expect(shown).toEqual(keys('zoom')); // the page order IS the rank + await expect(page.locator('.res .hit[data-hit="maxLevel"] .tab')).toHaveText('Zoom'); + await expect(page.locator('.res .hit[data-hit="__zoomIn"] .tab')).toHaveText('Hotkeys'); + // Real controls: a slider, a keybind box. + await expect(page.locator('.res .hit[data-hit="maxLevel"] input[type=range]')).toBeVisible(); + await expect(page.locator('.res .hit[data-hit="__zoomIn"] .bx')).toBeVisible(); }); -test('Ctrl+F focuses the search box, which shows a visible focus ring', async ({ page }) => { +test('a result control changes the setting in place, no navigation, the live ini follows', async ({ page }) => { await page.goto('/'); - await expect(page.locator('h1')).toHaveText('Hotkeys'); - await page.locator('main').click({ position: { x: 5, y: 5 } }); - await page.keyboard.press('Control+f'); - await expect(input(page)).toBeFocused(); - await expect(page.locator('.search')).toHaveCSS('border-top-color', 'rgb(242, 242, 242)'); // after its short fade + await input(page).fill('keyboard focus'); + const sw = page.locator('.res .hit[data-hit="trackFocus"]').getByRole('switch'); + await expect(sw).not.toBeChecked(); + await sw.click({ force: true }); + await expect(sw).toBeChecked(); + expect(await page.evaluate(() => window.__live.trackFocus)).toBe('1'); + await expect(page.locator('.res')).toBeVisible(); // still on the results + await expect(page.locator('.side .it.sel')).toHaveCount(0); + await expect(page.locator('.capsule')).toBeVisible(); // the session shows it as unsaved, like on its page + // The page shows the same value. + await input(page).press('Escape'); + await page.locator('.side .it[data-g="view"]').click(); + await expect(page.locator('[data-key="trackFocus"]').getByRole('switch')).toBeChecked(); }); -test('keyboard only: Tab reaches a result and Enter opens it, hidden advanced rows reachable', async ({ page }) => { +test('a select and a slider edit in place too', async ({ page }) => { await page.goto('/'); - await input(page).fill('engine'); - await expect(page.locator('.res .gl', { hasText: 'Zoom' })).toBeVisible(); - await page.locator('.res .hit[data-hit="model"]').focus(); - await expect(page.locator('.res .hit[data-hit="model"]')).toBeFocused(); - await page.keyboard.press('Enter'); - await expect(page.locator('.res')).toHaveCount(0); - await expect(page.locator('h1')).toHaveText('Zoom'); - await expect(page.locator('[data-key="model"]')).toBeVisible(); + await input(page).fill('mouse position'); + const row = page.locator('.res .hit[data-hit="mouseAlign"]'); + await expect(row).toBeVisible(); + await row.locator('select').selectOption('1'); + expect(await page.evaluate(() => window.__live.mouseAlign)).toBe('1'); + await input(page).fill('max zoom'); + const sl = page.locator('.res .hit[data-hit="maxLevel"] input[type=range]'); + await sl.focus(); + await page.keyboard.press('ArrowRight'); + expect(await page.evaluate(() => window.__live.maxLevel)).toBe('13'); + await expect(page.locator('.res')).toBeVisible(); }); -test('a hidden advanced row is found, shown on its own, and turns nothing on', async ({ page }) => { +test('advanced rows match and are editable from search while Show advanced settings is off', async ({ page }) => { await page.goto('/'); await page.locator('.side .it[data-g="zoom"]').click(); await expect(page.locator('[data-key="smoothZoomAccel"]')).toHaveCount(0); // advanced is off await input(page).fill('soft start'); - await expect(page.locator('.res .hit[data-hit="smoothZoomAccel"]')).toBeVisible(); + const accel = page.locator('.res .hit[data-hit="smoothZoomAccel"]'); + await expect(accel).toBeVisible(); await expect(page.locator('.res .hit[data-hit="smoothZoomRamp"]')).toBeVisible(); - await page.locator('.res .hit[data-hit="smoothZoomAccel"]').click(); - await expect(page.locator('h1')).toHaveText('Zoom'); - await expect(page.locator('[data-key="smoothZoomAccel"]')).toBeVisible(); // just that row - await expect(page.locator('[data-key="smoothZoomRamp"]')).toHaveCount(0); - await expect(page.locator('[data-key="model"]')).toHaveCount(0); - // The switch stayed off, and leaving the page drops the row again. + const sl = accel.locator('input[type=range]'); + await sl.focus(); + await page.keyboard.press('ArrowRight'); + expect(await page.evaluate(() => window.__live.smoothZoomAccel)).toBe('3.5'); + // The switch stayed off and the page still hides the row. await page.locator('.side .it[data-g="prefs"]').click(); await expect(page.locator('[data-key="showAdvanced"]').getByRole('switch')).not.toBeChecked(); await page.locator('.side .it[data-g="zoom"]').click(); await expect(page.locator('[data-key="smoothZoomAccel"]')).toHaveCount(0); + // The engine row (advanced) is editable from search as well. + await input(page).fill('engine'); + await expect(page.locator('.res .hit[data-hit="model"]')).toBeVisible(); +}); + +test('a row gated by another setting shows dimmed, not hidden', async ({ page }) => { + await page.goto('/'); + await input(page).fill('edge margin'); + const row = page.locator('.res .hit[data-hit="mouseMarginPct"] .row'); + await expect(row).toBeVisible(); + await expect(row).toHaveClass(/disabled/); // mouseAlign is Centred +}); + +test('tray item lists never appear in results; the Performance switch does', async ({ page }) => { + await page.goto('/'); + await page.locator('.side .it[data-g="tray"]').click(); + await expect(page.locator('main')).toContainText('Performance in the tray'); + await input(page).fill('tray'); + await expect(page.locator('.res .hit[data-hit="trayPerf"]')).toBeVisible(); + await expect(page.locator('.res .hit').filter({ has: page.locator('.drag, [draggable=true]') })).toHaveCount(0); + const ids = await page.locator('.res .hit').evaluateAll((els) => els.map((e) => e.getAttribute('data-hit'))); + expect(ids.filter((k) => k.startsWith('tray') && k !== 'trayPerf')).toEqual([]); + await input(page).fill('slider'); + await expect(page.locator('.res [data-hit^="tray"]:not([data-hit="trayPerf"])')).toHaveCount(0); +}); + +test('Esc clears, Ctrl+F focuses with a ring, Enter moves into the first result, Tab walks the controls', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('h1')).toHaveText('Hotkeys'); + await page.locator('main').click({ position: { x: 5, y: 5 } }); + await page.keyboard.press('Control+f'); + await expect(input(page)).toBeFocused(); + await expect(page.locator('.search')).toHaveCSS('border-top-color', 'rgb(242, 242, 242)'); // after its short fade + await input(page).fill('speed'); + await expect(page.locator('.res')).toBeVisible(); + await input(page).press('Enter'); + const firstKey = keys('speed')[0]; + await expect(page.locator('.res .hit[data-hit="' + firstKey + '"] input').first()).toBeFocused(); + await page.keyboard.press('Tab'); + const secondKey = keys('speed')[1]; + await expect(page.locator('.res .hit[data-hit="' + secondKey + '"] input').first()).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page.locator('.res')).toHaveCount(0); + await expect(input(page)).toHaveValue(''); + await expect(page.locator('h1')).toBeVisible(); +}); + +test('mouse clicks on a result show no focus ring; keyboard focus does', async ({ page }) => { + await page.goto('/'); + await input(page).fill('keyboard focus'); + const sw = page.locator('.res .hit[data-hit="trackFocus"] input[role=switch]'); + await sw.click({ force: true }); + const outline = (loc) => loc.evaluate((e) => getComputedStyle(e.nextElementSibling).outlineStyle); + expect(await outline(sw)).toBe('none'); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('Tab'); + expect(await outline(sw)).not.toBe('none'); }); test('no matches says so', async ({ page }) => { await page.goto('/'); await input(page).fill('qqqq'); await expect(page.locator('.res .sum')).toContainText('No settings match'); + await expect(page.locator('.res .hit')).toHaveCount(0); }); diff --git a/ui/tests/tray.spec.js b/ui/tests/tray.spec.js index 30f3b19..45e4da2 100644 --- a/ui/tests/tray.spec.js +++ b/ui/tests/tray.spec.js @@ -236,11 +236,11 @@ test('the saved lists load back from the ini', async ({ page }) => { await expect(chk(page, 'toggles', 'Keep cursor centred')).toHaveAttribute('aria-checked', 'true'); }); -test('search finds the Performance row and jumps to the tray tab', async ({ page }) => { +test('search finds the Performance row and edits it in the results (#317)', async ({ page }) => { await page.goto('/'); await page.locator('.side .search input').fill('performance'); - await page.locator('.side .search input').press('Enter'); - await expect(page.locator('h1')).toHaveText('Tray menu'); + await expect(page.locator('.res .hit[data-hit="trayPerf"]')).toBeVisible(); + await expect(page.locator('.res .hit[data-hit="trayPerf"] .tab')).toHaveText('Tray menu'); }); test('engine: the Engine item is on by default (#329), has an icon and a description, and writes trayToggles', async ({ page }) => {