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 @@
+
+
+
+
{@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}
+
+
+