This commit is contained in:
2026-02-10 01:26:18 -05:00
parent 64a2ba2751
commit 6053d4d02c
7 changed files with 367 additions and 436 deletions

View File

@@ -60,13 +60,14 @@ export function App() {
}); });
}); });
//useKeyboard( useKeyboard(
//(keyEvent) => { (keyEvent) => {
////handle intra layer navigation //handle intra layer navigation
//if(keyEvent.name == "up" || keyEvent.name) if (keyEvent.name == "up" || keyEvent.name) {
//}, }
//{ release: false }, // Not strictly necessary },
//); { release: false }, // Not strictly necessary
);
return ( return (
<ErrorBoundary <ErrorBoundary

17
src/config/keybind.jsonc Normal file
View File

@@ -0,0 +1,17 @@
{
"up": ["up", "k"],
"down": ["down", "j"],
"left": ["left", "h"],
"right": ["right", "l"],
"cycle": ["tab"], // this will cycle no matter the depth/orientation
"dive": ["return"],
"out": ["esc"],
"inverse": ["shift"],
"leader": ":", // will not trigger while focused on input
"quit": ["<leader>q"],
"audio-toggle": ["<leader>p"],
"audio-pause": [],
"audio-play": [],
"audio-next": ["<leader>n"],
"audio-prev": ["<leader>l"],
}

View File

@@ -1,134 +1,104 @@
import { createMemo } from "solid-js" import { createSignal, onMount } from "solid-js";
import type { ParsedKey, Renderable } from "@opentui/core" import { createSimpleContext } from "./helper";
import { createStore } from "solid-js/store" import {
import { useKeyboard, useRenderer } from "@opentui/solid" copyKeybindsIfNeeded,
import { createSimpleContext } from "./helper" loadKeybindsFromFile,
import { Keybind, DEFAULT_KEYBINDS, type KeybindsConfig } from "../utils/keybind" saveKeybindsToFile,
} from "../utils/keybinds-persistence";
import { createStore } from "solid-js/store";
/** // ── Type Definitions ────────────────────────────────────────────────────────────
* Keybind context provider for managing keyboard shortcuts.
*
* Features:
* - Leader key support (like vim's leader key)
* - Configurable keybindings
* - Key parsing and matching
* - Display-friendly key representations
*/
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
name: "Keybind",
init: (props: { keybinds?: Partial<KeybindsConfig> }) => {
// Merge default keybinds with custom keybinds
const customKeybinds = props.keybinds ?? {}
const mergedKeybinds = { ...DEFAULT_KEYBINDS, ...customKeybinds }
const keybinds = createMemo(() => { export type KeybindsResolved = {
const result: Record<string, Keybind.Info[]> = {} up: string[];
for (const [key, value] of Object.entries(mergedKeybinds)) { down: string[];
result[key] = Keybind.parse(value) left: string[];
} right: string[];
return result cycle: string[]; // this will cycle no matter the depth/orientation
}) dive: string[];
out: string[];
inverse: string[];
leader: string; // will not trigger while focused on input
quit: string[];
"audio-toggle": string[];
"audio-pause": [];
"audio-play": string[];
"audio-next": string[];
"audio-prev": string[];
};
const [store, setStore] = createStore({ // ── Context Implementation ────────────────────────────────────────────────────────────
leader: false,
})
const renderer = useRenderer() export const { use: useKeybinds, provider: KeybindProvider } =
createSimpleContext({
name: "Keybinds",
init: () => {
const [store, setStore] = createStore({
up: [],
down: [],
left: [],
right: [],
cycle: [],
dive: [],
out: [],
inverse: [],
leader: "",
quit: [],
"audio-toggle": [],
"audio-pause": [],
"audio-play": [],
"audio-next": [],
"audio-prev": [],
});
const [ready, setReady] = createSignal(false);
let focus: Renderable | null = null async function load() {
let timeout: NodeJS.Timeout | undefined await copyKeybindsIfNeeded();
const keybinds = await loadKeybindsFromFile();
function leader(active: boolean) { setStore(keybinds);
if (active) { setReady(true);
setStore("leader", true)
focus = renderer.currentFocusedRenderable
focus?.blur()
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
if (!store.leader) return
leader(false)
if (!focus || focus.isDestroyed) return
focus.focus()
}, 2000) // Leader key timeout
return
} }
if (!active) { async function save() {
if (focus && !renderer.currentFocusedRenderable) { saveKeybindsToFile(store);
focus.focus() }
function print(input: keyof KeybindsResolved): string {
const keys = store[input] || [];
return Array.isArray(keys) ? keys.join(", ") : keys;
}
function match(
keybind: keyof KeybindsResolved,
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean },
): boolean {
const keys = store[keybind];
if (!keys) return false;
for (const key of keys) {
if (evt.name === key) return true;
if (evt.shift && key.toLowerCase() !== key) return false;
if (evt.ctrl && !key.toLowerCase().includes("ctrl")) return false;
if (evt.meta && !key.toLowerCase().includes("meta")) return false;
} }
setStore("leader", false) return false;
}
}
// Handle leader key
useKeyboard(async (evt) => {
// Don't intercept leader key when a text-editing renderable (input/textarea)
// has focus — let it handle text input (including space for the leader key).
const focused = renderer.currentFocusedRenderable
if (focused && "insertText" in focused) return
if (!store.leader && result.match("leader", evt)) {
leader(true)
return
} }
if (store.leader && evt.name) { // Load on mount
setImmediate(() => { onMount(() => {
if (focus && renderer.currentFocusedRenderable === focus) { load().catch(() => {});
focus.focus() });
}
leader(false)
})
}
})
const result = { return {
get all() { get ready() {
return keybinds() return ready();
}, },
get leader() { get keybinds() {
return store.leader return store;
}, },
/** save,
* Parse a keyboard event into a Keybind.Info. print,
*/ match,
parse(evt: ParsedKey): Keybind.Info { };
// Handle special case for Ctrl+Underscore (represented as \x1F) },
if (evt.name === "\x1F") { });
return Keybind.fromParsedKey({ ...evt, name: "_", ctrl: true }, store.leader)
}
return Keybind.fromParsedKey(evt, store.leader)
},
/**
* Check if a keyboard event matches a registered keybind.
*/
match(key: keyof KeybindsConfig, evt: ParsedKey): boolean {
const keybind = keybinds()[key]
if (!keybind) return false
const parsed: Keybind.Info = result.parse(evt)
for (const kb of keybind) {
if (Keybind.match(kb, parsed)) {
return true
}
}
return false
},
/**
* Get a display string for a registered keybind.
*/
print(key: keyof KeybindsConfig): string {
const first = keybinds()[key]?.at(0)
if (!first) return ""
const display = Keybind.toString(first)
// Replace leader placeholder with actual leader key
const leaderKey = keybinds().leader?.[0]
if (leaderKey) {
return display.replace("<leader>", Keybind.toString(leaderKey))
}
return display
},
}
return result
},
})

View File

@@ -8,67 +8,71 @@ import {
type ParentProps, type ParentProps,
For, For,
Show, Show,
} from "solid-js" } from "solid-js";
import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useKeyboard, useTerminalDimensions } from "@opentui/solid";
import { useKeybind } from "../context/KeybindContext" import { KeybindsResolved, useKeybinds } from "../context/KeybindContext";
import { useDialog } from "./dialog" import { useDialog } from "./dialog";
import { useTheme } from "../context/ThemeContext" import { useTheme } from "../context/ThemeContext";
import type { KeybindsConfig } from "../utils/keybind" import { TextAttributes } from "@opentui/core";
import { TextAttributes } from "@opentui/core" import { emit } from "../utils/event-bus";
import { emit } from "../utils/event-bus"
/** /**
* Command option for the command palette. * Command option for the command palette.
*/ */
export type CommandOption = { export type CommandOption = {
/** Display title */ /** Display title */
title: string title: string;
/** Unique identifier */ /** Unique identifier */
value: string value: string;
/** Description shown below title */ /** Description shown below title */
description?: string description?: string;
/** Category for grouping */ /** Category for grouping */
category?: string category?: string;
/** Keybind reference */ /** Keybind reference */
keybind?: keyof KeybindsConfig keybind?: keyof KeybindsResolved;
/** Whether this command is suggested */ /** Whether this command is suggested */
suggested?: boolean suggested?: boolean;
/** Slash command configuration */ /** Slash command configuration */
slash?: { slash?: {
name: string name: string;
aliases?: string[] aliases?: string[];
} };
/** Whether to hide from command list */ /** Whether to hide from command list */
hidden?: boolean hidden?: boolean;
/** Whether command is enabled */ /** Whether command is enabled */
enabled?: boolean enabled?: boolean;
/** Footer text (usually keybind display) */ /** Footer text (usually keybind display) */
footer?: string footer?: string;
/** Handler when command is selected */ /** Handler when command is selected */
onSelect?: (dialog: ReturnType<typeof useDialog>) => void onSelect?: (dialog: ReturnType<typeof useDialog>) => void;
} };
type CommandContext = ReturnType<typeof init> type CommandContext = ReturnType<typeof init>;
const ctx = createContext<CommandContext>() const ctx = createContext<CommandContext>();
function init() { function init() {
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([]) const [registrations, setRegistrations] = createSignal<
const [suspendCount, setSuspendCount] = createSignal(0) Accessor<CommandOption[]>[]
const dialog = useDialog() >([]);
const keybind = useKeybind() const [suspendCount, setSuspendCount] = createSignal(0);
const dialog = useDialog();
const keybind = useKeybinds();
const entries = createMemo(() => { const entries = createMemo(() => {
const all = registrations().flatMap((x) => x()) const all = registrations().flatMap((x) => x());
return all.map((x) => ({ return all.map((x) => ({
...x, ...x,
footer: x.keybind ? keybind.print(x.keybind) : undefined, footer: x.keybind ? keybind.print(x.keybind) : undefined,
})) }));
}) });
const isEnabled = (option: CommandOption) => option.enabled !== false const isEnabled = (option: CommandOption) => option.enabled !== false;
const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden const isVisible = (option: CommandOption) =>
isEnabled(option) && !option.hidden;
const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option))) const visibleOptions = createMemo(() =>
entries().filter((option) => isVisible(option)),
);
const suggestedOptions = createMemo(() => const suggestedOptions = createMemo(() =>
visibleOptions() visibleOptions()
.filter((option) => option.suggested) .filter((option) => option.suggested)
@@ -77,23 +81,23 @@ function init() {
value: `suggested:${option.value}`, value: `suggested:${option.value}`,
category: "Suggested", category: "Suggested",
})), })),
) );
const suspended = () => suspendCount() > 0 const suspended = () => suspendCount() > 0;
// Handle keybind shortcuts // Handle keybind shortcuts
useKeyboard((evt) => { useKeyboard((evt) => {
if (suspended()) return if (suspended()) return;
if (dialog.isOpen) return if (dialog.isOpen) return;
for (const option of entries()) { for (const option of entries()) {
if (!isEnabled(option)) continue if (!isEnabled(option)) continue;
if (option.keybind && keybind.match(option.keybind, evt)) { if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault() evt.preventDefault();
option.onSelect?.(dialog) option.onSelect?.(dialog);
emit("command.execute", { command: option.value }) emit("command.execute", { command: option.value });
return return;
} }
} }
}) });
const result = { const result = {
/** /**
@@ -102,10 +106,10 @@ function init() {
trigger(name: string) { trigger(name: string) {
for (const option of entries()) { for (const option of entries()) {
if (option.value === name) { if (option.value === name) {
if (!isEnabled(option)) return if (!isEnabled(option)) return;
option.onSelect?.(dialog) option.onSelect?.(dialog);
emit("command.execute", { command: name }) emit("command.execute", { command: name });
return return;
} }
} }
}, },
@@ -114,159 +118,163 @@ function init() {
*/ */
slashes() { slashes() {
return visibleOptions().flatMap((option) => { return visibleOptions().flatMap((option) => {
const slash = option.slash const slash = option.slash;
if (!slash) return [] if (!slash) return [];
return { return {
display: "/" + slash.name, display: "/" + slash.name,
description: option.description ?? option.title, description: option.description ?? option.title,
aliases: slash.aliases?.map((alias) => "/" + alias), aliases: slash.aliases?.map((alias) => "/" + alias),
onSelect: () => result.trigger(option.value), onSelect: () => result.trigger(option.value),
} };
}) });
}, },
/** /**
* Enable/disable keybinds temporarily. * Enable/disable keybinds temporarily.
*/ */
keybinds(enabled: boolean) { keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1)) setSuspendCount((count) => count + (enabled ? -1 : 1));
}, },
suspended, suspended,
/** /**
* Show the command palette dialog. * Show the command palette dialog.
*/ */
show() { show() {
dialog.replace(() => <CommandDialog options={visibleOptions()} suggestedOptions={suggestedOptions()} />) dialog.replace(() => (
<CommandDialog
options={visibleOptions()}
suggestedOptions={suggestedOptions()}
/>
));
}, },
/** /**
* Register commands. Returns cleanup function. * Register commands. Returns cleanup function.
*/ */
register(cb: () => CommandOption[]) { register(cb: () => CommandOption[]) {
const results = createMemo(cb) const results = createMemo(cb);
setRegistrations((arr) => [results, ...arr]) setRegistrations((arr) => [results, ...arr]);
onCleanup(() => { onCleanup(() => {
setRegistrations((arr) => arr.filter((x) => x !== results)) setRegistrations((arr) => arr.filter((x) => x !== results));
}) });
}, },
/** /**
* Get all visible options. * Get all visible options.
*/ */
get options() { get options() {
return visibleOptions() return visibleOptions();
}, },
} };
return result return result;
} }
export function useCommandDialog() { export function useCommandDialog() {
const value = useContext(ctx) const value = useContext(ctx);
if (!value) { if (!value) {
throw new Error("useCommandDialog must be used within a CommandProvider") throw new Error("useCommandDialog must be used within a CommandProvider");
} }
return value return value;
} }
export function CommandProvider(props: ParentProps) { export function CommandProvider(props: ParentProps) {
const value = init() const value = init();
const dialog = useDialog() const dialog = useDialog();
const keybind = useKeybind() const keybind = useKeybinds();
// Open command palette on ctrl+p or command_list keybind // Open command palette on ctrl+p or command_list keybind
useKeyboard((evt) => { useKeyboard((evt) => {
if (value.suspended()) return if (value.suspended()) return;
if (dialog.isOpen) return if (dialog.isOpen) return;
if (evt.defaultPrevented) return if (evt.defaultPrevented) return;
if (keybind.match("command_list", evt)) { if (keybind.match("command_list", evt)) {
evt.preventDefault() evt.preventDefault();
value.show() value.show();
return return;
} }
}) });
return <ctx.Provider value={value}>{props.children}</ctx.Provider> return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
} }
/** /**
* Command palette dialog component. * Command palette dialog component.
*/ */
function CommandDialog(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) { function CommandDialog(props: {
const { theme } = useTheme() options: CommandOption[];
const dialog = useDialog() suggestedOptions: CommandOption[];
const dimensions = useTerminalDimensions() }) {
const [filter, setFilter] = createSignal("") const { theme } = useTheme();
const [selectedIndex, setSelectedIndex] = createSignal(0) const dialog = useDialog();
const dimensions = useTerminalDimensions();
const [filter, setFilter] = createSignal("");
const [selectedIndex, setSelectedIndex] = createSignal(0);
const filteredOptions = createMemo(() => { const filteredOptions = createMemo(() => {
const query = filter().toLowerCase() const query = filter().toLowerCase();
if (!query) { if (!query) {
return [...props.suggestedOptions, ...props.options] return [...props.suggestedOptions, ...props.options];
} }
return props.options.filter( return props.options.filter(
(option) => (option) =>
option.title.toLowerCase().includes(query) || option.title.toLowerCase().includes(query) ||
option.description?.toLowerCase().includes(query) || option.description?.toLowerCase().includes(query) ||
option.category?.toLowerCase().includes(query) option.category?.toLowerCase().includes(query),
) );
}) });
// Reset selection when filter changes // Reset selection when filter changes
createMemo(() => { createMemo(() => {
filter() filter();
setSelectedIndex(0) setSelectedIndex(0);
}) });
useKeyboard((evt) => { useKeyboard((evt) => {
if (evt.name === "escape") { if (evt.name === "escape") {
dialog.clear() dialog.clear();
evt.preventDefault() evt.preventDefault();
return return;
} }
if (evt.name === "return" || evt.name === "enter") { if (evt.name === "return" || evt.name === "enter") {
const option = filteredOptions()[selectedIndex()] const option = filteredOptions()[selectedIndex()];
if (option) { if (option) {
option.onSelect?.(dialog) option.onSelect?.(dialog);
dialog.clear() dialog.clear();
} }
evt.preventDefault() evt.preventDefault();
return return;
} }
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) { if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
setSelectedIndex((i) => Math.max(0, i - 1)) setSelectedIndex((i) => Math.max(0, i - 1));
evt.preventDefault() evt.preventDefault();
return return;
} }
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) { if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1)) setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1));
evt.preventDefault() evt.preventDefault();
return return;
} }
// Handle text input // Handle text input
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) { if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
setFilter((f) => f + evt.name) setFilter((f) => f + evt.name);
return return;
} }
if (evt.name === "backspace") { if (evt.name === "backspace") {
setFilter((f) => f.slice(0, -1)) setFilter((f) => f.slice(0, -1));
return return;
} }
}) });
const maxHeight = Math.floor(dimensions().height * 0.6) const maxHeight = Math.floor(dimensions().height * 0.6);
return ( return (
<box flexDirection="column" padding={1}> <box flexDirection="column" padding={1}>
{/* Search input */} {/* Search input */}
<box marginBottom={1}> <box marginBottom={1}>
<text fg={theme.textMuted}> <text fg={theme.textMuted}>{"> "}</text>
{"> "} <text fg={theme.text}>{filter() || "Type to search commands..."}</text>
</text>
<text fg={theme.text}>
{filter() || "Type to search commands..."}
</text>
</box> </box>
{/* Command list */} {/* Command list */}
@@ -274,14 +282,24 @@ function CommandDialog(props: { options: CommandOption[]; suggestedOptions: Comm
<For each={filteredOptions().slice(0, 10)}> <For each={filteredOptions().slice(0, 10)}>
{(option, index) => ( {(option, index) => (
<box <box
backgroundColor={index() === selectedIndex() ? theme.primary : undefined} backgroundColor={
index() === selectedIndex() ? theme.primary : undefined
}
padding={1} padding={1}
> >
<box flexDirection="column" flexGrow={1}> <box flexDirection="column" flexGrow={1}>
<box flexDirection="row" justifyContent="space-between"> <box flexDirection="row" justifyContent="space-between">
<text <text
fg={index() === selectedIndex() ? theme.selectedListItemText : theme.text} fg={
attributes={index() === selectedIndex() ? TextAttributes.BOLD : undefined} index() === selectedIndex()
? theme.selectedListItemText
: theme.text
}
attributes={
index() === selectedIndex()
? TextAttributes.BOLD
: undefined
}
> >
{option.title} {option.title}
</text> </text>
@@ -303,5 +321,5 @@ function CommandDialog(props: { options: CommandOption[]; suggestedOptions: Comm
</Show> </Show>
</box> </box>
</box> </box>
) );
} }

32
src/utils/jsonc.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* JSONC parser utility for handling JSON with comments
*
* JSONC (JSON with Comments) is a superset of JSON that allows single-line
* and multi-line comments, which is useful for configuration files.
*/
/**
* Remove JSONC comments from a string
*/
export function stripComments(jsonString: string): string {
const comments = [
{ pattern: /\/\/.*$/gm, replacement: "" },
{ pattern: /\/\*[\s\S]*?\*\//g, replacement: "" },
];
let result = jsonString;
for (const { pattern, replacement } of comments) {
result = result.replace(pattern, replacement);
}
return result;
}
/**
* Parse JSONC string into a JavaScript object
*/
export function parseJSONC(jsonString: string): unknown {
const stripped = stripComments(jsonString);
return JSON.parse(stripped);
}

View File

@@ -1,187 +0,0 @@
import type { ParsedKey } from "@opentui/core"
/**
* Keyboard shortcut parsing and matching utilities.
*
* Supports key combinations like:
* - "ctrl+c" - Control + c
* - "alt+x" - Alt + x
* - "shift+enter" - Shift + Enter
* - "<leader>n" - Leader key followed by n
* - "ctrl+shift+p" - Control + Shift + p
*/
export namespace Keybind {
export interface Info {
key: string
ctrl: boolean
alt: boolean
shift: boolean
meta: boolean
leader: boolean
}
/**
* Parse a keybind string into a structured Info object.
*
* Examples:
* - "ctrl+c" -> { key: "c", ctrl: true, ... }
* - "<leader>n" -> { key: "n", leader: true, ... }
* - "alt+shift+x" -> { key: "x", alt: true, shift: true, ... }
*/
export function parse(input: string): Info[] {
if (!input) return []
// Handle multiple keybinds separated by comma or space
const parts = input.split(/[,\s]+/).filter(Boolean)
return parts.map((part) => {
const info: Info = {
key: "",
ctrl: false,
alt: false,
shift: false,
meta: false,
leader: false,
}
// Check for leader key prefix
if (part.startsWith("<leader>")) {
info.leader = true
part = part.substring(8) // Remove "<leader>"
}
// Split by + for modifiers
const tokens = part.toLowerCase().split("+")
for (const token of tokens) {
switch (token) {
case "ctrl":
case "control":
info.ctrl = true
break
case "alt":
case "option":
info.alt = true
break
case "shift":
info.shift = true
break
case "meta":
case "cmd":
case "command":
case "win":
case "super":
info.meta = true
break
default:
// The last non-modifier token is the key
info.key = token
}
}
return info
})
}
/**
* Convert a ParsedKey event to a Keybind.Info.
*/
export function fromParsedKey(evt: ParsedKey, leader: boolean = false): Info {
// ParsedKey has ctrl, shift, meta but may not have alt directly
// We need to check what properties are available
const evtAny = evt as unknown as Record<string, unknown>
return {
key: evt.name?.toLowerCase() ?? "",
ctrl: evt.ctrl ?? false,
alt: (evtAny.alt as boolean) ?? false,
shift: evt.shift ?? false,
meta: evt.meta ?? false,
leader,
}
}
/**
* Check if a keybind matches a parsed key event.
*/
export function match(keybind: Info, evt: Info): boolean {
return (
keybind.key === evt.key &&
keybind.ctrl === evt.ctrl &&
keybind.alt === evt.alt &&
keybind.shift === evt.shift &&
keybind.meta === evt.meta &&
keybind.leader === evt.leader
)
}
/**
* Convert a keybind Info to a display string.
*/
export function toString(info: Info): string {
const parts: string[] = []
if (info.leader) parts.push("<leader>")
if (info.ctrl) parts.push("Ctrl")
if (info.alt) parts.push("Alt")
if (info.shift) parts.push("Shift")
if (info.meta) parts.push("Cmd")
if (info.key) {
// Capitalize special keys
const displayKey = info.key.length === 1 ? info.key.toUpperCase() : capitalize(info.key)
parts.push(displayKey)
}
return parts.join("+")
}
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}
}
/**
* Default keybindings configuration.
*/
export const DEFAULT_KEYBINDS = {
// Leader key (space by default)
leader: "space",
// Navigation
tab_next: "tab",
tab_prev: "shift+tab",
// App commands
command_list: "ctrl+p",
help: "?",
quit: "ctrl+c",
// Session/content
session_new: "<leader>n",
session_list: "<leader>s",
// Theme
theme_list: "<leader>t",
// Player
player_play: "space",
player_pause: "space",
player_next: "n",
player_prev: "p",
player_seek_forward: "l",
player_seek_backward: "h",
// List navigation
list_up: "k",
list_down: "j",
list_top: "g",
list_bottom: "G",
list_select: "enter",
// Search
search_focus: "/",
search_clear: "escape",
}
export type KeybindsConfig = typeof DEFAULT_KEYBINDS

View File

@@ -0,0 +1,80 @@
/**
* Keybinds persistence via JSONC file in XDG_CONFIG_HOME
*
* Handles copying keybind.jsonc from package to user config directory
* and loading/saving keybind configurations.
*/
import { copyFile, mkdir } from "fs/promises";
import path from "path";
import { parseJSONC } from "./jsonc";
import { getConfigFilePath, ensureConfigDir } from "./config-dir";
import type { KeybindsResolved } from "../context/KeybindContext";
const KEYBINDS_SOURCE = path.join(process.cwd(), "src", "config", "keybind.jsonc");
const KEYBINDS_FILE = "keybinds.jsonc";
/** Default keybinds from package */
const DEFAULT_KEYBINDS: KeybindsResolved = {
up: ["up", "k"],
down: ["down", "j"],
left: ["left", "h"],
right: ["right", "l"],
cycle: ["tab"],
dive: ["return"],
out: ["esc"],
inverse: ["shift"],
leader: ":",
quit: ["<leader>q"],
"audio-toggle": ["<leader>p"],
"audio-pause": [],
"audio-play": [],
"audio-next": ["<leader>n"],
"audio-prev": ["<leader>l"],
};
/** Copy keybind.jsonc to user config directory on first run */
export async function copyKeybindsIfNeeded(): Promise<void> {
try {
const targetPath = getConfigFilePath(KEYBINDS_FILE);
// Check if file already exists
const targetFile = Bun.file(targetPath);
if (await targetFile.exists()) return;
await ensureConfigDir();
await copyFile(KEYBINDS_SOURCE, targetPath);
} catch {
// Silently ignore errors
}
}
/** Load keybinds from JSONC file */
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
try {
const filePath = getConfigFilePath(KEYBINDS_FILE);
const file = Bun.file(filePath);
if (!(await file.exists())) return DEFAULT_KEYBINDS;
const raw = await file.text();
const parsed = parseJSONC(raw);
if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS;
return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved;
} catch {
return DEFAULT_KEYBINDS;
}
}
/** Save keybinds to JSONC file */
export async function saveKeybindsToFile(keybinds: KeybindsResolved): Promise<void> {
try {
await ensureConfigDir();
const filePath = getConfigFilePath(KEYBINDS_FILE);
await Bun.write(filePath, JSON.stringify(keybinds, null, 2));
} catch {
// Silently ignore write errors
}
}