diff --git a/README.md b/README.md index 5859e1d..df76efb 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ npm run dev # esbuild watch npm run build # typecheck + bundle + css npm test # round-trip, smoke npm run preview # UI in browser, no Obsidian -make install VAULT=/путь/к/vault # build and copy to vault +make install VAULT=/path/to/vault # build and copy to vault make zip # distributable ``` diff --git a/src/lib/fuzzy.ts b/src/lib/fuzzy.ts new file mode 100644 index 0000000..14d0402 --- /dev/null +++ b/src/lib/fuzzy.ts @@ -0,0 +1,83 @@ +export type Range = [start: number, end: number]; + +export interface Hit { + score: number; + ranges: Range[]; +} + +const BREAK = /[\s\-_/.:]/u; + +/** + * Subsequence match with the usual scoring bias: a run of adjacent letters and + * a letter that opens a word are worth more than a letter found anywhere. Long + * labels lose a sliver so that "web" ranks `web` above `website redesign`. + */ +export function fuzzy(query: string, text: string): Hit | null { + const needle = query.trim().toLowerCase(); + if (!needle) return { score: 0, ranges: [] }; + + const hay = text.toLowerCase(); + const ranges: Range[] = []; + let score = 0; + let cursor = 0; + let previous = -2; + + for (const letter of needle) { + const at = hay.indexOf(letter, cursor); + if (at === -1) return null; + + let bonus = 1; + if (at === previous + 1) bonus += 4; + if (at === 0) bonus += 6; + else if (BREAK.test(text[at - 1])) bonus += 4; + score += bonus; + + const last = ranges[ranges.length - 1]; + if (last && last[1] === at) last[1] = at + 1; + else ranges.push([at, at + 1]); + + previous = at; + cursor = at + 1; + } + + return { score: score - Math.min(text.length, 40) * 0.05, ranges }; +} + +export interface Ranked { + item: T; + ranges: Range[]; +} + +/** Keeps the incoming order when the query is empty, so lists stay stable. */ +export function rank( + query: string, + items: T[], + label: (item: T) => string, +): Ranked[] { + if (!query.trim()) return items.map((item) => ({ item, ranges: [] })); + return items + .map((item, index) => ({ item, index, hit: fuzzy(query, label(item)) })) + .filter( + (entry): entry is { item: T; index: number; hit: Hit } => entry.hit !== null, + ) + .sort((a, b) => b.hit.score - a.hit.score || a.index - b.index) + .map((entry) => ({ item: entry.item, ranges: entry.hit.ranges })); +} + +export interface Piece { + text: string; + hit: boolean; +} + +export function pieces(text: string, ranges: Range[]): Piece[] { + if (ranges.length === 0) return [{ text, hit: false }]; + const out: Piece[] = []; + let at = 0; + for (const [start, end] of ranges) { + if (start > at) out.push({ text: text.slice(at, start), hit: false }); + out.push({ text: text.slice(start, end), hit: true }); + at = end; + } + if (at < text.length) out.push({ text: text.slice(at), hit: false }); + return out; +} diff --git a/src/lib/grid.ts b/src/lib/grid.ts new file mode 100644 index 0000000..f806125 --- /dev/null +++ b/src/lib/grid.ts @@ -0,0 +1,93 @@ +export type Direction = "left" | "right" | "up" | "down"; + +export interface Rect { + left: number; + top: number; + width: number; + height: number; +} + +type RectOf = (item: T) => Rect; + +interface Band { + x: number; + top: number; + bottom: number; + height: number; +} + +/** + * Cards in a wrapped run are top-aligned, not stretched, so two on the same + * line can have quite different heights. Rows are therefore read from the + * bands the cards occupy rather than from their centres, which would put a + * short card "above" a tall one sitting right beside it. + */ +function sameRow(a: Band, b: Band): boolean { + const overlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); + return overlap > Math.min(a.height, b.height) / 2; +} + +/** + * Walks a wrapped run of cards as the grid it looks like rather than the flat + * list it is: left and right stay on the row, up and down cross to the nearest + * card in the column above or below. Falls back to reading order at the ends of + * a row, so holding a direction never dead-ends mid-run. + */ +export function step( + items: T[], + from: T, + direction: Direction, + rectOf: RectOf, +): T | null { + const at = items.indexOf(from); + if (at === -1) return items[0] ?? null; + + const bands: Band[] = items.map((item) => { + const rect = rectOf(item); + return { + x: rect.left + rect.width / 2, + top: rect.top, + bottom: rect.top + rect.height, + height: rect.height, + }; + }); + + const self = bands[at]; + const sideways = direction === "left" || direction === "right"; + const sign = direction === "left" || direction === "up" ? -1 : 1; + + let best: T | null = null; + let bestScore = Number.POSITIVE_INFINITY; + + for (let index = 0; index < bands.length; index += 1) { + if (index === at) continue; + const band = bands[index]; + const together = sameRow(self, band); + const dx = band.x - self.x; + const dy = band.top - self.top; + + if (sideways) { + if (!together) continue; + if (Math.sign(dx) !== sign || Math.abs(dx) < 1) continue; + const score = Math.abs(dx); + if (score < bestScore) { + bestScore = score; + best = items[index]; + } + } else { + if (together) continue; + if (Math.sign(dy) !== sign || Math.abs(dy) < 1) continue; + // Nearest row first, then the nearest column inside it. + const score = Math.abs(dy) * 4 + Math.abs(dx); + if (score < bestScore) { + bestScore = score; + best = items[index]; + } + } + } + + if (best !== null) return best; + // At the end of a row, carry on the way reading does. + if (sideways) return items[at + sign] ?? null; + return null; +} diff --git a/src/lib/keymap.ts b/src/lib/keymap.ts new file mode 100644 index 0000000..4df053f --- /dev/null +++ b/src/lib/keymap.ts @@ -0,0 +1,573 @@ +/** + * One table drives both the dispatcher and the shortcut sheet, so a binding + * can never be documented as something it does not do. + */ + +export type Scope = "day" | "task" | "tray" | "rail" | "view" | "compose"; + +export type Action = + | "dayLeft" + | "dayRight" + | "dayUp" + | "dayDown" + | "dayEnter" + | "taskNext" + | "taskPrev" + | "taskExit" + | "taskOpen" + | "taskToggle" + | "taskEdit" + | "taskEditRaw" + | "taskPriority" + | "taskBack" + | "taskForward" + | "taskWeekBack" + | "taskWeekForward" + | "taskToday" + | "taskUnschedule" + | "create" + | "undated" + | "railNext" + | "railPrev" + | "railFold" + | "railUnfold" + | "railChoose" + | "trayLeft" + | "trayRight" + | "trayUp" + | "trayDown" + | "undo" + | "redo" + | "today" + | "monthPrev" + | "monthNext" + | "toggleView" + | "toggleRail" + | "toggleDone" + | "search" + | "help" + | "dismiss"; + +export interface Shortcut { + /** Normalised combos this binding answers to. Empty for compose-only rows. */ + combos: string[]; + /** Key chips as they are drawn on the sheet. */ + keys: string[]; + label: string; + action: Action | null; + scopes: Scope[]; + group: Group; +} + +export type Group = "Moving" | "Panels" | "Writing" | "The selected task"; + +export const GROUPS: Group[] = [ + "Moving", + "Panels", + "Writing", + "The selected task", +]; + +const APPLE = + typeof navigator !== "undefined" && /Mac|iPhone|iPad/u.test(navigator.userAgent); + +export const MOD = APPLE ? "⌘" : "Ctrl"; + +export const SHORTCUTS: Shortcut[] = [ + // Moving + { + combos: ["h", "arrowleft"], + keys: ["h", "←"], + label: "A day back", + action: "dayLeft", + scopes: ["day", "task"], + group: "Moving", + }, + { + combos: ["l", "arrowright"], + keys: ["l", "→"], + label: "A day forward", + action: "dayRight", + scopes: ["day", "task"], + group: "Moving", + }, + { + combos: ["k", "arrowup"], + keys: ["k", "↑"], + label: "A week up", + action: "dayUp", + scopes: ["day"], + group: "Moving", + }, + { + combos: ["j", "arrowdown"], + keys: ["j", "↓"], + label: "A week down", + action: "dayDown", + scopes: ["day"], + group: "Moving", + }, + { + combos: ["enter"], + keys: ["⏎"], + label: "Into the day's tasks", + action: "dayEnter", + scopes: ["day"], + group: "Moving", + }, + { + combos: ["j", "arrowdown"], + keys: ["j", "↓"], + label: "Next task", + action: "taskNext", + scopes: ["task"], + group: "Moving", + }, + { + combos: ["k", "arrowup"], + keys: ["k", "↑"], + label: "Previous task", + action: "taskPrev", + scopes: ["task"], + group: "Moving", + }, + { + combos: ["escape"], + keys: ["esc"], + label: "Back to the days", + action: "taskExit", + scopes: ["task"], + group: "Moving", + }, + { + combos: ["escape"], + keys: ["esc"], + label: "Clear the search", + action: "dismiss", + scopes: ["view"], + group: "Moving", + }, + { + combos: ["u"], + keys: ["u"], + label: "To the undated tray, and back", + action: "undated", + scopes: ["view"], + group: "Moving", + }, + { + combos: ["t"], + keys: ["t"], + label: "Today", + action: "today", + scopes: ["view"], + group: "Moving", + }, + { + combos: ["["], + keys: ["["], + label: "Previous month", + action: "monthPrev", + scopes: ["view"], + group: "Moving", + }, + { + combos: ["]"], + keys: ["]"], + label: "Next month", + action: "monthNext", + scopes: ["view"], + group: "Moving", + }, + { + combos: ["v"], + keys: ["v"], + label: "Swap list and calendar", + action: "toggleView", + scopes: ["view"], + group: "Moving", + }, + { + combos: ["b"], + keys: ["b"], + label: "To the spheres, and back", + action: "toggleRail", + scopes: ["view"], + group: "Moving", + }, + { + combos: ["c"], + keys: ["c"], + label: "Completed tasks", + action: "toggleDone", + scopes: ["view"], + group: "Moving", + }, + { + combos: ["/"], + keys: ["/"], + label: "Search", + action: "search", + scopes: ["view"], + group: "Moving", + }, + { + combos: ["?"], + keys: ["?"], + label: "This sheet", + action: "help", + scopes: ["view"], + group: "Moving", + }, + + // Panels + { + combos: ["j", "arrowdown"], + keys: ["j", "↓"], + label: "Next row", + action: "railNext", + scopes: ["rail"], + group: "Panels", + }, + { + combos: ["k", "arrowup"], + keys: ["k", "↑"], + label: "Previous row", + action: "railPrev", + scopes: ["rail"], + group: "Panels", + }, + { + combos: ["l", "arrowright"], + keys: ["l", "→"], + label: "Unfold a sphere", + action: "railUnfold", + scopes: ["rail"], + group: "Panels", + }, + { + combos: ["h", "arrowleft"], + keys: ["h", "←"], + label: "Fold it again", + action: "railFold", + scopes: ["rail"], + group: "Panels", + }, + { + combos: ["enter"], + keys: ["⏎"], + label: "Filter by the row, and step out", + action: "railChoose", + scopes: ["rail"], + group: "Panels", + }, + { + combos: ["escape"], + keys: ["esc"], + label: "Leave the spheres", + action: "toggleRail", + scopes: ["rail"], + group: "Panels", + }, + { + combos: ["h", "arrowleft"], + keys: ["h", "←"], + label: "Across the undated cards", + action: "trayLeft", + scopes: ["tray"], + group: "Panels", + }, + { + combos: ["l", "arrowright"], + keys: ["l", "→"], + label: "…and back the other way", + action: "trayRight", + scopes: ["tray"], + group: "Panels", + }, + { + combos: ["k", "arrowup"], + keys: ["k", "↑"], + label: "A row up, then out of the tray", + action: "trayUp", + scopes: ["tray"], + group: "Panels", + }, + { + combos: ["j", "arrowdown"], + keys: ["j", "↓"], + label: "A row down", + action: "trayDown", + scopes: ["tray"], + group: "Panels", + }, + { + combos: ["escape"], + keys: ["esc"], + label: "Leave the tray", + action: "undated", + scopes: ["tray"], + group: "Panels", + }, + + // Writing + { + combos: ["n"], + keys: ["n"], + label: "Write a task where the cursor is", + action: "create", + scopes: ["view"], + group: "Writing", + }, + { + combos: [], + keys: ["⇥"], + label: "Next field: sphere, project, date, repeat", + action: null, + scopes: ["compose"], + group: "Writing", + }, + { + combos: [], + keys: ["↓", "↑"], + label: "Walk the matches", + action: null, + scopes: ["compose"], + group: "Writing", + }, + { + combos: [], + keys: ["\u2328"], + label: "Or just say it: aug 22, in 3 days, every week", + action: null, + scopes: ["compose"], + group: "Writing", + }, + { + combos: [], + keys: ["⌃n", "⌃p"], + label: "Walk the matches, hands home", + action: null, + scopes: ["compose"], + group: "Writing", + }, + { + combos: [], + keys: [`${MOD}j`, `${MOD}k`], + label: "Walk the matches, vim fingers", + action: null, + scopes: ["compose"], + group: "Writing", + }, + { + combos: [], + keys: ["⏎"], + label: "Create it, or save the edit", + action: null, + scopes: ["compose"], + group: "Writing", + }, + { + combos: [], + keys: [`${MOD}⏎`], + label: "Create it and keep writing", + action: null, + scopes: ["compose"], + group: "Writing", + }, + { + combos: [], + keys: ["esc"], + label: "Clear the field, then close", + action: null, + scopes: ["compose"], + group: "Writing", + }, + { + combos: ["meta+z", "ctrl+z"], + keys: [`${MOD}z`], + label: "Undo the last change", + action: "undo", + scopes: ["view"], + group: "Writing", + }, + { + combos: ["meta+shift+z", "ctrl+shift+z"], + keys: [`${MOD}\u21e7z`], + label: "Redo it", + action: "redo", + scopes: ["view"], + group: "Writing", + }, + + // The selected task + { + combos: ["x"], + keys: ["x"], + label: "Done and back again", + action: "taskToggle", + scopes: ["task"], + group: "The selected task", + }, + { + combos: ["shift+h"], + keys: ["⇧h"], + label: "Push a day back", + action: "taskBack", + scopes: ["task"], + group: "The selected task", + }, + { + combos: ["shift+l"], + keys: ["⇧l"], + label: "Push a day forward", + action: "taskForward", + scopes: ["task"], + group: "The selected task", + }, + { + combos: ["shift+k"], + keys: ["⇧k"], + label: "Push a week back", + action: "taskWeekBack", + scopes: ["task"], + group: "The selected task", + }, + { + combos: ["shift+j"], + keys: ["⇧j"], + label: "Push a week forward", + action: "taskWeekForward", + scopes: ["task"], + group: "The selected task", + }, + { + combos: ["shift+t"], + keys: ["⇧t"], + label: "Pull onto today", + action: "taskToday", + scopes: ["task"], + group: "The selected task", + }, + { + combos: ["shift+u"], + keys: ["⇧u"], + label: "Drop the date", + action: "taskUnschedule", + scopes: ["task"], + group: "The selected task", + }, + { + combos: ["e"], + keys: ["e"], + label: "Edit it: text, lane, date, repeat", + action: "taskEdit", + scopes: ["task"], + group: "The selected task", + }, + { + combos: ["shift+e"], + keys: ["⇧e"], + label: "Edit the raw line in Tasks", + action: "taskEditRaw", + scopes: ["task"], + group: "The selected task", + }, + { + combos: ["p"], + keys: ["p"], + label: "Priority", + action: "taskPriority", + scopes: ["task"], + group: "The selected task", + }, + { + combos: ["enter"], + keys: ["⏎"], + label: "Open in the note", + action: "taskOpen", + scopes: ["task"], + group: "The selected task", + }, +]; + +/** + * `event.code` keeps the physical key, so the vim block still answers on a + * Cyrillic layout where `event.key` reports "н" instead of "n". + */ +const BY_CODE: Record = { + BracketLeft: "[", + BracketRight: "]", + Slash: "/", + Escape: "escape", + Enter: "enter", + Tab: "tab", + ArrowLeft: "arrowleft", + ArrowRight: "arrowright", + ArrowUp: "arrowup", + ArrowDown: "arrowdown", +}; + +const SHIFTED_CODE: Record = { Slash: "?" }; + +function withModifiers(event: KeyboardEvent, key: string, shift: boolean): string { + const parts: string[] = []; + if (event.ctrlKey) parts.push("ctrl"); + if (event.metaKey) parts.push("meta"); + if (event.altKey) parts.push("alt"); + if (shift) parts.push("shift"); + parts.push(key); + return parts.join("+"); +} + +export function combos(event: KeyboardEvent): string[] { + const out: string[] = []; + + const key = event.key.toLowerCase(); + if (key !== "shift" && key !== "control" && key !== "meta" && key !== "alt") { + // Punctuation already carries the shift in the character itself. + out.push(withModifiers(event, key, event.shiftKey && /^[a-z]$/u.test(key))); + } + + const code = event.code; + const letter = /^Key([A-Z])$/u.exec(code); + if (letter) out.push(withModifiers(event, letter[1].toLowerCase(), event.shiftKey)); + else if (event.shiftKey && SHIFTED_CODE[code]) { + out.push(withModifiers(event, SHIFTED_CODE[code], false)); + } else if (BY_CODE[code]) { + out.push(withModifiers(event, BY_CODE[code], false)); + } + + return out; +} + +/** + * The one way to walk a list of matches, wherever one is on screen: arrows for + * the undecided, ⌃n/⌃p for the emacs hand, ⌘j/⌘k and ⌃j/⌃k for the vim one. + */ +export function listStep(event: KeyboardEvent): number | null { + const pressed = combos(event); + const hit = (...names: string[]): boolean => + names.some((name) => pressed.includes(name)); + if (hit("arrowdown", "ctrl+n", "ctrl+j", "meta+j")) return 1; + if (hit("arrowup", "ctrl+p", "ctrl+k", "meta+k")) return -1; + return null; +} + +/** + * Scope-specific bindings win over view-wide ones, so `j` walks tasks while a + * task is selected and walks weeks while it is not. + */ +export function resolve(event: KeyboardEvent, scopes: Scope[]): Action | null { + const pressed = combos(event); + for (const scope of scopes) { + for (const shortcut of SHORTCUTS) { + if (!shortcut.action) continue; + if (!shortcut.scopes.includes(scope)) continue; + if (pressed.some((each) => shortcut.combos.includes(each))) { + return shortcut.action; + } + } + } + return null; +} diff --git a/src/lib/place.ts b/src/lib/place.ts new file mode 100644 index 0000000..b6d15a4 --- /dev/null +++ b/src/lib/place.ts @@ -0,0 +1,55 @@ +export interface Box { + top: number; + left: number; + width: number; + height: number; +} + +export interface Spot { + top: number; + left: number; +} + +const GAP = 6; +const PAD = 10; + +/** + * The calendar draws its cards twice — once in the agenda and once in the + * month grid — and hides whichever the width does not call for. Both answer + * the same selector, so the one with an actual box on screen is the one to + * hang a panel off. + */ +export function onScreen(boxes: Box[]): Box | null { + return boxes.find((box) => box.width > 0 && box.height > 0) ?? null; +} + +/** + * Under the anchor, flipped above when there is no room, and always kept + * inside the window. With nothing to anchor to it sits in the middle, which + * is wrong but never looks broken. + */ +export function place( + anchor: Box | null, + panel: { width: number; height: number }, + view: { width: number; height: number }, +): Spot { + if (!anchor) { + return { + top: Math.max(PAD, (view.height - panel.height) / 2), + left: Math.max(PAD, (view.width - panel.width) / 2), + }; + } + + let top = anchor.top + anchor.height + GAP; + if (top + panel.height > view.height - PAD) { + const above = anchor.top - GAP - panel.height; + top = above >= PAD ? above : Math.max(PAD, view.height - PAD - panel.height); + } + + const left = Math.min( + Math.max(PAD, anchor.left), + Math.max(PAD, view.width - PAD - panel.width), + ); + + return { top, left }; +} diff --git a/src/lib/when.ts b/src/lib/when.ts new file mode 100644 index 0000000..5a8a0a0 --- /dev/null +++ b/src/lib/when.ts @@ -0,0 +1,233 @@ +import { localKey } from "./due"; + +/** + * The Tasks plugin keeps dates as plain `YYYY-MM-DD` and exposes no parser, so + * anything typed has to be resolved here. Recurrence is the other way round: + * the rule is stored as written and Tasks reads it, so only the shapes worth + * tidying get normalised and everything else passes straight through. + */ + +const WEEKDAYS = [ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", +]; + +const MONTHS = [ + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", +]; + +function tidy(text: string): string { + return text.trim().toLowerCase().replace(/\s+/gu, " "); +} + +function at(year: number, month: number, day: number): Date { + const date = new Date(year, month, day); + date.setHours(0, 0, 0, 0); + return date; +} + +function plus(from: Date, days: number): Date { + return at(from.getFullYear(), from.getMonth(), from.getDate() + days); +} + +function weekdayIndex(word: string): number { + return WEEKDAYS.findIndex( + (name) => name === word || (word.length >= 3 && name.startsWith(word)), + ); +} + +function monthIndex(word: string): number { + return MONTHS.findIndex( + (name) => name === word || (word.length >= 3 && name.startsWith(word)), + ); +} + +/** Strictly the next one, so "friday" on a Friday means the week after. */ +function upcoming(from: Date, weekday: number): Date { + return plus(from, ((weekday - from.getDay() + 7) % 7) || 7); +} + +const UNITS: Record = { + d: "day", + day: "day", + days: "day", + w: "week", + week: "week", + weeks: "week", + m: "month", + month: "month", + months: "month", + y: "year", + year: "year", + years: "year", +}; + +function shiftBy(from: Date, count: number, unit: string): Date | null { + const kind = UNITS[unit]; + if (!kind) return null; + if (kind === "day") return plus(from, count); + if (kind === "week") return plus(from, count * 7); + if (kind === "month") { + return at(from.getFullYear(), from.getMonth() + count, from.getDate()); + } + return at(from.getFullYear() + count, from.getMonth(), from.getDate()); +} + +function valid(year: number, month: number, day: number): Date | null { + const date = at(year, month, day); + const same = date.getFullYear() === year && date.getMonth() === month; + return same && date.getDate() === day ? date : null; +} + +/** Returns a `YYYY-MM-DD` key, or null when the words mean nothing here. */ +export function parseWhen(text: string, from = new Date()): string | null { + const said = tidy(text); + if (!said) return null; + + const today = at(from.getFullYear(), from.getMonth(), from.getDate()); + + const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/u.exec(said); + if (iso) { + const date = valid(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])); + return date ? localKey(date) : null; + } + + if (/^(today|tod|now)$/u.test(said)) return localKey(today); + if (/^(tomorrow|tom|tmr|tmrw)$/u.test(said)) return localKey(plus(today, 1)); + if (/^(yesterday|yest|yd)$/u.test(said)) return localKey(plus(today, -1)); + + const nextUnit = /^next (\w+)$/u.exec(said); + if (nextUnit) { + const shifted = shiftBy(today, 1, nextUnit[1]); + if (shifted) return localKey(shifted); + const weekday = weekdayIndex(nextUnit[1]); + if (weekday !== -1) return localKey(upcoming(today, weekday)); + return null; + } + + // "in 3 days", "in a week", "3 weeks", "3d" + const relative = + /^(?:in )?(\d+|a|an) ?([a-z]+)$/u.exec(said) ?? /^(?:in )?(\d+)([dwmy])$/u.exec(said); + if (relative) { + const count = /^\d+$/u.test(relative[1]) ? Number(relative[1]) : 1; + const shifted = shiftBy(today, count, relative[2]); + if (shifted) return localKey(shifted); + } + + const weekday = weekdayIndex(said); + if (weekday !== -1) return localKey(upcoming(today, weekday)); + + // "12 aug", "aug 12", either with an optional year. + const dayMonth = /^(\d{1,2}) ([a-z]+)(?: (\d{4}))?$/u.exec(said); + const monthDay = /^([a-z]+) (\d{1,2})(?:,? (\d{4}))?$/u.exec(said); + const named = dayMonth + ? { day: Number(dayMonth[1]), word: dayMonth[2], year: dayMonth[3] } + : monthDay + ? { day: Number(monthDay[2]), word: monthDay[1], year: monthDay[3] } + : null; + if (named) { + const month = monthIndex(named.word); + if (month !== -1) { + const year = named.year ? Number(named.year) : today.getFullYear(); + const date = valid(year, month, named.day); + if (!date) return null; + // Without a year, a date already gone means the one coming up. + if (!named.year && date < today) { + const next = valid(year + 1, month, named.day); + return next ? localKey(next) : null; + } + return localKey(date); + } + } + + // "12.08" and "12/8" read day first, the way the rest of the view does. + const numeric = /^(\d{1,2})[./](\d{1,2})(?:[./](\d{2,4}))?$/u.exec(said); + if (numeric) { + const day = Number(numeric[1]); + const month = Number(numeric[2]) - 1; + const year = numeric[3] + ? Number(numeric[3].length === 2 ? `20${numeric[3]}` : numeric[3]) + : today.getFullYear(); + const date = valid(year, month, day); + if (!date) return null; + if (!numeric[3] && date < today) { + const next = valid(year + 1, month, day); + return next ? localKey(next) : null; + } + return localKey(date); + } + + // A bare number is a day of this month, or the next one if it has passed. + const bare = /^(\d{1,2})$/u.exec(said); + if (bare) { + const day = Number(bare[1]); + const here = valid(today.getFullYear(), today.getMonth(), day); + if (here && here >= today) return localKey(here); + const next = valid(today.getFullYear(), today.getMonth() + 1, day); + return next ? localKey(next) : null; + } + + return null; +} + +const PLAIN: Record = { + daily: "every day", + weekly: "every week", + fortnightly: "every 2 weeks", + monthly: "every month", + quarterly: "every 3 months", + yearly: "every year", + annually: "every year", +}; + +/** + * Normalises the phrasings worth normalising and hands everything else to the + * Tasks plugin as typed, which knows far more recurrence than this ever should. + */ +export function parseRepeat(text: string): string | null { + const said = tidy(text); + if (!said) return null; + if (/^(none|no|never|off|no repeat)$/u.test(said)) return ""; + + if (PLAIN[said]) return PLAIN[said]; + + const bare = /^(\d+) ([a-z]+)$/u.exec(said); + if (bare && UNITS[bare[2]]) { + return `every ${bare[1]} ${UNITS[bare[2]]}${bare[1] === "1" ? "" : "s"}`; + } + + const single = UNITS[said]; + if (single) return `every ${single}`; + + const weekday = weekdayIndex(said.replace(/s$/u, "")); + if (weekday !== -1) return `every ${WEEKDAYS[weekday]}`; + + if (said.startsWith("every ")) { + const rest = said.slice(6); + const day = weekdayIndex(rest.replace(/s$/u, "")); + if (day !== -1) return `every ${WEEKDAYS[day]}`; + const counted = /^(\d+) ([a-z]+)$/u.exec(rest); + if (counted && UNITS[counted[2]]) { + return `every ${counted[1]} ${UNITS[counted[2]]}${counted[1] === "1" ? "" : "s"}`; + } + return said; + } + + return null; +} diff --git a/src/main.ts b/src/main.ts index dfada4f..5843d5d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import { } from "./settings"; import type { BeaverCalendarSettings } from "./settings"; import { BoardStore } from "./vault/store.svelte"; +import { transaction } from "./vault/history"; import { archiveDone } from "./vault/mutate"; import { BoardsView, VIEW_TYPE_BOARDS } from "./view"; @@ -104,13 +105,16 @@ export default class BeaverCalendarPlugin extends Plugin { return; } let moved = 0; - for (const file of files) { - moved += await archiveDone( - this.app, - file.path, - this.settings.archiveHeading, - ); - } + // One undo step for the whole sweep, however many boards it touched. + await transaction("the archive", async () => { + for (const file of files) { + moved += await archiveDone( + this.app, + file.path, + this.settings.archiveHeading, + ); + } + }); new Notice( moved > 0 ? `Beaver Calendar: archived ${moved} task(s)` diff --git a/src/settings.ts b/src/settings.ts index b114b71..f8b765c 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -17,6 +17,9 @@ export interface BeaverCalendarSettings { includeArchive: boolean; railCollapsed: boolean; undatedCollapsed: boolean; + /** The lane the last task was written into, so the composer opens there. */ + lastLanePath: string; + lastLaneProject: string; } export const DEFAULT_SETTINGS: BeaverCalendarSettings = { @@ -30,6 +33,8 @@ export const DEFAULT_SETTINGS: BeaverCalendarSettings = { includeArchive: false, railCollapsed: false, undatedCollapsed: false, + lastLanePath: "", + lastLaneProject: "", }; export function normalizeFolder(value: string): string { diff --git a/src/tailwind.css b/src/tailwind.css index fef5a32..5ec03f5 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -270,6 +270,25 @@ } } +/* A key, drawn as one: the heavier bottom edge is the keycap, not a shadow. */ +@utility kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.15rem; + height: 1.15rem; + padding: 0 0.25rem; + font-family: inherit; + font-size: 10px; + font-variant-numeric: tabular-nums; + line-height: 1; + color: var(--muted-foreground); + background: var(--background); + border: 1px solid var(--border); + border-bottom-color: color-mix(in oklab, var(--border) 55%, var(--foreground)); + border-radius: var(--radius-sm); +} + @utility scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; diff --git a/src/ui/anchored-panel.svelte b/src/ui/anchored-panel.svelte new file mode 100644 index 0000000..ac1f17e --- /dev/null +++ b/src/ui/anchored-panel.svelte @@ -0,0 +1,108 @@ + + + + + + diff --git a/src/ui/app.svelte b/src/ui/app.svelte index 9fe7fd2..f6dd5e6 100644 --- a/src/ui/app.svelte +++ b/src/ui/app.svelte @@ -1,13 +1,17 @@ -
+
{#if !store.railCollapsed} {/if}
- { - creating = true; - title = ""; - }} - /> - {#if creating} -
- { - if (event.key === "Enter") void commit(); - else if (event.key === "Escape") { - creating = false; - title = ""; - } - }} - placeholder={scope - ? `Task in ${scope.sphere} / ${scope.project}` - : "Create a board in the boards folder first"} - use:focus - bind:value={title} - /> -
- {/if} + {#if store.view === "calendar"} {:else} @@ -104,4 +90,12 @@ {/if}
+ +
+ +{#if store.helpOpen} + + + +{/if} diff --git a/src/ui/context.ts b/src/ui/context.ts index 85973d7..336b69e 100644 --- a/src/ui/context.ts +++ b/src/ui/context.ts @@ -9,12 +9,18 @@ export interface ViewContext { settings: BeaverCalendarSettings; component: Component; portal: () => HTMLElement; + /** The toolbar search box, so `/` can reach it from anywhere in the view. */ + searchInput: () => HTMLInputElement | null; + registerSearch: (node: HTMLInputElement | null) => void; + /** Hands the keyboard back to the view after a layer above it closes. */ + focusRoot: () => void; } const KEY = Symbol("beaver-calendar"); -export function provideView(context: ViewContext): void { +export function provideView(context: ViewContext): ViewContext { setContext(KEY, context); + return context; } export function useView(): ViewContext { diff --git a/src/ui/cursor-picker.svelte b/src/ui/cursor-picker.svelte new file mode 100644 index 0000000..a9cf769 --- /dev/null +++ b/src/ui/cursor-picker.svelte @@ -0,0 +1,73 @@ + + +{#if open && task} + + { + void setPriority(view.app, task, Number(option.value) as Priority); + close(); + }} + options={priorities} + value={String(task.priority)} + /> + +{/if} diff --git a/src/ui/due-picker.svelte b/src/ui/due-picker.svelte deleted file mode 100644 index 86f81a6..0000000 --- a/src/ui/due-picker.svelte +++ /dev/null @@ -1,139 +0,0 @@ - - - - - {#if recurrence} - - - -
- {#each QUICK as quick (quick.label)} - - {/each} -
- - - - {#if onrepeat} - {@const known = RULES.some((rule) => rule.value === (recurrence ?? ""))} -
-
- {#each RULES as rule (rule.value)} - - {/each} -
- {#if recurrence && !known} -

- Custom rule: {recurrence} -

- {/if} -
- {/if} - - {#if value} - - {/if} -
-
-
diff --git a/src/ui/keyboard.ts b/src/ui/keyboard.ts new file mode 100644 index 0000000..05a2bfd --- /dev/null +++ b/src/ui/keyboard.ts @@ -0,0 +1,268 @@ +import { resolve } from "../lib/keymap"; +import type { Action, Scope } from "../lib/keymap"; +import { shiftDays, today } from "../lib/due"; +import { step } from "../lib/grid"; +import type { Direction } from "../lib/grid"; +import { anchorDate, isOpen } from "../model/types"; +import type { Task } from "../model/types"; +import { redo, undo } from "../vault/history"; +import { setDate, setStatus } from "../vault/mutate"; +import { editInTasks, revealInFile } from "./task-menu"; +import type { ViewContext } from "./context"; + +function isTyping(target: EventTarget | null): boolean { + const node = target as HTMLElement | null; + if (!node || typeof node.tagName !== "string") return false; + return ( + node.tagName === "INPUT" || + node.tagName === "TEXTAREA" || + node.isContentEditable === true + ); +} + +function dateField(task: Task): "due" | "scheduled" { + return !task.due && task.scheduled ? "scheduled" : "due"; +} + +async function reschedule( + view: ViewContext, + task: Task, + key: string | null, +): Promise { + await setDate(view.app, task, dateField(task), key); + view.store.followDate(key); +} + +function shift(view: ViewContext, task: Task, days: number): Promise { + return reschedule(view, task, shiftDays(anchorDate(task) ?? today(), days)); +} + +/** + * The undated tray wraps its cards, so it reads as a grid even though the DOM + * only knows a list. Measuring what is actually on screen is the one honest + * way to walk it. + */ +function stepTray(view: ViewContext, direction: Direction): void { + const { store } = view; + const cards = [ + ...document.querySelectorAll("[data-tray-card]"), + ]; + const current = cards.find( + (card) => card.dataset.trayCard === store.cursorTask, + ); + + if (!current) { + if (cards.length > 0) store.cursorTask = cards[0].dataset.trayCard ?? null; + return; + } + + const next = step(cards, current, direction, (card) => + card.getBoundingClientRect(), + ); + if (next) { + store.cursorTask = next.dataset.trayCard ?? null; + return; + } + // Off the top of the tray is the way back into the month. + if (direction === "up") store.toggleUndated(); +} + +async function run(view: ViewContext, action: Action): Promise { + const { app, store } = view; + const task = store.taskById(store.cursorTask); + const grid = store.view === "calendar"; + + switch (action) { + case "dayLeft": + if (grid) store.moveCursor(-1); + return; + case "dayRight": + if (grid) store.moveCursor(1); + return; + case "dayUp": + if (grid) store.moveCursor(-7); + return; + case "dayDown": + if (grid) store.moveCursor(7); + return; + case "dayEnter": + store.enterDay(); + return; + + case "taskNext": + store.stepTask(1); + return; + case "taskPrev": + store.stepTask(-1); + return; + case "taskExit": + if (grid) store.focusDay(store.cursorDay); + else store.cursorTask = null; + return; + case "taskOpen": + if (task) await revealInFile(view, task); + return; + case "taskToggle": + if (task) await setStatus(app, task, isOpen(task.status) ? "x" : " "); + return; + case "taskEdit": + if (task) store.edit(task.id); + return; + case "taskEditRaw": + if (task) await editInTasks(view, task); + return; + case "taskPriority": + if (task) store.request = "priority"; + return; + case "taskBack": + if (task) await shift(view, task, -1); + return; + case "taskForward": + if (task) await shift(view, task, 1); + return; + case "taskWeekBack": + if (task) await shift(view, task, -7); + return; + case "taskWeekForward": + if (task) await shift(view, task, 7); + return; + case "taskToday": + if (task) await reschedule(view, task, today()); + return; + case "taskUnschedule": + if (task) await reschedule(view, task, null); + return; + + case "trayLeft": + stepTray(view, "left"); + return; + case "trayRight": + stepTray(view, "right"); + return; + case "trayUp": + stepTray(view, "up"); + return; + case "trayDown": + stepTray(view, "down"); + return; + + case "railNext": + store.stepRail(1); + return; + case "railPrev": + store.stepRail(-1); + return; + case "railUnfold": + store.foldRail(true); + return; + case "railFold": + store.foldRail(false); + return; + case "railChoose": + store.chooseRail(); + return; + + case "create": + openComposer(view); + return; + case "undated": + store.toggleUndated(); + return; + case "toggleRail": + store.toggleRail(); + return; + case "undo": + await undo(app); + return; + case "redo": + await redo(app); + return; + case "today": + store.goToday(); + return; + case "monthPrev": + store.stepMonth(-1); + return; + case "monthNext": + store.stepMonth(1); + return; + case "toggleView": + store.view = store.view === "calendar" ? "list" : "calendar"; + store.leaveToMain(); + store.savePrefs(); + return; + case "toggleDone": + store.showDone = !store.showDone; + store.savePrefs(); + return; + case "search": + view.searchInput()?.focus(); + return; + case "help": + store.helpOpen = true; + return; + case "dismiss": + if (store.search) store.search = ""; + else store.leaveToMain(); + return; + } +} + +/** Writing belongs on the day it writes into, wherever that day is drawn. */ +function openComposer(view: ViewContext): void { + const { store } = view; + if (store.view !== "calendar") { + store.write(null, "[data-composer='toolbar']"); + return; + } + // The month grid is not rendered on a narrow pane, so the panel hangs off + // the toolbar instead when the day it belongs to has no box on screen. + const selector = `[data-composer="day:${store.cursorDay}"]`; + const trigger = document.querySelector(selector); + store.write( + store.cursorDay, + trigger?.offsetParent ? selector : "[data-composer='toolbar']", + ); +} + +/** The list has no day grid, so its cursor lives on tasks from the first key. */ +function scopesFor(view: ViewContext): Scope[] { + const { store } = view; + if (store.zone === "rail") return ["rail", "view"]; + if (store.zone === "undated") return ["tray", "task", "view"]; + if (store.view === "list") return ["task", "view"]; + if (store.zone === "task") return ["task", "view"]; + return ["day", "view"]; +} + +export function handleKey(view: ViewContext, event: KeyboardEvent): void { + const { store } = view; + if (event.defaultPrevented) return; + if (store.editing) return; + + if (isTyping(event.target)) { + if (event.key === "Escape") (event.target as HTMLElement).blur(); + return; + } + + if (store.helpOpen) { + if (event.key === "Escape" || event.key === "?") { + event.preventDefault(); + store.helpOpen = false; + } + return; + } + + if (store.request) { + if (event.key === "Escape") store.request = null; + return; + } + + const action = resolve(event, scopesFor(view)); + if (!action) return; + event.preventDefault(); + event.stopPropagation(); + // The first key press is what earns the cursor its outline. + store.keyboard = true; + void run(view, action); +} diff --git a/src/ui/lane-picker.svelte b/src/ui/lane-picker.svelte deleted file mode 100644 index df4dd53..0000000 --- a/src/ui/lane-picker.svelte +++ /dev/null @@ -1,47 +0,0 @@ - - - { - const lane = parseLaneKey(value); - if (lane) onpick(lane.path, lane.project); - }} - {options} - searchable - triggerClass={cn("max-w-full", className)} - value={laneKey(path, project)} -> - {#snippet trigger(state)} - {@render renderTrigger(state)} - {/snippet} - diff --git a/src/ui/option-list.svelte b/src/ui/option-list.svelte new file mode 100644 index 0000000..8c8e904 --- /dev/null +++ b/src/ui/option-list.svelte @@ -0,0 +1,126 @@ + + + + +
+ {#each shown as { item, ranges }, at (item.value)} + + {:else} +

{empty}

+ {/each} +
diff --git a/src/ui/picker.svelte b/src/ui/picker.svelte index 63caf88..17a2d98 100644 --- a/src/ui/picker.svelte +++ b/src/ui/picker.svelte @@ -1,18 +1,14 @@ @@ -112,42 +97,14 @@ /> {/if} {#if options.length > 0} -
- {#each shown as option, index (option.value)} - - {:else} -

- Nothing found -

- {/each} -
+ {/if} {#if footer}
0 ? "mt-1 border-border border-t pt-1" : ""}> diff --git a/src/ui/shortcuts.svelte b/src/ui/shortcuts.svelte new file mode 100644 index 0000000..d62540d --- /dev/null +++ b/src/ui/shortcuts.svelte @@ -0,0 +1,105 @@ + + + diff --git a/src/ui/sphere-rail.svelte b/src/ui/sphere-rail.svelte index e6e220f..8a946e7 100644 --- a/src/ui/sphere-rail.svelte +++ b/src/ui/sphere-rail.svelte @@ -10,14 +10,29 @@ const view = useView(); const store = view.store; - let expanded = $state>(new Set()); + let box = $state(null); + const zone = $derived(dropZone()); + /** The rail only draws a cursor while the keyboard is the one steering. */ + const steering = $derived(store.keyboard && store.zone === "rail"); + + $effect(() => { + void store.cursorRail; + if (!steering) return; + box + ?.querySelector("[data-rail-cursor='true']") + ?.scrollIntoView({ block: "nearest" }); + }); + + function here(id: string): boolean { + return steering && store.cursorRail === id; + } function toggle(path: string) { - const next = new Set(expanded); + const next = new Set(store.railExpanded); if (next.has(path)) next.delete(path); else next.add(path); - expanded = next; + store.railExpanded = next; } function selectSphere(path: string) { @@ -29,14 +44,21 @@ store.sphereFilter = path; store.projectFilter = store.projectFilter === project ? null : project; } + + const CURSOR = "bg-secondary/70 inset-ring-1 inset-ring-ring";