diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000..123abef --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,48 @@ +name: Benchmark + +on: + push: + branches: [main] + pull_request: + branches: [main] + # `workflow_dispatch` allows CodSpeed to trigger backtest + # performance analysis in order to generate initial data. + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + benchmarks: + name: Run benchmarks + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build WASM + run: make + + - name: Install dependencies + run: deno install + + - name: Run benchmarks + uses: CodSpeedHQ/action@v4 + with: + mode: simulation + # IMPORTANT! deno task bench fails in CI due to incompatible V8 bindings + run: node bench/mod.ts diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index dee9fab..5fc0b91 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -5,19 +5,24 @@ on: [pull_request] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: preview: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 submodules: true + persist-credentials: false - name: setup deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 with: deno-version: v2.x @@ -26,16 +31,17 @@ jobs: - name: Get Version id: vars - run: echo ::set-output name=version::$(git describe --abbrev=0 --tags | sed 's/^v//')-pr+$(git rev-parse HEAD) + run: echo "version=$(git describe --abbrev=0 --tags | sed 's/^v//')-pr+$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: 20.x - registry-url: https://registry.npmjs.com + node-version: 24 - name: Build NPM - run: deno task build:npm ${{steps.vars.outputs.version}} + run: deno task build:npm "${STEPS_VARS_OUTPUTS_VERSION}" + env: + STEPS_VARS_OUTPUTS_VERSION: ${{steps.vars.outputs.version}} - name: Publish Preview Versions run: npx pkg-pr-new publish './build/npm' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 800f1d4..c130955 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,19 +7,19 @@ on: permissions: contents: read - id-token: write jobs: verify-jsr: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: true + persist-credentials: false - name: setup deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 with: deno-version: v2.x @@ -28,10 +28,12 @@ jobs: - name: Get Version id: vars - run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^v//') + run: echo "version=$(echo "${GITHUB_REF_NAME}" | sed 's/^v//')" >> $GITHUB_OUTPUT - name: Build JSR - run: deno task build:jsr ${{steps.vars.outputs.version}} + run: deno task build:jsr "${STEPS_VARS_OUTPUTS_VERSION}" + env: + STEPS_VARS_OUTPUTS_VERSION: ${{steps.vars.outputs.version}} - name: dry run publish run: deno publish --dry-run --allow-dirty @@ -40,12 +42,13 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: true + persist-credentials: false - name: setup deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 with: deno-version: v2.x @@ -54,22 +57,26 @@ jobs: - name: Get Version id: vars - run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^v//') + run: echo "version=$(echo "${GITHUB_REF_NAME}" | sed 's/^v//')" >> $GITHUB_OUTPUT - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 + package-manager-cache: false + cache: "" - name: Build NPM - run: deno task build:npm ${{steps.vars.outputs.version}} + run: deno task build:npm "${STEPS_VARS_OUTPUTS_VERSION}" + env: + STEPS_VARS_OUTPUTS_VERSION: ${{steps.vars.outputs.version}} - name: dry run publish run: npm publish --dry-run --tag=verify working-directory: ./build/npm - name: upload build - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: npm-build path: ./build/npm @@ -77,15 +84,20 @@ jobs: publish-npm: needs: [verify-jsr, verify-npm] runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 + package-manager-cache: false + cache: "" - name: download build - uses: actions/download-artifact@v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: npm-build path: ./build/npm @@ -97,15 +109,19 @@ jobs: publish-jsr: needs: [verify-jsr, verify-npm] runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: true + persist-credentials: false - name: setup deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 with: deno-version: v2.x @@ -114,10 +130,12 @@ jobs: - name: Get Version id: vars - run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^v//') + run: echo "version=$(echo "${GITHUB_REF_NAME}" | sed 's/^v//')" >> $GITHUB_OUTPUT - name: Build JSR - run: deno task build:jsr ${{steps.vars.outputs.version}} + run: deno task build:jsr "${STEPS_VARS_OUTPUTS_VERSION}" + env: + STEPS_VARS_OUTPUTS_VERSION: ${{steps.vars.outputs.version}} - name: Publish JSR - run: deno publish --allow-dirty --token=${{ secrets.JSR_TOKEN }} + run: deno publish --allow-dirty diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml index 3ec7956..09bce38 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify.yaml @@ -2,25 +2,32 @@ name: Verify on: push: - branches: main + branches: + - main pull_request: - branches: main + branches: + - main permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: true + persist-credentials: false - name: setup deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 with: deno-version: v2.x @@ -33,6 +40,45 @@ jobs: - name: build wasm run: make + - name: upload wasm artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: clayterm-wasm + path: | + clayterm.wasm + wasm.ts + + test-alt-os: + needs: test + strategy: + matrix: + os: + - name: macos + value: macos-latest + - name: windows + value: windows-latest + fail-fast: false + runs-on: ${{ matrix.os.value }} + name: test ${{ matrix.os.name }} + + steps: + - name: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: true + persist-credentials: false + + - name: setup deno + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + + - name: download wasm artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: clayterm-wasm + path: . + - name: test run: deno task test @@ -41,12 +87,13 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: true + persist-credentials: false - name: setup deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 with: deno-version: v2.x @@ -64,17 +111,18 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: true + persist-credentials: false - name: setup deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 with: deno-version: v2.x - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..4946ba1 --- /dev/null +++ b/BUILD.md @@ -0,0 +1,296 @@ +# Building clayterm from source + +This guide is for maintainers and builders working on clayterm itself. + +It covers: + +- cloning the repo correctly, +- initializing the `clay` git submodule, +- installing the toolchain needed to compile the C sources to WebAssembly, +- building the local development artifacts, and +- verifying that the repo is ready for development. + +It does **not** cover npm/JSR packaging or publishing. + +## What the local build produces + +The local source build is driven by `make`. + +It generates: + +- `clayterm.wasm` — the compiled WebAssembly module built from the C sources +- `wasm.ts` — a generated TypeScript file derived from `clayterm.wasm` + +`wasm.ts` is generated output, not hand-maintained source. + +## Clone the repo with submodules + +The build depends on the `clay` git submodule. + +Preferred fresh clone: + +```sh +git clone --recurse-submodules https://github.com/bombshell-dev/clayterm.git +cd clayterm +``` + +If you already cloned without submodules: + +```sh +git submodule update --init --recursive +``` + +Quick check: + +```sh +git submodule status --recursive +``` + +You should also see a populated `clay/` directory. If `clay/` is missing or +empty, fix the submodule state before building. + +## Required tools + +You need: + +- `git` +- `make` +- `clang` with wasm32-capable support +- `deno` + +Equivalent packages are fine if your package manager uses different names. + +## Install the toolchain + +### macOS + +Install Apple's command line tools first. They provide the base developer tools, +including `git` and `make`. + +```sh +xcode-select --install +``` + +Then install LLVM and Deno with Homebrew: + +```sh +brew install llvm deno +``` + +Use Homebrew LLVM before Apple's system `clang` when building clayterm: + +```sh +echo 'export PATH="$(brew --prefix llvm)/bin:$PATH"' >> ~/.zshrc +source ~/.zshrc +``` + +If you do not already have `git` available after installing the command line +tools, install it with Homebrew: + +```sh +brew install git +``` + +### Debian / Ubuntu + +Install the build toolchain and Git: + +```sh +sudo apt-get update +sudo apt-get install -y build-essential clang lld git curl +``` + +Install Deno with the official installer: + +```sh +curl -fsSL https://deno.land/install.sh | sh +``` + +Add Deno to your shell path: + +```sh +echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc +source ~/.bashrc +``` + +### Fedora / RHEL + +Install the build toolchain and Git: + +```sh +sudo dnf install -y clang lld make git curl +``` + +Install Deno with the official installer: + +```sh +curl -fsSL https://deno.land/install.sh | sh +``` + +Add Deno to your shell path: + +```sh +echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc +source ~/.bashrc +``` + +### Windows + +The recommended Windows build-host path is **WSL2 with Ubuntu**. + +From an elevated PowerShell prompt: + +```powershell +wsl --install -d Ubuntu +``` + +Then open the Ubuntu environment and follow the **Debian / Ubuntu** instructions +above. + +The build host runs inside WSL2, but the resulting WebAssembly artifacts are +intended to run on **native Windows** at runtime. + +## Verify the toolchain + +Before building, confirm the required tools are available: + +```sh +git --version +make --version +clang --version +deno --version +``` + +For a quick wasm-target smoke test, make sure `clang` can compile for `wasm32`: + +```sh +clang --target=wasm32 -c -x c /dev/null -o /tmp/clayterm-wasm-test.o +rm -f /tmp/clayterm-wasm-test.o +``` + +On macOS, if `which clang` still points to `/usr/bin/clang` and the wasm test +fails, make sure the Homebrew LLVM `bin/` directory is at the front of your +`PATH`. + +## Build from source + +Run the local source build from the repository root: + +```sh +make +``` + +This should produce: + +- `clayterm.wasm` +- `wasm.ts` + +For a clean rebuild: + +```sh +make clean && make +``` + +## When to rebuild + +Re-run `make` when: + +- you change files under `src/` +- you update the `clay` submodule +- `clayterm.wasm` or `wasm.ts` is missing +- generated outputs look stale after switching branches or pulling changes + +When in doubt, use a clean rebuild: + +```sh +make clean && make +``` + +## Verify the build + +After `make` succeeds, run the test suite: + +```sh +deno task test +``` + +Before opening a PR, it is also a good idea to run the same checks CI runs: + +```sh +deno task fmt:check +deno lint +``` + +## Troubleshooting + +### `clay/` is missing or empty + +Symptoms may include build failures such as: + +- `fatal error: '../clay/clay.h' file not found` + +Recovery: + +```sh +git submodule update --init --recursive +``` + +Then verify the submodule state and rebuild: + +```sh +git submodule status --recursive +make clean && make +``` + +### `clang` cannot target `wasm32` + +Symptoms may include: + +- target-related `clang` errors mentioning `wasm32` +- linker failures while producing `clayterm.wasm` + +Recovery: + +- make sure you are using an LLVM/Clang build with wasm support +- on macOS, prefer the Homebrew `llvm` toolchain over `/usr/bin/clang` +- on Linux/WSL2, make sure both `clang` and `lld` are installed +- rerun the wasm smoke test: + +```sh +clang --target=wasm32 -c -x c /dev/null -o /tmp/clayterm-wasm-test.o +rm -f /tmp/clayterm-wasm-test.o +``` + +If the smoke test fails, fix the toolchain first and only then rerun `make`. + +### Generated artifacts are missing or stale + +Symptoms may include: + +- `clayterm.wasm` is missing +- `wasm.ts` is missing +- you changed `src/` or updated `clay/`, but the generated outputs do not match + +Recovery: + +```sh +make clean && make +``` + +Then verify the repo is in a good state: + +```sh +deno task test +``` + +## Scope note + +This document is intentionally limited to local source builds for development. + +Out of scope: + +- `deno task build:npm` +- `deno task build:jsr` +- `npm publish` +- `deno publish` +- release tagging and package publishing workflows diff --git a/Makefile b/Makefile index bdd0d97..05a2dd1 100644 --- a/Makefile +++ b/Makefile @@ -3,18 +3,47 @@ TARGET = clayterm.wasm SRC = src/module.c CFLAGS = --target=wasm32 -nostdlib -O2 \ + -ffunction-sections -fdata-sections \ + -mbulk-memory \ -DCLAY_IMPLEMENTATION -DCLAY_WASM \ -Isrc -I. +EXPORTS = \ + -Wl,--export=__heap_base \ + -Wl,--export=clayterm_size \ + -Wl,--export=init \ + -Wl,--export=reduce \ + -Wl,--export=output \ + -Wl,--export=length \ + -Wl,--export=measure \ + -Wl,--export=Clay_SetPointerState \ + -Wl,--export=pointer_over_count \ + -Wl,--export=pointer_over_id_string_length \ + -Wl,--export=pointer_over_id_string_ptr \ + -Wl,--export=get_element_bounds \ + -Wl,--export=animating \ + -Wl,--export=error_count \ + -Wl,--export=error_type \ + -Wl,--export=error_message_length \ + -Wl,--export=error_message_ptr \ + -Wl,--export=input_size \ + -Wl,--export=input_init \ + -Wl,--export=input_scan \ + -Wl,--export=input_count \ + -Wl,--export=input_event \ + -Wl,--export=input_delay + LDFLAGS = -Wl,--no-entry \ -Wl,--import-memory \ -Wl,--stack-first \ - -Wl,--export-all \ + -Wl,--strip-all \ + -Wl,--gc-sections \ -Wl,--undefined=Clay__MeasureText \ - -Wl,--undefined=Clay__QueryScrollOffset + -Wl,--undefined=Clay__QueryScrollOffset \ + $(EXPORTS) all: $(TARGET) wasm.ts - @echo "Built $(TARGET) ($$(wc -c < $(TARGET)) bytes)" + @echo "Built $(TARGET) ($$(wc -c < $(TARGET)) bytes raw, $$(gzip -c $(TARGET) | wc -c) bytes gzip)" DEPS = $(wildcard src/*.c src/*.h) diff --git a/README.md b/README.md index 6af34a3..7e2fd70 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ computation. will run anywhere JavaScript runs with no native dependencies, and no build step for consumers. -### Demo +### Examples The application in this demo uses Clayterm for all layout and input parsing @@ -30,14 +30,14 @@ The application in this demo uses Clayterm for all layout and input parsing The input parser decodes raw terminal bytes into structured events. Here you can see each key event as the string "hello world" is typed. -![Keyboard events demo](demo/keyboard-key-events.gif) +![Keyboard events demo](examples/keyboard/keyboard-key-events.gif) #### Pointer Events Here we see hover styles applied to UI elements in response to the pointer state. Clay drives the hit testing; no manual coordinate math required. -![Pointer events demo](demo/keyboard-pointer-events.gif) +![Pointer events demo](examples/keyboard/keyboard-pointer-events.gif) ## Architecture @@ -149,25 +149,28 @@ Pass pointer state to `render()` to have clayterm do hit detection and return pointer events in addition to the byte sequence. ```typescript -let { output, events } = term.render([ - open("root", { - layout: { width: grow(), height: grow(), direction: "ltr" }, - }), - open("sidebar", { - layout: { width: fixed(20), height: grow() }, - bg: rgba(30, 30, 40), - }), - text("Sidebar"), - close(), - open("main", { - layout: { width: grow(), height: grow() }, - }), - text("Main content"), - close(), - close(), -], { - pointer: { x: mouseX, y: mouseY, down: mouseDown }, -}); +let { output, events } = term.render( + [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + open("sidebar", { + layout: { width: fixed(20), height: grow() }, + bg: rgba(30, 30, 40), + }), + text("Sidebar"), + close(), + open("main", { + layout: { width: grow(), height: grow() }, + }), + text("Main content"), + close(), + close(), + ], + { + pointer: { x: mouseX, y: mouseY, down: mouseDown }, + }, +); for (let event of events) { // { type: "pointerenter", id: "sidebar" } @@ -212,16 +215,12 @@ process.stdin.on("data", (buf) => { ## Development -Requires `clang` with wasm32 target support. +For local source builds, toolchain setup, and `clay` submodule instructions, see +[BUILD.md](BUILD.md). -First build the `.wasm` +Quick local validation: ```sh make -``` - -run tests - -```sh deno task test ``` diff --git a/bench/input.bench.ts b/bench/input.bench.ts new file mode 100644 index 0000000..8e2c96b --- /dev/null +++ b/bench/input.bench.ts @@ -0,0 +1,55 @@ +import { Bench } from "tinybench"; +import { withCodSpeed } from "@codspeed/tinybench-plugin"; +import { createInput } from "../input.ts"; + +function bytes(...values: number[]): Uint8Array { + return new Uint8Array(values); +} + +function str(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +let input = await createInput({ escLatency: 25 }); + +let longBurst = new Uint8Array(200); +for (let i = 0; i < 200; i++) { + longBurst[i] = 0x61 + (i % 26); +} + +let bench = withCodSpeed(new Bench()); + +bench + .add("printable ASCII (single char)", () => { + input.scan(bytes(0x61)); + }) + .add("printable ASCII (short string)", () => { + input.scan(str("hello world")); + }) + .add("arrow key (CSI sequence)", () => { + input.scan(bytes(0x1b, 0x5b, 0x41)); + }) + .add("modifier combo (Ctrl+Shift+Arrow)", () => { + input.scan(bytes(0x1b, 0x5b, 0x31, 0x3b, 0x38, 0x41)); + }) + .add("SGR mouse press", () => { + input.scan(str("\x1b[<0;35;12M")); + }) + .add("multi-event burst (arrows + text)", () => { + input.scan(bytes(0x1b, 0x5b, 0x41, 0x1b, 0x5b, 0x42, 0x68, 0x69)); + }) + .add("UTF-8 3-byte character", () => { + input.scan(bytes(0xe4, 0xb8, 0xad)); + }) + .add("UTF-8 4-byte emoji", () => { + input.scan(bytes(0xf0, 0x9f, 0x8e, 0x89)); + }) + .add("Kitty protocol (CSI u with modifiers)", () => { + input.scan(str("\x1b[97;3u")); + }) + .add("long input burst (200 bytes)", () => { + input.scan(longBurst); + }); + +await bench.run(); +console.table(bench.table()); diff --git a/bench/mod.ts b/bench/mod.ts new file mode 100644 index 0000000..ca9de34 --- /dev/null +++ b/bench/mod.ts @@ -0,0 +1,3 @@ +import "./input.bench.ts"; +import "./render.bench.ts"; +import "./ops.bench.ts"; diff --git a/bench/ops.bench.ts b/bench/ops.bench.ts new file mode 100644 index 0000000..3019792 --- /dev/null +++ b/bench/ops.bench.ts @@ -0,0 +1,124 @@ +import { Bench } from "tinybench"; +import { withCodSpeed } from "@codspeed/tinybench-plugin"; +import { close, fixed, grow, open, pack, rgba, text } from "../ops.ts"; +import type { Op } from "../ops.ts"; + +function makeBuf(size: number): ArrayBuffer { + return new ArrayBuffer(size); +} + +let simpleOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("Hello, World!"), + close(), +]; + +let complexOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("header", { + layout: { + width: grow(), + height: fixed(3), + padding: { left: 1, right: 1 }, + direction: "ltr", + }, + bg: rgba(30, 30, 40), + border: { + color: rgba(100, 100, 120), + bottom: 1, + }, + }), + text("Title", { color: rgba(255, 255, 255), fontSize: 1 }), + close(), + open("body", { + layout: { + width: grow(), + height: grow(), + direction: "ltr", + gap: 1, + }, + }), + open("sidebar", { + layout: { + width: fixed(20), + height: grow(), + direction: "ttb", + padding: { left: 1, right: 1, top: 1 }, + }, + bg: rgba(25, 25, 35), + border: { + color: rgba(60, 60, 80), + right: 1, + }, + }), + text("Menu Item 1"), + text("Menu Item 2"), + text("Menu Item 3"), + close(), + open("main", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 2, top: 1 }, + }, + }), + text("Main content area with longer text to exercise the encoder"), + close(), + close(), + open("footer", { + layout: { + width: grow(), + height: fixed(1), + padding: { left: 1 }, + direction: "ltr", + }, + bg: rgba(30, 30, 40), + }), + text("Status: OK"), + close(), + close(), +]; + +let listOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + ...Array.from({ length: 50 }, (_, i) => [ + open(`item-${i}`, { + layout: { + width: grow(), + height: fixed(1), + padding: { left: 2 }, + direction: "ltr", + }, + bg: i % 2 === 0 ? rgba(30, 30, 40) : rgba(35, 35, 45), + }), + text(`List item ${i}: some description text`), + close(), + ]).flat(), + close(), +]; + +let bench = withCodSpeed(new Bench()); + +bench + .add("simple tree (root + text)", () => { + let buf = makeBuf(4096); + pack(simpleOps, buf, 0); + }) + .add("complex layout (header + sidebar + main + footer)", () => { + let buf = makeBuf(8192); + pack(complexOps, buf, 0); + }) + .add("large list (50 items)", () => { + let buf = makeBuf(32768); + pack(listOps, buf, 0); + }); + +await bench.run(); +console.table(bench.table()); diff --git a/bench/render.bench.ts b/bench/render.bench.ts new file mode 100644 index 0000000..1c3b8c3 --- /dev/null +++ b/bench/render.bench.ts @@ -0,0 +1,158 @@ +import { Bench } from "tinybench"; +import { withCodSpeed } from "@codspeed/tinybench-plugin"; +import { createTerm } from "../term.ts"; +import { close, fixed, grow, open, rgba, text } from "../ops.ts"; +import type { Op } from "../ops.ts"; + +let term = await createTerm({ width: 80, height: 24 }); +let termPtr = await createTerm({ width: 80, height: 24 }); + +let helloOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("Hello, World!"), + close(), +]; + +let borderedOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("box", { + layout: { + width: grow(), + height: grow(), + padding: { left: 1, right: 1, top: 1, bottom: 1 }, + direction: "ttb", + }, + border: { + color: rgba(0, 255, 0), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, + }), + text("Bordered content"), + close(), + close(), +]; + +let dashboardOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("header", { + layout: { + width: grow(), + height: fixed(3), + padding: { left: 1 }, + direction: "ltr", + }, + bg: rgba(30, 30, 40), + border: { color: rgba(80, 80, 100), bottom: 1 }, + }), + text("Dashboard", { color: rgba(255, 255, 255) }), + close(), + open("body", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + open("sidebar", { + layout: { + width: fixed(20), + height: grow(), + direction: "ttb", + padding: { left: 1, top: 1 }, + }, + bg: rgba(25, 25, 35), + border: { color: rgba(60, 60, 80), right: 1 }, + }), + text("Nav 1"), + text("Nav 2"), + text("Nav 3"), + text("Nav 4"), + close(), + open("main", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 2, top: 1 }, + }, + }), + ...Array.from({ length: 10 }, (_, i) => [ + open(`row-${i}`, { + layout: { + width: grow(), + height: fixed(1), + direction: "ltr", + }, + bg: i % 2 === 0 ? rgba(35, 35, 45) : undefined, + }), + text(`Row ${i}: data value ${i * 42}`), + close(), + ]).flat(), + close(), + close(), + open("footer", { + layout: { + width: grow(), + height: fixed(1), + padding: { left: 1 }, + }, + bg: rgba(30, 30, 40), + }), + text("Ready"), + close(), + close(), +]; + +let uiOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("button", { + layout: { + width: fixed(20), + height: fixed(3), + padding: { left: 1, right: 1 }, + }, + bg: rgba(50, 50, 200), + border: { + color: rgba(100, 100, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, + }), + text("Click me"), + close(), + close(), +]; + +let bench = withCodSpeed(new Bench()); + +bench + .add("simple text", () => { + term.render(helloOps); + }) + .add("bordered box with corner radius", () => { + term.render(borderedOps); + }) + .add("dashboard layout", () => { + term.render(dashboardOps); + }) + .add("diff render (second frame)", () => { + term.render(dashboardOps); + term.render(dashboardOps); + }) + .add("render with pointer hit testing", () => { + termPtr.render(uiOps, { pointer: { x: 10, y: 1, down: false } }); + }); + +await bench.run(); +console.table(bench.table()); diff --git a/deno.json b/deno.json index fab9ce2..901feb9 100644 --- a/deno.json +++ b/deno.json @@ -7,7 +7,7 @@ "fmt:check": "deno fmt --check && clang-format --dry-run --Werror src/*.c src/*.h", "build:npm": "deno run -A tasks/build-npm.ts", "build:jsr": "deno run -A tasks/build-jsr.ts", - "demo": "deno run demo/keyboard.ts" + "bench": "deno run -A bench/mod.ts" }, "imports": { "@std/testing": "jsr:@std/testing@1", @@ -15,7 +15,9 @@ "@sinclair/typebox": "npm:@sinclair/typebox@^0.34", "dnt": "jsr:@deno/dnt@0.42.3", "effection": "npm:effection@^4.0.2", - "@std/encoding": "jsr:@std/encoding@1" + "@std/encoding": "jsr:@std/encoding@1", + "@codspeed/tinybench-plugin": "npm:@codspeed/tinybench-plugin@^5.4.0", + "tinybench": "npm:tinybench@^5.0.0" }, "exports": { ".": "./mod.ts", @@ -25,6 +27,7 @@ "include": ["*.ts"], "exclude": ["!wasm.ts"] }, + "nodeModulesDir": "auto", "fmt": { "exclude": ["clay", "build"] }, diff --git a/deno.lock b/deno.lock index 7947a80..d0f8ef2 100644 --- a/deno.lock +++ b/deno.lock @@ -20,9 +20,11 @@ "jsr:@std/testing@1": "1.0.17", "jsr:@ts-morph/bootstrap@0.27": "0.27.0", "jsr:@ts-morph/common@0.27": "0.27.0", + "npm:@codspeed/tinybench-plugin@^5.4.0": "5.4.0_tinybench@5.1.0", "npm:@sinclair/typebox@*": "0.34.48", "npm:@sinclair/typebox@0.34": "0.34.48", "npm:effection@^4.0.2": "4.0.2", + "npm:tinybench@5": "5.1.0", "npm:valrs@*": "0.1.0" }, "jsr": { @@ -109,14 +111,222 @@ } }, "npm": { + "@codspeed/core@5.4.0": { + "integrity": "sha512-SwGjXDixN/zX1awBR95LzS0KxIs931qwf7Hbk7BRWv1jAdlMYf9o9GlSnWER4zGBHz941BvzFQJ1O2RIofW3cg==", + "dependencies": [ + "axios", + "find-up", + "form-data", + "node-gyp-build" + ] + }, + "@codspeed/tinybench-plugin@5.4.0_tinybench@5.1.0": { + "integrity": "sha512-jzuFoyyoGxc3Lc+TTl54PnRsgqO3CYbbbnwYSVp/m/4rqvCwSUZChY9EuQJ6uZFbamT3UhWF2N6tDEGShkvsrw==", + "dependencies": [ + "@codspeed/core", + "stack-trace", + "tinybench" + ] + }, "@sinclair/typebox@0.34.48": { "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==" }, + "agent-base@6.0.2": { + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": [ + "debug" + ] + }, + "asynckit@0.4.0": { + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios@1.16.1": { + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "dependencies": [ + "follow-redirects", + "form-data", + "https-proxy-agent", + "proxy-from-env" + ] + }, + "call-bind-apply-helpers@1.0.2": { + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": [ + "es-errors", + "function-bind" + ] + }, + "combined-stream@1.0.8": { + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": [ + "delayed-stream" + ] + }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": [ + "ms" + ] + }, + "delayed-stream@1.0.0": { + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "dunder-proto@1.0.1": { + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": [ + "call-bind-apply-helpers", + "es-errors", + "gopd" + ] + }, "effection@4.0.2": { "integrity": "sha512-O8WMGP10nPuJDwbNGILcaCNWS+CvDYjcdsUSD79nWZ+WtUQ8h1MEV7JJwCSZCSeKx8+TdEaZ/8r6qPTR2o/o8w==" }, + "es-define-property@1.0.1": { + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors@1.3.0": { + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms@1.1.2": { + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": [ + "es-errors" + ] + }, + "es-set-tostringtag@2.1.0": { + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": [ + "es-errors", + "get-intrinsic", + "has-tostringtag", + "hasown" + ] + }, + "find-up@6.3.0": { + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "dependencies": [ + "locate-path", + "path-exists" + ] + }, + "follow-redirects@1.16.0": { + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==" + }, + "form-data@4.0.5": { + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": [ + "asynckit", + "combined-stream", + "es-set-tostringtag", + "hasown", + "mime-types" + ] + }, + "function-bind@1.1.2": { + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-intrinsic@1.3.0": { + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": [ + "call-bind-apply-helpers", + "es-define-property", + "es-errors", + "es-object-atoms", + "function-bind", + "get-proto", + "gopd", + "has-symbols", + "hasown", + "math-intrinsics" + ] + }, + "get-proto@1.0.1": { + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": [ + "dunder-proto", + "es-object-atoms" + ] + }, + "gopd@1.2.0": { + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "has-symbols@1.1.0": { + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "has-tostringtag@1.0.2": { + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": [ + "has-symbols" + ] + }, + "hasown@2.0.3": { + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dependencies": [ + "function-bind" + ] + }, + "https-proxy-agent@5.0.1": { + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": [ + "agent-base", + "debug" + ] + }, + "locate-path@7.2.0": { + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dependencies": [ + "p-locate" + ] + }, + "math-intrinsics@1.1.0": { + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "mime-db@1.52.0": { + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types@2.1.35": { + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": [ + "mime-db" + ] + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node-gyp-build@4.8.4": { + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "bin": true + }, + "p-limit@4.0.0": { + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dependencies": [ + "yocto-queue" + ] + }, + "p-locate@6.0.0": { + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dependencies": [ + "p-limit" + ] + }, + "path-exists@5.0.0": { + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==" + }, + "proxy-from-env@2.1.0": { + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==" + }, + "stack-trace@1.0.0-pre2": { + "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==" + }, + "tinybench@5.1.0": { + "integrity": "sha512-LXKNtFualiKOm6gADe1UXPtf8+Nfn1CtPMEHAT33Fd2YjQatrujkDcK0+4wRC1X6t7fxUDXUs6BsvuIgfkDgDg==" + }, "valrs@0.1.0": { "integrity": "sha512-BqVkjx3qhsRLHerblLDoqEx0OEx7ms0DB6LPv40oWkMfFKUVKrqVuklaGdrPrHyubC5hSHYfEtUiQXrCkC6xHQ==" + }, + "yocto-queue@1.2.2": { + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==" } }, "workspace": { @@ -125,8 +335,10 @@ "jsr:@std/encoding@1", "jsr:@std/expect@1", "jsr:@std/testing@1", + "npm:@codspeed/tinybench-plugin@^5.4.0", "npm:@sinclair/typebox@0.34", - "npm:effection@^4.0.2" + "npm:effection@^4.0.2", + "npm:tinybench@5" ] } } diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..212f7a0 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,63 @@ +# examples + +This directory contains runnable example applications that exercise different +features of libs. If any of these examples are not working, please open an issue +with information about your terminal, shell, operating system and any other +information that could be pertinent to reproducing the issue. + +> [!NOTE] +> Run the commands in this document from the repository root. + +## Prerequisites + +Build the generated WebAssembly bundle before running the examples: + +```sh +make +``` + +## Keyboard + +Path: `examples/keyboard/index.ts` + +Run it with: + +```sh +deno run examples/keyboard/index.ts +``` + +What it shows: + +- raw keyboard input decoded into structured key events +- progressive keyboard protocol support +- pointer tracking and hover/click-driven UI updates +- terminal mode configuration such as alternate buffer, hidden cursor, and mouse + reporting + +Related files: + +- `examples/keyboard/use-input.ts` wraps the input parser as a stream of decoded + events +- `examples/keyboard/use-stdin.ts` adapts stdin into a byte stream for the demo + +## Inline Regions + +Path: `examples/inline-regions/index.ts` + +Run it with: + +```sh +deno run examples/inline-regions/index.ts +``` + +What it shows: + +- rendering animated regions into normal terminal scrollback +- querying cursor position with DSR to place later frames correctly +- updating a previously allocated region without taking over the whole screen +- small animated demos including a spinner, a progress bar, and a nyan-cat-style + sequence + +This example is useful if you want to embed transient or animated UI output into +a normal command-line workflow instead of switching to a full-screen alternate +buffer interface. diff --git a/demo/inline-region.ts b/examples/inline-regions/index.ts similarity index 98% rename from demo/inline-region.ts rename to examples/inline-regions/index.ts index 75a5b92..601dea8 100644 --- a/demo/inline-region.ts +++ b/examples/inline-regions/index.ts @@ -24,9 +24,9 @@ import { rgba, SHOWCURSOR, text, -} from "../mod.ts"; -import { cursor, settings } from "../settings.ts"; -import { validated } from "../validate.ts"; +} from "../../mod.ts"; +import { cursor, settings } from "../../settings.ts"; +import { validated } from "../../validate.ts"; const encode = (s: string) => new TextEncoder().encode(s); const write = (b: Uint8Array) => Deno.stdout.writeSync(b); diff --git a/demo/clay-transitions.ts b/examples/keyboard/clay-transitions.ts similarity index 99% rename from demo/clay-transitions.ts rename to examples/keyboard/clay-transitions.ts index 77f7ce8..a7580da 100644 --- a/demo/clay-transitions.ts +++ b/examples/keyboard/clay-transitions.ts @@ -34,13 +34,13 @@ import { type PointerEvent, rgba, text, -} from "../mod.ts"; +} from "../../mod.ts"; import { alternateBuffer, cursor, mouseTracking, settings, -} from "../settings.ts"; +} from "../../settings.ts"; import { useInput } from "./use-input.ts"; import { useStdin } from "./use-stdin.ts"; diff --git a/demo/keyboard.ts b/examples/keyboard/index.ts similarity index 99% rename from demo/keyboard.ts rename to examples/keyboard/index.ts index 0dce0c3..ef8fec8 100644 --- a/demo/keyboard.ts +++ b/examples/keyboard/index.ts @@ -21,7 +21,7 @@ import { type PointerEvent, rgba, text, -} from "../mod.ts"; +} from "../../mod.ts"; import { alternateBuffer, cursor, @@ -29,7 +29,7 @@ import { progressiveInput, type Setting, settings, -} from "../settings.ts"; +} from "../../settings.ts"; import { useInput } from "./use-input.ts"; import { useStdin } from "./use-stdin.ts"; diff --git a/demo/keyboard-key-events.gif b/examples/keyboard/keyboard-key-events.gif similarity index 100% rename from demo/keyboard-key-events.gif rename to examples/keyboard/keyboard-key-events.gif diff --git a/demo/keyboard-pointer-events.gif b/examples/keyboard/keyboard-pointer-events.gif similarity index 100% rename from demo/keyboard-pointer-events.gif rename to examples/keyboard/keyboard-pointer-events.gif diff --git a/demo/transitions.ts b/examples/keyboard/transitions.ts similarity index 98% rename from demo/transitions.ts rename to examples/keyboard/transitions.ts index 96a8620..f800e0b 100644 --- a/demo/transitions.ts +++ b/examples/keyboard/transitions.ts @@ -28,8 +28,8 @@ import { percent, rgba, text, -} from "../mod.ts"; -import { alternateBuffer, cursor, settings } from "../settings.ts"; +} from "../../mod.ts"; +import { alternateBuffer, cursor, settings } from "../../settings.ts"; import { useInput } from "./use-input.ts"; import { useStdin } from "./use-stdin.ts"; diff --git a/demo/use-input.ts b/examples/keyboard/use-input.ts similarity index 98% rename from demo/use-input.ts rename to examples/keyboard/use-input.ts index 73a4467..f85566c 100644 --- a/demo/use-input.ts +++ b/examples/keyboard/use-input.ts @@ -11,7 +11,7 @@ import { suspend, until, } from "effection"; -import { createInput, type InputEvent, type InputOptions } from "../mod.ts"; +import { createInput, type InputEvent, type InputOptions } from "../../mod.ts"; function nothing() { return suspend() as unknown as Operation< diff --git a/demo/use-stdin.ts b/examples/keyboard/use-stdin.ts similarity index 100% rename from demo/use-stdin.ts rename to examples/keyboard/use-stdin.ts diff --git a/ops.ts b/ops.ts index bb363fb..637a0c4 100644 --- a/ops.ts +++ b/ops.ts @@ -5,6 +5,7 @@ import { easingByte, propertyMask } from "./ops-transitions.ts"; const OP_OPEN_ELEMENT = 0x02; const OP_TEXT = 0x03; const OP_CLOSE_ELEMENT = 0x04; +const OP_SNAPSHOT = 0x05; /* Property group masks for OPEN_ELEMENT */ const PROP_LAYOUT = 0x01; @@ -56,11 +57,27 @@ function packAxis(view: DataView, offset: number, axis: SizingAxis): number { return o; } -function packString(view: DataView, bytes: Uint8Array, o: number): number { +function packString( + view: DataView, + bytes: Uint8Array, + o: number, + end: number, + context: string, +): number { + let paddedLength = Math.ceil(bytes.length / 4) * 4; + let next = o + 4 + paddedLength; + if (next > end) { + throw new RangeError( + `clayterm transfer buffer capacity exceeded while packing ${context} ` + + `(${next} byte offset, ${end} byte limit). ` + + `Render a smaller visible slice or reduce frame content.`, + ); + } + view.setUint32(o, bytes.length, true); o += 4; new Uint8Array(view.buffer).set(bytes, o); - o += Math.ceil(bytes.length / 4) * 4; + o += paddedLength; return o; } @@ -86,7 +103,7 @@ export function pack( o += 4; let bytes = encoder.encode(op.id); - o = packString(view, bytes, o); + o = packString(view, bytes, o, end, "element id"); let mask = 0; if (op.layout) mask |= PROP_LAYOUT; @@ -196,6 +213,12 @@ export function pack( break; } + case OP_SNAPSHOT: { + new Uint8Array(mem).set(op.data, o); + o += op.data.length; + break; + } + case OP_TEXT: { view.setUint32(o, OP_TEXT, true); o += 4; @@ -212,7 +235,7 @@ export function pack( o += 4; let str = encoder.encode(op.content); - o = packString(view, str, o); + o = packString(view, str, o, end, "text content"); break; } } @@ -302,7 +325,12 @@ export interface Text { attrs?: number; } -export type Op = OpenElement | Text | CloseElement; +interface Snapshot { + directive: typeof OP_SNAPSHOT; + data: Uint8Array; +} + +export type Op = OpenElement | Text | CloseElement | Snapshot; export function open( id: string, @@ -321,3 +349,43 @@ export function text( export function close(): CloseElement { return { directive: OP_CLOSE_ELEMENT }; } + +function packSize(ops: Op[]): number { + let n = 0; + for (let op of ops) { + switch (op.directive) { + case OP_CLOSE_ELEMENT: + n += 4; + break; + case OP_SNAPSHOT: + n += op.data.length; + break; + case OP_OPEN_ELEMENT: { + n += 4; // opcode + n += 4 + Math.ceil(encoder.encode(op.id).length / 4) * 4; // id string + n += 4; // mask + if (op.layout) n += 6 * 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align + if (op.bg !== undefined) n += 4; + if (op.cornerRadius) n += 4; + if (op.border) n += 8; + if (op.clip) n += 4; + if (op.floating) n += 16; + if (op.transition) n += 8; + break; + } + case OP_TEXT: { + n += 4 + 4 + 4; // opcode + color + cfg + n += 4 + Math.ceil(encoder.encode(op.content).length / 4) * 4; // string + break; + } + } + } + return n; +} + +export function snapshot(ops: Op[]): Op { + let size = packSize(ops); + let buf = new ArrayBuffer(size); + let words = pack(ops, buf, 0, size); + return { directive: OP_SNAPSHOT, data: new Uint8Array(buf, 0, words * 4) }; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index fabda38..5f0f09d 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -406,6 +406,26 @@ Text directives MUST appear between a matching open/close pair. The set of styling properties accepted by `props` is part of the current implementation surface and may be extended. +#### 8.3.4 snapshot + +``` +snapshot(ops: Op[]): Op +``` + +Creates a snapshot by pre-packing the given directive array into its transfer +encoding. The returned value is an `Op` and can appear anywhere in a directive +array where the original ops would have appeared. The internal representation is +opaque. + +When the renderer encounters a snapshot during transfer, it copies the +pre-packed bytes directly into the command buffer without re-encoding. The +snapshot's ops MUST be structurally balanced (every `open` matched by a +`close`). + +Snapshots enable higher-level frameworks to implement dirty tracking: a +component whose inputs have not changed can reuse a previously created snapshot, +avoiding the cost of re-packing its subtree each frame. + ### 8.4 Sizing helpers These functions produce sizing-axis values for use in element layout @@ -462,6 +482,10 @@ that do not match a preceding open, is invalid input. Callers SHOULD validate directive arrays before rendering. The renderer's behavior when given an invalid directive array is unspecified by this specification. +A snapshot is semantically equivalent to splicing its source ops into the array +at the snapshot's position. The renderer MUST produce identical layout and +output regardless of whether ops are provided directly or via a snapshot. + ### 9.2 Transfer to the WASM module As part of the render transaction, the directive array is transferred into a @@ -469,6 +493,13 @@ form that the WASM module can process. This transfer is handled internally by the renderer and is not an operation the caller performs or observes. The transfer mechanism is an implementation detail described in Section 12.1. +If a frame exceeds transfer-buffer capacity while packing string content, the +renderer MUST throw a descriptive `RangeError` that identifies the condition as +a transfer-buffer, frame-capacity, or packing overflow. The renderer MUST NOT +expose only the raw host-level TypedArray message `"offset is out of bounds"` +for this condition. The error message SHOULD direct callers to render a smaller +visible slice or reduce frame content. + ### 9.3 Directive identity Each element directive carries an `id` provided by the caller via `open()`. diff --git a/tasks/build-npm.ts b/tasks/build-npm.ts index 7259863..da64792 100644 --- a/tasks/build-npm.ts +++ b/tasks/build-npm.ts @@ -20,9 +20,8 @@ await build({ typeCheck: false, compilerOptions: { lib: ["ESNext"], - target: "ES2020", - sourceMap: true, }, + skipSourceOutput: true, package: { name: "clayterm", version, @@ -31,15 +30,16 @@ await build({ license: "MIT", repository: { type: "git", - url: "git+https://github.com/thefrontside/clayterm.git", + url: "git+https://github.com/bombshell-dev/clayterm.git", }, bugs: { - url: "https://github.com/thefrontside/clayterm/issues", + url: "https://github.com/bombshell-dev/clayterm/issues", }, engines: { - node: ">= 16", + node: ">= 22", }, sideEffects: false, + type: "module", }, }); diff --git a/test/pack.test.ts b/test/pack.test.ts new file mode 100644 index 0000000..9b3bee8 --- /dev/null +++ b/test/pack.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "./suite.ts"; +import { close, open, pack, text } from "../ops.ts"; + +describe("pack", () => { + it("throws a descriptive RangeError when text exceeds the transfer buffer", () => { + let memory = new ArrayBuffer(64); + let error: unknown; + + try { + pack( + [ + open("root"), + text("x".repeat(128)), + close(), + ], + memory, + 0, + memory.byteLength, + ); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(RangeError); + expect((error as Error).message).toMatch( + /transfer buffer|capacity|packing/, + ); + expect((error as Error).message).toContain("text content"); + expect((error as Error).message).not.toBe("offset is out of bounds"); + expect((error as Error).message).toMatch( + /smaller visible slice|reduce frame content/, + ); + }); + + it("throws a descriptive RangeError when an element id exceeds the transfer buffer", () => { + let memory = new ArrayBuffer(16); + let error: unknown; + + try { + pack([open("x".repeat(64)), close()], memory, 0, memory.byteLength); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(RangeError); + expect((error as Error).message).toMatch( + /transfer buffer|capacity|packing/, + ); + expect((error as Error).message).toContain("element id"); + expect((error as Error).message).not.toBe("offset is out of bounds"); + }); +}); diff --git a/test/term.test.ts b/test/term.test.ts index 47d8c94..adcd10a 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -1,6 +1,15 @@ import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; -import { close, fixed, grow, open, rgba, text } from "../ops.ts"; +import { + close, + fixed, + grow, + type Op, + open, + rgba, + snapshot, + text, +} from "../ops.ts"; import { print } from "./print.ts"; const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); @@ -191,6 +200,99 @@ describe("term", () => { }); }); + describe("snapshot", () => { + it("produces identical output to direct ops", async () => { + let ops = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + bg: rgba(0, 0, 128), + }), + open("child", { + layout: { + width: grow(), + padding: { left: 1 }, + direction: "ttb", + }, + border: { + color: rgba(255, 255, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + }), + text("snapshot test"), + close(), + close(), + ]; + + let direct = await createTerm({ width: 40, height: 10 }); + let snapped = await createTerm({ width: 40, height: 10 }); + + let expected = direct.render(ops, { mode: "line" }).output; + let actual = snapped.render([snapshot(ops)], { mode: "line" }).output; + + expect(decode(actual)).toEqual(decode(expected)); + }); + + it("renders inside another element", async () => { + let child = snapshot([ + open("child", { + layout: { width: grow(), direction: "ttb" }, + }), + text("inner"), + close(), + ]); + + let direct = await createTerm({ width: 20, height: 5 }); + let snapped = await createTerm({ width: 20, height: 5 }); + + let wrapper = (content: Op[]) => [ + open("root", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 1, top: 1 }, + }, + border: { + color: rgba(255, 255, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + }), + ...content, + close(), + ]; + + let expected = direct.render( + wrapper([ + open("child", { + layout: { width: grow(), direction: "ttb" }, + }), + text("inner"), + close(), + ]), + { mode: "line" }, + ).output; + + let actual = snapped.render( + wrapper([child]), + { mode: "line" }, + ).output; + + expect(decode(actual)).toEqual(decode(expected)); + expect(trim(print(decode(actual), 20, 5))).toEqual(` +┌──────────────────┐ +│inner │ +│ │ +│ │ +└──────────────────┘`.trim()); + }); + }); + describe("row offset", () => { it("renders two frames at the offset position", async () => { let term = await createTerm({ width: 20, height: 5 }); diff --git a/test/transitions-pack.test.ts b/test/transitions-pack.test.ts index 885a89a..b505fd4 100644 --- a/test/transitions-pack.test.ts +++ b/test/transitions-pack.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "./suite.ts"; -import { close, open, pack } from "../mod.ts"; +import { close, open, pack, snapshot } from "../mod.ts"; describe("pack transition", () => { it("encodes a transition without throwing", () => { @@ -39,4 +39,13 @@ describe("pack transition", () => { // The transition block is exactly 8 bytes = 2 words. expect(withLen - withoutLen).toBe(2); }); + + it("includes transition bytes when sizing snapshots", () => { + expect(() => + snapshot([ + open("a", { transition: { duration: 0.2, properties: ["x"] } }), + close(), + ]) + ).not.toThrow(); + }); }); diff --git a/test/validate.test.ts b/test/validate.test.ts index 8db4af9..25a8e0e 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -19,6 +19,20 @@ describe("validate", () => { expect(validate([])).toBe(true); }); + it("accepts transition ops", () => { + expect(validate([ + open("x", { + transition: { + duration: 0.2, + easing: "easeOut", + properties: ["x", "bg"], + interactive: true, + }, + }), + close(), + ])).toBe(true); + }); + it("rejects ops with wrong directive", () => { expect(validate([{ directive: 0xff }])).toBe(false); }); @@ -31,6 +45,23 @@ describe("validate", () => { expect(validate([{ directive: 0x03 }])).toBe(false); }); + it("rejects invalid transition properties", () => { + expect(validate([ + open("x", { + // deno-lint-ignore no-explicit-any + transition: { duration: 0.2, properties: ["opacity" as any] }, + }), + close(), + ])).toBe(false); + }); + + it("rejects negative transition duration", () => { + expect(validate([ + open("x", { transition: { duration: -1, properties: ["x"] } }), + close(), + ])).toBe(false); + }); + it("rejects non-array", () => { expect(validate("garbage")).toBe(false); }); diff --git a/validate.ts b/validate.ts index 248ea48..a18e656 100644 --- a/validate.ts +++ b/validate.ts @@ -89,6 +89,34 @@ const Floating = Type.Object({ zIndex: Type.Optional(u16), }); +const TransitionProperty = Type.Union([ + Type.Literal("x"), + Type.Literal("y"), + Type.Literal("position"), + Type.Literal("width"), + Type.Literal("height"), + Type.Literal("size"), + Type.Literal("bg"), + Type.Literal("overlay"), + Type.Literal("borderColor"), + Type.Literal("borderWidth"), + Type.Literal("all"), +]); + +const Easing = Type.Union([ + Type.Literal("linear"), + Type.Literal("easeIn"), + Type.Literal("easeOut"), + Type.Literal("easeInOut"), +]); + +const Transition = Type.Object({ + duration: Type.Number({ minimum: 0 }), + easing: Type.Optional(Easing), + properties: Type.Array(TransitionProperty), + interactive: Type.Optional(Type.Boolean()), +}); + /* ── Op types (discriminated on `directive`) ──────────────────────── */ const CloseElement = Type.Object({ directive: Type.Literal(0x04) }); @@ -102,6 +130,7 @@ const OpenElement = Type.Object({ border: Type.Optional(Border), clip: Type.Optional(Clip), floating: Type.Optional(Floating), + transition: Type.Optional(Transition), }); const TextOp = Type.Object({