keybinds
This commit is contained in:
15
src/App.tsx
15
src/App.tsx
@@ -60,13 +60,14 @@ export function App() {
|
||||
});
|
||||
});
|
||||
|
||||
//useKeyboard(
|
||||
//(keyEvent) => {
|
||||
////handle intra layer navigation
|
||||
//if(keyEvent.name == "up" || keyEvent.name)
|
||||
//},
|
||||
//{ release: false }, // Not strictly necessary
|
||||
//);
|
||||
useKeyboard(
|
||||
(keyEvent) => {
|
||||
//handle intra layer navigation
|
||||
if (keyEvent.name == "up" || keyEvent.name) {
|
||||
}
|
||||
},
|
||||
{ release: false }, // Not strictly necessary
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
|
||||
17
src/config/keybind.jsonc
Normal file
17
src/config/keybind.jsonc
Normal 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"],
|
||||
}
|
||||
@@ -1,134 +1,104 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import type { ParsedKey, Renderable } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { Keybind, DEFAULT_KEYBINDS, type KeybindsConfig } from "../utils/keybind"
|
||||
import { createSignal, onMount } from "solid-js";
|
||||
import { createSimpleContext } from "./helper";
|
||||
import {
|
||||
copyKeybindsIfNeeded,
|
||||
loadKeybindsFromFile,
|
||||
saveKeybindsToFile,
|
||||
} from "../utils/keybinds-persistence";
|
||||
import { createStore } from "solid-js/store";
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
// ── Type Definitions ────────────────────────────────────────────────────────────
|
||||
|
||||
const keybinds = createMemo(() => {
|
||||
const result: Record<string, Keybind.Info[]> = {}
|
||||
for (const [key, value] of Object.entries(mergedKeybinds)) {
|
||||
result[key] = Keybind.parse(value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
export type KeybindsResolved = {
|
||||
up: string[];
|
||||
down: string[];
|
||||
left: string[];
|
||||
right: string[];
|
||||
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[];
|
||||
};
|
||||
|
||||
// ── Context Implementation ────────────────────────────────────────────────────────────
|
||||
|
||||
export const { use: useKeybinds, provider: KeybindProvider } =
|
||||
createSimpleContext({
|
||||
name: "Keybinds",
|
||||
init: () => {
|
||||
const [store, setStore] = createStore({
|
||||
leader: false,
|
||||
})
|
||||
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);
|
||||
|
||||
const renderer = useRenderer()
|
||||
|
||||
let focus: Renderable | null = null
|
||||
let timeout: NodeJS.Timeout | undefined
|
||||
|
||||
function leader(active: boolean) {
|
||||
if (active) {
|
||||
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
|
||||
async function load() {
|
||||
await copyKeybindsIfNeeded();
|
||||
const keybinds = await loadKeybindsFromFile();
|
||||
setStore(keybinds);
|
||||
setReady(true);
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
if (focus && !renderer.currentFocusedRenderable) {
|
||||
focus.focus()
|
||||
}
|
||||
setStore("leader", false)
|
||||
}
|
||||
async function save() {
|
||||
saveKeybindsToFile(store);
|
||||
}
|
||||
|
||||
// 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
|
||||
function print(input: keyof KeybindsResolved): string {
|
||||
const keys = store[input] || [];
|
||||
return Array.isArray(keys) ? keys.join(", ") : keys;
|
||||
}
|
||||
|
||||
if (store.leader && evt.name) {
|
||||
setImmediate(() => {
|
||||
if (focus && renderer.currentFocusedRenderable === focus) {
|
||||
focus.focus()
|
||||
}
|
||||
leader(false)
|
||||
})
|
||||
}
|
||||
})
|
||||
function match(
|
||||
keybind: keyof KeybindsResolved,
|
||||
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean },
|
||||
): boolean {
|
||||
const keys = store[keybind];
|
||||
if (!keys) return false;
|
||||
|
||||
const result = {
|
||||
get all() {
|
||||
return keybinds()
|
||||
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;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load on mount
|
||||
onMount(() => {
|
||||
load().catch(() => {});
|
||||
});
|
||||
|
||||
return {
|
||||
get ready() {
|
||||
return ready();
|
||||
},
|
||||
get leader() {
|
||||
return store.leader
|
||||
get keybinds() {
|
||||
return store;
|
||||
},
|
||||
/**
|
||||
* Parse a keyboard event into a Keybind.Info.
|
||||
*/
|
||||
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)
|
||||
save,
|
||||
print,
|
||||
match,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -8,67 +8,71 @@ import {
|
||||
type ParentProps,
|
||||
For,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { useKeybind } from "../context/KeybindContext"
|
||||
import { useDialog } from "./dialog"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import type { KeybindsConfig } from "../utils/keybind"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { emit } from "../utils/event-bus"
|
||||
} from "solid-js";
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid";
|
||||
import { KeybindsResolved, useKeybinds } from "../context/KeybindContext";
|
||||
import { useDialog } from "./dialog";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { emit } from "../utils/event-bus";
|
||||
|
||||
/**
|
||||
* Command option for the command palette.
|
||||
*/
|
||||
export type CommandOption = {
|
||||
/** Display title */
|
||||
title: string
|
||||
title: string;
|
||||
/** Unique identifier */
|
||||
value: string
|
||||
value: string;
|
||||
/** Description shown below title */
|
||||
description?: string
|
||||
description?: string;
|
||||
/** Category for grouping */
|
||||
category?: string
|
||||
category?: string;
|
||||
/** Keybind reference */
|
||||
keybind?: keyof KeybindsConfig
|
||||
keybind?: keyof KeybindsResolved;
|
||||
/** Whether this command is suggested */
|
||||
suggested?: boolean
|
||||
suggested?: boolean;
|
||||
/** Slash command configuration */
|
||||
slash?: {
|
||||
name: string
|
||||
aliases?: string[]
|
||||
}
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
};
|
||||
/** Whether to hide from command list */
|
||||
hidden?: boolean
|
||||
hidden?: boolean;
|
||||
/** Whether command is enabled */
|
||||
enabled?: boolean
|
||||
enabled?: boolean;
|
||||
/** Footer text (usually keybind display) */
|
||||
footer?: string
|
||||
footer?: string;
|
||||
/** Handler when command is selected */
|
||||
onSelect?: (dialog: ReturnType<typeof useDialog>) => void
|
||||
}
|
||||
onSelect?: (dialog: ReturnType<typeof useDialog>) => void;
|
||||
};
|
||||
|
||||
type CommandContext = ReturnType<typeof init>
|
||||
const ctx = createContext<CommandContext>()
|
||||
type CommandContext = ReturnType<typeof init>;
|
||||
const ctx = createContext<CommandContext>();
|
||||
|
||||
function init() {
|
||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
const [registrations, setRegistrations] = createSignal<
|
||||
Accessor<CommandOption[]>[]
|
||||
>([]);
|
||||
const [suspendCount, setSuspendCount] = createSignal(0);
|
||||
const dialog = useDialog();
|
||||
const keybind = useKeybinds();
|
||||
|
||||
const entries = createMemo(() => {
|
||||
const all = registrations().flatMap((x) => x())
|
||||
const all = registrations().flatMap((x) => x());
|
||||
return all.map((x) => ({
|
||||
...x,
|
||||
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
||||
}))
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
const isEnabled = (option: CommandOption) => option.enabled !== false
|
||||
const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
|
||||
const isEnabled = (option: CommandOption) => option.enabled !== false;
|
||||
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(() =>
|
||||
visibleOptions()
|
||||
.filter((option) => option.suggested)
|
||||
@@ -77,23 +81,23 @@ function init() {
|
||||
value: `suggested:${option.value}`,
|
||||
category: "Suggested",
|
||||
})),
|
||||
)
|
||||
const suspended = () => suspendCount() > 0
|
||||
);
|
||||
const suspended = () => suspendCount() > 0;
|
||||
|
||||
// Handle keybind shortcuts
|
||||
useKeyboard((evt) => {
|
||||
if (suspended()) return
|
||||
if (dialog.isOpen) return
|
||||
if (suspended()) return;
|
||||
if (dialog.isOpen) return;
|
||||
for (const option of entries()) {
|
||||
if (!isEnabled(option)) continue
|
||||
if (!isEnabled(option)) continue;
|
||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||
evt.preventDefault()
|
||||
option.onSelect?.(dialog)
|
||||
emit("command.execute", { command: option.value })
|
||||
return
|
||||
evt.preventDefault();
|
||||
option.onSelect?.(dialog);
|
||||
emit("command.execute", { command: option.value });
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const result = {
|
||||
/**
|
||||
@@ -102,10 +106,10 @@ function init() {
|
||||
trigger(name: string) {
|
||||
for (const option of entries()) {
|
||||
if (option.value === name) {
|
||||
if (!isEnabled(option)) return
|
||||
option.onSelect?.(dialog)
|
||||
emit("command.execute", { command: name })
|
||||
return
|
||||
if (!isEnabled(option)) return;
|
||||
option.onSelect?.(dialog);
|
||||
emit("command.execute", { command: name });
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -114,159 +118,163 @@ function init() {
|
||||
*/
|
||||
slashes() {
|
||||
return visibleOptions().flatMap((option) => {
|
||||
const slash = option.slash
|
||||
if (!slash) return []
|
||||
const slash = option.slash;
|
||||
if (!slash) return [];
|
||||
return {
|
||||
display: "/" + slash.name,
|
||||
description: option.description ?? option.title,
|
||||
aliases: slash.aliases?.map((alias) => "/" + alias),
|
||||
onSelect: () => result.trigger(option.value),
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Enable/disable keybinds temporarily.
|
||||
*/
|
||||
keybinds(enabled: boolean) {
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1));
|
||||
},
|
||||
suspended,
|
||||
/**
|
||||
* Show the command palette dialog.
|
||||
*/
|
||||
show() {
|
||||
dialog.replace(() => <CommandDialog options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
|
||||
dialog.replace(() => (
|
||||
<CommandDialog
|
||||
options={visibleOptions()}
|
||||
suggestedOptions={suggestedOptions()}
|
||||
/>
|
||||
));
|
||||
},
|
||||
/**
|
||||
* Register commands. Returns cleanup function.
|
||||
*/
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb)
|
||||
setRegistrations((arr) => [results, ...arr])
|
||||
const results = createMemo(cb);
|
||||
setRegistrations((arr) => [results, ...arr]);
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results))
|
||||
})
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Get all visible options.
|
||||
*/
|
||||
get options() {
|
||||
return visibleOptions()
|
||||
return visibleOptions();
|
||||
},
|
||||
}
|
||||
return result
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useCommandDialog() {
|
||||
const value = useContext(ctx)
|
||||
const value = useContext(ctx);
|
||||
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) {
|
||||
const value = init()
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
const value = init();
|
||||
const dialog = useDialog();
|
||||
const keybind = useKeybinds();
|
||||
|
||||
// Open command palette on ctrl+p or command_list keybind
|
||||
useKeyboard((evt) => {
|
||||
if (value.suspended()) return
|
||||
if (dialog.isOpen) return
|
||||
if (evt.defaultPrevented) return
|
||||
if (value.suspended()) return;
|
||||
if (dialog.isOpen) return;
|
||||
if (evt.defaultPrevented) return;
|
||||
if (keybind.match("command_list", evt)) {
|
||||
evt.preventDefault()
|
||||
value.show()
|
||||
return
|
||||
evt.preventDefault();
|
||||
value.show();
|
||||
return;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command palette dialog component.
|
||||
*/
|
||||
function CommandDialog(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
|
||||
const { theme } = useTheme()
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
function CommandDialog(props: {
|
||||
options: CommandOption[];
|
||||
suggestedOptions: CommandOption[];
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const dialog = useDialog();
|
||||
const dimensions = useTerminalDimensions();
|
||||
const [filter, setFilter] = createSignal("");
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
|
||||
const filteredOptions = createMemo(() => {
|
||||
const query = filter().toLowerCase()
|
||||
const query = filter().toLowerCase();
|
||||
if (!query) {
|
||||
return [...props.suggestedOptions, ...props.options]
|
||||
return [...props.suggestedOptions, ...props.options];
|
||||
}
|
||||
return props.options.filter(
|
||||
(option) =>
|
||||
option.title.toLowerCase().includes(query) ||
|
||||
option.description?.toLowerCase().includes(query) ||
|
||||
option.category?.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
option.category?.toLowerCase().includes(query),
|
||||
);
|
||||
});
|
||||
|
||||
// Reset selection when filter changes
|
||||
createMemo(() => {
|
||||
filter()
|
||||
setSelectedIndex(0)
|
||||
})
|
||||
filter();
|
||||
setSelectedIndex(0);
|
||||
});
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "escape") {
|
||||
dialog.clear()
|
||||
evt.preventDefault()
|
||||
return
|
||||
dialog.clear();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return" || evt.name === "enter") {
|
||||
const option = filteredOptions()[selectedIndex()]
|
||||
const option = filteredOptions()[selectedIndex()];
|
||||
if (option) {
|
||||
option.onSelect?.(dialog)
|
||||
dialog.clear()
|
||||
option.onSelect?.(dialog);
|
||||
dialog.clear();
|
||||
}
|
||||
evt.preventDefault()
|
||||
return
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||
evt.preventDefault()
|
||||
return
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
|
||||
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1))
|
||||
evt.preventDefault()
|
||||
return
|
||||
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle text input
|
||||
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
setFilter((f) => f + evt.name)
|
||||
return
|
||||
setFilter((f) => f + evt.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "backspace") {
|
||||
setFilter((f) => f.slice(0, -1))
|
||||
return
|
||||
setFilter((f) => f.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const maxHeight = Math.floor(dimensions().height * 0.6)
|
||||
const maxHeight = Math.floor(dimensions().height * 0.6);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" padding={1}>
|
||||
{/* Search input */}
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
{"> "}
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
{filter() || "Type to search commands..."}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{"> "}</text>
|
||||
<text fg={theme.text}>{filter() || "Type to search commands..."}</text>
|
||||
</box>
|
||||
|
||||
{/* Command list */}
|
||||
@@ -274,14 +282,24 @@ function CommandDialog(props: { options: CommandOption[]; suggestedOptions: Comm
|
||||
<For each={filteredOptions().slice(0, 10)}>
|
||||
{(option, index) => (
|
||||
<box
|
||||
backgroundColor={index() === selectedIndex() ? theme.primary : undefined}
|
||||
backgroundColor={
|
||||
index() === selectedIndex() ? theme.primary : undefined
|
||||
}
|
||||
padding={1}
|
||||
>
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text
|
||||
fg={index() === selectedIndex() ? theme.selectedListItemText : theme.text}
|
||||
attributes={index() === selectedIndex() ? TextAttributes.BOLD : undefined}
|
||||
fg={
|
||||
index() === selectedIndex()
|
||||
? theme.selectedListItemText
|
||||
: theme.text
|
||||
}
|
||||
attributes={
|
||||
index() === selectedIndex()
|
||||
? TextAttributes.BOLD
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{option.title}
|
||||
</text>
|
||||
@@ -303,5 +321,5 @@ function CommandDialog(props: { options: CommandOption[]; suggestedOptions: Comm
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
32
src/utils/jsonc.ts
Normal file
32
src/utils/jsonc.ts
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
80
src/utils/keybinds-persistence.ts
Normal file
80
src/utils/keybinds-persistence.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user