GUI: add a search box to the Settings page
I maintainer di solito rispondono entro 1 giorno
Valutazione
- Difficoltà
- 3/5
- Tempo stimato
- Mezza giornata
- Idoneità per principianti
- 82/100
- Tipo di issue
- Funzionalità
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Attiva
- Stack tecnologico
- react, typescript
- Ambito
- frontend
Direzione di ricerca
Start in gui/src/settingsModel.ts, where sections groups the rows, and add an optional query parameter that matches each row's heading, name and description. Then add a hookless search input in gui/src/Settings.tsx that copies the Escape handling in Field. Tests go in gui/src/settingsModel.test.ts and gui/src/settings.test.tsx. Done when typing filters the groups, an empty result shows a message, and the Updates group follows the same rule.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
What
The GUI's Settings page (in the desktop app and in the browser) lists about two dozen rows in up to six groups: Playback, Library, Sources, Streaming, Integrations and Updates. You see fewer without a Spotify login (no Streaming rows) or outside the desktop app (no Updates group). To find a row, you have to scroll and read each one. You can't type "seek" and jump to it.
To see it today:
- Open the GUI (see "Done when" for a way that needs no Spotify account).
- Press Ctrl+, (Cmd+, on macOS), or click the gear button in the top bar.
- Look for, say, the Seek Duration setting. You have to read your way down to it.
What should happen: a search box sits under the "Settings" title. As you type, the page keeps only the rows whose section name, setting name or description holds every word you typed, ignoring case. A group with no matching row hides, heading and all. If nothing matches, the page says so. Escape empties the box, and a second Escape leaves it, the same way the text fields on this page already work.
Where to look
gui/src/settingsModel.ts,sections(lines 25-34). This turns the flat row list into the groups you see:
It already drops a group with no rows. The one exception is Updates: it shows whenever there is an updater, even with no rows, because it also holds the updater's status line and button.export function sections(rows: SettingRow[], updater: boolean): Section[] { return GROUPS.map(({ group, heading }) => ({ group, heading, rows: rows.filter((row) => row.group === group), })).filter((section) => section.group === "updates" ? updater : section.rows.length > 0, ); }GROUPS(lines 10-17) has the headings.gui/src/bindings/SettingRow.ts: each row hasid,group,name,descriptionandvalue. The label you see on the page isname(there is nolabelfield). This file is generated from Rust, so don't edit it.gui/src/Settings.tsx, theSettingscomponent (lines 16-59). The page root is<div className="settings" tabIndex={-1} data-focus>(line 30), then the<h1>(line 31), the "Loading…" line whilerowsis stillnull(line 32), and thesections(...)call (line 33). The box goes between the<h1>and the sections.gui/src/Settings.tsx,Field, itsonKeyDown(lines 194-203): the Escape pattern to copy. The first Escape throws the edit away and callspreventDefault(). The second one is left alone, so the page's own Escape takes the keyboard out of the field.- There's already a filter box you can model yours on: the playlist filter.
gui/src/sortModel.ts,filterTracks(lines 96-105) splits the query on whitespace and keeps the rows that hold every term:const terms = query.toLowerCase().split(/\s+/).filter(Boolean);gui/src/sortModel.test.ts,describe("filterTracks")(line 113) is how it's tested.gui/src/ListPage.tsxlines 240-251 is the<input type="search">, and line 270 is its empty message,No songs match “…”.gui/src/ListPage.csslines 52-81 is its look (var(--raised),var(--hover),var(--text)).gui/src/Search.tsxline 170,<p className="search-note" role="status">, is how a page note gets read out by a screen reader.
- Keyboard handling, so you know what you don't have to do:
gui/src/areas.ts,shellKey(lines 51-69) decides what a key does on the whole page. Ctrl+, opens Settings (lines 53-54) and Escape is the page's escape (line 56). Line 57 returnsnullfor every other unmodified key while you're typing in a text field. So Space,q,/and the digit keys already go into your box and won't play/pause or switch pages.gui/src/Shell.tsx(line numbers on main today; open PR #789 shifts Shell.tsx):- The window
keydownlistener, theuseLayoutEffectthat callsshellKey(describe(...))(line 428), and thedescribefunction below the component (line 614).handled: event.defaultPreventedindescribemeans that a key your input'sonKeyDownalready calledpreventDefault()on is ignored by the page. With nothing else to handle, Escape ends inblurText()(line 656), which takes focus out of the text field. focusScreen(line 640): when you open Settings, the page focuses the first[data-focus]element, which is the.settingsdiv.- The
hidden={kind !== page}wrappers inShell's return (line 530): every page stays mounted and is only hidden. So whatever is typed in your box is still there when you come back to Settings. That's on purpose here and matches how other pages keep their place. (The terminal's Settings screen clears its filter each time you open it. The GUI doesn't need to copy that.)
- The window
- Tests:
gui/src/settingsModel.test.ts,describe("sections")(lines 29-86). Itsrowhelper (lines 14-18) setsnameto the id anddescriptionto"", so for search tests you'll want rows with real names (see the sketch below).gui/src/settings.test.tsx: the imports (lines 3 and 8), therowsfixture (lines 11-40), thepagerender helper (lines 42-51), thedata-focuscheck (line 94), and the Loading test (lines 194-206).gui/src/testTree.ts,byName(lines 19-26): finds an element by itsaria-label, so a test can call itsonKeyDown. It calls components as plain functions, so it only works on components without hooks.gui/src/dialogs.test.tsxlines 7-14 shows the fake key event.
- Screenshots: the
settingsscene ingui/e2e/scenes.shot.ts(lines 344-350) opens the page against the fake bridge, with the rows fromsettingsingui/e2e/fixtures.ts(lines 848-947).
Possible solution
- The filter, in
settingsModel.ts. Givesectionsa third parameter,query = "", so the existing call and tests keep working unchanged. Match each row against its section heading, name and description, so typing a group name like "integrations" keeps that whole group. This is the playlist filter's plain substring match, not the terminal's fuzzy, scored one:/** The sections with rows that hold every word of `query`; Updates shows with an updater and no query. */ export function sections( rows: SettingRow[], updater: boolean, query = "", ): Section[] { const terms = query.toLowerCase().split(/\s+/).filter(Boolean); return GROUPS.map(({ group, heading }) => ({ group, heading, rows: rows.filter( (row) => row.group === group && matches(row, heading, terms), ), })).filter((section) => section.group === "updates" ? updater && (terms.length === 0 || section.rows.length > 0) : section.rows.length > 0, ); } function matches(row: SettingRow, heading: string, terms: string[]): boolean { const haystack = `${heading} ${row.name} ${row.description}`.toLowerCase(); return terms.every((term) => haystack.includes(term)); } - The box, in
Settings.tsx. Keep the query inSettingswithuseState("")and pass it tosections. Put the input in a small hookless component, so a test can reach its key handler withbyName:
Render it once/** The filter above the sections; Escape empties it, a second Escape leaves it. */ export function SettingsSearch({ query, onQuery, }: { query: string; onQuery: (query: string) => void; }) { return ( <input type="search" className="settings-search" aria-label="Search settings" placeholder="Search settings" autoComplete="off" spellCheck={false} value={query} onChange={(event) => onQuery(event.currentTarget.value)} onKeyDown={(event) => { if (event.nativeEvent.isComposing) return; if (event.key === "Escape" && query !== "") { event.preventDefault(); onQuery(""); } }} /> ); }rowsisn'tnull. When the query isn't blank and no section is left, show<p className="settings-empty" role="status">No settings match “{query.trim()}”.</p>, the same wording as the playlist filter. Therole="status"lets a screen reader say that nothing matched. - The look, in
Settings.css. Add a.settings-searchrule modelled on.list-filter(ListPage.css lines 52-81): same height, radius,var(--raised)background,var(--hover)on hover and avar(--text)border on focus, full width, with some space below it. The.settings > *rule (lines 9-11) already caps it at 800px. Use the existing color tokens only, no new hex colors. - A screenshot scene. In
gui/e2e/scenes.shot.ts, add it right after the closing});of thesettingsscene (line 350), beforelibrary folders, so reviewers see it in CI'sgui-shotsartifact:
With the fixture rows intest("settings search", async ({ page }) => { await openWith(page, [...playing, ...library, ...settings]); await page.getByText("Set Fire to the Rain").first().waitFor(); await page.getByRole("button", { name: "Settings" }).click(); await page .getByRole("searchbox", { name: "Search settings" }) .fill("stream"); await shot(page, "settings-search"); });gui/e2e/fixtures.ts, "stream" leaves only the Streaming group. That holds for the fixture only: in a real build with Qobuz, "Qobuz Quality" ("Stream quality for Qobuz tracks") matches too. The box also shows in the oldsettingsscene, sosettings.pngchanges as well. That's expected. - Add one line to
CHANGELOG.mdunder## [Unreleased],### Added. That section changes often, so if it conflicts, rebase on main and keep both entries.
Traps to watch for:
- Updates. Keep the
updaterrule exactly as it is when the query is empty. With a query, Updates follows the same rule as the others: it shows only if one of its rows matches. Don't make it show for every query just because there's an updater. Searching "version" will hide the status line, and that's fine. - Escape. Call
preventDefault()only when you actually emptied the box. That's what lets the second Escape reach the page and leave the field. Don't rely on the browser for this: Chromium empties atype="search"box on Escape by itself, Firefox doesn't, and the Linux desktop app runs on WebKitGTK. Handling it yourself makes all of them behave the same. Also skip the key while an IME composition is open (event.nativeEvent.isComposing), like the top bar's search box does inTopBar.tsx(line 100). - Leave
data-focuson the.settingsdiv. Don't move it to the input. Ctrl+, should keep landing on the page (which lets you scroll it with the keyboard), and the test atsettings.test.tsxline 94 checks the attribute. - Don't touch
areas.tsorShell.tsx. The page's key handling already does the right thing for a text field. In particular, don't bind/to the settings box:/is the main Search key everywhere. Keeping the query when you leave and come back is fine; clearing it on open would need a change inShell.tsx, so don't. - Use a label that doesn't collide. Don't reuse
Search(the top bar box,id="top-search") orFilter(the playlist filter). Playwright matchesgetByRole(..., { name })as a case-insensitive substring, so if a scene ever looks up the top bar box by"Search"while Settings is open, it needsexact: true. No scene does that today, and the new scene's"Search settings"lookup is fine as written. - No server change. This is page-only. Don't touch
src/gui/protocol.rs(the row list,GUI_SETTINGS, is at lines 190-223), don't add anAction, and don't editgui/src/bindings/, which is generated. - About two dozen rows is tiny, so you don't need
useDeferredValueor a debounce like the playlist page has.
Tests to add:
- In
gui/src/settingsModel.test.ts, a newdescribenext todescribe("sections"), with rows that have real names:describe("sections with a query", () => { const named = (name: string, group: string, description = ""): SettingRow => ({ id: `behavior.${name}`, group, name, description, value: { kind: "bool", value: true }, }); const all = [ named("Discord Rich Presence", "integrations", "Show your current track in Discord"), named("Streaming Bitrate", "streaming", "Native streaming quality in kbps"), named("Media Key Controls", "playback", "Let OS media keys control playback"), named("Auto-Update Delay", "updates", "How long a release must be out"), ]; const shown = (query: string) => sections(all, true, query).map((section) => [ section.heading, section.rows.map((row) => row.name), ]); it("keeps the rows whose name or description holds every word, ignoring case", () => { expect(shown("DISCORD")).toEqual([["Integrations", ["Discord Rich Presence"]]]); expect(shown("kbps")).toEqual([["Streaming", ["Streaming Bitrate"]]]); expect(shown("media playback")).toEqual([["Playback", ["Media Key Controls"]]]); }); it("a query that names a section keeps that section's rows", () => { expect(shown("integrations")).toEqual([["Integrations", ["Discord Rich Presence"]]]); }); it("hides every section without a match, Updates included", () => { expect(shown("bitrate")).toEqual([["Streaming", ["Streaming Bitrate"]]]); expect(shown("nothing like this")).toEqual([]); }); it("a blank query changes nothing", () => { expect(sections(all, true, " ")).toEqual(sections(all, true)); expect(sections(all, false, "")).toEqual(sections(all, false)); }); }); - In
gui/src/settings.test.tsx, addvito thevitestimport (line 3) andSettingsSearchto the./Settingsimport (line 8), then:
Also add a short render check. With theit("Escape in the settings search empties it, and leaves a second Escape to the page", () => { const typed: string[] = []; const escape = (query: string) => { const preventDefault = vi.fn(); const input = byName( <SettingsSearch query={query} onQuery={(next) => typed.push(next)} />, "Search settings", ); (input.props.onKeyDown as (event: unknown) => void)({ key: "Escape", nativeEvent: { isComposing: false }, preventDefault, }); return preventDefault; }; expect(escape("disc")).toHaveBeenCalled(); expect(escape("")).not.toHaveBeenCalled(); expect(typed).toEqual([""]); });pagehelper, the box is in the markup once rows arrive (aria-label="Search settings"). For the "not while loading" half, don't usepage: itsshownparameter is typedSettingRow[], sopage(null, null)failsnpm run typecheck. Copy the Loading test (lines 194-206), which renders<Settings rows={null} ... />directly, and check the box isn't there. - All the existing tests in both files should pass unchanged. If one breaks, the default
queryisn't behaving like before. - Name tests as behavior sentences, like the ones already there.
- Leave tools/gates.count alone, new tests need no counter change.
Done when
- In
gui/, these pass:npm ci,npm run lint,npm run format:check,npm run typecheck,npm testandnpm run build. Ifformat:checkcomplains,npx prettier --write .fixes it. npm test -- settingsruns bothsettings.test.tsxandsettingsModel.test.ts. Check that the output shows your test ran; a filter that matches nothing runs nothing.- Screenshot check, no Spotify needed: run
npx playwright install chromiumonce, then ingui/runnpm run buildandnpx playwright test -g "settings search" --project=chromium. Opengui/shots/chromium/settings-search.png: you should see the box with "stream" in it and only the Streaming group under it. (npm run shotsbuilds and runs every scene in all three browsers, but it needsnpx playwright install chromium firefox webkitfirst.) To click around yourself, putawait page.pause();at the end of your scene for a moment and add--headed. Remove the pause before you commit. - Optional check in the real app, also without Spotify. On Linux, install the prerequisites in docs/installation.md (Building from Source > Prerequisites) first. Run
npm run buildingui/, then from the repo root run:
If it's your first launch, the page asks a few questions; pick Local Files as your source, which needs no account. Without a Spotify login there's no Streaming group, and the browser page has no Updates group (only the desktop app has an updater). Press Ctrl+, and try it:cargo run --no-default-features --features telemetry,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz --bin spotatui-gui- Type
seek: only Playback is left, with Seek Duration (ms). - Type
folder: only Sources is left, with Music Folder. - Type
zzz: you see "No settings match “zzz”." - Press Escape: the box empties, every group is back, and the cursor is still in the box. Press Escape again: the cursor leaves the box.
- While typing in the box, Space,
q,1and/go into the box and don't play/pause or switch pages.
- Type
- Attach the screenshot to your PR.
- Lingua principale
- Rust
- Stelle
- 1.4k
- Fork
- 238
- Merge medio
- 8h 56m
- PR unite (30g)
- 208
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di LargeModGames/spotatui
-
bug good first issue
Difficoltà 2/5 1-3 ore Idoneità per principianti 84/100
LargeModGames/spotatui#878 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 90/100
LargeModGames/spotatui#872 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
bug good first issue
Difficoltà 2/5 1-3 ore Idoneità per principianti 92/100
LargeModGames/spotatui#871 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
documentation good first issue
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
LargeModGames/spotatui#832 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
bug good first issue
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
LargeModGames/spotatui#829 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di LargeModGames/spotatui
Issue simili
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 88/100
pop-os/cosmic-comp#2956 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
libp2p/rust-libp2p#6694 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
EmbarkStudios/cargo-deny#910 ·
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
tursodatabase/turso#10054 ·
I maintainer di solito rispondono entro 1 giorno
-
build: Update rust versionForse già presa Una pull request collegata a questa issue è aperta o già unita. Apertadependencies
Difficoltà 2/5 1-3 ore Idoneità per principianti 67/100
ibm-granite-community/granite-cli#229 ·
I maintainer di solito rispondono entro 1 giorno