diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ad92582 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..4261f3e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,17 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Serve Esbuild", + "detail": "Run a local server serving html files & transpiling TS -> JS", + "command": "npm", + "type": "process", + "args": ["run", "serve"], + "problemMatcher": ["$esbuild-watch"], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + } + ] +} diff --git a/debug.html b/debug.html index c79ae55..7019993 100644 --- a/debug.html +++ b/debug.html @@ -6,6 +6,7 @@ <script src="js/debug.js"></script> <script> document.body.append(...TickDebug()); + document.body.append(...IdvDebug()); </script> </body> </html> diff --git a/debug.ts b/debug.ts index 19908a0..b181e09 100644 --- a/debug.ts +++ b/debug.ts @@ -1,3 +1,4 @@ import { TickDebug } from "./debug/tick"; +import { IdvDebug } from "./debug/idv"; -Object.assign(globalThis, { TickDebug }); +Object.assign(globalThis, { TickDebug, IdvDebug }); diff --git a/debug/idv.ts b/debug/idv.ts new file mode 100644 index 0000000..f5447bb --- /dev/null +++ b/debug/idv.ts @@ -0,0 +1,58 @@ +import { h } from "../lib/html"; +import { Idv, StringProperty } from "../lib/idv"; + +export function IdvDebug() { + const textarea = h( + "textarea", + { + cols: 80, + rows: 40, + oninput(ev) { + parse(); + }, + }, + `# Idv Testing Ground +User: tangent128 + Uid: 10000 + Shell: tclsh + Group: users + Group: sudo + Banner: + +------------------+ + |Welcome to Debian!| + +------------------+ + +User: tirga + Uid: 10101 + Shell: bash + Group: users +` + ); + + const pre = h("pre"); + + function parse() { + try { + const idv = Idv.parse(textarea.value); + + pre.textContent = JSON.stringify( + idv.getMergedMap("User", UserFromDocument), + null, + 2 + ); + } catch (e) { + pre.textContent = String(e); + } + } + parse(); + + return [textarea, pre]; +} + +const UserFromDocument = (idv: Idv) => { + return { + shell: idv.getProperty("Shell", StringProperty), + groups: idv.getList("Group", StringProperty), + banner: idv.getProperty("Banner", StringProperty), + }; +}; diff --git a/idv.md b/idv.md new file mode 100644 index 0000000..9ad7de1 --- /dev/null +++ b/idv.md @@ -0,0 +1,168 @@ +# The Indented Document Values Format + +## Overview + +The Indented Document Values (IDV) format is a meta-syntax for machine-readable textual data. + +IDV is designed to prioritize human readability and writability by minimizing visual noise- there are no sigils, quotes, or brackets, only colons, indentation, and (when necessary) backslash escapes. + +As a tradeoff, IDV is not a self-describing data format- while it can be used for defining a serialization or configuration format, systems using it need to layer their own semantics on top of it. + +### Example + +``` +Person: Alice + Uid: 1000 + Phone: 555-1234 + Group: users + Group: sudo + Banner: + ============================ + This is my ASCII art login message + ============================ + +Person: Bob + Uid: 1001 + Phone: 555-5656 + Group: users +``` + +## Syntax + +IDV is a line-oriented format. Before any other parsing is done, the input is split into lines, and any trailing whitespace on a line (including line separators) is ignored. + +### Comments + +A **Comment** is any line whose first character is a `#` character. Comment lines are for human use and are ignored by the parser. + +``` +# This line is ignored +``` + +### Blank Lines + +A **Blank Line** is any line that only contains whitespace. Because trailing whitespace is always trimmed, all Blank Lines are indistinguishable from each other. + +Blank Lines are ignored unless they are part of a Document. (see below) + +### Entries + +An **Entry** is composed of one or more lines: + +#### Tags + +Each entry begins with a **Tag**, terminated by a colon (`:`). A Tag can contain any characters except leading or trailing whitespace, newlines, and colons: + +``` +Tag: +``` + +#### Distinguishers + +Optionally, a Distinguisher can follow the Tag on the same line. A Distinguisher can contain any characters except leading or trailing whitespace, and newlines: + +``` +Tag: distinguisher +``` + +#### Escapes + +Within Tags and Distinguishers, backslash escapes may be used to represent non-permitted or inconvenient characters: + +``` +Tag With \: And Spaces: + +Tag: \ distinguisher with leading whitespace and\nA newline +``` + +| Escape sequence | Replacement | +| --------------- | ----------------- | +| \\_\<space>_ | A literal space | +| \\n | A newline | +| \\: | A colon (`:`) | +| \\\\ | A backslash (`\`) | + +> TODO: additional escapes? ie, hex or unicode? + +#### Documents + +After the first line of an entry, any indented lines make up the **Document** portion of the entry: + +``` +Tag: distinguisher + First Line + Second Line + Third Line +``` + +The first line of a Document defines the Document's indentation- subsequent lines can be indented deeper, but no line may be indented _less_ than the first line. This indentation is removed from the beginning of each line when determining the Document's value. + +Blank Lines can not carry indentation information. To resolve this ambiguity, Documents may not begin or end with Blank Lines- such lines are ignored. Blank Lines that occur _between_ indented lines _are_ considered part of the Document. + +``` +Tag: + + The above blank line is ignored. + The below blank line is part of the Document. + + The below blank line is ignored. + +Tag: + Other stuff +``` + +Backslash escapes are _not_ processed within a Document. However, backslashes may be processed later, by higher-layered semantics. + +In many cases the Document will contain recursive IDV data, and the rules above are designed to play nicely with this case- but it is up to the concrete format to decide how to parse the Document. It could just as easily contain free text, XML, or a base64 blob. + +#### Disambiguations: + +1. The Tag and Distinguisher are both trimmed of surrounding whitespace before being interpreted, but internal whitespace is left intact. +1. The Distinguisher may contain literal colons; these are treated as regular characters and carry no special meaning. + +## Data Model + +Applying minimal interpretation, IDV data can be represented as a list of Entries. + +An Entry can be represented as a 3-tuple of: + +1. a string (the Tag) +2. a string (the optional Distinguisher) +3. a list of strings (the lines of the Document) + +How Entries are interpreted by the appication is not specified, but see below for some suggested patterns that should line up with things people usually want to do. + +## Patterns + +### Primitive Property + +> TODO: one of distinguisher | document non-empty, parsing based on expected type + +### Object Property + +> TODO: distinguisher ignored, document is IDV + +### Union Property + +> TODO: distinguisher determines how the document is parsed + +### List + +> TODO: property specified multiple times + +### Map + +> TODO: distinguisher defines key, document parsed for value + +### Property Map + +> TODO: Category defines key, parsed as property for value + +### Merged Map + +## See Also + +> TODO: +> +> - yaml +> - dpkg control files diff --git a/js_sketch.code-workspace b/js_sketch.code-workspace deleted file mode 100644 index 85ed6cf..0000000 --- a/js_sketch.code-workspace +++ /dev/null @@ -1,27 +0,0 @@ -{ - "folders": [ - { - "path": "." - } - ], - "settings": { - "editor.formatOnSave": true - }, - "tasks": { - "version": "2.0.0", - "tasks": [ - { - "label": "Serve Esbuild", - "detail": "Run a local server serving html files & transpiling TS -> JS", - "command": "npm", - "type": "process", - "args": ["run", "serve"], - "problemMatcher": ["$esbuild-watch"], - "presentation": { - "reveal": "always", - "panel": "dedicated" - } - } - ] - } -} diff --git a/lib/idv.ts b/lib/idv.ts new file mode 100644 index 0000000..5cf7d2b --- /dev/null +++ b/lib/idv.ts @@ -0,0 +1,125 @@ +export type DistinguisherParser<T> = (distinguisher: string) => T; +export type DocumentParser<T> = (document: string[]) => T; +export type UnionParser<T> = (distinguisher: string, document: string[]) => T; +export type IdvConverter<T> = (idv: Idv) => T; + +const LEADING_WHITESPACE = /^([ \t]+)/; +const ENTRY = /^(.+?):\s*(.*)/; + +export class Idv { + collections: Map<string, undefined | [string, string[]][]> = new Map(); + + public static parse(input: string): Idv { + const idv = new Idv(); + return idv.import(input); + } + public static parseLines(input: string[]): Idv { + const idv = new Idv(); + return idv.importLines(input); + } + public import(input: string): Idv { + const lines = input.split("\n").map((line) => line.trimEnd()); + return this.importLines(lines); + } + public importLines(input: string[]): Idv { + let currentDocument: string[] | undefined = undefined; + let currentIndent: string | undefined = undefined; + let bufferedBlankLines: string[] = []; + + input.forEach((line) => { + const indent = LEADING_WHITESPACE.exec(line)?.[1]; + if (indent) { + if (currentDocument == undefined) { + throw new Error("Indented document found before an entry"); + } + if (currentIndent == undefined) { + currentIndent = indent; + } + if (line.startsWith(currentIndent)) { + currentDocument.push(...bufferedBlankLines); + bufferedBlankLines = []; + currentDocument.push(line.substring(currentIndent.length)); + } else { + throw new Error( + "Inconsistent indentation- line indented less than the first line of its document" + ); + } + } else if (line == "") { + bufferedBlankLines.push(""); + } else if (line[0] == "#") { + // skip + } else { + const matches = ENTRY.exec(line); + if (matches) { + const [, collection, distinguisher] = matches; + + if (!this.collections.has(collection)) { + this.collections.set(collection, []); + } + + currentDocument = []; + currentIndent = undefined; + // TODO: implement backslash escaping in the distinguisher + this.collections + .get(collection)! + .push([distinguisher, currentDocument]); + } else { + throw new Error("Failed to parse a property"); + } + } + }); + + return this; + } + + public getProperty<T>(name: string, parse: UnionParser<T>): T | undefined { + const firstEntry = this.collections.get(name)?.[0]; + return firstEntry && parse(firstEntry[0], firstEntry[1]); + } + + public getList<T>(name: string, parse: UnionParser<T>): T[] { + return (this.collections.get(name) ?? []).map(([distinguisher, document]) => + parse(distinguisher, document) + ); + } + + public getMap<T>( + name: string, + parseDocument: DocumentParser<T> + ): Record<string, T> { + return Object.fromEntries( + (this.collections.get(name) ?? []).map(([distinguisher, document]) => [ + distinguisher, + parseDocument(document), + ]) + ); + } + + public getMergedMap<T>( + name: string, + convertIdv: IdvConverter<T> + ): Record<string, T> { + const idvMap: Map<string, Idv> = new Map(); + + (this.collections.get(name) ?? []).forEach(([distinguisher, document]) => { + let idv = idvMap.get(distinguisher); + if (idv == undefined) { + idvMap.set(distinguisher, (idv = new Idv())); + } + + idv.importLines(document); + }); + + const result: Record<string, T> = {}; + + for (const [distinguisher, idv] of idvMap.entries()) { + result[distinguisher] = convertIdv(idv); + } + + return result; + } +} + +// TODO: implement backslash-escaping in the document? +export const StringProperty = (distinguisher: string, lines: string[]) => + lines.length == 0 ? distinguisher : lines.join("\n"); diff --git a/lib/keys.ts b/lib/keys.ts new file mode 100644 index 0000000..c3b335f --- /dev/null +++ b/lib/keys.ts @@ -0,0 +1,67 @@ +import { Source } from "./source"; + +export type KeyName = "up" | "down" | "left" | "right" | "a" | "b" | "menu"; + +const KEY_NAMES: Record<string, KeyName> = { + // compact keys (WASD+ZXC) + KeyZ: "a", + KeyX: "b", + KeyC: "menu", + KeyW: "up", + KeyS: "down", + KeyA: "left", + KeyD: "right", + // full-board keys (arrows+space/shift/enter) + Space: "a", + ShiftLeft: "b", + ShiftRight: "b", + Enter: "menu", + ArrowUp: "up", + ArrowDown: "down", + ArrowLeft: "left", + ArrowRight: "right", +}; + +/** A keypress/release event for an abstract button, or else ["focus", "release"] if for some reason future release events might not be registered. */ +export type KeyEvent = [KeyName | "focus", "press" | "release"]; + +export function keyControl(source: HTMLElement): Source<KeyEvent> { + const tabIndex = source.getAttribute("tabIndex"); + source.setAttribute( + "tabindex", + tabIndex == "" || tabIndex == null ? "-1" : tabIndex + ); + + return ((callback?: (keyEvent: KeyEvent) => void) => { + if (callback) { + const handle = (evt: KeyboardEvent, action: "press" | "release") => { + const keyName = KEY_NAMES[evt.code]; + if (keyName != undefined) { + evt.preventDefault(); + evt.stopPropagation(); + + callback([keyName, action]); + } + }; + const keyUp = (evt: KeyboardEvent) => handle(evt, "release"); + const keyDown = (evt: KeyboardEvent) => handle(evt, "press"); + const focus = () => callback(["focus", "press"]); + const blur = () => callback(["focus", "release"]); + + source.addEventListener("keyup", keyUp, false); + source.addEventListener("keydown", keyDown, false); + source.addEventListener("focus", focus, false); + source.addEventListener("blur", blur, false); + source.focus({ focusVisible: true } as FocusOptions); + + return () => { + source.removeEventListener("keyup", keyUp, false); + source.removeEventListener("keydown", keyDown, false); + source.removeEventListener("focus", focus, false); + source.removeEventListener("blur", blur, false); + }; + } else { + return ["blur", "release"]; + } + }) as Source<KeyEvent>; +} diff --git a/lib/tick.ts b/lib/tick.ts index a4b8c2b..d158357 100644 --- a/lib/tick.ts +++ b/lib/tick.ts @@ -1,13 +1,9 @@ -import { Cancel, Source } from "./source"; +import { Source } from "./source"; export type PhysicsTick = ["physics"]; export type RenderTick = ["render", number]; export function tick(fps: number): Source<PhysicsTick | RenderTick> { - function tickSource(): PhysicsTick; - function tickSource( - callback: (tick: PhysicsTick | RenderTick) => void - ): Cancel; function tickSource(callback?: (tick: PhysicsTick | RenderTick) => void) { if (callback) { let lastPhysicsTick: number = new Date().getTime(); @@ -32,5 +28,5 @@ export function tick(fps: number): Source<PhysicsTick | RenderTick> { } } - return tickSource; + return tickSource as Source<PhysicsTick | RenderTick>; } diff --git a/pixelflood.html b/pixelflood.html new file mode 100644 index 0000000..a9d9d7f --- /dev/null +++ b/pixelflood.html @@ -0,0 +1,11 @@ +<html> + <head> + <title>Pixelflood Art</title> + </head> + <body> + <script src="js/pixelflood.js"></script> + <script> + document.body.append(...PixelfloodApplet()); + </script> + </body> +</html> diff --git a/pixelflood.ts b/pixelflood.ts new file mode 100644 index 0000000..ba288a7 --- /dev/null +++ b/pixelflood.ts @@ -0,0 +1,45 @@ +// TODO choose generator +// TODO get/put image data in render/tick loop +// TODO rediscover RGB/HSV pixelflood techniques + +import { canvas2d, h } from "./lib/html"; + +interface Controls { + seed: HTMLInputElement; +} + +function PixelfloodApplet() { + const [canvas, cx] = canvas2d({}); + const [controls, controlUi] = ControlUi(); + + return [canvas, ...controlUi]; +} + +function numInput(init: number) { + return h("input", { type: "number", valueAsNumber: init }); +} + +function ControlUi(): [Controls, HTMLElement[]] { + let seed, width, height; + + const html = [ + h("h2", {}, "Controls"), + h( + "div", + {}, + h( + "label", + {}, + "Width: ", + (width = numInput(128)), + "Height: ", + (height = numInput(128)) + ) + ), + h("div", {}, h("label", {}, "Random Seed: ", (seed = numInput(128)))), + ]; + + return [{ seed }, html]; +} + +Object.assign(globalThis, { PixelfloodApplet }); diff --git a/tsconfig.json b/tsconfig.json index 41fc1da..d1ec214 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "isolatedModules": true, "lib": ["ES2019", "DOM"], "strict": true, + "target": "ES2020", "noEmit": true } }