feat(keys): vim keys in any layout, z motions, no Option chords

Keys on a non-Latin layout map to their physical US position, so j, k,
gg and / work on the Russian layout. Lists get z z, z t, z b, and the
first row scrolls the page to the top. The editor drops Alt chords for
Esc then a letter, reorder in groups, piggy banks and subscriptions
moves to J and K.
This commit is contained in:
hh committed 2026-10-16 14:51:02 +00:00
1 parent 63a1971900
commit a1e7489ad4
9 files changed
+132 -94

No files matched your search

+30 -23
View File
@@ -52,15 +52,15 @@
withdrawal: "withdrawal", withdrawal: "withdrawal",
}; };
const FIELDS: [string, string, string][] = [ const FIELDS: [string, string, string][] = [
["alt+d", "description", "description"], ["d", "description", "description"],
["alt+f", "source", "source account"], ["f", "source", "source account"],
["alt+t", "destination", "destination account"], ["t", "destination", "destination account"],
["alt+w", "date", "date"], ["w", "date", "date"],
["alt+m", "amount", "amount"], ["m", "amount", "amount"],
["alt+k", "category", "category"], ["c", "category", "category"],
["alt+b", "budget", "budget"], ["b", "budget", "budget"],
["alt+g", "tags", "tags"], ["#", "tags", "tags"],
["alt+r", "notes", "notes"], ["r", "notes", "notes"],
]; ];
const INLINE = new Set([ const INLINE = new Set([
"description", "description",
@@ -104,6 +104,7 @@
const originalType = start.type; const originalType = start.type;
let form = $state<Form>(start); let form = $state<Form>(start);
let lastSplit = start.splits[0].key;
let saved = $state(untrack(() => group)); let saved = $state(untrack(() => group));
let snapshot = JSON.stringify(start); let snapshot = JSON.stringify(start);
let errors = $state<Errors>({}); let errors = $state<Errors>({});
@@ -204,9 +205,17 @@
tick().then(() => focusField("description", false)); tick().then(() => focusField("description", false));
}); });
function splitOf(target: EventTarget | null): number | null {
const holder =
target instanceof Element ? target.closest("[data-split]") : null;
return holder ? Number(holder.getAttribute("data-split")) : null;
}
function currentKey(): number { function currentKey(): number {
const holder = document.activeElement?.closest("[data-split]"); const key = splitOf(document.activeElement) ?? lastSplit;
return Number(holder?.getAttribute("data-split") ?? form.splits[0].key); return form.splits.some((split) => split.key === key)
? key
: form.splits[0].key;
} }
function focusField(field: string, select = true) { function focusField(field: string, select = true) {
@@ -452,29 +461,25 @@
}, },
{ {
group: "Editor", group: "Editor",
input: true, keys: "X",
keys: "mod+alt+Backspace",
label: "Remove the current split", label: "Remove the current split",
run: () => removeSplit(), run: () => removeSplit(),
}, },
...TX_TYPES.map((type, index) => ({ ...TX_TYPES.map((type, index) => ({
group: "Editor", group: "Editor",
input: true, keys: `${index + 1}`,
keys: `alt+${index + 1}`,
label: `Make it a ${type}`, label: `Make it a ${type}`,
run: () => setType(type), run: () => setType(type),
})), })),
...FIELDS.map(([keys, field, label]) => ({ ...FIELDS.map(([keys, field, label]) => ({
group: "Editor", group: "Editor",
input: true,
keys, keys,
label: `Go to ${label}`, label: `Go to ${label}`,
run: () => focusField(field), run: () => focusField(field),
})), })),
{ {
group: "Editor", group: "Editor",
input: true, keys: "T",
keys: "alt+i",
label: "Go to the split transaction title", label: "Go to the split transaction title",
run: () => document.getElementById("group-title")?.focus(), run: () => document.getElementById("group-title")?.focus(),
}, },
@@ -520,7 +525,11 @@
{/snippet} {/snippet}
</PageHeader> </PageHeader>
<div class="flex flex-col gap-4" bind:this={root}> <div
class="flex flex-col gap-4"
onfocusin={(event) => (lastSplit = splitOf(event.target) ?? lastSplit)}
bind:this={root}
>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2"> <div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<div <div
aria-label="Transaction type" aria-label="Transaction type"
@@ -540,9 +549,7 @@
type="button" type="button"
> >
{TYPE_LABEL[type]} {TYPE_LABEL[type]}
<span class="kbd hidden lg:inline-flex" <span class="kbd hidden lg:inline-flex">{index + 1}</span>
>{prettyKeys(`alt+${index + 1}`)[0]}</span
>
</button> </button>
{/each} {/each}
</div> </div>
@@ -614,7 +621,7 @@
<Field <Field
error={errors.group_title?.[0]} error={errors.group_title?.[0]}
for="group-title" for="group-title"
keys="alt+i" keys="T"
label="Description of the split transaction" label="Description of the split transaction"
> >
<input <input
+10 -13
View File
@@ -8,7 +8,6 @@
import Field from "#lib/components/inputs/Field.svelte"; import Field from "#lib/components/inputs/Field.svelte";
import { INPUT } from "#lib/components/inputs/styles.js"; import { INPUT } from "#lib/components/inputs/styles.js";
import TagsInput from "#lib/components/inputs/TagsInput.svelte"; import TagsInput from "#lib/components/inputs/TagsInput.svelte";
import { prettyKeys } from "#lib/keys.svelte.js";
import { session } from "#lib/session.svelte.js"; import { session } from "#lib/session.svelte.js";
import AccountField from "./AccountField.svelte"; import AccountField from "./AccountField.svelte";
import type { Catalog } from "./catalog.svelte.js"; import type { Catalog } from "./catalog.svelte.js";
@@ -205,9 +204,7 @@
> >
<Trash class="size-3.5" /> <Trash class="size-3.5" />
Remove split Remove split
<span class="kbd hidden lg:inline-flex" <span class="kbd hidden lg:inline-flex">X</span>
>{prettyKeys("mod+alt+Backspace")[0]}</span
>
</button> </button>
{/if} {/if}
{/snippet} {/snippet}
@@ -216,7 +213,7 @@
<Field <Field
error={err("description")} error={err("description")}
for={id("description")} for={id("description")}
keys={keyHint("alt+d")} keys={keyHint("d")}
label="Description" label="Description"
> >
<AutocompleteInput <AutocompleteInput
@@ -229,10 +226,10 @@
bind:value={split.description} bind:value={split.description}
/> />
</Field> </Field>
{@render account("source", "Source account", "alt+f")} {@render account("source", "Source account", "f")}
{@render account("destination", "Destination account", "alt+t")} {@render account("destination", "Destination account", "t")}
{#if first} {#if first}
<Field error={err("date")} for={id("date")} keys="alt+w" label="Date"> <Field error={err("date")} for={id("date")} keys="w" label="Date">
<DateInput <DateInput
id={id("date")} id={id("date")}
invalid={Boolean(err("date"))} invalid={Boolean(err("date"))}
@@ -245,7 +242,7 @@
<Field <Field
error={err("amount")} error={err("amount")}
for={id("amount")} for={id("amount")}
keys={keyHint("alt+m")} keys={keyHint("m")}
label="Amount" label="Amount"
> >
<AmountInput <AmountInput
@@ -328,7 +325,7 @@
<Field <Field
error={err("budget_id")} error={err("budget_id")}
for={id("budget")} for={id("budget")}
keys={keyHint("alt+b")} keys={keyHint("b")}
label="Budget" label="Budget"
> >
<RefField <RefField
@@ -343,7 +340,7 @@
<Field <Field
error={err("category_name")} error={err("category_name")}
for={id("category")} for={id("category")}
keys={keyHint("alt+k")} keys={keyHint("c")}
label="Category" label="Category"
> >
<AutocompleteInput <AutocompleteInput
@@ -385,7 +382,7 @@
<Field <Field
error={err("tags")} error={err("tags")}
for={id("tags")} for={id("tags")}
keys={keyHint("alt+g")} keys={keyHint("#")}
label="Tags" label="Tags"
> >
<TagsInput <TagsInput
@@ -409,7 +406,7 @@
<Field <Field
error={err("notes")} error={err("notes")}
for={id("notes")} for={id("notes")}
keys={keyHint("alt+r")} keys={keyHint("r")}
label="Notes" label="Notes"
> >
<textarea <textarea
+1 -1
View File
@@ -39,7 +39,7 @@
</script> </script>
<Box {body} class={className} {footer} {title} {tools}> <Box {body} class={className} {footer} {title} {tools}>
<div class="scroll-mt-24" {id}> <div class="scroll-mt-12" {id}>
{#if loading} {#if loading}
<p class="px-3 py-3 text-cladd-fg-soft">Loading</p> <p class="px-3 py-3 text-cladd-fg-soft">Loading</p>
{:else if empty} {:else if empty}
+1 -1
View File
@@ -27,7 +27,7 @@
</div> </div>
{#if drill} {#if drill}
<div <div
class="cladd-surface cladd-outline scroll-mt-24 overflow-hidden rounded-cladd-lg" class="cladd-surface cladd-outline scroll-mt-12 overflow-hidden rounded-cladd-lg"
id="drill-{drill.box}" id="drill-{drill.box}"
> >
<DrillPanel {drill} /> <DrillPanel {drill} />
+48 -5
View File
@@ -13,14 +13,57 @@ const SEQUENCE_MS = 1200;
const EDITABLE = const EDITABLE =
"input, textarea, select, [contenteditable=''], [contenteditable='true']"; "input, textarea, select, [contenteditable=''], [contenteditable='true']";
const LATIN_LETTER = /^[a-z]$/i;
const ANY_LETTER = /^\p{L}$/u;
const US_KEYS: Record<string, [string, string]> = {
Backquote: ["`", "~"],
Backslash: ["\\", "|"],
BracketLeft: ["[", "{"],
BracketRight: ["]", "}"],
Comma: [",", "<"],
Digit0: ["0", ")"],
Digit1: ["1", "!"],
Digit2: ["2", "@"],
Digit3: ["3", "#"],
Digit4: ["4", "$"],
Digit5: ["5", "%"],
Digit6: ["6", "^"],
Digit7: ["7", "&"],
Digit8: ["8", "*"],
Digit9: ["9", "("],
Equal: ["=", "+"],
Minus: ["-", "_"],
Period: [".", ">"],
Quote: ["'", '"'],
Semicolon: [";", ":"],
Slash: ["/", "?"],
};
let foreignLayout = false;
function physicalKey({ code, shiftKey }: KeyboardEvent): string | undefined {
if (code.startsWith("Key")) {
const letter = code.slice(3);
return shiftKey ? letter : letter.toLowerCase();
}
return US_KEYS[code]?.[shiftKey ? 1 : 0];
}
function baseKey(event: KeyboardEvent): string { function baseKey(event: KeyboardEvent): string {
if (event.altKey && event.code.startsWith("Key")) { const { key } = event;
return event.code.slice(3).toLowerCase(); if (key === " ") {
return "Space";
} }
if (event.altKey && event.code.startsWith("Digit")) { if (key.length !== 1) {
return event.code.slice(5); return key;
} }
return event.key === " " ? "Space" : event.key; const foreign = !LATIN_LETTER.test(key) && ANY_LETTER.test(key);
const plain = !(event.altKey || event.ctrlKey || event.metaKey);
if (plain && (foreign || LATIN_LETTER.test(key))) {
foreignLayout = foreign;
}
const positional = event.altKey || foreign || (plain && foreignLayout);
return (positional && physicalKey(event)) || key;
} }
function token(event: KeyboardEvent): string { function token(event: KeyboardEvent): string {
+36 -3
View File
@@ -1,5 +1,7 @@
import { type Binding, useKeys } from "#lib/keys.svelte.js"; import { type Binding, useKeys } from "#lib/keys.svelte.js";
const TOP_REACH = 0.6;
interface Options<T> { interface Options<T> {
edit?: (item: T) => void; edit?: (item: T) => void;
extra?: () => Binding[]; extra?: () => Binding[];
@@ -56,9 +58,22 @@ export class ListNav<T> {
return; return;
} }
this.cursor = Math.max(0, Math.min(count - 1, to)); this.cursor = Math.max(0, Math.min(count - 1, to));
document this.reveal("nearest");
.getElementById(this.rowId(this.cursor)) }
?.scrollIntoView({ block: "nearest" });
reveal(block: ScrollLogicalPosition) {
const row = document.getElementById(this.rowId(this.cursor));
if (!row) {
return;
}
const nearTop =
row.getBoundingClientRect().top + window.scrollY <
window.innerHeight * TOP_REACH;
if (this.cursor === 0 && block === "nearest" && nearTop) {
window.scrollTo({ top: 0 });
return;
}
row.scrollIntoView({ block });
} }
toggle(item: T | undefined = this.current) { toggle(item: T | undefined = this.current) {
@@ -164,6 +179,24 @@ export class ListNav<T> {
label: "Last row", label: "Last row",
run: () => this.move(this.items.length - 1), run: () => this.move(this.items.length - 1),
}, },
{
group: "List",
keys: "z z",
label: "Scroll row to the middle",
run: () => this.reveal("center"),
},
{
group: "List",
keys: "z t",
label: "Scroll row to the top",
run: () => this.reveal("start"),
},
{
group: "List",
keys: "z b",
label: "Scroll row to the bottom",
run: () => this.reveal("end"),
},
{ {
group: "List", group: "List",
hidden: true, hidden: true,
+2 -16
View File
@@ -139,27 +139,13 @@
}, },
{ {
group: "Groups", group: "Groups",
keys: "alt+j", keys: "J",
label: "Move group down", label: "Move group down",
run: () => move(1), run: () => move(1),
}, },
{ {
group: "Groups", group: "Groups",
keys: "alt+k", keys: "K",
label: "Move group up",
run: () => move(-1),
},
{
group: "Groups",
hidden: true,
keys: "alt+∆",
label: "Move group down",
run: () => move(1),
},
{
group: "Groups",
hidden: true,
keys: "alt+˚",
label: "Move group up", label: "Move group up",
run: () => move(-1), run: () => move(-1),
}, },
+2 -16
View File
@@ -132,27 +132,13 @@
}, },
{ {
group: "Piggy banks", group: "Piggy banks",
keys: "alt+j", keys: "J",
label: "Move down in group", label: "Move down in group",
run: () => move(1), run: () => move(1),
}, },
{ {
group: "Piggy banks", group: "Piggy banks",
keys: "alt+k", keys: "K",
label: "Move up in group",
run: () => move(-1),
},
{
group: "Piggy banks",
hidden: true,
keys: "alt+∆",
label: "Move down in group",
run: () => move(1),
},
{
group: "Piggy banks",
hidden: true,
keys: "alt+˚",
label: "Move up in group", label: "Move up in group",
run: () => move(-1), run: () => move(-1),
}, },
+2 -16
View File
@@ -181,27 +181,13 @@
extra: () => [ extra: () => [
{ {
group: "Subscriptions", group: "Subscriptions",
keys: "alt+j", keys: "J",
label: "Move down in group", label: "Move down in group",
run: () => move(1), run: () => move(1),
}, },
{ {
group: "Subscriptions", group: "Subscriptions",
keys: "alt+k", keys: "K",
label: "Move up in group",
run: () => move(-1),
},
{
group: "Subscriptions",
hidden: true,
keys: "alt+∆",
label: "Move down in group",
run: () => move(1),
},
{
group: "Subscriptions",
hidden: true,
keys: "alt+˚",
label: "Move up in group", label: "Move up in group",
run: () => move(-1), run: () => move(-1),
}, },