Skip to content
Open
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
17 changes: 17 additions & 0 deletions PR_DESCRIPTION_SETTINGS_SEARCH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## What and why

Adds a global Settings search field. It searches setting titles and descriptions across every Settings category, shows matching results with their category, and opens the selected category. The Settings layout now responds to the Settings pane width with container queries: narrow panes stack the header, make the search field full width, and turn the vertical category sidebar into a horizontal scrollable rail so settings cards retain usable content width.

## How it was checked

- [x] `npm run verify` passes — 33 checks passed
- [x] `cargo test` not required — no `src-tauri/` files changed
- [x] `npm run build` passes
- [x] Search check covers empty, multi-term, category, and no-match queries
- [ ] Screenshot or clip below (UI changes)

## Notes for the reviewer

- Search metadata is isolated in `src/ui/components/settingsSearchIndex.ts`; the page component only owns query state and category navigation.
- The compact layout is a named container query, not a viewport breakpoint. It responds correctly when Settings is constrained by application chrome, window scaling, or an embedded pane.
- Selecting a search result clears the query and opens its category.
65 changes: 65 additions & 0 deletions src/ui/components/SettingsSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useMemo } from "react";
import { SearchIcon } from "../icons";
import { searchSettings, type SettingsTab } from "./settingsSearchIndex";

export type { SettingsTab } from "./settingsSearchIndex";

export function SettingsSearch({
query,
onQueryChange,
}: {
query: string;
onQueryChange: (query: string) => void;
}) {
const resultCount = useMemo(() => searchSettings(query).length, [query]);
const active = query.trim().length > 0;

return (
<label className="flex h-10 min-w-0 items-center gap-2 rounded-xl border border-border bg-card/70 px-3 text-muted-foreground transition-colors focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/20">
<SearchIcon size={18} aria-hidden="true" />
<input
type="search"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") onQueryChange("");
}}
placeholder="Search settings"
className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
aria-label="Search settings"
/>
{active ? <span className="text-xs tabular-nums">{resultCount}</span> : null}
</label>
);
}

export function SettingsSearchResults({
query,
onSelect,
}: {
query: string;
onSelect: (tab: SettingsTab) => void;
}) {
const results = useMemo(() => searchSettings(query), [query]);

return (
<section className="flex flex-col gap-1 rounded-xl border border-border bg-card/50 p-1.5" aria-label="Setting search results">
{results.length > 0 ? results.map((result) => (
<button
key={`${result.tab}-${result.title}`}
type="button"
onClick={() => onSelect(result.tab)}
className="flex flex-col gap-0.5 rounded-lg px-3 py-2 text-left transition-colors hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<span className="flex items-baseline justify-between gap-3">
<strong className="text-sm font-medium text-foreground">{result.title}</strong>
<span className="shrink-0 text-xs text-muted-foreground">{result.category}</span>
</span>
<span className="text-sm text-muted-foreground">{result.description}</span>
</button>
)) : (
<p className="px-3 py-2 text-sm text-muted-foreground">No settings match “{query.trim()}”.</p>
)}
</section>
);
}
20 changes: 20 additions & 0 deletions src/ui/components/settingsSearch.check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export {};

import { searchSettings } from "./settingsSearchIndex";

function check(condition: boolean, message: string): void {
if (!condition) throw new Error(`FAILED: ${message}`);
}

check(searchSettings("").length === 0, "empty search has no results");
check(
searchSettings("discord pause").some((entry) => entry.title === "Discord presence" && entry.tab === "about"),
"search matches terms across a setting description",
);
check(
searchSettings("quality").some((entry) => entry.title === "Streaming quality"),
"search finds a library setting",
);
check(searchSettings("this cannot match").length === 0, "unknown query has no results");

console.log("settingsSearch.check.ts passed");
51 changes: 51 additions & 0 deletions src/ui/components/settingsSearchIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export type SettingsTab = "about" | "appearance" | "playback" | "system" | "shortcuts" | "window";

export type SettingsSearchEntry = {
tab: SettingsTab;
category: string;
title: string;
description: string;
};

const ENTRIES: SettingsSearchEntry[] = [
{ tab: "about", category: "Account", title: "Account", description: "Sign in and manage your YouTube Music account." },
{ tab: "about", category: "Account", title: "Last.fm", description: "Connect Last.fm and scrobble plays." },
{ tab: "about", category: "Account", title: "Discord presence", description: "Show what you are playing and hide status when paused." },
{ tab: "about", category: "Account", title: "Updates", description: "Check for and install Zuno updates." },
{ tab: "appearance", category: "Appearance", title: "Theme", description: "Choose light, dark, or system theme." },
{ tab: "appearance", category: "Appearance", title: "Motion", description: "Control animations and visual effects." },
{ tab: "appearance", category: "Appearance", title: "Toolbar", description: "Choose which controls appear in the title bar." },
{ tab: "appearance", category: "Appearance", title: "Onboarding", description: "Restart the first-run introduction." },
{ tab: "appearance", category: "Appearance", title: "Made for you", description: "Show personalized recommendations on Home." },
{ tab: "playback", category: "Playback", title: "Output device", description: "Choose where Zuno sends audio." },
{ tab: "playback", category: "Playback", title: "Equalizer", description: "Adjust audio frequencies and presets." },
{ tab: "playback", category: "Playback", title: "Gapless playback", description: "Remove silence between tracks." },
{ tab: "playback", category: "Playback", title: "Crossfade", description: "Blend the end of one track into the next." },
{ tab: "playback", category: "Playback", title: "Restore tabs and queues", description: "Restore your playback session after restarting." },
{ tab: "playback", category: "Playback", title: "Audio engine", description: "Choose the playback method and authenticated streaming." },
{ tab: "system", category: "Library", title: "Local music", description: "Create playlists from folders on this computer." },
{ tab: "system", category: "Library", title: "Cache", description: "Set cache size and clear cached content." },
{ tab: "system", category: "Library", title: "Downloads", description: "Set offline storage limits and remove downloads." },
{ tab: "system", category: "Library", title: "Streaming quality", description: "Choose quality for music played over the network." },
{ tab: "system", category: "Library", title: "Download quality", description: "Choose quality for offline music." },
{ tab: "system", category: "Library", title: "Lyrics", description: "Translate lyrics, set text size, and choose a source." },
{ tab: "system", category: "Library", title: "Launch at startup", description: "Start Zuno when your computer starts." },
{ tab: "system", category: "Library", title: "Minimize to tray", description: "Keep Zuno available from the system tray." },
{ tab: "system", category: "Library", title: "Remember window size and location", description: "Restore the main window geometry." },
{ tab: "system", category: "Library", title: "Troubleshooting", description: "Open logs or reset Zuno data." },
{ tab: "window", category: "Window", title: "Mini player", description: "Show a compact player when the main window loses focus." },
{ tab: "window", category: "Window", title: "Library sidebar", description: "Choose the playlist rail size and hover behavior." },
{ tab: "window", category: "Window", title: "Window controls", description: "Choose macOS, Windows, or native title-bar buttons." },
{ tab: "window", category: "Window", title: "Compact player bar", description: "Use a smaller playback bar." },
{ tab: "window", category: "Window", title: "System media controls", description: "Show playback in system media controls." },
{ tab: "shortcuts", category: "Shortcuts", title: "Keyboard shortcuts", description: "View, record, or clear keyboard bindings." },
];

export function searchSettings(query: string): SettingsSearchEntry[] {
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) return [];
return ENTRIES.filter((entry) => {
const haystack = `${entry.category} ${entry.title} ${entry.description}`.toLocaleLowerCase();
return terms.every((term) => haystack.includes(term));
});
}
46 changes: 31 additions & 15 deletions src/ui/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ import {
usePotatoPcMode,
} from "../settings/renderEffects";
import { setMadeForYouVisible, useMadeForYouVisible } from "../settings/homeSections";
import {
SettingsSearch,
SettingsSearchResults,
type SettingsTab,
} from "../components/SettingsSearch";
import { GoogleSignInButton } from "../components/GoogleSignInButton";
import { ExternalLinkButton } from "../components/ExternalLinkButton";
import {
Expand Down Expand Up @@ -655,8 +660,6 @@ function SettingsCardHeader({
}

/** Quiet outbound links in the page header. */
type SettingsTab = "about" | "appearance" | "playback" | "system" | "shortcuts" | "window";

type WindowControlStyle = "macos" | "windows" | "native";

const SETTINGS_TABS: Array<{
Expand Down Expand Up @@ -738,6 +741,7 @@ export function SettingsPage({
const [lastFmBusy, setLastFmBusy] = useState(false);
const [lastFmError, setLastFmError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<SettingsTab>("about");
const [settingsQuery, setSettingsQuery] = useState("");
const themePreference = useThemePreference();
const [listeningShortcut, setListeningShortcut] = useState<KeyboardShortcutAction | null>(null);
const keyboardShortcuts = useKeyboardShortcuts();
Expand Down Expand Up @@ -1122,8 +1126,8 @@ export function SettingsPage({
};

return (
<main className="flex min-h-0 flex-1 flex-col gap-7">
<header className="flex flex-wrap items-start justify-between gap-x-6 gap-y-3">
<main className="@container/settings flex min-h-0 flex-1 flex-col gap-7">
<header className="flex flex-wrap items-start justify-between gap-x-6 gap-y-3 @max-4xl/settings:flex-col @max-4xl/settings:items-stretch">
<div className="flex flex-col gap-1.5">
<h1>Settings</h1>
<p className="text-sm text-muted-foreground">
Expand All @@ -1136,7 +1140,10 @@ export function SettingsPage({
part of the description above and were routinely missed. They stay unfilled so they
still sit below the category nav in the hierarchy.
*/}
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center justify-end gap-2 @max-4xl/settings:justify-start">
<div className="w-72 max-w-full @max-4xl/settings:w-full">
<SettingsSearch query={settingsQuery} onQueryChange={setSettingsQuery} />
</div>
<ExternalLinkButton
icon={<StarIcon size={16} aria-hidden="true" />}
label="Star on GitHub"
Expand All @@ -1150,12 +1157,11 @@ export function SettingsPage({
</div>
</header>

{/* Vertical nav rather than a pill row: it has room for a description per
category and scales as sections are added, the way desktop settings do.
The nav sticks so the categories stay reachable while a long panel scrolls. */}
<div className="flex min-h-0 flex-1 items-start gap-10">
{/* The category rail follows this pane's width, not the application viewport: an embedded
or scaled Settings pane can be narrow even on a wide desktop window. */}
<div className="flex min-h-0 flex-1 items-start gap-10 @max-4xl/settings:flex-col @max-4xl/settings:gap-4">
<nav
className="sticky top-0 flex w-56 shrink-0 flex-col gap-0.5"
className="sticky top-0 flex w-56 shrink-0 flex-col gap-0.5 @max-4xl/settings:relative @max-4xl/settings:w-full @max-4xl/settings:flex-row @max-4xl/settings:overflow-x-auto @max-4xl/settings:rounded-xl @max-4xl/settings:bg-card/50 @max-4xl/settings:p-1"
role="tablist"
aria-label="Settings categories"
>
Expand All @@ -1170,7 +1176,7 @@ export function SettingsPage({
aria-selected={isActive}
onClick={() => setActiveTab(tab.id)}
className={cn(
"group/tab relative flex items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors",
"group/tab relative flex items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors @max-4xl/settings:shrink-0 @max-4xl/settings:px-2.5 @max-4xl/settings:py-2",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
isActive ? "text-foreground" : "text-muted-foreground hover:bg-card/60 hover:text-foreground",
)}
Expand All @@ -1192,7 +1198,7 @@ export function SettingsPage({
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">{tab.label}</span>
<span className="block truncate text-xs text-muted-foreground">
<span className="block truncate text-xs text-muted-foreground @max-4xl/settings:hidden">
{tab.description}
</span>
</span>
Expand All @@ -1201,8 +1207,17 @@ export function SettingsPage({
})}
</nav>

<div className="flex min-h-0 w-full min-w-0 max-w-2xl flex-1 flex-col">

<div className="flex min-h-0 w-full min-w-0 max-w-2xl flex-1 flex-col @max-4xl/settings:max-w-none">
{settingsQuery.trim() ? (
<SettingsSearchResults
query={settingsQuery}
onSelect={(tab) => {
setActiveTab(tab);
setSettingsQuery("");
}}
/>
) : (
<>
{activeTab === "about" && (
<div className="flex flex-col gap-5" role="tabpanel" aria-label="About settings">
<section className={SETTINGS_CARD} aria-labelledby="account-settings-title">
Expand Down Expand Up @@ -2373,7 +2388,8 @@ export function SettingsPage({
</section>
</div>
)}

</>
)}
</div>
</div>
</main>
Expand Down
Loading