Launcher
Input: a keyboard-focused app launcher (<Input>, arrows/Enter/Esc, hover, click, enter/exit animation) toggled with halo toggle launcher, plus a corner button with a popup attached to it.

Run it
swift run halo start --config examples/launcher # from a clone
halo start --config "$(brew --prefix)/share/halo/examples/launcher" # Homebrew
Source
main.tsx
// An app launcher plus a popup, exercising mouse, keyboard focus and named surfaces.
//
// halo toggle launcher # bind this to a hotkey in your WM, e.g. AeroSpace:
// # alt-space = 'exec-and-forget halo toggle launcher'
//
// Launcher: type to filter, ↑/↓ or hover to select, Enter or click to open, Esc or click elsewhere to close.
// Corner button: click for a popup anchored to it; click anywhere else to close it.
import {
Box,
Image,
Input,
PerScreen,
Surface,
Text,
exec,
hide,
sh,
show,
type NativeNode,
type ScreenInfo,
} from "halo";
import { clock } from "halo/services";
import { computed, signal } from "halo/signals";
const card = "#1e1e2ef2";
const foreground = "#cdd6f4";
const muted = "#9399b2";
const accent = "#89b4fa";
const selectedRow = "#89b4fa33";
interface App {
name: string;
path: string;
}
const apps = signal<App[]>([]);
sh(
"ls -d /Applications/*.app /Applications/*/*.app /System/Applications/*.app /System/Applications/Utilities/*.app ~/Applications/*.app 2>/dev/null",
).then((result) =>
apps.set(
result.stdout
.split("\n")
.filter(Boolean)
.map((path) => ({
path,
name: path
.split("/")
.pop()!
.replace(/\.app$/, ""),
}))
.sort((a, b) => a.name.localeCompare(b.name)),
),
);
function Launcher(props: { screen: ScreenInfo }) {
const query = signal("");
const selected = signal(0);
const results = computed(() => {
const q = query().toLowerCase();
const matches = apps().filter((app) => app.name.toLowerCase().includes(q));
matches.sort(
(a, b) =>
Number(!a.name.toLowerCase().startsWith(q)) -
Number(!b.name.toLowerCase().startsWith(q)),
);
return matches.slice(0, 7);
});
const open = (app: App | undefined) => {
if (!app) return;
exec("open", [app.path]);
hide("launcher");
};
const move = (delta: number) =>
selected.update((index) =>
Math.max(0, Math.min(results().length - 1, index + delta)),
);
return (
<Surface
name="launcher"
defaultHidden
screen={props.screen}
size={{ width: 560, height: 400 }}
layer="overlay"
input="keyboard"
onFocus={() => (query.set(""), selected.set(0))}
onBlur={() => hide("launcher")}
enter={{
from: { opacity: 0, scale: 0.96, translateY: -8 },
curve: { stiffness: 380, damping: 28 },
}}
exit={{ to: { opacity: 0, scale: 0.98 }, duration: 110, curve: "ease-in" }}
>
<Box
style={{
padding: 12,
gap: 8,
background: card,
radius: 18,
borderWidth: 1,
borderColor: "#ffffff1a",
}}
>
<Box style={{ flexDirection: "row", align: "center", gap: 10, padding: [6, 8] }}>
<Image symbol="magnifyingglass" size={18} color={muted} />
<Input
autofocus
value={query}
placeholder="Search apps"
style={{ grow: 1, fontSize: 20, color: foreground }}
onInput={(value) => (query.set(value), selected.set(0))}
onKeyDown={({ key }) => {
if (key === "ArrowDown") return (move(1), true);
if (key === "ArrowUp") return (move(-1), true);
if (key === "Enter") return (open(results()[selected()]), true);
if (key === "Escape") return (hide("launcher"), true);
}}
/>
</Box>
<Box style={{ height: 1, background: "#ffffff14" }} />
<Box style={{ gap: 2 }}>
{() =>
results().map((app, index) => (
<Box
style={{
flexDirection: "row",
align: "center",
gap: 10,
padding: [6, 10],
radius: 8,
background: () => (selected() === index ? selectedRow : "transparent"),
transition: { background: 90 },
}}
onHover={(hovered) => hovered && selected.set(index)}
onClick={() => open(app)}
>
<Image app={app.path} size={28} />
<Text style={{ fontSize: 14, color: foreground }}>{app.name}</Text>
</Box>
))
}
</Box>
</Box>
</Surface>
);
}
function CornerButton(props: { screen: ScreenInfo }) {
const open = signal(false);
const hovered = signal(false);
let button: NativeNode | undefined;
return (
<>
<Surface
screen={props.screen}
anchor={["bottom", "left"]}
margins={[0, 0, 16, 16]}
size={{ width: 44, height: 44 }}
layer="aboveMenu"
input="interactive"
>
<Box
ref={(node) => (button = node)}
style={{
justify: "center",
align: "center",
radius: 12,
background: () => (hovered() || open() ? accent : card),
}}
onHover={hovered.set}
onClick={() => open.update((value) => !value)}
>
<Image
symbol="calendar"
size={18}
color={() => (hovered() || open() ? "#1e1e2e" : foreground)}
/>
</Box>
</Surface>
<Surface
screen={props.screen}
size={{ width: 220, height: 128 }}
layer="overlay"
input="interactive"
visible={open}
attach={() => ({ to: button, side: "above", align: "start", offset: 8 })}
onClickOutside={() => open.set(false)}
>
<Box
style={{
padding: 14,
gap: 4,
background: card,
radius: 14,
borderWidth: 1,
borderColor: "#ffffff1a",
}}
>
<Text style={{ fontSize: 28, fontWeight: "semibold", color: foreground }}>
{clock.format("HH:mm")}
</Text>
<Text style={{ fontSize: 13, color: muted }}>
{clock.format("EEEE, d MMMM")}
</Text>
<Box style={{ flexDirection: "row", justify: "end", padding: [6, 0, 0, 0] }}>
<Box
style={{ padding: [4, 10], radius: 6, background: accent }}
onClick={() => (open.set(false), show("launcher"))}
>
<Text style={{ fontSize: 12, fontWeight: "semibold", color: "#1e1e2e" }}>
Open launcher
</Text>
</Box>
</Box>
</Box>
</Surface>
</>
);
}
export default () => (
<PerScreen>
{(screen) => (
<>
<Launcher screen={screen} />
<CornerButton screen={screen} />
</>
)}
</PerScreen>
);