[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

Merge remote-tracking branch 'origin/main' into transitions

# Conflicts:
# examples/keyboard/clay-transitions.ts
# examples/keyboard/transitions.ts

Ryan Rauh (May 31, 2026, 9:27 AM EDT) 4145cac5 949dea5e

+1406 -90
+48
.github/workflows/benchmark.yml
··· 1 + name: Benchmark 2 + 3 + on: 4 + push: 5 + branches: [main] 6 + pull_request: 7 + branches: [main] 8 + # `workflow_dispatch` allows CodSpeed to trigger backtest 9 + # performance analysis in order to generate initial data. 10 + workflow_dispatch: 11 + 12 + permissions: 13 + contents: read 14 + id-token: write 15 + 16 + jobs: 17 + benchmarks: 18 + name: Run benchmarks 19 + runs-on: ubuntu-latest 20 + 21 + steps: 22 + - name: Checkout 23 + uses: actions/checkout@v4 24 + with: 25 + submodules: true 26 + 27 + - name: Setup Deno 28 + uses: denoland/setup-deno@v2 29 + with: 30 + deno-version: v2.x 31 + 32 + - name: Setup Node 33 + uses: actions/setup-node@v4 34 + with: 35 + node-version: 22 36 + 37 + - name: Build WASM 38 + run: make 39 + 40 + - name: Install dependencies 41 + run: deno install 42 + 43 + - name: Run benchmarks 44 + uses: CodSpeedHQ/action@v4 45 + with: 46 + mode: simulation 47 + # IMPORTANT! deno task bench fails in CI due to incompatible V8 bindings 48 + run: node bench/mod.ts
+13 -7
.github/workflows/preview.yml
··· 5 5 permissions: 6 6 contents: read 7 7 8 + concurrency: 9 + group: ${{ github.workflow }}-${{ github.ref }} 10 + cancel-in-progress: true 11 + 8 12 jobs: 9 13 preview: 10 14 runs-on: ubuntu-latest 11 15 timeout-minutes: 10 12 16 steps: 13 17 - name: checkout 14 - uses: actions/checkout@v4 18 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 15 19 with: 16 20 fetch-depth: 0 17 21 submodules: true 22 + persist-credentials: false 18 23 19 24 - name: setup deno 20 - uses: denoland/setup-deno@v2 25 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 21 26 with: 22 27 deno-version: v2.x 23 28 ··· 26 31 27 32 - name: Get Version 28 33 id: vars 29 - run: echo ::set-output name=version::$(git describe --abbrev=0 --tags | sed 's/^v//')-pr+$(git rev-parse HEAD) 34 + run: echo "version=$(git describe --abbrev=0 --tags | sed 's/^v//')-pr+$(git rev-parse HEAD)" >> $GITHUB_OUTPUT 30 35 31 36 - name: Setup Node 32 - uses: actions/setup-node@v4 37 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 33 38 with: 34 - node-version: 20.x 35 - registry-url: https://registry.npmjs.com 39 + node-version: 24 36 40 37 41 - name: Build NPM 38 - run: deno task build:npm ${{steps.vars.outputs.version}} 42 + run: deno task build:npm "${STEPS_VARS_OUTPUTS_VERSION}" 43 + env: 44 + STEPS_VARS_OUTPUTS_VERSION: ${{steps.vars.outputs.version}} 39 45 40 46 - name: Publish Preview Versions 41 47 run: npx pkg-pr-new publish './build/npm'
+36 -18
.github/workflows/publish.yml
··· 7 7 8 8 permissions: 9 9 contents: read 10 - id-token: write 11 10 12 11 jobs: 13 12 verify-jsr: 14 13 runs-on: ubuntu-latest 15 14 steps: 16 15 - name: checkout 17 - uses: actions/checkout@v4 16 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 18 17 with: 19 18 submodules: true 19 + persist-credentials: false 20 20 21 21 - name: setup deno 22 - uses: denoland/setup-deno@v2 22 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 23 23 with: 24 24 deno-version: v2.x 25 25 ··· 28 28 29 29 - name: Get Version 30 30 id: vars 31 - run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^v//') 31 + run: echo "version=$(echo "${GITHUB_REF_NAME}" | sed 's/^v//')" >> $GITHUB_OUTPUT 32 32 33 33 - name: Build JSR 34 - run: deno task build:jsr ${{steps.vars.outputs.version}} 34 + run: deno task build:jsr "${STEPS_VARS_OUTPUTS_VERSION}" 35 + env: 36 + STEPS_VARS_OUTPUTS_VERSION: ${{steps.vars.outputs.version}} 35 37 36 38 - name: dry run publish 37 39 run: deno publish --dry-run --allow-dirty ··· 40 42 runs-on: ubuntu-latest 41 43 steps: 42 44 - name: checkout 43 - uses: actions/checkout@v4 45 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 44 46 with: 45 47 submodules: true 48 + persist-credentials: false 46 49 47 50 - name: setup deno 48 - uses: denoland/setup-deno@v2 51 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 49 52 with: 50 53 deno-version: v2.x 51 54 ··· 54 57 55 58 - name: Get Version 56 59 id: vars 57 - run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^v//') 60 + run: echo "version=$(echo "${GITHUB_REF_NAME}" | sed 's/^v//')" >> $GITHUB_OUTPUT 58 61 59 62 - name: Setup Node 60 - uses: actions/setup-node@v6 63 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 61 64 with: 62 65 node-version: 24 66 + package-manager-cache: false 67 + cache: "" 63 68 64 69 - name: Build NPM 65 - run: deno task build:npm ${{steps.vars.outputs.version}} 70 + run: deno task build:npm "${STEPS_VARS_OUTPUTS_VERSION}" 71 + env: 72 + STEPS_VARS_OUTPUTS_VERSION: ${{steps.vars.outputs.version}} 66 73 67 74 - name: dry run publish 68 75 run: npm publish --dry-run --tag=verify 69 76 working-directory: ./build/npm 70 77 71 78 - name: upload build 72 - uses: actions/upload-artifact@v4 79 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 73 80 with: 74 81 name: npm-build 75 82 path: ./build/npm ··· 77 84 publish-npm: 78 85 needs: [verify-jsr, verify-npm] 79 86 runs-on: ubuntu-latest 87 + permissions: 88 + contents: read 89 + id-token: write 80 90 81 91 steps: 82 92 - name: Setup Node 83 - uses: actions/setup-node@v6 93 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 84 94 with: 85 95 node-version: 24 96 + package-manager-cache: false 97 + cache: "" 86 98 87 99 - name: download build 88 - uses: actions/download-artifact@v4 100 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 89 101 with: 90 102 name: npm-build 91 103 path: ./build/npm ··· 97 109 publish-jsr: 98 110 needs: [verify-jsr, verify-npm] 99 111 runs-on: ubuntu-latest 112 + permissions: 113 + contents: read 114 + id-token: write 100 115 101 116 steps: 102 117 - name: checkout 103 - uses: actions/checkout@v4 118 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 104 119 with: 105 120 submodules: true 121 + persist-credentials: false 106 122 107 123 - name: setup deno 108 - uses: denoland/setup-deno@v2 124 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 109 125 with: 110 126 deno-version: v2.x 111 127 ··· 114 130 115 131 - name: Get Version 116 132 id: vars 117 - run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^v//') 133 + run: echo "version=$(echo "${GITHUB_REF_NAME}" | sed 's/^v//')" >> $GITHUB_OUTPUT 118 134 119 135 - name: Build JSR 120 - run: deno task build:jsr ${{steps.vars.outputs.version}} 136 + run: deno task build:jsr "${STEPS_VARS_OUTPUTS_VERSION}" 137 + env: 138 + STEPS_VARS_OUTPUTS_VERSION: ${{steps.vars.outputs.version}} 121 139 122 140 - name: Publish JSR 123 - run: deno publish --allow-dirty --token=${{ secrets.JSR_TOKEN }} 141 + run: deno publish --allow-dirty
+57 -9
.github/workflows/verify.yaml
··· 2 2 3 3 on: 4 4 push: 5 - branches: main 5 + branches: 6 + - main 6 7 pull_request: 7 - branches: main 8 + branches: 9 + - main 8 10 9 11 permissions: 10 12 contents: read 11 13 14 + concurrency: 15 + group: ${{ github.workflow }}-${{ github.ref }} 16 + cancel-in-progress: true 17 + 12 18 jobs: 13 19 test: 14 20 runs-on: ubuntu-latest 15 21 16 22 steps: 17 23 - name: checkout 18 - uses: actions/checkout@v4 24 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 19 25 with: 20 26 submodules: true 27 + persist-credentials: false 21 28 22 29 - name: setup deno 23 - uses: denoland/setup-deno@v2 30 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 24 31 with: 25 32 deno-version: v2.x 26 33 ··· 33 40 - name: build wasm 34 41 run: make 35 42 43 + - name: upload wasm artifact 44 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 45 + with: 46 + name: clayterm-wasm 47 + path: | 48 + clayterm.wasm 49 + wasm.ts 50 + 51 + test-alt-os: 52 + needs: test 53 + strategy: 54 + matrix: 55 + os: 56 + - name: macos 57 + value: macos-latest 58 + - name: windows 59 + value: windows-latest 60 + fail-fast: false 61 + runs-on: ${{ matrix.os.value }} 62 + name: test ${{ matrix.os.name }} 63 + 64 + steps: 65 + - name: checkout 66 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 67 + with: 68 + submodules: true 69 + persist-credentials: false 70 + 71 + - name: setup deno 72 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 73 + with: 74 + deno-version: v2.x 75 + 76 + - name: download wasm artifact 77 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 78 + with: 79 + name: clayterm-wasm 80 + path: . 81 + 36 82 - name: test 37 83 run: deno task test 38 84 ··· 41 87 runs-on: ubuntu-latest 42 88 steps: 43 89 - name: checkout 44 - uses: actions/checkout@v4 90 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 45 91 with: 46 92 submodules: true 93 + persist-credentials: false 47 94 48 95 - name: setup deno 49 - uses: denoland/setup-deno@v2 96 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 50 97 with: 51 98 deno-version: v2.x 52 99 ··· 64 111 runs-on: ubuntu-latest 65 112 steps: 66 113 - name: checkout 67 - uses: actions/checkout@v4 114 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 68 115 with: 69 116 submodules: true 117 + persist-credentials: false 70 118 71 119 - name: setup deno 72 - uses: denoland/setup-deno@v2 120 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 73 121 with: 74 122 deno-version: v2.x 75 123 76 124 - name: Setup Node 77 - uses: actions/setup-node@v4 125 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 78 126 with: 79 127 node-version: 24 80 128
+296
BUILD.md
··· 1 + # Building clayterm from source 2 + 3 + This guide is for maintainers and builders working on clayterm itself. 4 + 5 + It covers: 6 + 7 + - cloning the repo correctly, 8 + - initializing the `clay` git submodule, 9 + - installing the toolchain needed to compile the C sources to WebAssembly, 10 + - building the local development artifacts, and 11 + - verifying that the repo is ready for development. 12 + 13 + It does **not** cover npm/JSR packaging or publishing. 14 + 15 + ## What the local build produces 16 + 17 + The local source build is driven by `make`. 18 + 19 + It generates: 20 + 21 + - `clayterm.wasm` — the compiled WebAssembly module built from the C sources 22 + - `wasm.ts` — a generated TypeScript file derived from `clayterm.wasm` 23 + 24 + `wasm.ts` is generated output, not hand-maintained source. 25 + 26 + ## Clone the repo with submodules 27 + 28 + The build depends on the `clay` git submodule. 29 + 30 + Preferred fresh clone: 31 + 32 + ```sh 33 + git clone --recurse-submodules https://github.com/bombshell-dev/clayterm.git 34 + cd clayterm 35 + ``` 36 + 37 + If you already cloned without submodules: 38 + 39 + ```sh 40 + git submodule update --init --recursive 41 + ``` 42 + 43 + Quick check: 44 + 45 + ```sh 46 + git submodule status --recursive 47 + ``` 48 + 49 + You should also see a populated `clay/` directory. If `clay/` is missing or 50 + empty, fix the submodule state before building. 51 + 52 + ## Required tools 53 + 54 + You need: 55 + 56 + - `git` 57 + - `make` 58 + - `clang` with wasm32-capable support 59 + - `deno` 60 + 61 + Equivalent packages are fine if your package manager uses different names. 62 + 63 + ## Install the toolchain 64 + 65 + ### macOS 66 + 67 + Install Apple's command line tools first. They provide the base developer tools, 68 + including `git` and `make`. 69 + 70 + ```sh 71 + xcode-select --install 72 + ``` 73 + 74 + Then install LLVM and Deno with Homebrew: 75 + 76 + ```sh 77 + brew install llvm deno 78 + ``` 79 + 80 + Use Homebrew LLVM before Apple's system `clang` when building clayterm: 81 + 82 + ```sh 83 + echo 'export PATH="$(brew --prefix llvm)/bin:$PATH"' >> ~/.zshrc 84 + source ~/.zshrc 85 + ``` 86 + 87 + If you do not already have `git` available after installing the command line 88 + tools, install it with Homebrew: 89 + 90 + ```sh 91 + brew install git 92 + ``` 93 + 94 + ### Debian / Ubuntu 95 + 96 + Install the build toolchain and Git: 97 + 98 + ```sh 99 + sudo apt-get update 100 + sudo apt-get install -y build-essential clang lld git curl 101 + ``` 102 + 103 + Install Deno with the official installer: 104 + 105 + ```sh 106 + curl -fsSL https://deno.land/install.sh | sh 107 + ``` 108 + 109 + Add Deno to your shell path: 110 + 111 + ```sh 112 + echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc 113 + source ~/.bashrc 114 + ``` 115 + 116 + ### Fedora / RHEL 117 + 118 + Install the build toolchain and Git: 119 + 120 + ```sh 121 + sudo dnf install -y clang lld make git curl 122 + ``` 123 + 124 + Install Deno with the official installer: 125 + 126 + ```sh 127 + curl -fsSL https://deno.land/install.sh | sh 128 + ``` 129 + 130 + Add Deno to your shell path: 131 + 132 + ```sh 133 + echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc 134 + source ~/.bashrc 135 + ``` 136 + 137 + ### Windows 138 + 139 + The recommended Windows build-host path is **WSL2 with Ubuntu**. 140 + 141 + From an elevated PowerShell prompt: 142 + 143 + ```powershell 144 + wsl --install -d Ubuntu 145 + ``` 146 + 147 + Then open the Ubuntu environment and follow the **Debian / Ubuntu** instructions 148 + above. 149 + 150 + The build host runs inside WSL2, but the resulting WebAssembly artifacts are 151 + intended to run on **native Windows** at runtime. 152 + 153 + ## Verify the toolchain 154 + 155 + Before building, confirm the required tools are available: 156 + 157 + ```sh 158 + git --version 159 + make --version 160 + clang --version 161 + deno --version 162 + ``` 163 + 164 + For a quick wasm-target smoke test, make sure `clang` can compile for `wasm32`: 165 + 166 + ```sh 167 + clang --target=wasm32 -c -x c /dev/null -o /tmp/clayterm-wasm-test.o 168 + rm -f /tmp/clayterm-wasm-test.o 169 + ``` 170 + 171 + On macOS, if `which clang` still points to `/usr/bin/clang` and the wasm test 172 + fails, make sure the Homebrew LLVM `bin/` directory is at the front of your 173 + `PATH`. 174 + 175 + ## Build from source 176 + 177 + Run the local source build from the repository root: 178 + 179 + ```sh 180 + make 181 + ``` 182 + 183 + This should produce: 184 + 185 + - `clayterm.wasm` 186 + - `wasm.ts` 187 + 188 + For a clean rebuild: 189 + 190 + ```sh 191 + make clean && make 192 + ``` 193 + 194 + ## When to rebuild 195 + 196 + Re-run `make` when: 197 + 198 + - you change files under `src/` 199 + - you update the `clay` submodule 200 + - `clayterm.wasm` or `wasm.ts` is missing 201 + - generated outputs look stale after switching branches or pulling changes 202 + 203 + When in doubt, use a clean rebuild: 204 + 205 + ```sh 206 + make clean && make 207 + ``` 208 + 209 + ## Verify the build 210 + 211 + After `make` succeeds, run the test suite: 212 + 213 + ```sh 214 + deno task test 215 + ``` 216 + 217 + Before opening a PR, it is also a good idea to run the same checks CI runs: 218 + 219 + ```sh 220 + deno task fmt:check 221 + deno lint 222 + ``` 223 + 224 + ## Troubleshooting 225 + 226 + ### `clay/` is missing or empty 227 + 228 + Symptoms may include build failures such as: 229 + 230 + - `fatal error: '../clay/clay.h' file not found` 231 + 232 + Recovery: 233 + 234 + ```sh 235 + git submodule update --init --recursive 236 + ``` 237 + 238 + Then verify the submodule state and rebuild: 239 + 240 + ```sh 241 + git submodule status --recursive 242 + make clean && make 243 + ``` 244 + 245 + ### `clang` cannot target `wasm32` 246 + 247 + Symptoms may include: 248 + 249 + - target-related `clang` errors mentioning `wasm32` 250 + - linker failures while producing `clayterm.wasm` 251 + 252 + Recovery: 253 + 254 + - make sure you are using an LLVM/Clang build with wasm support 255 + - on macOS, prefer the Homebrew `llvm` toolchain over `/usr/bin/clang` 256 + - on Linux/WSL2, make sure both `clang` and `lld` are installed 257 + - rerun the wasm smoke test: 258 + 259 + ```sh 260 + clang --target=wasm32 -c -x c /dev/null -o /tmp/clayterm-wasm-test.o 261 + rm -f /tmp/clayterm-wasm-test.o 262 + ``` 263 + 264 + If the smoke test fails, fix the toolchain first and only then rerun `make`. 265 + 266 + ### Generated artifacts are missing or stale 267 + 268 + Symptoms may include: 269 + 270 + - `clayterm.wasm` is missing 271 + - `wasm.ts` is missing 272 + - you changed `src/` or updated `clay/`, but the generated outputs do not match 273 + 274 + Recovery: 275 + 276 + ```sh 277 + make clean && make 278 + ``` 279 + 280 + Then verify the repo is in a good state: 281 + 282 + ```sh 283 + deno task test 284 + ``` 285 + 286 + ## Scope note 287 + 288 + This document is intentionally limited to local source builds for development. 289 + 290 + Out of scope: 291 + 292 + - `deno task build:npm` 293 + - `deno task build:jsr` 294 + - `npm publish` 295 + - `deno publish` 296 + - release tagging and package publishing workflows
+31 -3
Makefile
··· 3 3 SRC = src/module.c 4 4 5 5 CFLAGS = --target=wasm32 -nostdlib -O2 \ 6 + -ffunction-sections -fdata-sections \ 7 + -mbulk-memory \ 6 8 -DCLAY_IMPLEMENTATION -DCLAY_WASM \ 7 9 -Isrc -I. 8 10 11 + EXPORTS = \ 12 + -Wl,--export=__heap_base \ 13 + -Wl,--export=clayterm_size \ 14 + -Wl,--export=init \ 15 + -Wl,--export=reduce \ 16 + -Wl,--export=output \ 17 + -Wl,--export=length \ 18 + -Wl,--export=measure \ 19 + -Wl,--export=Clay_SetPointerState \ 20 + -Wl,--export=pointer_over_count \ 21 + -Wl,--export=pointer_over_id_string_length \ 22 + -Wl,--export=pointer_over_id_string_ptr \ 23 + -Wl,--export=get_element_bounds \ 24 + -Wl,--export=error_count \ 25 + -Wl,--export=error_type \ 26 + -Wl,--export=error_message_length \ 27 + -Wl,--export=error_message_ptr \ 28 + -Wl,--export=input_size \ 29 + -Wl,--export=input_init \ 30 + -Wl,--export=input_scan \ 31 + -Wl,--export=input_count \ 32 + -Wl,--export=input_event \ 33 + -Wl,--export=input_delay 34 + 9 35 LDFLAGS = -Wl,--no-entry \ 10 36 -Wl,--import-memory \ 11 37 -Wl,--stack-first \ 12 - -Wl,--export-all \ 38 + -Wl,--strip-all \ 39 + -Wl,--gc-sections \ 13 40 -Wl,--undefined=Clay__MeasureText \ 14 - -Wl,--undefined=Clay__QueryScrollOffset 41 + -Wl,--undefined=Clay__QueryScrollOffset \ 42 + $(EXPORTS) 15 43 16 44 all: $(TARGET) wasm.ts 17 - @echo "Built $(TARGET) ($$(wc -c < $(TARGET)) bytes)" 45 + @echo "Built $(TARGET) ($$(wc -c < $(TARGET)) bytes raw, $$(gzip -c $(TARGET) | wc -c) bytes gzip)" 18 46 19 47 DEPS = $(wildcard src/*.c src/*.h) 20 48
+28 -29
README.md
··· 21 21 will run anywhere JavaScript runs with no native dependencies, and no build step 22 22 for consumers. 23 23 24 - ### Demo 24 + ### Examples 25 25 26 26 The application in this demo uses Clayterm for all layout and input parsing 27 27 ··· 30 30 The input parser decodes raw terminal bytes into structured events. Here you can 31 31 see each key event as the string "hello world" is typed. 32 32 33 - ![Keyboard events demo](demo/keyboard-key-events.gif) 33 + ![Keyboard events demo](examples/keyboard/keyboard-key-events.gif) 34 34 35 35 #### Pointer Events 36 36 37 37 Here we see hover styles applied to UI elements in response to the pointer 38 38 state. Clay drives the hit testing; no manual coordinate math required. 39 39 40 - ![Pointer events demo](demo/keyboard-pointer-events.gif) 40 + ![Pointer events demo](examples/keyboard/keyboard-pointer-events.gif) 41 41 42 42 ## Architecture 43 43 ··· 149 149 pointer events in addition to the byte sequence. 150 150 151 151 ```typescript 152 - let { output, events } = term.render([ 153 - open("root", { 154 - layout: { width: grow(), height: grow(), direction: "ltr" }, 155 - }), 156 - open("sidebar", { 157 - layout: { width: fixed(20), height: grow() }, 158 - bg: rgba(30, 30, 40), 159 - }), 160 - text("Sidebar"), 161 - close(), 162 - open("main", { 163 - layout: { width: grow(), height: grow() }, 164 - }), 165 - text("Main content"), 166 - close(), 167 - close(), 168 - ], { 169 - pointer: { x: mouseX, y: mouseY, down: mouseDown }, 170 - }); 152 + let { output, events } = term.render( 153 + [ 154 + open("root", { 155 + layout: { width: grow(), height: grow(), direction: "ltr" }, 156 + }), 157 + open("sidebar", { 158 + layout: { width: fixed(20), height: grow() }, 159 + bg: rgba(30, 30, 40), 160 + }), 161 + text("Sidebar"), 162 + close(), 163 + open("main", { 164 + layout: { width: grow(), height: grow() }, 165 + }), 166 + text("Main content"), 167 + close(), 168 + close(), 169 + ], 170 + { 171 + pointer: { x: mouseX, y: mouseY, down: mouseDown }, 172 + }, 173 + ); 171 174 172 175 for (let event of events) { 173 176 // { type: "pointerenter", id: "sidebar" } ··· 212 215 213 216 ## Development 214 217 215 - Requires `clang` with wasm32 target support. 218 + For local source builds, toolchain setup, and `clay` submodule instructions, see 219 + [BUILD.md](BUILD.md). 216 220 217 - First build the `.wasm` 221 + Quick local validation: 218 222 219 223 ```sh 220 224 make 221 - ``` 222 - 223 - run tests 224 - 225 - ```sh 226 225 deno task test 227 226 ```
+55
bench/input.bench.ts
··· 1 + import { Bench } from "tinybench"; 2 + import { withCodSpeed } from "@codspeed/tinybench-plugin"; 3 + import { createInput } from "../input.ts"; 4 + 5 + function bytes(...values: number[]): Uint8Array { 6 + return new Uint8Array(values); 7 + } 8 + 9 + function str(s: string): Uint8Array { 10 + return new TextEncoder().encode(s); 11 + } 12 + 13 + let input = await createInput({ escLatency: 25 }); 14 + 15 + let longBurst = new Uint8Array(200); 16 + for (let i = 0; i < 200; i++) { 17 + longBurst[i] = 0x61 + (i % 26); 18 + } 19 + 20 + let bench = withCodSpeed(new Bench()); 21 + 22 + bench 23 + .add("printable ASCII (single char)", () => { 24 + input.scan(bytes(0x61)); 25 + }) 26 + .add("printable ASCII (short string)", () => { 27 + input.scan(str("hello world")); 28 + }) 29 + .add("arrow key (CSI sequence)", () => { 30 + input.scan(bytes(0x1b, 0x5b, 0x41)); 31 + }) 32 + .add("modifier combo (Ctrl+Shift+Arrow)", () => { 33 + input.scan(bytes(0x1b, 0x5b, 0x31, 0x3b, 0x38, 0x41)); 34 + }) 35 + .add("SGR mouse press", () => { 36 + input.scan(str("\x1b[<0;35;12M")); 37 + }) 38 + .add("multi-event burst (arrows + text)", () => { 39 + input.scan(bytes(0x1b, 0x5b, 0x41, 0x1b, 0x5b, 0x42, 0x68, 0x69)); 40 + }) 41 + .add("UTF-8 3-byte character", () => { 42 + input.scan(bytes(0xe4, 0xb8, 0xad)); 43 + }) 44 + .add("UTF-8 4-byte emoji", () => { 45 + input.scan(bytes(0xf0, 0x9f, 0x8e, 0x89)); 46 + }) 47 + .add("Kitty protocol (CSI u with modifiers)", () => { 48 + input.scan(str("\x1b[97;3u")); 49 + }) 50 + .add("long input burst (200 bytes)", () => { 51 + input.scan(longBurst); 52 + }); 53 + 54 + await bench.run(); 55 + console.table(bench.table());
+3
bench/mod.ts
··· 1 + import "./input.bench.ts"; 2 + import "./render.bench.ts"; 3 + import "./ops.bench.ts";
+124
bench/ops.bench.ts
··· 1 + import { Bench } from "tinybench"; 2 + import { withCodSpeed } from "@codspeed/tinybench-plugin"; 3 + import { close, fixed, grow, open, pack, rgba, text } from "../ops.ts"; 4 + import type { Op } from "../ops.ts"; 5 + 6 + function makeBuf(size: number): ArrayBuffer { 7 + return new ArrayBuffer(size); 8 + } 9 + 10 + let simpleOps: Op[] = [ 11 + open("root", { 12 + layout: { width: grow(), height: grow(), direction: "ttb" }, 13 + }), 14 + text("Hello, World!"), 15 + close(), 16 + ]; 17 + 18 + let complexOps: Op[] = [ 19 + open("root", { 20 + layout: { width: grow(), height: grow(), direction: "ttb" }, 21 + }), 22 + open("header", { 23 + layout: { 24 + width: grow(), 25 + height: fixed(3), 26 + padding: { left: 1, right: 1 }, 27 + direction: "ltr", 28 + }, 29 + bg: rgba(30, 30, 40), 30 + border: { 31 + color: rgba(100, 100, 120), 32 + bottom: 1, 33 + }, 34 + }), 35 + text("Title", { color: rgba(255, 255, 255), fontSize: 1 }), 36 + close(), 37 + open("body", { 38 + layout: { 39 + width: grow(), 40 + height: grow(), 41 + direction: "ltr", 42 + gap: 1, 43 + }, 44 + }), 45 + open("sidebar", { 46 + layout: { 47 + width: fixed(20), 48 + height: grow(), 49 + direction: "ttb", 50 + padding: { left: 1, right: 1, top: 1 }, 51 + }, 52 + bg: rgba(25, 25, 35), 53 + border: { 54 + color: rgba(60, 60, 80), 55 + right: 1, 56 + }, 57 + }), 58 + text("Menu Item 1"), 59 + text("Menu Item 2"), 60 + text("Menu Item 3"), 61 + close(), 62 + open("main", { 63 + layout: { 64 + width: grow(), 65 + height: grow(), 66 + direction: "ttb", 67 + padding: { left: 2, top: 1 }, 68 + }, 69 + }), 70 + text("Main content area with longer text to exercise the encoder"), 71 + close(), 72 + close(), 73 + open("footer", { 74 + layout: { 75 + width: grow(), 76 + height: fixed(1), 77 + padding: { left: 1 }, 78 + direction: "ltr", 79 + }, 80 + bg: rgba(30, 30, 40), 81 + }), 82 + text("Status: OK"), 83 + close(), 84 + close(), 85 + ]; 86 + 87 + let listOps: Op[] = [ 88 + open("root", { 89 + layout: { width: grow(), height: grow(), direction: "ttb" }, 90 + }), 91 + ...Array.from({ length: 50 }, (_, i) => [ 92 + open(`item-${i}`, { 93 + layout: { 94 + width: grow(), 95 + height: fixed(1), 96 + padding: { left: 2 }, 97 + direction: "ltr", 98 + }, 99 + bg: i % 2 === 0 ? rgba(30, 30, 40) : rgba(35, 35, 45), 100 + }), 101 + text(`List item ${i}: some description text`), 102 + close(), 103 + ]).flat(), 104 + close(), 105 + ]; 106 + 107 + let bench = withCodSpeed(new Bench()); 108 + 109 + bench 110 + .add("simple tree (root + text)", () => { 111 + let buf = makeBuf(4096); 112 + pack(simpleOps, buf, 0); 113 + }) 114 + .add("complex layout (header + sidebar + main + footer)", () => { 115 + let buf = makeBuf(8192); 116 + pack(complexOps, buf, 0); 117 + }) 118 + .add("large list (50 items)", () => { 119 + let buf = makeBuf(32768); 120 + pack(listOps, buf, 0); 121 + }); 122 + 123 + await bench.run(); 124 + console.table(bench.table());
+158
bench/render.bench.ts
··· 1 + import { Bench } from "tinybench"; 2 + import { withCodSpeed } from "@codspeed/tinybench-plugin"; 3 + import { createTerm } from "../term.ts"; 4 + import { close, fixed, grow, open, rgba, text } from "../ops.ts"; 5 + import type { Op } from "../ops.ts"; 6 + 7 + let term = await createTerm({ width: 80, height: 24 }); 8 + let termPtr = await createTerm({ width: 80, height: 24 }); 9 + 10 + let helloOps: Op[] = [ 11 + open("root", { 12 + layout: { width: grow(), height: grow(), direction: "ttb" }, 13 + }), 14 + text("Hello, World!"), 15 + close(), 16 + ]; 17 + 18 + let borderedOps: Op[] = [ 19 + open("root", { 20 + layout: { width: grow(), height: grow(), direction: "ttb" }, 21 + }), 22 + open("box", { 23 + layout: { 24 + width: grow(), 25 + height: grow(), 26 + padding: { left: 1, right: 1, top: 1, bottom: 1 }, 27 + direction: "ttb", 28 + }, 29 + border: { 30 + color: rgba(0, 255, 0), 31 + left: 1, 32 + right: 1, 33 + top: 1, 34 + bottom: 1, 35 + }, 36 + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, 37 + }), 38 + text("Bordered content"), 39 + close(), 40 + close(), 41 + ]; 42 + 43 + let dashboardOps: Op[] = [ 44 + open("root", { 45 + layout: { width: grow(), height: grow(), direction: "ttb" }, 46 + }), 47 + open("header", { 48 + layout: { 49 + width: grow(), 50 + height: fixed(3), 51 + padding: { left: 1 }, 52 + direction: "ltr", 53 + }, 54 + bg: rgba(30, 30, 40), 55 + border: { color: rgba(80, 80, 100), bottom: 1 }, 56 + }), 57 + text("Dashboard", { color: rgba(255, 255, 255) }), 58 + close(), 59 + open("body", { 60 + layout: { width: grow(), height: grow(), direction: "ltr" }, 61 + }), 62 + open("sidebar", { 63 + layout: { 64 + width: fixed(20), 65 + height: grow(), 66 + direction: "ttb", 67 + padding: { left: 1, top: 1 }, 68 + }, 69 + bg: rgba(25, 25, 35), 70 + border: { color: rgba(60, 60, 80), right: 1 }, 71 + }), 72 + text("Nav 1"), 73 + text("Nav 2"), 74 + text("Nav 3"), 75 + text("Nav 4"), 76 + close(), 77 + open("main", { 78 + layout: { 79 + width: grow(), 80 + height: grow(), 81 + direction: "ttb", 82 + padding: { left: 2, top: 1 }, 83 + }, 84 + }), 85 + ...Array.from({ length: 10 }, (_, i) => [ 86 + open(`row-${i}`, { 87 + layout: { 88 + width: grow(), 89 + height: fixed(1), 90 + direction: "ltr", 91 + }, 92 + bg: i % 2 === 0 ? rgba(35, 35, 45) : undefined, 93 + }), 94 + text(`Row ${i}: data value ${i * 42}`), 95 + close(), 96 + ]).flat(), 97 + close(), 98 + close(), 99 + open("footer", { 100 + layout: { 101 + width: grow(), 102 + height: fixed(1), 103 + padding: { left: 1 }, 104 + }, 105 + bg: rgba(30, 30, 40), 106 + }), 107 + text("Ready"), 108 + close(), 109 + close(), 110 + ]; 111 + 112 + let uiOps: Op[] = [ 113 + open("root", { 114 + layout: { width: grow(), height: grow(), direction: "ttb" }, 115 + }), 116 + open("button", { 117 + layout: { 118 + width: fixed(20), 119 + height: fixed(3), 120 + padding: { left: 1, right: 1 }, 121 + }, 122 + bg: rgba(50, 50, 200), 123 + border: { 124 + color: rgba(100, 100, 255), 125 + left: 1, 126 + right: 1, 127 + top: 1, 128 + bottom: 1, 129 + }, 130 + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, 131 + }), 132 + text("Click me"), 133 + close(), 134 + close(), 135 + ]; 136 + 137 + let bench = withCodSpeed(new Bench()); 138 + 139 + bench 140 + .add("simple text", () => { 141 + term.render(helloOps); 142 + }) 143 + .add("bordered box with corner radius", () => { 144 + term.render(borderedOps); 145 + }) 146 + .add("dashboard layout", () => { 147 + term.render(dashboardOps); 148 + }) 149 + .add("diff render (second frame)", () => { 150 + term.render(dashboardOps); 151 + term.render(dashboardOps); 152 + }) 153 + .add("render with pointer hit testing", () => { 154 + termPtr.render(uiOps, { pointer: { x: 10, y: 1, down: false } }); 155 + }); 156 + 157 + await bench.run(); 158 + console.table(bench.table());
+2 -2
demo/clay-transitions.ts examples/keyboard/clay-transitions.ts
··· 34 34 type PointerEvent, 35 35 rgba, 36 36 text, 37 - } from "../mod.ts"; 37 + } from "../../mod.ts"; 38 38 import { 39 39 alternateBuffer, 40 40 cursor, 41 41 mouseTracking, 42 42 settings, 43 - } from "../settings.ts"; 43 + } from "../../settings.ts"; 44 44 import { useInput } from "./use-input.ts"; 45 45 import { useStdin } from "./use-stdin.ts"; 46 46
+3 -3
demo/inline-region.ts examples/inline-regions/index.ts
··· 24 24 rgba, 25 25 SHOWCURSOR, 26 26 text, 27 - } from "../mod.ts"; 28 - import { cursor, settings } from "../settings.ts"; 29 - import { validated } from "../validate.ts"; 27 + } from "../../mod.ts"; 28 + import { cursor, settings } from "../../settings.ts"; 29 + import { validated } from "../../validate.ts"; 30 30 31 31 const encode = (s: string) => new TextEncoder().encode(s); 32 32 const write = (b: Uint8Array) => Deno.stdout.writeSync(b);
demo/keyboard-key-events.gif examples/keyboard/keyboard-key-events.gif
demo/keyboard-pointer-events.gif examples/keyboard/keyboard-pointer-events.gif
+2 -2
demo/keyboard.ts examples/keyboard/index.ts
··· 21 21 type PointerEvent, 22 22 rgba, 23 23 text, 24 - } from "../mod.ts"; 24 + } from "../../mod.ts"; 25 25 import { 26 26 alternateBuffer, 27 27 cursor, ··· 29 29 progressiveInput, 30 30 type Setting, 31 31 settings, 32 - } from "../settings.ts"; 32 + } from "../../settings.ts"; 33 33 import { useInput } from "./use-input.ts"; 34 34 import { useStdin } from "./use-stdin.ts"; 35 35
+2 -2
demo/transitions.ts examples/keyboard/transitions.ts
··· 28 28 percent, 29 29 rgba, 30 30 text, 31 - } from "../mod.ts"; 32 - import { alternateBuffer, cursor, settings } from "../settings.ts"; 31 + } from "../../mod.ts"; 32 + import { alternateBuffer, cursor, settings } from "../../settings.ts"; 33 33 import { useInput } from "./use-input.ts"; 34 34 import { useStdin } from "./use-stdin.ts"; 35 35
+1 -1
demo/use-input.ts examples/keyboard/use-input.ts
··· 11 11 suspend, 12 12 until, 13 13 } from "effection"; 14 - import { createInput, type InputEvent, type InputOptions } from "../mod.ts"; 14 + import { createInput, type InputEvent, type InputOptions } from "../../mod.ts"; 15 15 16 16 function nothing() { 17 17 return suspend() as unknown as Operation<
demo/use-stdin.ts examples/keyboard/use-stdin.ts
+5 -2
deno.json
··· 7 7 "fmt:check": "deno fmt --check && clang-format --dry-run --Werror src/*.c src/*.h", 8 8 "build:npm": "deno run -A tasks/build-npm.ts", 9 9 "build:jsr": "deno run -A tasks/build-jsr.ts", 10 - "demo": "deno run demo/keyboard.ts" 10 + "bench": "deno run -A bench/mod.ts" 11 11 }, 12 12 "imports": { 13 13 "@std/testing": "jsr:@std/testing@1", ··· 15 15 "@sinclair/typebox": "npm:@sinclair/typebox@^0.34", 16 16 "dnt": "jsr:@deno/dnt@0.42.3", 17 17 "effection": "npm:effection@^4.0.2", 18 - "@std/encoding": "jsr:@std/encoding@1" 18 + "@std/encoding": "jsr:@std/encoding@1", 19 + "@codspeed/tinybench-plugin": "npm:@codspeed/tinybench-plugin@^5.4.0", 20 + "tinybench": "npm:tinybench@^5.0.0" 19 21 }, 20 22 "exports": { 21 23 ".": "./mod.ts", ··· 25 27 "include": ["*.ts"], 26 28 "exclude": ["!wasm.ts"] 27 29 }, 30 + "nodeModulesDir": "auto", 28 31 "fmt": { 29 32 "exclude": ["clay", "build"] 30 33 },
+213 -1
deno.lock
··· 20 20 "jsr:@std/testing@1": "1.0.17", 21 21 "jsr:@ts-morph/bootstrap@0.27": "0.27.0", 22 22 "jsr:@ts-morph/common@0.27": "0.27.0", 23 + "npm:@codspeed/tinybench-plugin@^5.4.0": "5.4.0_tinybench@5.1.0", 23 24 "npm:@sinclair/typebox@*": "0.34.48", 24 25 "npm:@sinclair/typebox@0.34": "0.34.48", 25 26 "npm:effection@^4.0.2": "4.0.2", 27 + "npm:tinybench@5": "5.1.0", 26 28 "npm:valrs@*": "0.1.0" 27 29 }, 28 30 "jsr": { ··· 109 111 } 110 112 }, 111 113 "npm": { 114 + "@codspeed/core@5.4.0": { 115 + "integrity": "sha512-SwGjXDixN/zX1awBR95LzS0KxIs931qwf7Hbk7BRWv1jAdlMYf9o9GlSnWER4zGBHz941BvzFQJ1O2RIofW3cg==", 116 + "dependencies": [ 117 + "axios", 118 + "find-up", 119 + "form-data", 120 + "node-gyp-build" 121 + ] 122 + }, 123 + "@codspeed/tinybench-plugin@5.4.0_tinybench@5.1.0": { 124 + "integrity": "sha512-jzuFoyyoGxc3Lc+TTl54PnRsgqO3CYbbbnwYSVp/m/4rqvCwSUZChY9EuQJ6uZFbamT3UhWF2N6tDEGShkvsrw==", 125 + "dependencies": [ 126 + "@codspeed/core", 127 + "stack-trace", 128 + "tinybench" 129 + ] 130 + }, 112 131 "@sinclair/typebox@0.34.48": { 113 132 "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==" 114 133 }, 134 + "agent-base@6.0.2": { 135 + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", 136 + "dependencies": [ 137 + "debug" 138 + ] 139 + }, 140 + "asynckit@0.4.0": { 141 + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" 142 + }, 143 + "axios@1.16.1": { 144 + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", 145 + "dependencies": [ 146 + "follow-redirects", 147 + "form-data", 148 + "https-proxy-agent", 149 + "proxy-from-env" 150 + ] 151 + }, 152 + "call-bind-apply-helpers@1.0.2": { 153 + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", 154 + "dependencies": [ 155 + "es-errors", 156 + "function-bind" 157 + ] 158 + }, 159 + "combined-stream@1.0.8": { 160 + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 161 + "dependencies": [ 162 + "delayed-stream" 163 + ] 164 + }, 165 + "debug@4.4.3": { 166 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 167 + "dependencies": [ 168 + "ms" 169 + ] 170 + }, 171 + "delayed-stream@1.0.0": { 172 + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" 173 + }, 174 + "dunder-proto@1.0.1": { 175 + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", 176 + "dependencies": [ 177 + "call-bind-apply-helpers", 178 + "es-errors", 179 + "gopd" 180 + ] 181 + }, 115 182 "effection@4.0.2": { 116 183 "integrity": "sha512-O8WMGP10nPuJDwbNGILcaCNWS+CvDYjcdsUSD79nWZ+WtUQ8h1MEV7JJwCSZCSeKx8+TdEaZ/8r6qPTR2o/o8w==" 117 184 }, 185 + "es-define-property@1.0.1": { 186 + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" 187 + }, 188 + "es-errors@1.3.0": { 189 + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" 190 + }, 191 + "es-object-atoms@1.1.2": { 192 + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", 193 + "dependencies": [ 194 + "es-errors" 195 + ] 196 + }, 197 + "es-set-tostringtag@2.1.0": { 198 + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", 199 + "dependencies": [ 200 + "es-errors", 201 + "get-intrinsic", 202 + "has-tostringtag", 203 + "hasown" 204 + ] 205 + }, 206 + "find-up@6.3.0": { 207 + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", 208 + "dependencies": [ 209 + "locate-path", 210 + "path-exists" 211 + ] 212 + }, 213 + "follow-redirects@1.16.0": { 214 + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==" 215 + }, 216 + "form-data@4.0.5": { 217 + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", 218 + "dependencies": [ 219 + "asynckit", 220 + "combined-stream", 221 + "es-set-tostringtag", 222 + "hasown", 223 + "mime-types" 224 + ] 225 + }, 226 + "function-bind@1.1.2": { 227 + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" 228 + }, 229 + "get-intrinsic@1.3.0": { 230 + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", 231 + "dependencies": [ 232 + "call-bind-apply-helpers", 233 + "es-define-property", 234 + "es-errors", 235 + "es-object-atoms", 236 + "function-bind", 237 + "get-proto", 238 + "gopd", 239 + "has-symbols", 240 + "hasown", 241 + "math-intrinsics" 242 + ] 243 + }, 244 + "get-proto@1.0.1": { 245 + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", 246 + "dependencies": [ 247 + "dunder-proto", 248 + "es-object-atoms" 249 + ] 250 + }, 251 + "gopd@1.2.0": { 252 + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" 253 + }, 254 + "has-symbols@1.1.0": { 255 + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" 256 + }, 257 + "has-tostringtag@1.0.2": { 258 + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", 259 + "dependencies": [ 260 + "has-symbols" 261 + ] 262 + }, 263 + "hasown@2.0.3": { 264 + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", 265 + "dependencies": [ 266 + "function-bind" 267 + ] 268 + }, 269 + "https-proxy-agent@5.0.1": { 270 + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", 271 + "dependencies": [ 272 + "agent-base", 273 + "debug" 274 + ] 275 + }, 276 + "locate-path@7.2.0": { 277 + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", 278 + "dependencies": [ 279 + "p-locate" 280 + ] 281 + }, 282 + "math-intrinsics@1.1.0": { 283 + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" 284 + }, 285 + "mime-db@1.52.0": { 286 + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" 287 + }, 288 + "mime-types@2.1.35": { 289 + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 290 + "dependencies": [ 291 + "mime-db" 292 + ] 293 + }, 294 + "ms@2.1.3": { 295 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 296 + }, 297 + "node-gyp-build@4.8.4": { 298 + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", 299 + "bin": true 300 + }, 301 + "p-limit@4.0.0": { 302 + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", 303 + "dependencies": [ 304 + "yocto-queue" 305 + ] 306 + }, 307 + "p-locate@6.0.0": { 308 + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", 309 + "dependencies": [ 310 + "p-limit" 311 + ] 312 + }, 313 + "path-exists@5.0.0": { 314 + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==" 315 + }, 316 + "proxy-from-env@2.1.0": { 317 + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==" 318 + }, 319 + "stack-trace@1.0.0-pre2": { 320 + "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==" 321 + }, 322 + "tinybench@5.1.0": { 323 + "integrity": "sha512-LXKNtFualiKOm6gADe1UXPtf8+Nfn1CtPMEHAT33Fd2YjQatrujkDcK0+4wRC1X6t7fxUDXUs6BsvuIgfkDgDg==" 324 + }, 118 325 "valrs@0.1.0": { 119 326 "integrity": "sha512-BqVkjx3qhsRLHerblLDoqEx0OEx7ms0DB6LPv40oWkMfFKUVKrqVuklaGdrPrHyubC5hSHYfEtUiQXrCkC6xHQ==" 327 + }, 328 + "yocto-queue@1.2.2": { 329 + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==" 120 330 } 121 331 }, 122 332 "workspace": { ··· 125 335 "jsr:@std/encoding@1", 126 336 "jsr:@std/expect@1", 127 337 "jsr:@std/testing@1", 338 + "npm:@codspeed/tinybench-plugin@^5.4.0", 128 339 "npm:@sinclair/typebox@0.34", 129 - "npm:effection@^4.0.2" 340 + "npm:effection@^4.0.2", 341 + "npm:tinybench@5" 130 342 ] 131 343 } 132 344 }
+63
examples/README.md
··· 1 + # examples 2 + 3 + This directory contains runnable example applications that exercise different 4 + features of libs. If any of these examples are not working, please open an issue 5 + with information about your terminal, shell, operating system and any other 6 + information that could be pertinent to reproducing the issue. 7 + 8 + > [!NOTE] 9 + > Run the commands in this document from the repository root. 10 + 11 + ## Prerequisites 12 + 13 + Build the generated WebAssembly bundle before running the examples: 14 + 15 + ```sh 16 + make 17 + ``` 18 + 19 + ## Keyboard 20 + 21 + Path: `examples/keyboard/index.ts` 22 + 23 + Run it with: 24 + 25 + ```sh 26 + deno run examples/keyboard/index.ts 27 + ``` 28 + 29 + What it shows: 30 + 31 + - raw keyboard input decoded into structured key events 32 + - progressive keyboard protocol support 33 + - pointer tracking and hover/click-driven UI updates 34 + - terminal mode configuration such as alternate buffer, hidden cursor, and mouse 35 + reporting 36 + 37 + Related files: 38 + 39 + - `examples/keyboard/use-input.ts` wraps the input parser as a stream of decoded 40 + events 41 + - `examples/keyboard/use-stdin.ts` adapts stdin into a byte stream for the demo 42 + 43 + ## Inline Regions 44 + 45 + Path: `examples/inline-regions/index.ts` 46 + 47 + Run it with: 48 + 49 + ```sh 50 + deno run examples/inline-regions/index.ts 51 + ``` 52 + 53 + What it shows: 54 + 55 + - rendering animated regions into normal terminal scrollback 56 + - querying cursor position with DSR to place later frames correctly 57 + - updating a previously allocated region without taking over the whole screen 58 + - small animated demos including a spinner, a progress bar, and a nyan-cat-style 59 + sequence 60 + 61 + This example is useful if you want to embed transient or animated UI output into 62 + a normal command-line workflow instead of switching to a full-screen alternate 63 + buffer interface.
+72 -5
ops.ts
··· 5 5 const OP_OPEN_ELEMENT = 0x02; 6 6 const OP_TEXT = 0x03; 7 7 const OP_CLOSE_ELEMENT = 0x04; 8 + const OP_SNAPSHOT = 0x05; 8 9 9 10 /* Property group masks for OPEN_ELEMENT */ 10 11 const PROP_LAYOUT = 0x01; ··· 56 57 return o; 57 58 } 58 59 59 - function packString(view: DataView, bytes: Uint8Array, o: number): number { 60 + function packString( 61 + view: DataView, 62 + bytes: Uint8Array, 63 + o: number, 64 + end: number, 65 + context: string, 66 + ): number { 67 + let paddedLength = Math.ceil(bytes.length / 4) * 4; 68 + let next = o + 4 + paddedLength; 69 + if (next > end) { 70 + throw new RangeError( 71 + `clayterm transfer buffer capacity exceeded while packing ${context} ` + 72 + `(${next} byte offset, ${end} byte limit). ` + 73 + `Render a smaller visible slice or reduce frame content.`, 74 + ); 75 + } 76 + 60 77 view.setUint32(o, bytes.length, true); 61 78 o += 4; 62 79 new Uint8Array(view.buffer).set(bytes, o); 63 - o += Math.ceil(bytes.length / 4) * 4; 80 + o += paddedLength; 64 81 return o; 65 82 } 66 83 ··· 86 103 o += 4; 87 104 88 105 let bytes = encoder.encode(op.id); 89 - o = packString(view, bytes, o); 106 + o = packString(view, bytes, o, end, "element id"); 90 107 91 108 let mask = 0; 92 109 if (op.layout) mask |= PROP_LAYOUT; ··· 196 213 break; 197 214 } 198 215 216 + case OP_SNAPSHOT: { 217 + new Uint8Array(mem).set(op.data, o); 218 + o += op.data.length; 219 + break; 220 + } 221 + 199 222 case OP_TEXT: { 200 223 view.setUint32(o, OP_TEXT, true); 201 224 o += 4; ··· 212 235 o += 4; 213 236 214 237 let str = encoder.encode(op.content); 215 - o = packString(view, str, o); 238 + o = packString(view, str, o, end, "text content"); 216 239 break; 217 240 } 218 241 } ··· 302 325 attrs?: number; 303 326 } 304 327 305 - export type Op = OpenElement | Text | CloseElement; 328 + interface Snapshot { 329 + directive: typeof OP_SNAPSHOT; 330 + data: Uint8Array; 331 + } 332 + 333 + export type Op = OpenElement | Text | CloseElement | Snapshot; 306 334 307 335 export function open( 308 336 id: string, ··· 321 349 export function close(): CloseElement { 322 350 return { directive: OP_CLOSE_ELEMENT }; 323 351 } 352 + 353 + function packSize(ops: Op[]): number { 354 + let n = 0; 355 + for (let op of ops) { 356 + switch (op.directive) { 357 + case OP_CLOSE_ELEMENT: 358 + n += 4; 359 + break; 360 + case OP_SNAPSHOT: 361 + n += op.data.length; 362 + break; 363 + case OP_OPEN_ELEMENT: { 364 + n += 4; // opcode 365 + n += 4 + Math.ceil(encoder.encode(op.id).length / 4) * 4; // id string 366 + n += 4; // mask 367 + if (op.layout) n += 6 * 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align 368 + if (op.bg !== undefined) n += 4; 369 + if (op.cornerRadius) n += 4; 370 + if (op.border) n += 8; 371 + if (op.clip) n += 4; 372 + if (op.floating) n += 16; 373 + break; 374 + } 375 + case OP_TEXT: { 376 + n += 4 + 4 + 4; // opcode + color + cfg 377 + n += 4 + Math.ceil(encoder.encode(op.content).length / 4) * 4; // string 378 + break; 379 + } 380 + } 381 + } 382 + return n; 383 + } 384 + 385 + export function snapshot(ops: Op[]): Op { 386 + let size = packSize(ops); 387 + let buf = new ArrayBuffer(size); 388 + let words = pack(ops, buf, 0, size); 389 + return { directive: OP_SNAPSHOT, data: new Uint8Array(buf, 0, words * 4) }; 390 + }
+3
package.json
··· 1 + { 2 + "type": "module" 3 + }
+31
specs/renderer-spec.md
··· 406 406 The set of styling properties accepted by `props` is part of the current 407 407 implementation surface and may be extended. 408 408 409 + #### 8.3.4 snapshot 410 + 411 + ``` 412 + snapshot(ops: Op[]): Op 413 + ``` 414 + 415 + Creates a snapshot by pre-packing the given directive array into its transfer 416 + encoding. The returned value is an `Op` and can appear anywhere in a directive 417 + array where the original ops would have appeared. The internal representation is 418 + opaque. 419 + 420 + When the renderer encounters a snapshot during transfer, it copies the 421 + pre-packed bytes directly into the command buffer without re-encoding. The 422 + snapshot's ops MUST be structurally balanced (every `open` matched by a 423 + `close`). 424 + 425 + Snapshots enable higher-level frameworks to implement dirty tracking: a 426 + component whose inputs have not changed can reuse a previously created snapshot, 427 + avoiding the cost of re-packing its subtree each frame. 428 + 409 429 ### 8.4 Sizing helpers 410 430 411 431 These functions produce sizing-axis values for use in element layout ··· 462 482 directive arrays before rendering. The renderer's behavior when given an invalid 463 483 directive array is unspecified by this specification. 464 484 485 + A snapshot is semantically equivalent to splicing its source ops into the array 486 + at the snapshot's position. The renderer MUST produce identical layout and 487 + output regardless of whether ops are provided directly or via a snapshot. 488 + 465 489 ### 9.2 Transfer to the WASM module 466 490 467 491 As part of the render transaction, the directive array is transferred into a 468 492 form that the WASM module can process. This transfer is handled internally by 469 493 the renderer and is not an operation the caller performs or observes. The 470 494 transfer mechanism is an implementation detail described in Section 12.1. 495 + 496 + If a frame exceeds transfer-buffer capacity while packing string content, the 497 + renderer MUST throw a descriptive `RangeError` that identifies the condition as 498 + a transfer-buffer, frame-capacity, or packing overflow. The renderer MUST NOT 499 + expose only the raw host-level TypedArray message `"offset is out of bounds"` 500 + for this condition. The error message SHOULD direct callers to render a smaller 501 + visible slice or reduce frame content. 471 502 472 503 ### 9.3 Directive identity 473 504
+5 -5
tasks/build-npm.ts
··· 20 20 typeCheck: false, 21 21 compilerOptions: { 22 22 lib: ["ESNext"], 23 - target: "ES2020", 24 - sourceMap: true, 25 23 }, 24 + skipSourceOutput: true, 26 25 package: { 27 26 name: "clayterm", 28 27 version, ··· 31 30 license: "MIT", 32 31 repository: { 33 32 type: "git", 34 - url: "git+https://github.com/thefrontside/clayterm.git", 33 + url: "git+https://github.com/bombshell-dev/clayterm.git", 35 34 }, 36 35 bugs: { 37 - url: "https://github.com/thefrontside/clayterm/issues", 36 + url: "https://github.com/bombshell-dev/clayterm/issues", 38 37 }, 39 38 engines: { 40 - node: ">= 16", 39 + node: ">= 22", 41 40 }, 42 41 sideEffects: false, 42 + type: "module", 43 43 }, 44 44 }); 45 45
+52
test/pack.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { close, open, pack, text } from "../ops.ts"; 3 + 4 + describe("pack", () => { 5 + it("throws a descriptive RangeError when text exceeds the transfer buffer", () => { 6 + let memory = new ArrayBuffer(64); 7 + let error: unknown; 8 + 9 + try { 10 + pack( 11 + [ 12 + open("root"), 13 + text("x".repeat(128)), 14 + close(), 15 + ], 16 + memory, 17 + 0, 18 + memory.byteLength, 19 + ); 20 + } catch (caught) { 21 + error = caught; 22 + } 23 + 24 + expect(error).toBeInstanceOf(RangeError); 25 + expect((error as Error).message).toMatch( 26 + /transfer buffer|capacity|packing/, 27 + ); 28 + expect((error as Error).message).toContain("text content"); 29 + expect((error as Error).message).not.toBe("offset is out of bounds"); 30 + expect((error as Error).message).toMatch( 31 + /smaller visible slice|reduce frame content/, 32 + ); 33 + }); 34 + 35 + it("throws a descriptive RangeError when an element id exceeds the transfer buffer", () => { 36 + let memory = new ArrayBuffer(16); 37 + let error: unknown; 38 + 39 + try { 40 + pack([open("x".repeat(64)), close()], memory, 0, memory.byteLength); 41 + } catch (caught) { 42 + error = caught; 43 + } 44 + 45 + expect(error).toBeInstanceOf(RangeError); 46 + expect((error as Error).message).toMatch( 47 + /transfer buffer|capacity|packing/, 48 + ); 49 + expect((error as Error).message).toContain("element id"); 50 + expect((error as Error).message).not.toBe("offset is out of bounds"); 51 + }); 52 + });
+103 -1
test/term.test.ts
··· 1 1 import { beforeEach, describe, expect, it } from "./suite.ts"; 2 2 import { createTerm, type Term } from "../term.ts"; 3 - import { close, fixed, grow, open, rgba, text } from "../ops.ts"; 3 + import { 4 + close, 5 + fixed, 6 + grow, 7 + type Op, 8 + open, 9 + rgba, 10 + snapshot, 11 + text, 12 + } from "../ops.ts"; 4 13 import { print } from "./print.ts"; 5 14 6 15 const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); ··· 188 197 189 198 expect(result.info.get("nonexistent")).toBeUndefined(); 190 199 expect(result.info.get("")).toBeUndefined(); 200 + }); 201 + }); 202 + 203 + describe("snapshot", () => { 204 + it("produces identical output to direct ops", async () => { 205 + let ops = [ 206 + open("root", { 207 + layout: { width: grow(), height: grow(), direction: "ttb" }, 208 + bg: rgba(0, 0, 128), 209 + }), 210 + open("child", { 211 + layout: { 212 + width: grow(), 213 + padding: { left: 1 }, 214 + direction: "ttb", 215 + }, 216 + border: { 217 + color: rgba(255, 255, 255), 218 + left: 1, 219 + right: 1, 220 + top: 1, 221 + bottom: 1, 222 + }, 223 + }), 224 + text("snapshot test"), 225 + close(), 226 + close(), 227 + ]; 228 + 229 + let direct = await createTerm({ width: 40, height: 10 }); 230 + let snapped = await createTerm({ width: 40, height: 10 }); 231 + 232 + let expected = direct.render(ops, { mode: "line" }).output; 233 + let actual = snapped.render([snapshot(ops)], { mode: "line" }).output; 234 + 235 + expect(decode(actual)).toEqual(decode(expected)); 236 + }); 237 + 238 + it("renders inside another element", async () => { 239 + let child = snapshot([ 240 + open("child", { 241 + layout: { width: grow(), direction: "ttb" }, 242 + }), 243 + text("inner"), 244 + close(), 245 + ]); 246 + 247 + let direct = await createTerm({ width: 20, height: 5 }); 248 + let snapped = await createTerm({ width: 20, height: 5 }); 249 + 250 + let wrapper = (content: Op[]) => [ 251 + open("root", { 252 + layout: { 253 + width: grow(), 254 + height: grow(), 255 + direction: "ttb", 256 + padding: { left: 1, top: 1 }, 257 + }, 258 + border: { 259 + color: rgba(255, 255, 255), 260 + left: 1, 261 + right: 1, 262 + top: 1, 263 + bottom: 1, 264 + }, 265 + }), 266 + ...content, 267 + close(), 268 + ]; 269 + 270 + let expected = direct.render( 271 + wrapper([ 272 + open("child", { 273 + layout: { width: grow(), direction: "ttb" }, 274 + }), 275 + text("inner"), 276 + close(), 277 + ]), 278 + { mode: "line" }, 279 + ).output; 280 + 281 + let actual = snapped.render( 282 + wrapper([child]), 283 + { mode: "line" }, 284 + ).output; 285 + 286 + expect(decode(actual)).toEqual(decode(expected)); 287 + expect(trim(print(decode(actual), 20, 5))).toEqual(` 288 + ┌──────────────────┐ 289 + │inner │ 290 + │ │ 291 + │ │ 292 + └──────────────────┘`.trim()); 191 293 }); 192 294 }); 193 295