start revive

This commit is contained in:
2026-07-30 21:25:03 -04:00
parent b7c4938c54
commit d8f11040bc
25 changed files with 5344 additions and 2559 deletions

View File

@@ -1,136 +1,375 @@
import { createSignal, onMount } from "solid-js";
import { createSimpleContext } from "./helper";
import {
copyKeybindsIfNeeded,
loadKeybindsFromFile,
saveKeybindsToFile,
copyKeybindsIfNeeded,
loadKeybindsFromFile,
saveKeybindsToFile,
} from "../utils/keybinds-persistence";
import { createStore } from "solid-js/store";
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[];
inverseModifier: string;
leader: string; // will not trigger while focused on input
quit: string[];
select: string[]; // for selecting/activating items
"audio-toggle": string[];
"audio-pause": string[];
"audio-play": string[];
"audio-next": string[];
"audio-prev": string[];
"audio-seek-forward": string[];
"audio-seek-backward": string[];
};
// ── Keybind model ───────────────────────────────────────────────────────────
// Yazi-style: every binding is one or more "strokes". A stroke is a single
// key press (key + optional ctrl/shift/meta). Multi-stroke bindings form a
// sequence (e.g. ["g","g"] = gg, ["space","n"] = <leader>n). The matcher
// buffers keystrokes, prefers the longest matching sequence, and exposes the
// pending buffer reactively so the status bar can show it (very yazi).
export enum KeybindAction {
UP,
DOWN,
LEFT,
RIGHT,
CYCLE,
DIVE,
OUT,
QUIT,
SELECT,
AUDIO_TOGGLE,
AUDIO_PAUSE,
AUDIO_PLAY,
AUDIO_NEXT,
AUDIO_PREV,
AUDIO_SEEK_F,
AUDIO_SEEK_B,
/** A single key press. `key` is the lowercase logical key name
* ("j", "return", "space", "up", "f1", ...). */
export interface Stroke {
key: string;
ctrl?: boolean;
shift?: boolean;
meta?: boolean;
}
/** Raw config spec for one action: a list of alternative sequences. Each
* alternative is itself a list of stroke-notation strings. So
* "j" -> [[ {key:"j"} ]]
* ["j","down"] -> [[ {key:"j"} ], [ {key:"down"} ]]
* [["g","g"],"G"] -> [[ {key:"g"},{key:"g"} ], [ {key:"g",shift:true} ]] */
export type KeybindSpec = string | (string | string[])[];
/** Canonical action names. Must match keys in keybinds.jsonc. */
export type KeybindActionName =
| "move-down"
| "move-up"
| "page-down"
| "page-up"
| "full-down"
| "full-up"
| "jump-down"
| "jump-up"
| "goto-top"
| "goto-bottom"
| "swipe-prev"
| "swipe-next"
| "open"
| "open-interactive"
| "toggle-select"
| "visual-mode"
| "toggle-all"
| "invert-all"
| "escape"
| "tab-prev"
| "tab-next"
| "tab-goto-1"
| "tab-goto-2"
| "tab-goto-3"
| "tab-goto-4"
| "tab-goto-5"
| "tab-goto-6"
| "command"
| "quit"
| "help"
| "search"
| "filter"
| "sort"
| "toggle-hidden"
| "refresh"
| "audio-toggle"
| "audio-next"
| "audio-prev"
| "audio-seek-forward"
| "audio-seek-backward"
// legacy compat (kept so older callers don't crash)
| "select"
| "leader"
| "inverseModifier"
| "cycle"
| "dive"
| "out"
| "up"
| "down"
| "left"
| "right"
| "audio-pause"
| "audio-play";
/** Resolved config: action -> list of alternative stroke-sequences. */
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
const SEQ_TIMEOUT_MS = 600;
// ── Stroke parsing ───────────────────────────────────────────────────────────
// Notation: "ctrl-d", "shift-j", "meta-x", "C-d", "M-x", "S-j".
// uppercase letter "G" => {key:"g", shift:true}
// special: return/enter/escape/tab/space/backspace/up/down/left/right
export function parseStroke(notation: string): Stroke {
const raw = notation.trim();
let ctrl = false,
shift = false,
meta = false;
let key = raw;
const parts = raw.split(/[-+]/);
if (parts.length > 1) {
key = parts[parts.length - 1];
for (const mod of parts.slice(0, -1)) {
const m = mod.toLowerCase();
if (m === "ctrl" || m === "c") ctrl = true;
else if (m === "shift" || m === "s") shift = true;
else if (m === "meta" || m === "alt" || m === "m") meta = true;
}
}
// Uppercase single letter w/o modifier => shift+letter (vim convention)
if (
parts.length === 1 &&
key.length === 1 &&
key >= "A" &&
key <= "Z" &&
!ctrl &&
!shift &&
!meta
) {
shift = true;
key = key.toLowerCase();
}
return { key: key.toLowerCase(), ctrl, shift, meta };
}
/** Turn one raw spec into a list of alternative stroke-sequences. */
export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] {
if (spec == null) return [];
const alts: Stroke[][] = [];
const push = (item: string | string[]) => {
const seq = Array.isArray(item) ? item : [item];
alts.push(seq.map(parseStroke));
};
if (Array.isArray(spec)) {
for (const item of spec) push(item);
} else {
push(spec);
}
return alts;
}
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
export function strokeFromEvent(evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}): Stroke {
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
return {
key: (evt.name ?? "").toLowerCase(),
ctrl: !!evt.ctrl,
shift: !!evt.shift,
meta: !!evt.meta,
};
}
function strokeEq(a: Stroke, b: Stroke): boolean {
return (
a.key === b.key &&
!!a.ctrl === !!b.ctrl &&
!!a.shift === !!b.shift &&
!!a.meta === !!b.meta
);
}
/** A human label for a stroke, for the status bar / help. */
export function strokeLabel(s: Stroke): string {
let out = "";
if (s.ctrl) out += "C-";
if (s.meta) out += "M-";
if (s.shift) out += "S-";
out += s.key;
return out;
}
export function sequenceLabel(seq: Stroke[]): string {
return seq.map(strokeLabel).join(" ");
}
export const { use: useKeybinds, provider: KeybindProvider } =
createSimpleContext({
name: "Keybinds",
init: () => {
const [store, setStore] = createStore({
up: [],
down: [],
left: [],
right: [],
cycle: [],
dive: [],
out: [],
inverseModifier: "",
leader: "",
quit: [],
select: [],
refresh: [],
"audio-toggle": [],
"audio-pause": [],
"audio-play": [],
"audio-next": [],
"audio-prev": [],
"audio-seek-forward": [],
"audio-seek-backward": [],
} as KeybindsResolved);
const [ready, setReady] = createSignal(false);
createSimpleContext({
name: "Keybinds",
init: () => {
const [store, setStore] = createStore<KeybindsResolved>({});
// Resolved sequences per action, recomputed when store changes.
const [resolved, setResolved] = createSignal<Record<string, Stroke[][]>>(
{},
);
const [ready, setReady] = createSignal(false);
const [pending, setPending] = createSignal<Stroke[]>([]);
async function load() {
await copyKeybindsIfNeeded();
const keybinds = await loadKeybindsFromFile();
setStore(keybinds);
setReady(true);
}
let pendingTimer: ReturnType<typeof setTimeout> | undefined;
async function save() {
saveKeybindsToFile(store);
}
function recompute() {
const out: Record<string, Stroke[][]> = {};
for (const name of Object.keys(store) as string[]) {
out[name] = parseBindingSpec((store as any)[name]);
}
setResolved(out);
}
function print(input: keyof KeybindsResolved): string {
const keys = store[input] || [];
return Array.isArray(keys) ? keys.join(", ") : keys;
}
async function load() {
await copyKeybindsIfNeeded();
const keybinds = await loadKeybindsFromFile();
setStore(keybinds);
recompute();
setReady(true);
}
function match(
keybind: keyof KeybindsResolved,
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean },
): boolean {
const keys = store[keybind];
if (!keys) return false;
async function save() {
saveKeybindsToFile(store as KeybindsResolved);
}
for (const key of keys) {
if (evt.name === key) return true;
}
return false;
}
function print(input: KeybindActionName): string {
const alts = resolved()[input] ?? [];
return alts.map(sequenceLabel).join(" / ") || "—";
}
function isInverting(evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}) {
if (store.inverseModifier === "ctrl" && evt.ctrl) return true;
if (store.inverseModifier === "meta" && evt.meta) return true;
if (store.inverseModifier === "shift" && evt.shift) return true;
return false;
}
function clearPending() {
if (pendingTimer) {
clearTimeout(pendingTimer);
pendingTimer = undefined;
}
if (pending().length > 0) setPending([]);
}
// Load on mount
onMount(() => {
load().catch(() => {});
});
function armTimer() {
if (pendingTimer) clearTimeout(pendingTimer);
pendingTimer = setTimeout(() => clearPending(), SEQ_TIMEOUT_MS);
}
return {
get ready() {
return ready();
},
get keybinds() {
return store;
},
save,
print,
match,
isInverting,
};
},
});
/** Look up every action whose sequence-list the candidate is a prefix of
* (i.e. a longer match is still possible) and every action that the
* candidate exactly equals. */
function classify(candidate: Stroke[]) {
const exact: KeybindActionName[] = [];
const prefix: KeybindActionName[] = [];
const map = resolved();
for (const name of Object.keys(map) as KeybindActionName[]) {
for (const seq of map[name] ?? []) {
if (seq.length < candidate.length) continue;
let isPrefix = true;
for (let i = 0; i < candidate.length; i++) {
if (!strokeEq(seq[i], candidate[i])) {
isPrefix = false;
break;
}
}
if (!isPrefix) continue;
if (seq.length === candidate.length) exact.push(name);
else prefix.push(name);
}
}
return { exact, prefix };
}
/** Legacy single-key matcher (used by command-palette registrations and
* older call sites). Returns true iff `name`'s sequence list contains a
* single-stroke alternative equal to the event. */
function match(
name: KeybindActionName,
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean },
): boolean {
const alts = resolved()[name] ?? [];
const s = strokeFromEvent(evt);
// skip in command/input mode unless explicitly handled by caller
for (const seq of alts) {
if (seq.length === 1 && strokeEq(seq[0], s)) return true;
}
return false;
}
/** New sequence-aware matcher. Returns the resolved action or null.
* Callers should invoke this once per keypress in a single router. */
function tryMatch(evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}): KeybindActionName | null {
const stroke = strokeFromEvent(evt);
const candidate = [...pending(), stroke];
const { exact, prefix } = classify(candidate);
// Still mid-sequence: wait for more keys (unless this stroke also
// exactly matches something AND nothing depends on a longer prefix).
if (prefix.length > 0) {
setPending(candidate);
armTimer();
// If there's also an exact match, we *could* fire now — but yazi
// prefers to wait for the longer sequence within the timeout, then
// falls through. We honor that: only fire exact if no prefix.
if (exact.length > 0) {
// ambiguous prefix+exact: keep waiting (e.g. `g` could be gg)
}
return null;
}
// No longer-match possible: decide on exact.
clearPending();
if (exact.length === 0) {
// The new stroke might itself begin a fresh sequence.
const fresh = classify([stroke]);
if (fresh.prefix.length > 0) {
setPending([stroke]);
armTimer();
return null;
}
if (fresh.exact.length > 0) {
// prefer longest-sequence match among fresh.exact
return pickLongest(fresh.exact);
}
return null;
}
return pickLongest(exact);
}
function pickLongest(names: KeybindActionName[]): KeybindActionName {
const map = resolved();
let best = names[0];
let bestLen = 0;
for (const n of names) {
for (const seq of map[n] ?? []) {
if (seq.length > bestLen) {
bestLen = seq.length;
best = n;
}
}
}
return best;
}
// `isInverting` kept for legacy callers; yazi model has no inverse mod,
// so it always reports false. Migrated callers should use tryMatch().
function isInverting(_evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}): boolean {
return false;
}
onMount(() => {
load().catch(() => {});
});
return {
get ready() {
return ready();
},
get keybinds() {
return store;
},
get resolved() {
return resolved();
},
pending,
match,
tryMatch,
isInverting,
print,
save,
load,
clearPending,
};
},
});

View File

@@ -1,73 +1,305 @@
import { createEffect, createSignal, on } from "solid-js";
import { createEffect, createSignal, on, batch, createMemo } from "solid-js";
import { createSimpleContext } from "./helper";
import { TABS, TabsCount, LayerDepths } from "@/utils/navigation";
import { TABS, TabsCount } from "@/utils/navigation";
// Page-specific pane counts
const PANE_COUNTS = {
[TABS.FEED]: 1,
[TABS.MYSHOWS]: 2,
[TABS.DISCOVER]: 2,
[TABS.SEARCH]: 3,
[TABS.PLAYER]: 1,
[TABS.SETTINGS]: 5,
};
// ── Yazi-style navigation state ──────────────────────────────────────────────
// PodTui's interaction model after the yazi redesign. A single source of truth
// for: which tab is active, which pane within a tab is focused (parent |
// current | preview), the current mode (normal/visual/command/input), the
// count register (for `5j` style motions), and the command-bar buffer.
//
// Panes are addressed by index 0..N-1 within the active tab. Each tab declares
// how many panes it has via the PaneSystem registry (see navigation.ts). h/l
// (swipe-prev / swipe-next) move pane focus; j/k move within the focused pane's
// list (handled per-pane via the focusedIndex accessors below).
export enum NavMode {
NORMAL = "NORMAL",
VISUAL = "VISUAL",
COMMAND = "COMMAND",
INPUT = "INPUT",
}
/** Slot semantics mirror yazi's three columns. Slots beyond 2 exist for
* tabs that need more panes (e.g. search = query/results/detail). */
export enum PaneSlot {
PARENT = 0, // left — the container list (e.g. shows)
CURRENT = 1, // middle — the items (e.g. episodes)
PREVIEW = 2, // right — detail of the hovered item
}
export type PaneId = number; // 0-based index into the active tab's pane list
// ── Selection store ───────────────────────────────────────────────────────────
// A Set per (tab, paneKey). `paneKey` is a string each pane uses to namespace
// its selection (e.g. "myshows:episodes"). Visual mode toggles into range
// selection anchored at the focused index.
type SelectionMap = Record<string, Set<string>>;
const HAS_VISUAL = (mode: NavMode) => mode === NavMode.VISUAL;
export const { use: useNavigation, provider: NavigationProvider } =
createSimpleContext({
name: "Navigation",
init: () => {
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
const [activeDepth, setActiveDepth] = createSignal(0);
const [inputFocused, setInputFocused] = createSignal(false);
createSimpleContext({
name: "Navigation",
init: () => {
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
const [activePane, setActivePane] = createSignal<PaneId>(
PaneSlot.CURRENT,
);
const [mode, setMode] = createSignal<NavMode>(NavMode.NORMAL);
const [count, setCount] = createSignal<number | null>(null);
const [inputFocused, setInputFocused] = createSignal(false);
createEffect(
on(
() => activeTab,
() => setActiveDepth(0),
),
);
// per-pane focused index (for j/k movement). Keyed by `${tab}:${pane}`.
const [paneIndices, setPaneIndices] = createSignal<
Record<string, number>
>({});
const [selections, setSelections] = createSignal<SelectionMap>({});
const [visualAnchor, setVisualAnchor] = createSignal<{
paneKey: string;
index: number;
} | null>(null);
const nextTab = () => {
if (activeTab() >= TabsCount) {
setActiveTab(1);
return;
}
setActiveTab(activeTab() + 1);
};
const [commandBuffer, setCommandBuffer] = createSignal("");
const [commandError, setCommandError] = createSignal<string | null>(null);
const prevTab = () => {
if (activeTab() <= 1) {
setActiveTab(TabsCount);
return;
}
setActiveTab(activeTab() - 1);
};
// Reset depth/pane/mode on tab change.
createEffect(
on(activeTab, () => {
batch(() => {
setActivePane(PaneSlot.CURRENT);
setMode(NavMode.NORMAL);
setCount(null);
setCommandBuffer("");
setCommandError(null);
setVisualAnchor(null);
});
}),
);
const nextPane = () => {
// Move to next pane within the current tab's pane structure
const count = PANE_COUNTS[activeTab()];
if (count <= 1) return; // No panes to navigate (feed/player)
setActiveDepth((prev) => (prev % count) + 1);
};
// ── tab switching ──────────────────────────────────────────────────────
const gotoTab = (tab: TABS) => {
if (tab < 1 || tab > TabsCount) return;
setActiveTab(tab);
};
const nextTab = () =>
setActiveTab((t) => (t >= TabsCount ? 1 : ((t + 1) as TABS)));
const prevTab = () =>
setActiveTab((t) => (t <= 1 ? TabsCount : ((t - 1) as TABS)));
const prevPane = () => {
// Move to previous pane within the current tab's pane structure
const count = PANE_COUNTS[activeTab()];
if (count <= 1) return; // No panes to navigate (feed/player)
setActiveDepth((prev) => (prev - 2 + count) % count + 1);
};
// ── pane focus ──────────────────────────────────────────────────────────
const setPane = (pane: PaneId) => setActivePane(pane);
return {
activeTab,
activeDepth,
inputFocused,
setActiveTab,
setActiveDepth,
setInputFocused,
nextTab,
prevTab,
nextPane,
prevPane,
};
},
});
/** Move focus to the adjacent pane. `dir` = -1 (left/parent) or +1
* (right/preview). Clamped to [0, paneCount-1]. */
const swipe = (dir: -1 | 1, paneCount: number) => {
if (paneCount <= 1) return;
setActivePane((p) => {
const n = Math.max(0, Math.min(paneCount - 1, p + dir));
return n;
});
};
// ── per-pane focus index ────────────────────────────────────────────────
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
const focusedIndex = (pane: PaneId = activePane()) =>
paneIndices()[paneKey(pane)] ?? 0;
const setFocusedIndex = (pane: PaneId, index: number) =>
setPaneIndices((m) => ({ ...m, [`${activeTab()}:${pane}`]: index }));
/** Apply a clamped relative motion to the active pane's focus. Returns
* the new index so callers can update their own scroll state. */
const move = (
delta: number,
listLen: number,
countOverride?: number,
): number => {
if (listLen <= 0) return 0;
const steps = countOverride ?? count() ?? 1;
const pane = activePane();
const cur = focusedIndex(pane);
let next = cur + delta * steps;
// wrap-around like yazi (arrow wraps top<->bottom)
next = ((next % listLen) + listLen) % listLen;
setFocusedIndex(pane, next);
// visual-mode range selection: add newly-traversed items to selection
if (HAS_VISUAL(mode()) && visualAnchor()) {
growVisualSelection(next);
}
return next;
};
const gotoIndex = (index: number, listLen: number): number => {
if (listLen <= 0) return 0;
const pane = activePane();
const next = Math.max(0, Math.min(listLen - 1, index));
setFocusedIndex(pane, next);
if (HAS_VISUAL(mode()) && visualAnchor()) growVisualSelection(next);
return next;
};
// ── selection ───────────────────────────────────────────────────────────
const selSet = (key: string): Set<string> =>
selections()[key] ?? new Set();
const toggleSelected = (id: string) => {
const key = paneKey();
setSelections((m) => {
const set = new Set(m[key] ?? []);
if (set.has(id)) set.delete(id);
else set.add(id);
return { ...m, [key]: set };
});
};
const isSelected = (id: string) => selSet(paneKey()).has(id);
const clearSelection = (key?: string) => {
const k = key ?? paneKey();
setSelections((m) => {
if (!(k in m)) return m;
const next = { ...m };
delete next[k];
return next;
});
};
const selectedIds = createMemo(() => [...selSet(paneKey())]);
/** Enter visual mode, anchoring range selection at the current focus. */
const enterVisual = () => {
const pane = activePane();
setVisualAnchor({ paneKey: paneKey(pane), index: focusedIndex(pane) });
setMode(NavMode.VISUAL);
};
/** Grow selection between the visual anchor and `index` for the active
* pane. Callers pass item ids aligned to indices; we store ids via the
* resolve callback registered per-pane (see registerResolver). */
let resolvers: Record<string, (index: number) => string | undefined> = {};
const registerResolver = (
key: string,
fn: (i: number) => string | undefined,
) => {
resolvers[key] = fn;
};
const growVisualSelection = (index: number) => {
const anchor = visualAnchor();
if (!anchor) return;
const resolve = resolvers[anchor.paneKey];
if (!resolve) return;
const lo = Math.min(anchor.index, index);
const hi = Math.max(anchor.index, index);
const ids: string[] = [];
for (let i = lo; i <= hi; i++) {
const id = resolve(i);
if (id) ids.push(id);
}
const key = anchor.paneKey;
setSelections((m) => ({ ...m, [key]: new Set(ids) }));
};
// ── modes ────────────────────────────────────────────────────────────────
const enterCommand = () => {
setMode(NavMode.COMMAND);
setCommandBuffer("");
setCommandError(null);
};
const enterInput = () => setMode(NavMode.INPUT);
const exitCommand = () => {
batch(() => {
setMode(NavMode.NORMAL);
setCommandBuffer("");
setCommandError(null);
});
};
const exitVisual = () => {
batch(() => {
setMode(NavMode.NORMAL);
setVisualAnchor(null);
});
};
const toNormal = () => {
if (mode() === NavMode.VISUAL) {
clearSelection();
exitVisual();
} else {
setMode(NavMode.NORMAL);
}
};
// ── command buffer ───────────────────────────────────────────────────────
const appendCommand = (ch: string) => setCommandBuffer((b) => b + ch);
const backspaceCommand = () => setCommandBuffer((b) => b.slice(0, -1));
const submitCommand = (): string => {
const cmd = commandBuffer().trim();
exitCommand();
return cmd;
};
// ── count register ───────────────────────────────────────────────────────
const pushCountDigit = (d: number) => setCount((c) => (c ?? 0) * 10 + d);
const consumeCount = (): number => {
const c = count();
setCount(null);
return c ?? 1;
};
return {
activeTab,
activePane,
mode,
count,
inputFocused,
commandBuffer,
commandError,
visualAnchor,
selections,
selectedIds,
// tab
setActiveTab: gotoTab,
nextTab,
prevTab,
// pane focus
setActivePane: setPane,
swipe,
// focus index
focusedIndex,
setFocusedIndex,
move,
gotoIndex,
// selection
isSelected,
toggleSelected,
clearSelection,
selectedIdsFor: (key: string) => [...selSet(key)],
registerResolver,
enterVisual,
exitVisual,
// modes
setActiveTabSignal: setActiveTab,
setActiveDepth: setPane, // legacy alias
activeDepth: activePane, // legacy alias
setInputFocused,
nextPane: () => {}, // legacy noop; swipe() replaces this
prevPane: () => {},
setMode,
enterCommand,
enterInput,
exitCommand,
toNormal,
// command buffer
setCommandBuffer,
appendCommand,
backspaceCommand,
submitCommand,
setCommandError,
// count
pushCountDigit,
consumeCount,
};
},
});