feat(*): one task editor, vim navigation and undo

This commit is contained in:
hh
2026-08-15 03:13:50 +02:00
parent 0873cbb366
commit 83ca32d98e
37 changed files with 4043 additions and 505 deletions
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import { GROUPS, listStep, resolve, SHORTCUTS } from "../src/lib/keymap";
import type { Scope } from "../src/lib/keymap";
function press(
key: string,
init: { code?: string; ctrl?: boolean; meta?: boolean; shift?: boolean } = {},
): KeyboardEvent {
return {
key,
code: init.code ?? "",
ctrlKey: init.ctrl ?? false,
metaKey: init.meta ?? false,
altKey: false,
shiftKey: init.shift ?? false,
} as KeyboardEvent;
}
const DAY: Scope[] = ["day", "view"];
const TASK: Scope[] = ["task", "view"];
const TRAY: Scope[] = ["tray", "task", "view"];
const RAIL: Scope[] = ["rail", "view"];
describe("resolve", () => {
it("walks days on the grid and tasks inside one", () => {
expect(resolve(press("j", { code: "KeyJ" }), DAY)).toBe("dayDown");
expect(resolve(press("j", { code: "KeyJ" }), TASK)).toBe("taskNext");
});
it("leaves the day with h and l from either scope", () => {
expect(resolve(press("h", { code: "KeyH" }), DAY)).toBe("dayLeft");
expect(resolve(press("h", { code: "KeyH" }), TASK)).toBe("dayLeft");
});
it("separates a shifted letter from its bare form", () => {
expect(resolve(press("H", { code: "KeyH", shift: true }), TASK)).toBe(
"taskBack",
);
expect(resolve(press("h", { code: "KeyH" }), TASK)).toBe("dayLeft");
});
it("reads the physical key, so a Cyrillic layout still answers", () => {
expect(resolve(press("н", { code: "KeyN" }), DAY)).toBe("create");
expect(resolve(press("Т", { code: "KeyT", shift: true }), TASK)).toBe(
"taskToday",
);
});
it("falls through to the view scope", () => {
expect(resolve(press("/", { code: "Slash" }), DAY)).toBe("search");
expect(resolve(press("?", { code: "Slash", shift: true }), DAY)).toBe("help");
});
it("means different things by Enter and Escape per scope", () => {
expect(resolve(press("Enter", { code: "Enter" }), DAY)).toBe("dayEnter");
expect(resolve(press("Enter", { code: "Enter" }), TASK)).toBe("taskOpen");
expect(resolve(press("Escape", { code: "Escape" }), TASK)).toBe("taskExit");
expect(resolve(press("Escape", { code: "Escape" }), DAY)).toBe("dismiss");
});
it("ignores a modifier the table never asked for", () => {
expect(resolve(press("n", { code: "KeyN", meta: true }), DAY)).toBeNull();
});
it("answers nothing for an unbound key", () => {
expect(resolve(press("q", { code: "KeyQ" }), TASK)).toBeNull();
});
it("walks the tray as a grid while task actions still reach through", () => {
expect(resolve(press("h", { code: "KeyH" }), TRAY)).toBe("trayLeft");
expect(resolve(press("j", { code: "KeyJ" }), TRAY)).toBe("trayDown");
expect(resolve(press("x", { code: "KeyX" }), TRAY)).toBe("taskToggle");
expect(resolve(press("L", { code: "KeyL", shift: true }), TRAY)).toBe(
"taskForward",
);
});
it("gives the rail its own reading of the same keys", () => {
expect(resolve(press("j", { code: "KeyJ" }), RAIL)).toBe("railNext");
expect(resolve(press("l", { code: "KeyL" }), RAIL)).toBe("railUnfold");
expect(resolve(press("h", { code: "KeyH" }), RAIL)).toBe("railFold");
expect(resolve(press("Enter", { code: "Enter" }), RAIL)).toBe("railChoose");
});
it("closes the panel it opened with the same key", () => {
expect(resolve(press("u", { code: "KeyU" }), TRAY)).toBe("undated");
expect(resolve(press("u", { code: "KeyU" }), DAY)).toBe("undated");
expect(resolve(press("b", { code: "KeyB" }), RAIL)).toBe("toggleRail");
expect(resolve(press("b", { code: "KeyB" }), DAY)).toBe("toggleRail");
});
it("takes undo on either modifier, and redo with shift", () => {
expect(resolve(press("z", { code: "KeyZ", meta: true }), DAY)).toBe("undo");
expect(resolve(press("z", { code: "KeyZ", ctrl: true }), DAY)).toBe("undo");
expect(
resolve(press("Z", { code: "KeyZ", meta: true, shift: true }), DAY),
).toBe("redo");
expect(resolve(press("z", { code: "KeyZ" }), DAY)).toBeNull();
});
});
describe("listStep", () => {
it("takes arrows, the emacs pair and the vim pair", () => {
expect(listStep(press("ArrowDown", { code: "ArrowDown" }))).toBe(1);
expect(listStep(press("ArrowUp", { code: "ArrowUp" }))).toBe(-1);
expect(listStep(press("n", { code: "KeyN", ctrl: true }))).toBe(1);
expect(listStep(press("p", { code: "KeyP", ctrl: true }))).toBe(-1);
expect(listStep(press("j", { code: "KeyJ", meta: true }))).toBe(1);
expect(listStep(press("k", { code: "KeyK", meta: true }))).toBe(-1);
});
it("leaves a bare letter alone so it can be typed", () => {
expect(listStep(press("n", { code: "KeyN" }))).toBeNull();
expect(listStep(press("j", { code: "KeyJ" }))).toBeNull();
});
});
describe("the sheet and the dispatcher share one table", () => {
it("gives every documented binding a group the sheet renders", () => {
for (const shortcut of SHORTCUTS) {
expect(GROUPS).toContain(shortcut.group);
expect(shortcut.keys.length).toBeGreaterThan(0);
expect(shortcut.label.length).toBeGreaterThan(0);
}
});
it("only leaves combos empty on rows the composer handles itself", () => {
for (const shortcut of SHORTCUTS) {
if (shortcut.combos.length === 0) {
expect(shortcut.action).toBeNull();
expect(shortcut.scopes).toEqual(["compose"]);
} else {
expect(shortcut.action).not.toBeNull();
}
}
});
it("never binds one combo twice inside a scope", () => {
for (const scope of ["day", "task", "tray", "rail", "view"] as Scope[]) {
const seen = new Map<string, string>();
for (const shortcut of SHORTCUTS) {
if (!shortcut.scopes.includes(scope)) continue;
for (const each of shortcut.combos) {
expect(
seen.has(each),
`${each} is bound twice in ${scope}: ${seen.get(each)} and ${shortcut.label}`,
).toBe(false);
seen.set(each, shortcut.label);
}
}
}
});
});