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

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
}
}