mirror of
https://github.com/priyanshujain/margin-calendar.git
synced 2026-10-02 11:07:04 +00:00
Tauri 2, React 19 and zustand on the front, Rust behind. Rust owns auth, all HTTP to Google, the SQLite store, the sync loop, recurrence expansion and timezone maths. TypeScript owns rendering and never talks to Google, which keeps the content security policy locked to ipc:. Week, day and agenda views, and no month view: it would be a second layout engine, and the fit and fold logic that makes a day fit the window without scrolling is the whole point of the app. Runs on macOS, Linux, Android and iOS. Desktop catches Google's OAuth redirect on a loopback port. A phone cannot, and Google rejects loopback for mobile client types anyway, so it redirects to a custom URI scheme and needs its own public OAuth clients, which docs/mobile.md covers. Refresh tokens are sealed with XChaCha20-Poly1305 in the app data directory on every platform, with no OS credential store in the picture. On a phone the chrome becomes a top bar and a bottom tab bar, overlays become sheets, hover affordances become taps, and dragging out an event waits for a long press. Navigation moves one day at a time everywhere, a swipe included.
129 lines
5.8 KiB
TypeScript
129 lines
5.8 KiB
TypeScript
// The keymap, declared once. `keymap.ts` dispatches from this table and `Shortcuts.tsx` renders
|
|
// the `?` sheet from it, so a binding that exists but is undocumented is not something you can
|
|
// write: the sheet is generated, never maintained.
|
|
//
|
|
// A combo is canonical: modifiers in `cmd+ctrl+alt` order, then `KeyboardEvent.key` verbatim.
|
|
// `cmd` means the platform's primary modifier, Command on macOS and Control everywhere else, which
|
|
// is what the native menu's `CmdOrCtrl` accelerators mean too. Shift is not a modifier here: it is
|
|
// already baked into the key, so `H` is the shifted `h` and reads that way in the table.
|
|
//
|
|
// Nothing is chorded and nothing is modal. Two keys never combine into a third meaning.
|
|
|
|
import type { CommandId } from "./commands";
|
|
|
|
/**
|
|
* Which frame of the context stack a binding belongs to. `overlay` carries no bindings of its own:
|
|
* pushing it is how an open panel shadows the whole view keymap while leaving `global` reachable.
|
|
*/
|
|
export type KeyContext = "global" | "view" | "overlay";
|
|
|
|
export type BindingGroup = "Navigation" | "Views" | "Events" | "App";
|
|
|
|
interface BindingBase {
|
|
/** Every combo that runs it. The sheet shows them all; the dispatcher accepts any. */
|
|
keys: readonly string[];
|
|
context: KeyContext;
|
|
group: BindingGroup;
|
|
/** Off by default: a key must never be stolen from an input. */
|
|
allowInInput?: boolean;
|
|
}
|
|
|
|
export interface CommandBinding extends BindingBase {
|
|
command: CommandId;
|
|
}
|
|
|
|
/** A key the keymap deliberately does not own, documented so the sheet is not a half-truth. */
|
|
export interface NoteBinding extends BindingBase {
|
|
command: null;
|
|
label: string;
|
|
}
|
|
|
|
export type Binding = CommandBinding | NoteBinding;
|
|
|
|
export const BINDINGS: readonly Binding[] = [
|
|
{ keys: ["h"], command: "day-prev", context: "view", group: "Navigation" },
|
|
{ keys: ["l"], command: "day-next", context: "view", group: "Navigation" },
|
|
{ keys: ["H"], command: "week-prev", context: "view", group: "Navigation" },
|
|
{ keys: ["L"], command: "week-next", context: "view", group: "Navigation" },
|
|
{ keys: ["t", "cmd+t"], command: "today", context: "view", group: "Navigation" },
|
|
{ keys: ["j"], command: "select-next", context: "view", group: "Navigation" },
|
|
{ keys: ["k"], command: "select-prev", context: "view", group: "Navigation" },
|
|
{ keys: ["m"], command: "mini-month", context: "view", group: "Navigation" },
|
|
|
|
{ keys: ["d", "cmd+1"], command: "view-day", context: "view", group: "Views" },
|
|
{ keys: ["w", "cmd+2"], command: "view-week", context: "view", group: "Views" },
|
|
{ keys: ["a", "cmd+3"], command: "view-agenda", context: "view", group: "Views" },
|
|
{ keys: ["z"], command: "fold", context: "view", group: "Views" },
|
|
{ keys: ["Z"], command: "unfold-all", context: "view", group: "Views" },
|
|
|
|
{ keys: ["c", "cmd+n"], command: "new-event", context: "view", group: "Events" },
|
|
{ keys: ["Enter"], command: "open-selection", context: "view", group: "Events" },
|
|
{ keys: ["e"], command: "edit-selection", context: "view", group: "Events" },
|
|
{ keys: ["x"], command: "delete-selection", context: "view", group: "Events" },
|
|
{ keys: ["/", "cmd+f"], command: "search", context: "view", group: "Events" },
|
|
|
|
// The palette is the one thing a text field may not swallow: it is how you get out of anywhere.
|
|
{ keys: ["cmd+k"], command: "command-palette", context: "global", group: "App", allowInInput: true },
|
|
{ keys: ["?", "cmd+/"], command: "shortcuts", context: "view", group: "App" },
|
|
// Escape unwinds the layer stack in `src/escape.ts`, which knows about nested confirmations.
|
|
{ keys: ["Escape"], command: null, label: "Dismiss whatever is open", context: "global", group: "App" },
|
|
{ keys: ["cmd+r"], command: "sync-now", context: "view", group: "App" },
|
|
{ keys: ["cmd+,"], command: "settings", context: "view", group: "App" },
|
|
];
|
|
|
|
export const GROUPS: readonly BindingGroup[] = ["Navigation", "Views", "Events", "App"];
|
|
|
|
const isMac =
|
|
typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.userAgent ?? "");
|
|
|
|
/** The primary modifier as the platform names it. */
|
|
export const PRIMARY_LABEL = isMac ? "⌘" : "Ctrl+";
|
|
|
|
/** True when the event holds the platform's primary modifier, whatever the hardware calls it. */
|
|
export const primaryHeld = (e: { metaKey: boolean; ctrlKey: boolean }): boolean =>
|
|
isMac ? e.metaKey : e.ctrlKey;
|
|
|
|
export const secondaryHeld = (e: { metaKey: boolean; ctrlKey: boolean }): boolean =>
|
|
isMac ? e.ctrlKey : e.metaKey;
|
|
|
|
/** `Cmd+K` and `cmd+k` are the same binding; the table may be written either way. */
|
|
export function normalizeCombo(combo: string): string {
|
|
const parts = combo.split("+");
|
|
const key = parts.pop() ?? "";
|
|
const mods = new Set(parts.map((p) => p.toLowerCase()));
|
|
const prefix = ["cmd", "ctrl", "alt"].filter((m) => mods.has(m)).join("+");
|
|
return prefix ? `${prefix}+${key.toLowerCase()}` : key;
|
|
}
|
|
|
|
const NAMED: Record<string, string> = {
|
|
Enter: "↩",
|
|
Escape: "⎋",
|
|
ArrowUp: "↑",
|
|
ArrowDown: "↓",
|
|
ArrowLeft: "←",
|
|
ArrowRight: "→",
|
|
Tab: "⇥",
|
|
" ": "Space",
|
|
};
|
|
|
|
/** `cmd+k` becomes ⌘K, `H` becomes ⇧H. What the sheet and the palette both print. */
|
|
export function keyLabel(combo: string): string {
|
|
const parts = normalizeCombo(combo).split("+");
|
|
const key = parts.pop() ?? "";
|
|
const mods = parts
|
|
.map((m) => (m === "cmd" ? PRIMARY_LABEL : m === "ctrl" ? "⌃" : "⌥"))
|
|
.join("");
|
|
const named = NAMED[key];
|
|
if (named) return `${mods}${named}`;
|
|
if (mods) return `${mods}${key.toUpperCase()}`;
|
|
return /^[A-Z]$/.test(key) ? `⇧${key}` : key;
|
|
}
|
|
|
|
export const bindingLabel = (binding: Binding, labelOf: (id: CommandId) => string): string =>
|
|
binding.command === null ? binding.label : labelOf(binding.command);
|
|
|
|
/** The combos a command answers to, for a palette row or a button's title attribute. */
|
|
export function keysFor(id: CommandId): readonly string[] {
|
|
return BINDINGS.find((b) => b.command === id)?.keys ?? [];
|
|
}
|