keybinds
This commit is contained in:
15
src/App.tsx
15
src/App.tsx
@@ -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
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 { 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[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Context Implementation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const { use: useKeybinds, provider: KeybindProvider } =
|
||||||
|
createSimpleContext({
|
||||||
|
name: "Keybinds",
|
||||||
|
init: () => {
|
||||||
const [store, setStore] = createStore({
|
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()
|
async function load() {
|
||||||
|
await copyKeybindsIfNeeded();
|
||||||
let focus: Renderable | null = null
|
const keybinds = await loadKeybindsFromFile();
|
||||||
let timeout: NodeJS.Timeout | undefined
|
setStore(keybinds);
|
||||||
|
setReady(true);
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!active) {
|
async function save() {
|
||||||
if (focus && !renderer.currentFocusedRenderable) {
|
saveKeybindsToFile(store);
|
||||||
focus.focus()
|
|
||||||
}
|
|
||||||
setStore("leader", false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle leader key
|
function print(input: keyof KeybindsResolved): string {
|
||||||
useKeyboard(async (evt) => {
|
const keys = store[input] || [];
|
||||||
// Don't intercept leader key when a text-editing renderable (input/textarea)
|
return Array.isArray(keys) ? keys.join(", ") : keys;
|
||||||
// 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) {
|
function match(
|
||||||
setImmediate(() => {
|
keybind: keyof KeybindsResolved,
|
||||||
if (focus && renderer.currentFocusedRenderable === focus) {
|
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean },
|
||||||
focus.focus()
|
): boolean {
|
||||||
}
|
const keys = store[keybind];
|
||||||
leader(false)
|
if (!keys) return false;
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = {
|
for (const key of keys) {
|
||||||
get all() {
|
if (evt.name === key) return true;
|
||||||
return keybinds()
|
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() {
|
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
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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
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