From 4f3825b3e02d1dff97be6a56495eea7698a4fd02 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:22:19 +0200 Subject: [PATCH 1/2] feat(ui): ranked fuzzy Settings search with in-place editing (#317) Search now ranks every row (label, keywords, description, caption/tab), tolerates typos, and shows the real controls in the results so settings change without leaving the search. Every labelled schema row gets meta keywords. Tray item lists stay out of search. Version 0.21.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- docs/architecture/09-settings-ui.md | 10 + src/version.h | 6 +- ui/src/Settings.svelte | 29 ++- ui/src/controls/Preview.svelte | 2 +- ui/src/controls/SettingRow.svelte | 7 +- ui/src/search/Results.svelte | 38 ++-- ui/src/search/search.js | 134 +++++++++++-- ui/src/settings-schema.js | 75 ++++---- ui/tests/search.spec.js | 288 ++++++++++++++++++++++------ ui/tests/tray.spec.js | 6 +- 10 files changed, 439 insertions(+), 156 deletions(-) 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..099df05 100644 --- a/ui/src/search/search.js +++ b/ui/src/search/search.js @@ -1,26 +1,134 @@ -// 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').replace(/ck/g, 'k'); + +// 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) { + let best = 0; + const b = budget(q), 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]) { // 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; +} + +// Score one row for the query words (0 = no match). +export function scoreRow(f, q) { + let total = 0; + 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); + best = Math.max(best, wordStrength(w, f.desc) * W.desc); + best = Math.max(best, wordStrength(w, f.cap) * 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 = q.join(' '); + if (f.labelNorm === phrase) total += 400; + else if (f.labelNorm.startsWith(phrase)) total += 60; + else if (q.length > 1 && f.labelNorm.includes(phrase)) total += 30; + if (q.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..23d9b4e 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,262 @@ 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('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 }) => { From 00b565e93baa35e43eaab61e0355f08856f9d48f Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:27:40 +0200 Subject: [PATCH 2/2] fix(search): ignore stopwords and drop fuzzy matches in free text Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- ui/src/search/search.js | 29 +++++++++++++++++++---------- ui/tests/search.spec.js | 9 +++++++++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/ui/src/search/search.js b/ui/src/search/search.js index 099df05..46ba83d 100644 --- a/ui/src/search/search.js +++ b/ui/src/search/search.js @@ -41,19 +41,19 @@ export function editDistance(a, b, max = 2) { // 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').replace(/ck/g, 'k'); +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) { +export function wordStrength(q, targets, fuzzy = true) { let best = 0; - const b = budget(q), fq = fold(q); + 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]) { // fuzzy needs the same first letter: far fewer false hits + 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; @@ -85,9 +85,16 @@ function fieldsOf(row, card, group) { 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, q) { +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; @@ -95,8 +102,10 @@ export function scoreRow(f, q) { 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); - best = Math.max(best, wordStrength(w, f.desc) * W.desc); - best = Math.max(best, wordStrength(w, f.cap) * W.cap); + // 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; @@ -105,11 +114,11 @@ export function scoreRow(f, q) { // 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 = q.join(' '); + const phrase = all.join(' '); if (f.labelNorm === phrase) total += 400; else if (f.labelNorm.startsWith(phrase)) total += 60; - else if (q.length > 1 && f.labelNorm.includes(phrase)) total += 30; - if (q.length > 1 && f.kwPhrases.some((k) => k.includes(phrase))) total += 25; + 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; } diff --git a/ui/tests/search.spec.js b/ui/tests/search.spec.js index 23d9b4e..6361a54 100644 --- a/ui/tests/search.spec.js +++ b/ui/tests/search.spec.js @@ -46,6 +46,15 @@ test('empty and unmatched queries return nothing', () => { 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);