feat: add scoped shortcuts

This commit is contained in:
Max Koon
2025-12-12 18:44:18 -05:00
parent 27f6e627d4
commit c6dd174376
13 changed files with 451 additions and 138 deletions

View File

@@ -11,7 +11,7 @@ import type {
TextInputProps,
} from "react-native";
import { useTerminalDimensions } from "@opentui/react";
import { RGBA } from "@opentui/core";
import { BorderSides, RGBA } from "@opentui/core";
import { platform } from "node:os";
import { exec } from "node:child_process";
@@ -66,6 +66,27 @@ export function View({ children, style }: ViewProps) {
: undefined;
const padding = attr(style, "padding", "number");
const paddingTop = attr(style, "paddingTop", "number");
const paddingLeft = attr(style, "paddingLeft", "number");
const paddingBottom = attr(style, "paddingBottom", "number");
const paddingRight = attr(style, "paddingRight", "number");
const gap = attr(style, "gap", "number");
const borderBottomWidth = attr(style, "borderBottomWidth", "number");
const borderTopWidth = attr(style, "borderTopWidth", "number");
const borderLeftWidth = attr(style, "borderLeftWidth", "number");
const borderRightWidth = attr(style, "borderRightWidth", "number");
const borderBottomColor = attr(style, "borderBottomColor", "string");
const borderTopColor = attr(style, "borderTopColor", "string");
const borderLeftColor = attr(style, "borderLeftColor", "string");
const borderRightColor = attr(style, "borderRightColor", "string");
const borderColor = attr(style, "borderColor", "string");
const top = attr(style, "top", "number");
const width = attr(style, "width", "number");
const props = {
overflow: attr(style, "overflow", "string"),
@@ -75,7 +96,6 @@ export function View({ children, style }: ViewProps) {
justifyContent: attr(style, "justifyContent", "string"),
flexShrink: attr(style, "flexShrink", "number"),
flexDirection: attr(style, "flexDirection", "string"),
top: attr(style, "top", "number"),
zIndex: attr(style, "zIndex", "number"),
left: attr(style, "left", "number"),
right: attr(style, "right", "number"),
@@ -84,13 +104,40 @@ export function View({ children, style }: ViewProps) {
attr(style, "flex", "number") || attr(style, "flexGrow", "number"),
};
const border = (() => {
const sides: BorderSides[] = [];
if (borderBottomWidth) sides.push("bottom");
if (borderTopWidth) sides.push("top");
if (borderLeftWidth) sides.push("left");
if (borderRightWidth) sides.push("right");
if (!sides.length) return undefined;
return sides;
})();
return (
<box
backgroundColor={bg}
paddingTop={padding && Math.round(padding / RATIO_HEIGHT)}
paddingBottom={padding && Math.round(padding / RATIO_HEIGHT)}
paddingLeft={padding && Math.round(padding / RATIO_WIDTH)}
paddingRight={padding && Math.round(padding / RATIO_WIDTH)}
paddingTop={
(paddingTop && Math.round(paddingTop / RATIO_HEIGHT)) ||
(padding && Math.round(padding / RATIO_HEIGHT))
}
paddingBottom={
(paddingBottom && Math.round(paddingBottom / RATIO_HEIGHT)) ||
(padding && Math.round(padding / RATIO_HEIGHT))
}
paddingLeft={
(paddingLeft && Math.round(paddingLeft / RATIO_WIDTH)) ||
(padding && Math.round(padding / RATIO_WIDTH))
}
paddingRight={
(paddingRight && Math.round(paddingRight / RATIO_WIDTH)) ||
(padding && Math.round(padding / RATIO_WIDTH))
}
gap={gap && Math.round(gap / RATIO_HEIGHT)}
border={border}
borderColor={borderColor}
width={width && Math.round(width / RATIO_WIDTH)}
top={top && Math.round(top / RATIO_HEIGHT)}
{...props}
>
{children}
@@ -234,7 +281,8 @@ export function TextInput({
}: TextInputProps) {
return (
<input
width={20}
minWidth={20}
minHeight={1}
backgroundColor="white"
textColor="black"
focused={true}

View File

@@ -1,6 +1,6 @@
import type { Transaction } from "@rocicorp/zero";
import { authDataSchema, type AuthData } from "./auth";
import { type Schema } from "./zero-schema.gen";
import { type Category, type Schema } from "./zero-schema.gen";
import { isLoggedIn } from "./zql";
type Tx = Transaction<Schema>;
@@ -74,15 +74,29 @@ export function createMutators(authData: AuthData | null) {
id,
budgetId,
order,
}: { id: string; budgetId: string; order?: number },
}: { id: string; budgetId: string; order: number },
) {
isLoggedIn(authData);
if (order != undefined) {
const after = await tx.query.category
.where("budgetId", "=", budgetId)
.where("order", ">", order);
after.forEach((item) => {
tx.mutate.category.update({
id: item.id,
order: item.order + 1,
});
});
}
tx.mutate.category.insert({
id,
budgetId,
amount: 0,
every: "week",
order: order || 0,
order: order + 1,
label: "My category",
color: "#f06",
createdBy: authData.user.id,
@@ -90,20 +104,46 @@ export function createMutators(authData: AuthData | null) {
},
async deleteCategory(tx: Tx, { id }: { id: string }) {
isLoggedIn(authData);
const item = await tx.query.category.where("id", "=", id).one();
if (!item) throw Error("Item does not exist");
tx.mutate.category.update({
id,
removedAt: new Date().getTime(),
removedBy: authData.user.id,
});
const after = await tx.query.category
.where("budgetId", "=", item.budgetId)
.where("order", ">", item.order)
.run();
for (const item of after) {
tx.mutate.category.update({ id: item.id, order: item.order - 1 });
}
// after.forEach((item) => {
// });
},
async updateCategory(
tx: Tx,
{ id, label }: { id: string; label: string },
{
id,
label,
order,
amount,
every,
}: {
id: string;
label?: string;
order?: number;
amount?: number;
every?: Category["every"];
},
) {
isLoggedIn(authData);
tx.mutate.category.update({
id,
label,
order,
amount,
every,
});
},
},

View File

@@ -67,7 +67,7 @@ export const queries = {
isLoggedIn(authData);
return builder.budget
.related("categories", (q) =>
q.where("removedAt", "IS", null).orderBy("order", "desc"),
q.where("removedAt", "IS", null).orderBy("order", "asc"),
)
.limit(10);
},

View File

@@ -21,7 +21,7 @@ export function Provider({ children, visible, close }: ProviderProps) {
{/* <Pressable onPress={() => close && close()} style={{ justifyContent: 'center', alignItems: 'center', flex: 1, backgroundColor: 'rgba(0,0,0,0.2)', }}> */}
<View
style={{
justifyContent: "center",
// justifyContent: "center",
alignItems: "center",
flex: 1,
backgroundColor: "rgba(0,0,0,0.2)",
@@ -39,12 +39,10 @@ interface ContentProps {
}
export function Content({ children }: ContentProps) {
const { close } = use(Context);
useShortcut("escape", () => close?.());
useShortcut("escape", () => close?.(), "dialog");
return (
<View
style={{ backgroundColor: "white", padding: 12, alignItems: "center" }}
>
<View style={{ backgroundColor: "white", alignItems: "center", top: 120 }}>
{children}
</View>
);

View File

@@ -1,19 +1,15 @@
import {
createContext,
use,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { createContext, use, useEffect, useState, type ReactNode } from "react";
import { View, Text } from "react-native";
import type { KeyEvent } from "@opentui/core";
import { useShortcut } from "../lib/shortcuts/hooks";
import type { Key } from "../lib/shortcuts";
const HEADER_COLOR = "#7158e2";
const TABLE_COLORS = ["#ddd", "#eee"];
const SELECTED_COLOR = "#f7b730";
const COLORS = {
focused: "#ddd",
selected: "#eaebf6",
focused_selected: "#d5d7ef",
};
const EXTRA = 5;
@@ -24,7 +20,7 @@ interface TableState {
columns: Column[];
columnMap: Map<string, number>;
idx: number;
selectedFrom: number | undefined;
selectedIdx: Set<number>;
}
const INITAL_STATE = {
@@ -32,7 +28,7 @@ const INITAL_STATE = {
columns: [],
columnMap: new Map(),
idx: 0,
selectedFrom: undefined,
selectedIdx: new Set(),
} satisfies TableState;
export const Context = createContext<TableState>(INITAL_STATE);
@@ -69,7 +65,7 @@ export function Provider<T extends ValidRecord>({
shortcuts,
}: ProviderProps<T>) {
const [idx, setIdx] = useState(0);
const [selectedFrom, setSelectedFrom] = useState<number>();
const [selectedIdx, setSelectedIdx] = useState(new Set<number>());
useShortcut("j", () => {
setIdx((prev) => Math.min(prev + 1, data.length - 1));
@@ -84,6 +80,17 @@ export function Provider<T extends ValidRecord>({
setIdx((prev) => Math.max(prev - 1, 0));
});
useShortcut("escape", () => {
setSelectedIdx(new Set());
});
useShortcut("x", () => {
setSelectedIdx((last) => {
const newSelected = new Set(last);
newSelected.add(idx);
return newSelected;
});
});
useEffect(() => {
setIdx((prev) => Math.max(Math.min(prev, data.length - 1), 0));
}, [data]);
@@ -91,9 +98,9 @@ export function Provider<T extends ValidRecord>({
if (shortcuts) {
for (const shortcut of shortcuts) {
useShortcut(shortcut.key, () => {
const from = selectedFrom ? Math.min(idx, selectedFrom) : idx;
const to = selectedFrom ? Math.max(idx, selectedFrom) : idx;
const selected = data.slice(from, to + 1);
const selected = data.filter(
(_, index) => idx == index || selectedIdx.has(index),
);
shortcut.handler({ selected, index: idx });
});
}
@@ -112,14 +119,14 @@ export function Provider<T extends ValidRecord>({
);
return (
<Context.Provider value={{ data, columns, columnMap, idx, selectedFrom }}>
<Context.Provider value={{ data, columns, columnMap, idx, selectedIdx }}>
{children}
</Context.Provider>
);
}
export function Body() {
const { columns, data, columnMap, idx, selectedFrom } = use(Context);
const { columns, data, columnMap, idx, selectedIdx } = use(Context);
return (
<View>
<View style={{ backgroundColor: HEADER_COLOR, flexDirection: "row" }}>
@@ -136,19 +143,21 @@ export function Body() {
))}
</View>
{data.map((row, index) => {
const isSelected =
index == idx ||
(selectedFrom != undefined &&
((selectedFrom <= index && index <= idx) ||
(idx <= index && index <= selectedFrom)));
const isSelected = selectedIdx.has(index);
const isFocused = index == idx;
return (
<View
key={index}
style={{
backgroundColor: isSelected
? SELECTED_COLOR
: TABLE_COLORS[index % 2],
backgroundColor:
isSelected && isFocused
? COLORS.focused_selected
: isFocused
? COLORS.focused
: isSelected
? COLORS.selected
: undefined,
}}
>
<TableRow

View File

@@ -1,6 +1,6 @@
import { useSyncExternalStore } from "react";
import { View, Text } from "react-native";
import { keysStore } from "./store";
import { keysStore, type ScopeKeys } from "./store";
export function ShortcutDebug() {
const entries = useSyncExternalStore(
@@ -19,14 +19,23 @@ export function ShortcutDebug() {
padding: 10,
}}
>
<Text style={{ color: "red", fontFamily: "mono" }}>Registered:</Text>
<Text style={{ color: "red", fontFamily: "mono" }}>Scopes:</Text>
{entries.map(([scope, keys]) => (
<ScopeView key={scope} scope={scope} keys={keys} />
))}
</View>
);
}
function ScopeView({ scope, keys }: { scope: string; keys: ScopeKeys }) {
return (
<Text style={{ color: "red", fontFamily: "mono", textAlign: "right" }}>
{entries
.values()
{scope}:
{keys
.entries()
.map(([key, _]) => key)
.toArray()
.join(",")}
</Text>
</View>
);
}

View File

@@ -3,16 +3,20 @@ import { keysStore } from "./store";
import type { Key } from "./types";
import { enforceKeyOptions } from "./util";
export const useShortcut = (key: Key, handler: () => void) => {
export const useShortcut = (
key: Key,
handler: () => void,
scope: string = "global",
) => {
const keyOptions = enforceKeyOptions(key);
const keyName = keyOptions.name;
const ref = useRef(handler);
ref.current = handler;
useEffect(() => {
keysStore.register(keyName, ref);
keysStore.register(keyName, ref, scope);
return () => {
keysStore.deregister(keyName);
keysStore.deregister(keyName, scope);
};
}, []);
};

View File

@@ -1,16 +1,18 @@
import { type RefObject } from "react";
// internal map
const keys = new Map<string, RefObject<() => void>>();
export type ScopeKeys = Map<string, RefObject<() => void>>;
// cached snapshot (stable reference)
let snapshot: [string, RefObject<() => void>][] = [];
// outer reactive container
const scopes = new Map<string, ScopeKeys>();
let listeners = new Set<() => void>();
// stable snapshot for subscribers
let snapshot: [string, ScopeKeys][] = [];
const listeners = new Set<() => void>();
function emit() {
// refresh snapshot ONLY when keys actually change
snapshot = Array.from(keys.entries());
// replace identity so subscribers re-render
snapshot = Array.from(scopes.entries());
for (const fn of listeners) fn();
}
@@ -21,20 +23,36 @@ export const keysStore = {
},
getSnapshot() {
return snapshot; // stable unless emit() ran
return snapshot;
},
register(key: string, ref: RefObject<() => void>) {
keys.set(key, ref);
register(key: string, ref: RefObject<() => void>, scope: string) {
const prev = scopes.get(scope);
const next = new Map(prev); // <-- important: new identity
next.set(key, ref);
scopes.set(scope, next); // <-- outer identity also changes
emit();
},
deregister(key: string) {
keys.delete(key);
deregister(key: string, scope: string) {
const prev = scopes.get(scope);
if (!prev) return;
const next = new Map(prev);
next.delete(key);
if (next.size === 0) {
scopes.delete(scope);
} else {
scopes.set(scope, next);
}
emit();
},
getHandler(key: string) {
return keys.get(key)?.current;
// last scope wins — clarify this logic as needed
const last = Array.from(scopes.values()).at(-1);
return last?.get(key)?.current;
},
};

View File

@@ -10,7 +10,12 @@ import {
import { useQuery, useZero } from "@rocicorp/zero/react";
import * as Table from "../components/Table";
import { Button } from "../components/Button";
import * as Dialog from "../components/Dialog";
import { RenameCategoryDialog } from "./budget/RenameCategoryDialog";
import {
UpdateCategoryAmountDialog,
type CategoryWithComputed,
type Updating,
} from "./budget/UpdateCategoryAmountDialog";
const COLUMNS: Table.Column[] = [
{ name: "label", label: "Name" },
@@ -24,9 +29,9 @@ export function Budget() {
const { auth } = use(RouterContext);
const [budgets] = useQuery(queries.getBudgets(auth));
const [renaming, setRenaming] = useState<Category>();
const [editCategoryAmount, setEditCategoryAmount] = useState<Updating>();
const z = useZero<Schema, Mutators>();
const refText = useRef("");
const newBudget = () => {
const id = new Date().getTime().toString();
@@ -60,8 +65,8 @@ export function Budget() {
const data = budget.categories.slice().map((category) => {
const { amount } = category;
const week = amount;
const month = amount * 4;
const week = amount / 4;
const month = amount;
const year = amount * 12;
return {
@@ -79,7 +84,7 @@ export function Budget() {
z.mutate.budget.createCategory({
id,
budgetId: budget.id,
order: index - 1,
order: index,
});
};
@@ -95,38 +100,37 @@ export function Budget() {
}
};
const onEditCategoryYearly = ({
selected,
}: { selected: CategoryWithComputed[] }) => {
for (const category of selected) {
setEditCategoryAmount({ category, every: "year" });
}
};
const onEditCategoryMonthly = ({
selected,
}: { selected: CategoryWithComputed[] }) => {
for (const category of selected) {
setEditCategoryAmount({ category, every: "month" });
}
};
const onEditCategoryWeekly = ({
selected,
}: { selected: CategoryWithComputed[] }) => {
for (const category of selected) {
setEditCategoryAmount({ category, every: "week" });
}
};
return (
<>
<Dialog.Provider
visible={renaming != undefined}
close={() => setRenaming(undefined)}
>
<Dialog.Content>
<Text style={{ fontFamily: "mono" }}>Edit Category</Text>
<TextInput
style={{ fontFamily: "mono" }}
autoFocus
selectTextOnFocus
defaultValue={renaming?.label}
onChangeText={(t) => {
refText.current = t;
}}
onKeyPress={(e) => {
if (!renaming) return;
if (e.nativeEvent.key == "Enter") {
z.mutate.budget.updateCategory({
id: renaming.id,
label: refText.current,
});
setRenaming(undefined);
} else if (e.nativeEvent.key == "Escape") {
setRenaming(undefined);
}
}}
<RenameCategoryDialog renaming={renaming} setRenaming={setRenaming} />
<UpdateCategoryAmountDialog
updating={editCategoryAmount}
setUpdating={setEditCategoryAmount}
/>
</Dialog.Content>
</Dialog.Provider>
<View style={{ alignItems: "flex-start" }}>
<Text style={{ fontFamily: "mono", textAlign: "left" }}>
@@ -140,6 +144,9 @@ export function Budget() {
{ key: "i", handler: newCategory },
{ key: "d", handler: deleteCategory },
{ key: "r", handler: renameCategory },
{ key: "y", handler: onEditCategoryYearly },
{ key: "m", handler: onEditCategoryMonthly },
{ key: "w", handler: onEditCategoryWeekly },
]}
>
<View style={{ flex: 1 }}>

View File

@@ -0,0 +1,77 @@
import { useRef, useState } from "react";
import * as Dialog from "../../components/Dialog";
import { View, Text, TextInput } from "react-native";
import { type Category, type Mutators, type Schema } from "@money/shared";
import { useZero } from "@rocicorp/zero/react";
interface RenameCategoryDialogProps {
renaming: Category | undefined;
setRenaming: (v: Category | undefined) => void;
}
export function RenameCategoryDialog({
renaming,
setRenaming,
}: RenameCategoryDialogProps) {
const refText = useRef("");
const [renamingText, setRenamingText] = useState("");
const z = useZero<Schema, Mutators>();
return (
<Dialog.Provider
visible={renaming != undefined}
close={() => setRenaming(undefined)}
>
<Dialog.Content>
<View style={{ width: 400 }}>
<View
style={{
borderBottomWidth: 1,
paddingTop: 12,
paddingLeft: 12,
paddingRight: 12,
}}
>
<TextInput
style={{
fontFamily: "mono",
// @ts-ignore
outline: "none",
}}
autoFocus
selectTextOnFocus
defaultValue={renaming?.label}
onChangeText={(t) => {
refText.current = t;
setRenamingText(t);
}}
onKeyPress={(e) => {
if (!renaming) return;
if (e.nativeEvent.key == "Enter") {
if (refText.current.trim() == "")
return setRenaming(undefined);
z.mutate.budget.updateCategory({
id: renaming.id,
label: refText.current,
});
setRenaming(undefined);
} else if (e.nativeEvent.key == "Escape") {
setRenaming(undefined);
}
}}
/>
</View>
<View
style={{ paddingLeft: 12, paddingRight: 12, paddingBottom: 12 }}
>
<Text style={{ fontFamily: "mono" }}>
Rename category to: {renamingText || renaming?.label}
</Text>
<Text style={{ fontFamily: "mono" }}> Cancel</Text>
</View>
</View>
</Dialog.Content>
</Dialog.Provider>
);
}

View File

@@ -0,0 +1,107 @@
import { useRef, useState } from "react";
import * as Dialog from "../../components/Dialog";
import { View, Text, TextInput } from "react-native";
import { type Category, type Mutators, type Schema } from "@money/shared";
import { useZero } from "@rocicorp/zero/react";
export type Updating = {
category: CategoryWithComputed;
every: Category["every"];
};
export type CategoryWithComputed = Category & {
month: number;
year: number;
};
interface UpdateCategoryAmountDialogProps {
updating: Updating | undefined;
setUpdating: (v: Updating | undefined) => void;
}
export function UpdateCategoryAmountDialog({
updating,
setUpdating,
}: UpdateCategoryAmountDialogProps) {
const category = updating?.category;
const every = updating?.every;
const refText = useRef("");
const [amountText, setAmountText] = useState("");
const z = useZero<Schema, Mutators>();
return (
<Dialog.Provider
visible={category != undefined}
close={() => setUpdating(undefined)}
>
<Dialog.Content>
<View style={{ width: 400 }}>
<View
style={{
borderBottomWidth: 1,
paddingTop: 12,
paddingLeft: 12,
paddingRight: 12,
}}
>
<TextInput
style={{
fontFamily: "mono",
// @ts-ignore
outline: "none",
}}
autoFocus
selectTextOnFocus
defaultValue={category?.month.toString()}
onChangeText={(t) => {
refText.current = t;
setAmountText(t);
}}
onKeyPress={(e) => {
if (!category) return;
if (e.nativeEvent.key == "Enter") {
if (refText.current.trim() == "")
return setUpdating(undefined);
try {
const parsed = parseFloat(refText.current);
const amount = (function () {
switch (every) {
case "year":
return parsed / 12;
case "month":
return parsed;
case "week":
return parsed * 4;
}
})();
z.mutate.budget.updateCategory({
id: category.id,
amount,
every,
});
setUpdating(undefined);
} catch (e) {}
} else if (e.nativeEvent.key == "Escape") {
setUpdating(undefined);
}
}}
/>
</View>
<View
style={{ paddingLeft: 12, paddingRight: 12, paddingBottom: 12 }}
>
<Text style={{ fontFamily: "mono" }}>
Update monthly amount to: {amountText || category?.month}
</Text>
<Text style={{ fontFamily: "mono" }}> Cancel</Text>
</View>
</View>
</Dialog.Content>
</Dialog.Provider>
);
}

View File

@@ -40,13 +40,11 @@ export function Transactions() {
<Table.Provider
data={items}
columns={COLUMNS}
onKey={(key) => {
if (key.name == "r" && key.shift) {
z.mutate.link.updateTransactions();
}
}}
shortcuts={[
{ key: "r", handler: () => z.mutate.link.updateTransactions() },
]}
>
<View style={{ flex: 1 }}>
<View style={{ padding: 10, flex: 1 }}>
<View style={{ flexShrink: 0 }}>
<Table.Body />
</View>
@@ -59,18 +57,16 @@ export function Transactions() {
}
function Selected() {
const { data, idx, selectedFrom } = use(Table.Context);
const { data, selectedIdx } = use(Table.Context);
if (selectedFrom == undefined)
if (selectedIdx.size == 0)
return (
<View style={{ backgroundColor: "#ddd" }}>
<Text style={{ fontFamily: "mono" }}>No items selected</Text>
</View>
);
const from = Math.min(idx, selectedFrom);
const to = Math.max(idx, selectedFrom);
const selected = data.slice(from, to + 1) as Transaction[];
const selected = data.filter((_, i) => selectedIdx.has(i)) as Transaction[];
const count = selected.length;
const sum = selected.reduce((prev, curr) => prev + curr.amount, 0);