diff --git a/src/lib/components/HighlightText.svelte b/src/lib/components/HighlightText.svelte new file mode 100644 index 0000000..9fea66e --- /dev/null +++ b/src/lib/components/HighlightText.svelte @@ -0,0 +1,43 @@ + + + + {#each parts as part, i (i)} + {#if part.highlighted} + {part.content} + {:else} + {part.content} + {/if} + {/each} + + + diff --git a/src/lib/components/Search.svelte b/src/lib/components/Search.svelte new file mode 100644 index 0000000..62559d4 --- /dev/null +++ b/src/lib/components/Search.svelte @@ -0,0 +1,145 @@ + + +
+
+ + + +
+ + {#if search.query} + + {:else} + + {isMac ? "⌘K" : "Ctrl+K"} + + {/if} +
+ + diff --git a/src/lib/components/SearchSubItem.svelte b/src/lib/components/SearchSubItem.svelte new file mode 100644 index 0000000..0b2d9ae --- /dev/null +++ b/src/lib/components/SearchSubItem.svelte @@ -0,0 +1,59 @@ + + + + + diff --git a/src/lib/components/Tab.svelte b/src/lib/components/Tab.svelte index f22f90a..18848cc 100644 --- a/src/lib/components/Tab.svelte +++ b/src/lib/components/Tab.svelte @@ -1,42 +1,64 @@ + const pageSelected = $derived(path === route); + const isSelected = $derived(pageSelected || selected); + const hasHighlight = $derived(highlightRanges.length > 0); + - +
{@render icon()}
-
{@render children()}
+
+ {#if hasHighlight && label} + + {:else} + {@render children()} + {/if} +
{#if isExternal}
- +
{/if}
- \ No newline at end of file + diff --git a/src/lib/stores/search.svelte.ts b/src/lib/stores/search.svelte.ts new file mode 100644 index 0000000..a9b2c66 --- /dev/null +++ b/src/lib/stores/search.svelte.ts @@ -0,0 +1,26 @@ +interface SearchState { + query: string; + selectedIndex: number; + highlightedSettingId: string | null; +} + +const search: SearchState = $state({ + query: "", + selectedIndex: 0, + highlightedSettingId: null +}); + +export function clearSearch() { + search.query = ""; + search.selectedIndex = 0; +} + +export function setHighlightedSetting(id: string | null) { + search.highlightedSettingId = id; +} + +export function clearHighlightedSetting() { + search.highlightedSettingId = null; +} + +export default search; diff --git a/src/lib/utils/search.test.ts b/src/lib/utils/search.test.ts new file mode 100644 index 0000000..70349ab --- /dev/null +++ b/src/lib/utils/search.test.ts @@ -0,0 +1,449 @@ +import {describe, expect, it} from "vitest"; +import { + getMatchRanges, + hasMatch, + searchSettings, + flattenSearchResults, + type ExternalTab, + type SearchResult +} from "./search"; + +describe("getMatchRanges", () => { + it("returns empty array for empty query", () => { + const ranges = getMatchRanges("test", ""); + expect(ranges).toEqual([]); + }); + + it("returns empty array for null query", () => { + const ranges = getMatchRanges("test", null as any); + expect(ranges).toEqual([]); + }); + + it("finds single match", () => { + const ranges = getMatchRanges("Hello World", "wor"); + expect(ranges).toEqual([{start: 6, end: 9}]); + }); + + it("finds multiple matches", () => { + const ranges = getMatchRanges("test test test", "test"); + expect(ranges).toEqual([ + {start: 0, end: 4}, + {start: 5, end: 9}, + {start: 10, end: 14} + ]); + }); + + it("is case-insensitive", () => { + const ranges = getMatchRanges("Hello World", "hello"); + expect(ranges).toEqual([{start: 0, end: 5}]); + }); + + it("returns empty array when no match found", () => { + const ranges = getMatchRanges("Hello World", "xyz"); + expect(ranges).toEqual([]); + }); + + it("handles overlapping matches correctly", () => { + const ranges = getMatchRanges("aaaa", "aa"); + expect(ranges).toEqual([ + {start: 0, end: 2}, + {start: 2, end: 4} + ]); + }); + + it("matches special characters", () => { + const ranges = getMatchRanges("test-case_name", "case"); + expect(ranges).toEqual([{start: 5, end: 9}]); + }); + + it("matches numbers", () => { + const ranges = getMatchRanges("Option 1", "1"); + expect(ranges).toEqual([{start: 7, end: 8}]); + }); + + it("matches spaces", () => { + const ranges = getMatchRanges("Hello World", " "); + expect(ranges).toEqual([{start: 5, end: 6}]); + }); +}); + +describe("hasMatch", () => { + it("returns false for empty query", () => { + expect(hasMatch("test", "")).toBe(false); + }); + + it("returns false for null query", () => { + expect(hasMatch("test", null as any)).toBe(false); + }); + + it("returns true when text contains query", () => { + expect(hasMatch("Hello World", "world")).toBe(true); + }); + + it("is case-insensitive", () => { + expect(hasMatch("Hello World", "HELLO")).toBe(true); + }); + + it("returns false when text does not contain query", () => { + expect(hasMatch("Hello World", "xyz")).toBe(false); + }); + + it("returns true for partial matches", () => { + expect(hasMatch("Application", "app")).toBe(true); + }); +}); + +describe("searchSettings", () => { + const mockSettings = [ + { + id: "application", + name: "Application", + groups: [ + { + id: "general", + name: "General", + settings: [ + {id: "setting1", name: "Font Size", note: "Set the font size in pixels"}, + {id: "setting2", name: "Font Family", note: "Choose a font family"} + ] + }, + { + id: "advanced", + name: "Advanced", + settings: [ + {id: "setting3", name: "Window Title", note: "Customize window title"}, + {id: "setting4", name: "Theme", note: "Select a theme"} + ] + } + ] + }, + { + id: "colors", + name: "Colors", + groups: [ + { + id: "base", + name: "Base Colors", + settings: [ + {id: "setting5", name: "Background", note: "Background color"}, + {id: "setting6", name: "Foreground", note: "Text color"} + ] + } + ] + } + ]; + + const mockExternalTabs: ExternalTab[] = [ + {id: "github", name: "GitHub", route: "https://github.com/zerebos/ghostty-config"}, + {id: "ghostty", name: "Ghostty", route: "https://ghostty.org/"} + ]; + + it("returns empty array for empty query", () => { + const results = searchSettings("", mockSettings, mockExternalTabs); + expect(results).toEqual([]); + }); + + it("returns empty array for whitespace-only query", () => { + const results = searchSettings(" ", mockSettings, mockExternalTabs); + expect(results).toEqual([]); + }); + + it("trims leading and trailing whitespace from query", () => { + const results = searchSettings(" font ", mockSettings, []); + expect(results.length).toBeGreaterThan(0); + expect(results[0]?.matchedSettings.length).toBeGreaterThan(0); + }); + + it("matches categories by name", () => { + const results = searchSettings("colors", mockSettings, []); + expect(results.length).toBe(1); + expect(results[0]?.categoryId).toBe("colors"); + expect(results[0]?.categoryMatchRanges).toEqual([{start: 0, end: 6}]); + }); + + it("matches settings by name", () => { + const results = searchSettings("font", mockSettings, []); + expect(results.length).toBe(1); + expect(results[0]?.categoryId).toBe("application"); + expect(results[0]?.matchedSettings.length).toBe(2); + expect(results[0]?.matchedSettings[0]?.id).toBe("setting1"); + expect(results[0]?.matchedSettings[1]?.id).toBe("setting2"); + }); + + it("matches settings by note", () => { + const results = searchSettings("window", mockSettings, []); + expect(results.length).toBe(1); + expect(results[0]?.categoryId).toBe("application"); + expect(results[0]?.matchedSettings.length).toBe(1); + expect(results[0]?.matchedSettings[0]?.id).toBe("setting3"); + }); + + it("matches both category and settings when both match", () => { + const results = searchSettings("application", mockSettings, []); + expect(results.length).toBe(1); + expect(results[0]?.categoryId).toBe("application"); + expect(results[0]?.categoryMatchRanges.length).toBeGreaterThan(0); + expect(results[0]?.matchedSettings.length).toBe(0); + }); + + it("matches settings across multiple groups", () => { + const results = searchSettings("color", mockSettings, []); + expect(results.length).toBe(1); + expect(results[0]?.categoryId).toBe("colors"); + expect(results[0]?.matchedSettings.length).toBe(2); + }); + + it("is case-insensitive for category matching", () => { + const results = searchSettings("COLORS", mockSettings, []); + expect(results.length).toBe(1); + expect(results[0]?.categoryId).toBe("colors"); + }); + + it("is case-insensitive for setting matching", () => { + const results = searchSettings("FONT", mockSettings, []); + expect(results.length).toBe(1); + expect(results[0]?.matchedSettings.length).toBe(2); + }); + + it("returns empty array when no matches found", () => { + const results = searchSettings("nonexistent", mockSettings, []); + expect(results).toEqual([]); + }); + + it("matches external tabs", () => { + const results = searchSettings("github", mockSettings, mockExternalTabs); + const externalResult = results.find((r) => r.type === "external"); + expect(externalResult).toBeDefined(); + expect(externalResult?.categoryId).toBe("github"); + expect(externalResult?.categoryMatchRanges).toEqual([{start: 0, end: 6}]); + }); + + it("returns both category and external results when both match", () => { + const externalTabsWithApp = [ + {id: "appstore", name: "App Store", route: "https://apple.com/appstore"}, + ...mockExternalTabs + ]; + const results = searchSettings("app", mockSettings, externalTabsWithApp); + const categoryResult = results.find((r) => r.type === "category"); + const externalResult = results.find((r) => r.type === "external"); + + expect(categoryResult).toBeDefined(); + expect(externalResult).toBeDefined(); + expect(results.length).toBeGreaterThanOrEqual(2); + }); + + it("handles empty settings data", () => { + const results = searchSettings("test", [], []); + expect(results).toEqual([]); + }); + + it("handles empty external tabs", () => { + const results = searchSettings("github", mockSettings, []); + const externalResult = results.find((r) => r.type === "external"); + expect(externalResult).toBeUndefined(); + }); + + it("generates correct match ranges for category names", () => { + const results = searchSettings("app", mockSettings, []); + expect(results[0]?.categoryMatchRanges).toEqual([{start: 0, end: 3}]); + }); + + it("generates correct match ranges for setting names", () => { + const results = searchSettings("size", mockSettings, []); + expect(results[0]?.matchedSettings[0]?.matchRanges).toEqual([{start: 5, end: 9}]); + }); + + it("generates multiple match ranges for repeated terms", () => { + const results = searchSettings("font", mockSettings, []); + const settingMatches = results[0]?.matchedSettings; + expect(settingMatches?.length).toBeGreaterThan(1); + expect(settingMatches[0]?.matchRanges.length).toBeGreaterThan(0); + expect(settingMatches[1]?.matchRanges.length).toBeGreaterThan(0); + }); + + it("does not match if note is undefined", () => { + const settingsWithoutNote = [ + { + id: "test", + name: "Test Category", + groups: [ + { + id: "group1", + name: "Group 1", + settings: [{id: "setting1", name: "Test Setting"}] + } + ] + } + ]; + const results = searchSettings("test", settingsWithoutNote, []); + expect(results.length).toBe(1); + expect(results[0]?.matchedSettings.length).toBe(1); + }); +}); + +describe("flattenSearchResults", () => { + const mockResults: SearchResult[] = [ + { + type: "category", + categoryId: "application", + categoryName: "Application", + categoryRoute: "/settings/application", + categoryMatchRanges: [], + matchedSettings: [ + {id: "setting1", name: "Font Size", matchRanges: []}, + {id: "setting2", name: "Font Family", matchRanges: []} + ] + }, + { + type: "external", + categoryId: "github", + categoryName: "GitHub", + categoryRoute: "https://github.com/test", + categoryMatchRanges: [], + matchedSettings: [] + } + ]; + + it("flattens category results correctly", () => { + const flattened = flattenSearchResults(mockResults); + + expect(flattened[0]?.type).toBe("category"); + expect(flattened[0]?.result.categoryId).toBe("application"); + }); + + it("flattens setting sub-items correctly", () => { + const flattened = flattenSearchResults(mockResults); + + expect(flattened[1]?.type).toBe("setting"); + expect(flattened[1]?.setting?.id).toBe("setting1"); + expect(flattened[2]?.type).toBe("setting"); + expect(flattened[2]?.setting?.id).toBe("setting2"); + }); + + it("flattens external results correctly", () => { + const flattened = flattenSearchResults(mockResults); + + const externalItem = flattened.find((item) => item.type === "external"); + expect(externalItem).toBeDefined(); + expect(externalItem?.result.categoryId).toBe("github"); + }); + + it("maintains correct order: category, settings, then external", () => { + const flattened = flattenSearchResults(mockResults); + + expect(flattened[0]?.type).toBe("category"); + expect(flattened[1]?.type).toBe("setting"); + expect(flattened[2]?.type).toBe("setting"); + expect(flattened[3]?.type).toBe("external"); + }); + + it("preserves result reference in all flattened items", () => { + const flattened = flattenSearchResults(mockResults); + + const categoryItem = flattened[0]; + const settingItem = flattened[1]; + const externalItem = flattened[3]; + + expect(categoryItem?.result).toBe(mockResults[0]); + expect(settingItem?.result).toBe(mockResults[0]); + expect(externalItem?.result).toBe(mockResults[1]); + }); + + it("handles empty results array", () => { + const flattened = flattenSearchResults([]); + expect(flattened).toEqual([]); + }); + + it("handles category with no matched settings", () => { + const results: SearchResult[] = [ + { + type: "category", + categoryId: "test", + categoryName: "Test", + categoryRoute: "/settings/test", + categoryMatchRanges: [], + matchedSettings: [] + } + ]; + + const flattened = flattenSearchResults(results); + + expect(flattened.length).toBe(1); + expect(flattened[0]?.type).toBe("category"); + }); + + it("handles multiple categories with settings", () => { + const results: SearchResult[] = [ + { + type: "category", + categoryId: "app", + categoryName: "Application", + categoryRoute: "/settings/app", + categoryMatchRanges: [], + matchedSettings: [{id: "s1", name: "Setting 1", matchRanges: []}] + }, + { + type: "category", + categoryId: "colors", + categoryName: "Colors", + categoryRoute: "/settings/colors", + categoryMatchRanges: [], + matchedSettings: [{id: "s2", name: "Setting 2", matchRanges: []}] + } + ]; + + const flattened = flattenSearchResults(results); + + expect(flattened.length).toBe(4); + expect(flattened[0]?.type).toBe("category"); + expect(flattened[1]?.type).toBe("setting"); + expect(flattened[2]?.type).toBe("category"); + expect(flattened[3]?.type).toBe("setting"); + }); + + it("handles external-only results", () => { + const results: SearchResult[] = [ + { + type: "external", + categoryId: "github", + categoryName: "GitHub", + categoryRoute: "https://github.com", + categoryMatchRanges: [], + matchedSettings: [] + } + ]; + + const flattened = flattenSearchResults(results); + + expect(flattened.length).toBe(1); + expect(flattened[0]?.type).toBe("external"); + }); + + it("handles mixed category and external results", () => { + const results: SearchResult[] = [ + { + type: "external", + categoryId: "github", + categoryName: "GitHub", + categoryRoute: "https://github.com", + categoryMatchRanges: [], + matchedSettings: [] + }, + { + type: "category", + categoryId: "app", + categoryName: "Application", + categoryRoute: "/settings/app", + categoryMatchRanges: [], + matchedSettings: [{id: "s1", name: "Setting 1", matchRanges: []}] + } + ]; + + const flattened = flattenSearchResults(results); + + expect(flattened.length).toBe(3); + expect(flattened[0]?.type).toBe("external"); + expect(flattened[1]?.type).toBe("category"); + expect(flattened[2]?.type).toBe("setting"); + }); +}); diff --git a/src/lib/utils/search.ts b/src/lib/utils/search.ts new file mode 100644 index 0000000..5f53e51 --- /dev/null +++ b/src/lib/utils/search.ts @@ -0,0 +1,144 @@ +export interface MatchRange { + start: number; + end: number; +} + +export interface MatchedSetting { + id: string; + name: string; + matchRanges: MatchRange[]; +} + +export interface SearchResult { + type: "category" | "external"; + categoryId: string; + categoryName: string; + categoryRoute: string; + categoryMatchRanges: MatchRange[]; + matchedSettings: MatchedSetting[]; +} + +export interface ExternalTab { + id: string; + name: string; + route: string; +} + +interface SettingsPanel { + id: string; + name: string; + groups: SettingsGroup[]; +} + +interface SettingsGroup { + id: string; + name: string; + settings: SettingsItem[]; +} + +interface SettingsItem { + id: string; + name: string; + note?: string; +} + +export interface FlattenedItem { + type: "category" | "setting" | "external"; + result: SearchResult; + setting?: MatchedSetting; +} + +export function getMatchRanges(text: string, query: string): MatchRange[] { + if (!query) return []; + + const ranges: MatchRange[] = []; + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + let startIndex = 0; + + while (startIndex < lowerText.length) { + const index = lowerText.indexOf(lowerQuery, startIndex); + if (index === -1) break; + ranges.push({start: index, end: index + query.length}); + startIndex = index + query.length; + } + + return ranges; +} + +export function hasMatch(text: string, query: string): boolean { + if (!query) return false; + return text.toLowerCase().includes(query.toLowerCase()); +} + +export function searchSettings( + query: string, + settingsData: SettingsPanel[], + externalTabs: ExternalTab[] = [] +): SearchResult[] { + const q = query.trim(); + if (!q) return []; + + const results: SearchResult[] = []; + + for (const panel of settingsData) { + const categoryMatchRanges = getMatchRanges(panel.name, q); + const matchedSettings: MatchedSetting[] = []; + + for (const group of panel.groups) { + for (const setting of group.settings) { + if (hasMatch(setting.name, q) || (setting.note && hasMatch(setting.note, q))) { + matchedSettings.push({ + id: setting.id, + name: setting.name, + matchRanges: getMatchRanges(setting.name, q) + }); + } + } + } + + if (categoryMatchRanges.length > 0 || matchedSettings.length > 0) { + results.push({ + type: "category", + categoryId: panel.id, + categoryName: panel.name, + categoryRoute: `/settings/${panel.id}`, + categoryMatchRanges, + matchedSettings + }); + } + } + + for (const tab of externalTabs) { + const matchRanges = getMatchRanges(tab.name, q); + if (matchRanges.length > 0) { + results.push({ + type: "external", + categoryId: tab.id, + categoryName: tab.name, + categoryRoute: tab.route, + categoryMatchRanges: matchRanges, + matchedSettings: [] + }); + } + } + + return results; +} + +export function flattenSearchResults(results: SearchResult[]): FlattenedItem[] { + const flattened: FlattenedItem[] = []; + + for (const result of results) { + if (result.type === "external") { + flattened.push({type: "external", result}); + } else { + flattened.push({type: "category", result}); + for (const setting of result.matchedSettings) { + flattened.push({type: "setting", result, setting}); + } + } + } + + return flattened; +} diff --git a/src/lib/views/Page.svelte b/src/lib/views/Page.svelte index 0a8a618..a94e57f 100644 --- a/src/lib/views/Page.svelte +++ b/src/lib/views/Page.svelte @@ -15,23 +15,14 @@ $effect(() => {app.title = title;}); let isScrolling = $state(false); - let bufferHeight = $state(53); function containerScroll(event: Event) { isScrolling = (event.target as HTMLDivElement).scrollTop > 0; - const scrollerPos = (event.target as HTMLDivElement).scrollTop; - if (scrollerPos >= 53) { - bufferHeight = 0; - } - else { - bufferHeight = 53 - scrollerPos; - } } let scroller: HTMLDivElement|undefined = $state(); onNavigate(() => { isScrolling = false; - bufferHeight = 53; if (scroller) scroller.scrollTop = 0; }); @@ -43,7 +34,7 @@

{app.title}

{#key app.title} -
+
{@render children()}
{/key} @@ -90,7 +81,8 @@ display: flex; flex-direction: column; overflow-y: auto; - padding: 8px 20px 10px 20px; + margin: 54px 0px 0px 0px; + padding: 7px 20px 10px 20px; flex: 1; } \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 13466d1..489c1e7 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,6 +2,11 @@ import Gap from "$lib/components/Gap.svelte"; import Tab from "$lib/components/Tab.svelte"; import User from "$lib/components/User.svelte"; + import Search from "$lib/components/Search.svelte"; + import SearchSubItem from "$lib/components/SearchSubItem.svelte"; + import {searchSettings, flattenSearchResults, type ExternalTab} from "$lib/utils/search"; + import search, {clearSearch, setHighlightedSetting} from "$lib/stores/search.svelte"; + import {goto} from "$app/navigation"; import "../app.css"; import application from "$lib/images/tabs/application.webp"; @@ -27,11 +32,17 @@ import config from "$lib/stores/config.svelte"; import app from "$lib/stores/state.svelte"; import type {Snippet} from "svelte"; + import settings from "$lib/data/settings"; + + const externalTabs: ExternalTab[] = [ + {id: "github", name: "GitHub", route: "https://github.com/zerebos/ghostty-config"}, + {id: "ghostty", name: "Ghostty", route: "https://ghostty.org/"} + ]; const cssConfigVars = $derived.by(() => { let str = ""; - const add = (key: string, val: string) => str += `--config-${key}: ${val};`; + const add = (key: string, val: string) => (str += `--config-${key}: ${val};`); // Add the base colors add("bg", config.background); @@ -53,21 +64,142 @@ const {children}: {children: Snippet} = $props(); - - - const htmlTitle = $derived.by(() => { const name = app.title === "Ghostty Config" ? "" : app.title; let title = "Ghostty Config"; if (name) title = `${name} - ${title}`; return title; }); + + const searchResults = $derived(searchSettings(search.query, settings, externalTabs)); + const flattenedResults = $derived(flattenSearchResults(searchResults)); + const isSearching = $derived(search.query.trim().length > 0); + const hasResults = $derived(flattenedResults.length > 0); + + // Track when query changes to reset selectedIndex + let lastQuery = $state(search.query); + + $effect(() => { + const currentQuery = search.query; + const totalItems = flattenedResults.length; + + // If query changed, reset to 0 + if (currentQuery !== lastQuery) { + lastQuery = currentQuery; + if (search.selectedIndex !== 0) { + search.selectedIndex = 0; + } + } else if (totalItems > 0 && search.selectedIndex >= totalItems) { + // Clamp if index is out of bounds + search.selectedIndex = totalItems - 1; + } else if (totalItems === 0 && search.selectedIndex !== 0) { + search.selectedIndex = 0; + } + }); + + let searchNavContainer: HTMLElement | undefined = $state(); + + function scrollSelectedIntoView(index: number) { + if (!searchNavContainer) return; + const items = searchNavContainer.querySelectorAll('[role="option"]'); + const selectedItem = items[index] as HTMLElement | undefined; + if (selectedItem) { + selectedItem.scrollIntoView({block: "nearest", behavior: "smooth"}); + } + } + + function handleKeydown(e: KeyboardEvent) { + if (!isSearching) return; + + const activeElement = document.activeElement; + const isInputActive = + activeElement?.tagName === "INPUT" || + activeElement?.tagName === "TEXTAREA" || + activeElement?.tagName === "SELECT" || + activeElement?.getAttribute("contenteditable") === "true"; + + if (isInputActive && !activeElement?.classList.contains("search-input")) return; + + const totalItems = flattenedResults.length; + if (totalItems === 0) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + search.selectedIndex = (search.selectedIndex + 1) % totalItems; + scrollSelectedIntoView(search.selectedIndex); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + search.selectedIndex = (search.selectedIndex - 1 + totalItems) % totalItems; + scrollSelectedIntoView(search.selectedIndex); + } else if (e.key === "Enter") { + e.preventDefault(); + selectItem(search.selectedIndex); + } else if (e.key === "Tab" && !e.shiftKey) { + // Allow tab to move focus out of search results + clearSearch(); + } else if (e.key === "Escape") { + e.preventDefault(); + clearSearch(); + const searchInput = document.querySelector(".search-input") as HTMLElement | null; + searchInput?.focus(); + } + } + + async function selectItem(index: number) { + const item = flattenedResults[index]; + if (!item) return; + + const route = item.result.categoryRoute; + const isExternal = item.type === "external" || route.startsWith("http"); + + if (item.type === "setting" && item.setting) { + setHighlightedSetting(item.setting.id); + if (isExternal) { + globalThis.open(route, "_blank", "noopener noreferrer"); + } else { + await goto(route); + } + } else { + if (isExternal) { + globalThis.open(route, "_blank", "noopener noreferrer"); + } else { + await goto(route); + } + } + clearSearch(); + } + + function getResultIcon(categoryId: string): string { + const iconMap: Record = { + application, + clipboard, + window, + colors, + fonts, + keybinds, + mouse, + gtk, + linux, + macos, + github, + ghostty, + sync, + calligraphy + }; + return iconMap[categoryId] || application; + } + + function isExternalCategory(categoryId: string): boolean { + return categoryId === "github" || categoryId === "ghostty"; + } {htmlTitle} + +
+
{@render children()}
- diff --git a/src/routes/settings/[category]/+page.svelte b/src/routes/settings/[category]/+page.svelte index bf98353..63f6c68 100644 --- a/src/routes/settings/[category]/+page.svelte +++ b/src/routes/settings/[category]/+page.svelte @@ -2,6 +2,7 @@ import Page from "$lib/views/Page.svelte"; import {page} from "$app/stores"; + import {tick} from "svelte"; import Switch from "$lib/components/settings/Switch.svelte"; import Item from "$lib/components/settings/Item.svelte"; import Group from "$lib/components/settings/Group.svelte"; @@ -22,19 +23,90 @@ import AppIconPreview from "$lib/views/AppIconPreview.svelte"; import type {HexColor} from "$lib/utils/colors"; import {resolve} from "$app/paths"; + import search, {clearHighlightedSetting} from "$lib/stores/search.svelte"; - - const category = $derived(settings.find(c => c.id === $page.params.category)); + const category = $derived(settings.find((c) => c.id === $page.params.category)); const title = $derived(category?.name ?? $page.params.category); - + let highlightTimeout: number | null = null; + + function smoothScrollTo(element: HTMLElement, duration: number = 300) { + const container = document.querySelector(".content-container") as HTMLElement; + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + const scrollTop = container.scrollTop; + const elementTop = + elementRect.top - + containerRect.top + + scrollTop - + containerRect.height / 2 + + elementRect.height / 2; + const targetScroll = Math.max(0, elementTop); + const startTime = performance.now(); + const startScroll = container.scrollTop; + + function animate(currentTime: number) { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + const easeProgress = 1 - Math.pow(1 - progress, 3); + container.scrollTop = startScroll + (targetScroll - startScroll) * easeProgress; + + if (progress < 1) { + requestAnimationFrame(animate); + } + } + + requestAnimationFrame(animate); + } + + async function scrollToHighlighted() { + const highlightedId = search.highlightedSettingId; + if (!highlightedId) return; + + if (highlightTimeout !== null) { + clearTimeout(highlightTimeout); + } + + await tick(); + await new Promise((r) => setTimeout(r, 50)); + + const element = document.querySelector( + `[data-setting-id="${highlightedId}"]` + ) as HTMLDivElement | null; + + if (element) { + smoothScrollTo(element, 200); + element.classList.add("highlight-flash"); + highlightTimeout = window.setTimeout(() => { + if (search.highlightedSettingId === highlightedId) { + element.classList.remove("highlight-flash"); + clearHighlightedSetting(); + } + }, 2000); + } + } + + $effect(() => { + if (search.highlightedSettingId) { + scrollToHighlighted(); + } + }); + {#if category} {#if category.id === "fonts"} - The font playground has moved to a separate page. + The font playground has moved to a separate page. {:else if category.id === "colors"} - You can reset a color to its default value by right clicking! + You can reset a color to its default value by right clicking! {/if} {#each category.groups as group (group.id)} @@ -53,28 +125,83 @@ {/if} {#each group.settings as setting, i (setting.id)} {#if i !== 0}{/if} - - {#if setting.type === "switch"} - - {:else if setting.type === "text"} - - {:else if setting.type === "number"} - - {:else if setting.type === "dropdown"} - - {:else if setting.type === "theme"} - - {:else if setting.type === "color"} - - {:else if setting.type === "palette"} - - {/if} - +
+ + {#if setting.type === "switch"} + + {:else if setting.type === "text"} + + {:else if setting.type === "number"} + + {:else if setting.type === "dropdown"} + + {:else if setting.type === "theme"} + + {:else if setting.type === "color"} + + {:else if setting.type === "palette"} + + {/if} + +
{/each}
{/each} {:else}

What Happened?

-

You shouldn't be here! If you followed a link, please report the bug on GitHub. Otherwise, go ahead and start browsing on the left.

+

+ You shouldn't be here! If you followed a link, please report the bug on GitHub. + Otherwise, go ahead and start browsing on the left. +

{/if} -
\ No newline at end of file + + +