Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

GUI: add a search box to the Settings page

Chiusa
#840 1 commento 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

@niukanen1 ci sta già lavorando.

Dal 16/10/2026.

  • #868 di @niukanen1 — integrata

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

enhancement good first issue javascript

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:

  1. Open the GUI (see "Done when" for a way that needs no Spotify account).
  2. Press Ctrl+, (Cmd+, on macOS), or click the gear button in the top bar.
  3. 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:
    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,
      );
    }
    
    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. GROUPS (lines 10-17) has the headings.
  • gui/src/bindings/SettingRow.ts: each row has id, group, name, description and value. The label you see on the page is name (there is no label field). This file is generated from Rust, so don't edit it.
  • gui/src/Settings.tsx, the Settings component (lines 16-59). The page root is <div className="settings" tabIndex={-1} data-focus> (line 30), then the <h1> (line 31), the "Loading…" line while rows is still null (line 32), and the sections(...) call (line 33). The box goes between the <h1> and the sections.
  • gui/src/Settings.tsx, Field, its onKeyDown (lines 194-203): the Escape pattern to copy. The first Escape throws the edit away and calls preventDefault(). 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.tsx lines 240-251 is the <input type="search">, and line 270 is its empty message, No songs match “…”.
    • gui/src/ListPage.css lines 52-81 is its look (var(--raised), var(--hover), var(--text)).
    • gui/src/Search.tsx line 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 returns null for 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 keydown listener, the useLayoutEffect that calls shellKey(describe(...)) (line 428), and the describe function below the component (line 614). handled: event.defaultPrevented in describe means that a key your input's onKeyDown already called preventDefault() on is ignored by the page. With nothing else to handle, Escape ends in blurText() (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 .settings div.
      • The hidden={kind !== page} wrappers in Shell'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.)
  • Tests:
    • gui/src/settingsModel.test.ts, describe("sections") (lines 29-86). Its row helper (lines 14-18) sets name to the id and description to "", 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), the rows fixture (lines 11-40), the page render helper (lines 42-51), the data-focus check (line 94), and the Loading test (lines 194-206).
    • gui/src/testTree.ts, byName (lines 19-26): finds an element by its aria-label, so a test can call its onKeyDown. It calls components as plain functions, so it only works on components without hooks. gui/src/dialogs.test.tsx lines 7-14 shows the fake key event.
  • Screenshots: the settings scene in gui/e2e/scenes.shot.ts (lines 344-350) opens the page against the fake bridge, with the rows from settings in gui/e2e/fixtures.ts (lines 848-947).

Possible solution

  1. The filter, in settingsModel.ts. Give sections a 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));
    }
    
  2. The box, in Settings.tsx. Keep the query in Settings with useState("") and pass it to sections. Put the input in a small hookless component, so a test can reach its key handler with byName:
    /** 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("");
            }
          }}
        />
      );
    }
    
    Render it once rows isn't null. 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. The role="status" lets a screen reader say that nothing matched.
  3. The look, in Settings.css. Add a .settings-search rule modelled on .list-filter (ListPage.css lines 52-81): same height, radius, var(--raised) background, var(--hover) on hover and a var(--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.
  4. A screenshot scene. In gui/e2e/scenes.shot.ts, add it right after the closing }); of the settings scene (line 350), before library folders, so reviewers see it in CI's gui-shots artifact:
    test("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");
    });
    
    With the fixture rows in 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 old settings scene, so settings.png changes as well. That's expected.
  5. Add one line to CHANGELOG.md under ## [Unreleased], ### Added. That section changes often, so if it conflicts, rebase on main and keep both entries.

Traps to watch for:

  • Updates. Keep the updater rule 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 a type="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 in TopBar.tsx (line 100).
  • Leave data-focus on the .settings div. 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 at settings.test.tsx line 94 checks the attribute.
  • Don't touch areas.ts or Shell.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 in Shell.tsx, so don't.
  • Use a label that doesn't collide. Don't reuse Search (the top bar box, id="top-search") or Filter (the playlist filter). Playwright matches getByRole(..., { name }) as a case-insensitive substring, so if a scene ever looks up the top bar box by "Search" while Settings is open, it needs exact: 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 an Action, and don't edit gui/src/bindings/, which is generated.
  • About two dozen rows is tiny, so you don't need useDeferredValue or a debounce like the playlist page has.

Tests to add:

  • In gui/src/settingsModel.test.ts, a new describe next to describe("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, add vi to the vitest import (line 3) and SettingsSearch to the ./Settings import (line 8), then:
    it("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([""]);
    });
    
    Also add a short render check. With the page helper, the box is in the markup once rows arrive (aria-label="Search settings"). For the "not while loading" half, don't use page: its shown parameter is typed SettingRow[], so page(null, null) fails npm 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 query isn'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 test and npm run build. If format:check complains, npx prettier --write . fixes it.
  • npm test -- settings runs both settings.test.tsx and settingsModel.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 chromium once, then in gui/ run npm run build and npx playwright test -g "settings search" --project=chromium. Open gui/shots/chromium/settings-search.png: you should see the box with "stream" in it and only the Streaming group under it. (npm run shots builds and runs every scene in all three browsers, but it needs npx playwright install chromium firefox webkit first.) To click around yourself, put await 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 build in gui/, then from the repo root run:
    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
    
    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:
    • 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, 1 and / go into the box and don't play/pause or switch pages.
  • 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

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di LargeModGames/spotatui

Tutte le issue di LargeModGames/spotatui

Issue simili

Altre issue su Rust

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.