Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/architecture/09-settings-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions src/version.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"
29 changes: 12 additions & 17 deletions ui/src/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -233,24 +232,21 @@
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;
if (mod && e.key.toLowerCase() === 'f') { e.preventDefault(); const i = searchInput(); if (i) { i.focus(); i.select(); } return; }
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(); }
}

Expand All @@ -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,
Expand All @@ -291,12 +286,12 @@
<TitleBar {maximized} onMinimize={() => windowControl('minimize')} onMaximize={() => windowControl('maximize')} onClose={requestClose} />
<div class="body">
<Sidebar groups={top} {bottom} active={searching ? '' : activeId} version={VERSION} {query} {onSearch}
onSelect={(id) => { revealKey = ''; clearSearch(); select(id); }} />
onSelect={(id) => { clearSearch(); select(id); }} />
<main class="main" data-page={searching ? 'search' : group.id} bind:this={main} tabindex="-1" aria-label={group.label}>
{#key searching ? '?search' : activeId}
<div class="page" class:fade={navigated}>
{#if searching}
<Results {results} {query} onJump={jump} />
<Results {results} {query} {values} {extra} {live} onChange={change} onSet={change} {isDisabled} />
{:else}
<Banner title={group.label} description={group.desc} icon={group.icon} />
{#each group.cards as card, i (group.id + i)}
Expand Down
2 changes: 1 addition & 1 deletion ui/src/controls/Preview.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 5 additions & 2 deletions ui/src/controls/SettingRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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, ''));
Expand All @@ -35,7 +36,7 @@
{:else}
<div class="row" class:disabled class:wide={row.wide} data-key={row.key}>
<div class="meta">
{#if row.label}<div class="label" id={labelId}>{row.label}</div>{/if}
{#if row.label}<div class="lrow"><div class="label" id={labelId}>{row.label}</div>{#if tab}<span class="tab">{tab}</span>{/if}</div>{/if}
{#if row.desc}<div class="desc" id={descId}>{row.desc}</div>{/if}
</div>
<div class="ctl">
Expand Down Expand Up @@ -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; }
Expand Down
38 changes: 16 additions & 22 deletions ui/src/search/Results.svelte
Original file line number Diff line number Diff line change
@@ -1,37 +1,31 @@
<script>
// Search results, grouped by settings group. Each result is a button that jumps to its row.
let { results = [], query = '', onJump = () => {} } = $props();
const total = $derived(results.reduce((n, g) => n + g.rows.length, 0));
// Search results: ONE ranked list, best match first. Each result is the setting's real row (the same
// SettingRow, with the same value and handlers as on its page), so it is edited right here and
// clicking it goes nowhere. The tab name sits quietly beside the label.
import SettingRow from '../controls/SettingRow.svelte';
let { results = [], query = '', values = {}, extra = {}, live = () => {}, onChange = () => {}, onSet = () => {},
isDisabled = () => false } = $props();
</script>

<div class="res" role="region" aria-label="Search results">
<h1>Search</h1>
<p class="sum">{total === 0 ? 'No settings match "' + query + '".' : total + (total === 1 ? ' setting' : ' settings') + ' match "' + query + '". Press Enter to open the first, Esc to clear.'}</p>
{#each results as g (g.id)}
<div class="gl">{g.label}</div>
<p class="sum">{results.length === 0 ? 'No settings match "' + query + '".' : results.length + (results.length === 1 ? ' setting' : ' settings') + ' match "' + query + '", best first. Change them here, Esc clears.'}</p>
{#if results.length}
<div class="list">
{#each g.rows as r (r.key)}
<button type="button" class="hit" data-hit={r.key} onclick={() => onJump(r)}>
<span class="t">{r.label}</span>
{#if r.caption}<span class="c">{r.caption}</span>{/if}
{#if r.desc}<span class="d">{r.desc}</span>{/if}
</button>
{#each results as r (r.key)}
<div class="hit" data-hit={r.key}>
<SettingRow row={r.row} value={values[r.key]} {values} {extra} {live} tab={r.groupLabel}
disabled={isDisabled(r.row)} onChange={(v) => onChange(r.key, v)} onSet={onSet} />
</div>
{/each}
</div>
{/each}
{/if}
</div>

<style>
.res { padding-top: 28px; }
h1 { margin: 0 0 6px; font: 600 24px var(--s); color: var(--fg); }
.sum { margin: 0 0 6px; color: var(--fg3); font: 13px var(--s); }
.gl { margin: 22px 0 10px 2px; font: 500 13px var(--s); letter-spacing: .02em; color: var(--fg3); }
.list { border: 1px solid var(--cardb); background: var(--card); border-radius: var(--rc); box-shadow: var(--cshadow); overflow: hidden; }
.hit { display: grid; grid-template-columns: 1fr auto; gap: 2px 12px; width: 100%; text-align: left;
padding: 12px 16px; color: var(--fg); }
.sum { margin: 0 0 18px; color: var(--fg3); font: 13px var(--s); }
.list { border: 1px solid var(--cardb); background: var(--card); border-radius: var(--rc); box-shadow: var(--cshadow); }
.hit + .hit { border-top: 1px solid var(--rowline); }
.hit:hover { background: var(--hover); }
.t { font: 500 13.5px var(--s); }
.c { font: 11px var(--m); color: var(--fg3); align-self: center; }
.d { grid-column: 1 / -1; font: 12px var(--s); color: var(--fg3); }
</style>
143 changes: 130 additions & 13 deletions ui/src/search/search.js
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading