198 lines
4.4 KiB
Svelte
198 lines
4.4 KiB
Svelte
<script lang="ts">
|
|
import Icon from '@iconify/svelte';
|
|
import { desktop, type DesktopAction } from '#lib/content';
|
|
import { allSitePages } from '#lib/site/pages';
|
|
import { runDesktopAction } from '#lib/desktop/actions';
|
|
import { windowManager } from '#lib/desktop/windows.svelte';
|
|
|
|
interface LauncherEntry {
|
|
label: string;
|
|
detail: string;
|
|
icon: string;
|
|
open: DesktopAction;
|
|
}
|
|
|
|
const pageEntries: LauncherEntry[] = allSitePages.map((page) => ({
|
|
label: page.title,
|
|
detail: page.path,
|
|
icon: page.icon,
|
|
open: { page: page.path }
|
|
}));
|
|
|
|
const iconEntries: LauncherEntry[] = desktop.iconGroups
|
|
.flatMap((group) => group.icons)
|
|
.filter((item) => !('page' in item.open))
|
|
.map((item) => ({
|
|
label: item.label,
|
|
detail: launcherDetail(item.open),
|
|
icon: item.icon,
|
|
open: item.open
|
|
}));
|
|
|
|
const entries = [...pageEntries, ...iconEntries];
|
|
|
|
let query = $state('');
|
|
let selectedIndex = $state(0);
|
|
let searchElement = $state<HTMLInputElement>();
|
|
|
|
const filteredEntries = $derived(
|
|
entries.filter((entry) => `${entry.label} ${entry.detail}`.toLowerCase().includes(query.toLowerCase()))
|
|
);
|
|
|
|
$effect(() => {
|
|
query;
|
|
selectedIndex = 0;
|
|
});
|
|
|
|
$effect(() => {
|
|
searchElement?.focus();
|
|
});
|
|
|
|
function close() {
|
|
windowManager.launcherOpen = false;
|
|
}
|
|
|
|
function launch(entry: LauncherEntry | undefined) {
|
|
if (entry) runDesktopAction(entry.open);
|
|
}
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if (event.key === 'Escape') close();
|
|
else if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
selectedIndex = Math.min(filteredEntries.length - 1, selectedIndex + 1);
|
|
} else if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
selectedIndex = Math.max(0, selectedIndex - 1);
|
|
} else if (event.key === 'Enter') launch(filteredEntries[selectedIndex]);
|
|
}
|
|
|
|
function launcherDetail(action: DesktopAction): string {
|
|
if ('terminal' in action) return `kitty ${action.terminal}`.trim();
|
|
if ('url' in action) return action.url;
|
|
if ('app' in action) return action.app;
|
|
return '';
|
|
}
|
|
</script>
|
|
|
|
<button class="backdrop" aria-label="Close launcher" onclick={close}></button>
|
|
<div class="launcher" role="dialog" aria-label="Launcher">
|
|
<div class="launcher-surface">
|
|
<label class="search">
|
|
<Icon icon="mdi:magnify" width="20" />
|
|
<input bind:this={searchElement} bind:value={query} onkeydown={handleKeydown} placeholder="Search apps and pages" />
|
|
</label>
|
|
<ul class="results">
|
|
{#each filteredEntries as entry, index (entry.label + entry.detail)}
|
|
<li>
|
|
<button class="result" class:selected={index === selectedIndex} onclick={() => launch(entry)} onmouseenter={() => (selectedIndex = index)}>
|
|
<Icon icon={entry.icon} width="20" />
|
|
<span class="result-label">{entry.label}</span>
|
|
<span class="result-detail">{entry.detail}</span>
|
|
</button>
|
|
</li>
|
|
{:else}
|
|
<li class="empty">No matches</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 60;
|
|
background: color-mix(in srgb, var(--dark-background) 40%, transparent);
|
|
border: none;
|
|
cursor: default;
|
|
}
|
|
|
|
.launcher {
|
|
position: fixed;
|
|
top: 18vh;
|
|
left: 50%;
|
|
z-index: 70;
|
|
width: min(640px, calc(100vw - 24px));
|
|
padding: var(--border-size);
|
|
background: var(--border-active);
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
.launcher-surface {
|
|
background: var(--background);
|
|
}
|
|
|
|
.search {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.6rem 0.8rem;
|
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.search input {
|
|
flex: 1;
|
|
padding: 0;
|
|
background: none;
|
|
border: none;
|
|
outline: none;
|
|
box-shadow: none;
|
|
color: var(--foreground);
|
|
font: inherit;
|
|
}
|
|
|
|
.results {
|
|
max-height: 50vh;
|
|
margin: 0;
|
|
padding: 0.3rem;
|
|
overflow-y: auto;
|
|
list-style: none;
|
|
}
|
|
|
|
.result {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
width: 100%;
|
|
padding: 0.4rem 0.5rem;
|
|
background: none;
|
|
border: none;
|
|
text-align: left;
|
|
}
|
|
|
|
.result.selected {
|
|
background: color-mix(in srgb, var(--foreground) 15%, transparent);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.result-label {
|
|
flex: 1;
|
|
}
|
|
|
|
.result-detail,
|
|
.empty {
|
|
color: var(--muted);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.empty {
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.launcher {
|
|
top: calc(var(--bar-height) + 8px);
|
|
}
|
|
|
|
.results {
|
|
max-height: 60dvh;
|
|
}
|
|
|
|
.result {
|
|
padding: 0.6rem 0.5rem;
|
|
}
|
|
}
|
|
</style>
|