Skip to content

pilatesjs/pilates

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

187 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Pilates

npm @pilates/core npm @pilates/render npm @pilates/react npm @pilates/widgets bundle size @pilates/core license MIT

Pilates demo — react-build-dashboard example

Headless flex layout engine for terminal UIs. Pure TypeScript, zero runtime dependencies.

📖 API reference

Pilates is a flex layout engine designed for the terminal: integer cell coordinates, CJK / emoji / wide-char awareness, ANSI escape passthrough, and unbundled from any UI framework. Use it directly to compute layouts, or wrap the included renderer to produce styled strings.

import { render } from '@pilates/render';

process.stdout.write(
  render({
    width: 80,
    height: 6,
    flexDirection: 'row',
    children: [
      { flex: 1, border: 'rounded', title: 'Logs',   children: [{ text: 'user logged in' }] },
      { width: 20, border: 'single', title: 'Status', children: [{ text: 'ok', color: 'green', bold: true }] },
    ],
  }),
);
// ╭─ Logs ───────────────────────────────────────────────────╮┌─ Status ─────────┐
// │user logged in                                            ││ok                │
// │                                                          ││                  │
// │                                                          ││                  │
// ╰──────────────────────────────────────────────────────────╯└──────────────────┘

Why

Terminal UIs in JavaScript are dominated by Ink, which couples two distinct concerns into one package: a WASM flex layout engine and a React reconciler. If you want the layout half, you have to take all of React. Pilates separates them:

  • @pilates/core — the engine. Imperative Node API, returns integer cell coordinates. Pure TypeScript, zero runtime dependencies. Handles CJK / emoji / wide-char widths, integer-cell rounding, the CSS Flexbox freeze loop, and absolute positioning. Validated cell-for-cell against a reference WASM flexbox implementation across 33 oracle fixtures.
  • @pilates/render — the out-of-box renderer. Declarative POJO tree → painted ANSI string with borders, titles, colors, and text wrap. Uses core internally; depends only on it.
  • @pilates/diff — cell-level frame diffing + minimal ANSI redraw sequences for live TUIs. Pairs with @pilates/render.
  • @pilates/react — optional React reconciler on top of the same engine, for consumers who want JSX and hooks. Independent of the core / render / diff stack — you don't pay for it if you don't import it.
  • @pilates/widgets — interactive widgets (TextInput, Select, Spinner) built on @pilates/react. For wizard-style CLI flows.

Packages

Package Status What
@pilates/core 2.0.1 Engine: imperative Node API, returns layout boxes.
@pilates/render 1.0.2 Out-of-box: declarative tree → painted string.
@pilates/diff 0.2.1 Cell-level frame diff + minimal ANSI redraw.
@pilates/react 0.4.1 React reconciler — author terminal UIs with JSX, hooks, mouse, focus, scroll.
@pilates/widgets 0.1.0-rc.4 Interactive widgets (TextInput, Select, Spinner, MultiSelect, Tabs, Table, ProgressBar, TextArea) for @pilates/react.

Examples

Eleven runnable examples live under examples/ — six built on the imperative @pilates/render API, five built on @pilates/react.

Imperative (@pilates/render):

Example What it shows
chat-log Two-pane chat layout: scrolling messages + status sidebar. Wide-char & emoji passthrough.
dashboard System-monitor layout: status header, four stat tiles in a row, metrics strip.
gallery Grid of cards that wraps to multiple rows on a narrow container.
modal Confirm-action modal floating over a list — exercises absolute positioning.
progress-table Multi-row progress dashboard with bars and color-coded status.
split-pane Editor-style: header + 3-pane body (files / editor / outline) + status footer.

React (@pilates/react + @pilates/widgets):

Example What it shows
react-build-dashboard Flagship demo. Interactive build-pipeline dashboard: <ScrollView> × 2, mouse, useFocus, keyboard nav, animation, <ProgressBar> + <Spinner> widgets, all stitched together.
react-counter Minimal reconciler example: counter incrementing every 250ms, demonstrating the diff-based redraw loop.
react-dashboard React port of dashboard with a live tick counter on the header.
react-modal React port of modal: centered confirmation dialog over a scrollable list.
react-wizard Multi-step TextInput → Select → Spinner wizard exercising every @pilates/widgets component.
pnpm install
# imperative
pnpm --filter @pilates-examples/chat-log dev
pnpm --filter @pilates-examples/progress-table dev
# react
pnpm --filter @pilates-examples/react-counter dev
pnpm --filter @pilates-examples/react-wizard dev
# flagship
pnpm --filter @pilates-examples/react-build-dashboard dev

Quick start (using just the engine)

import { Node, Edge } from '@pilates/core';

const root = Node.create();
root.setFlexDirection('row');
root.setWidth(80);
root.setHeight(24);
root.setPadding(Edge.All, 1);

const main = Node.create();
main.setFlex(1);
const sidebar = Node.create();
sidebar.setWidth(20);

root.insertChild(main, 0);
root.insertChild(sidebar, 1);
root.calculateLayout();

main.getComputedLayout();    // { left:1, top:1, width:58, height:22 }
sidebar.getComputedLayout(); // { left:59, top:1, width:20, height:22 }

You'd then paint to the terminal yourself — or pass the same shape via the declarative API to @pilates/render to skip the painting:

import { render } from '@pilates/render';

process.stdout.write(
  render({
    width: 80,
    height: 24,
    flexDirection: 'row',
    padding: 1,
    children: [{ flex: 1 }, { width: 20 }],
  }),
);

What's supported

Category Properties
Direction flexDirection (row / column / -reverse), flexWrap (nowrap / wrap / wrap-reverse)
Sizing width, height, minWidth, minHeight, maxWidth, maxHeight
Flex flex (shorthand), flexGrow, flexShrink, flexBasis
Spacing padding / margin per edge, gap (row + column)
Alignment justifyContent, alignItems, alignSelf, alignContent (all CSS values)
Position positionType (relative / absolute), position per edge
Visibility display (flex / none)
Render-only border (5 styles), borderColor, title, color, bgColor, bold, italic, underline, dim, inverse, wrap

Out of v1: aspectRatio, RTL/LTR direction inheritance, baseline alignment, input handling, animations, scroll containers, style inheritance.

Performance

Pilates vs WASM Yoga: pure-TS Pilates is 1.7-10× faster across the 9-scenario benchmark suite, including hot-relayout and structural mutation

Pure-TypeScript layout, validated cell-for-cell against WASM Yoga. Across the 9 scenarios in our bench suite, the pure-TS engine is faster than WASM Yoga on each — including the structural-mutation workload (append + remove a row per frame) Yoga led on through mid-2026. Numbers are median latency from pnpm bench (Node 22, win32-x64, ~5s tinybench window with bootstrap CI95; a hand-picked suite, not a universal claim — real workloads will differ):

Scenario Pilates core yoga-layout (WASM) Pilates speedup
tiny (10 nodes) 4.5µs 19.0µs 4.2× faster
realistic (~100) 121µs 328µs 2.7× faster
stress (~1000) 601µs 1.94ms 3.2× faster
big (~5000) 3.32ms 9.17ms 2.8× faster
huge (~10000) 8.62ms 18.5ms 2.1× faster
hot-relayout (1k persistent, mutate one leaf/frame) 16.3µs 83.0µs 5.1× faster
hot-relayout + boundaries (same + explicit-sized rows) 15.8µs 77.8µs 4.9× faster
hot-relayout (text mutation, fixed-size table) 8.9µs 90.6µs 10× faster
hot-structural (append + remove a row / frame) 71.3µs 118.3µs 1.7× faster

The hot-relayout and hot-structural patterns — building a tree once and mutating-and-relaying out per frame — are the workloads Yoga's WASM compute advantage traditionally won on. The Spineless incremental layout engine (an attribute-grammar dependency graph + priority-queue recomputation; refined through phases 8–17 with a typed-array runtime, linear-recurrence main-axis positions, and fold-default input elimination) flips that: a single leaf mutation re-evaluates only the fields actually downstream of the change, and structural mutations patch only the affected subtree.

For trees of pure fixed-size cells (e.g. a data table with one cell's text length changing per frame), the direct @pilates/core (spineless) runtime mutation goes through in ~0.2µs — 380× faster than the Yoga round-trip. That path is @internal for now; the public calculateLayout ships the engine and is what every other Pilates consumer uses.

WASM Yoga's compute kernel is genuinely fast in isolation, but every setProperty / Node.create crosses the JS↔WASM boundary; that marshalling cost dominates at TUI tree sizes (10–10k nodes), and the Spineless engine's incremental recompute then beats WASM's per-frame full layout. Pure-TS Pilates pays no marshalling cost.

Reproduce with pnpm bench. Full numbers + scenario shapes in bench/RESULTS.md.

Validation

Every flex feature is verified cell-for-cell against a reference WASM flexbox implementation:

  • 33 oracle fixtures (fixed widths, flex distributions, padding, margin, gap, min/max, all justifyContent / alignItems / alignSelf / alignContent values, flexWrap, flexWrap: wrap-reverse, every absolute positioning anchor)
  • 200+ unit + algorithm + render tests
  • Unicode width fuzzer running through 200 randomized strings against @xterm/headless per CI run, plus a fixture set of pinned agreement cases and documented divergences (where modern terminals render wider than xterm.js's Unicode-11 tables)
  • Property-based fuzz with fast-check over layout invariants — non-overflow, sibling non-overlap, reproducibility — across randomly generated trees

Reducing fuzzer counterexamples to fixtures

When a fast-check fuzz test fails it emits a JSON counterexample — typically 100+ lines of nested style objects. Convert it to a hand-readable .spec.json fixture with:

pnpm tsx tools/reduce-fixture.ts counterexample.json --fast-check \
  --tag <bucket> \
  --out packages/core/test/fixtures/<bucket>/<name>.spec.json \
  --name "<bucket>/<name>"

--tag is required (and validates against the known bucket list); repeat the flag to add multiple tags. If Pilates and Yoga agree on the layout the tool emits a consensus fixture (expected map). If they diverge it emits a divergent fixture (expectedPilates + expectedYoga + divergenceReason: "<TODO: fill in>") and prints a warning; the divergent tag is auto-appended for divergent emissions so you only need to fill in the reason before committing.

Notable design choices

  • Default flexShrink: 0 in core (React Native convention, not CSS's 1) — declared widths stay declared. The render layer flips this to 1 for text leaves so wrapped text fits its container.
  • Absolute offsets are relative to the parent's outer box, not its content (post-padding) box — React Native semantics, not CSS. Keeps consumers porting from Ink / RN consistent.
  • Integer cell rounding rounds absolute corners and derives size from rounded edges — sibling boxes butt cleanly across uneven splits ([100, flex:1, flex:1, flex:1][34, 33, 33]).

Status

@pilates/core@2.0.1 is on npm. Core algorithm + flex pipeline are feature-complete, validated cell-for-cell against WASM Yoga, and faster than Yoga on each of the 9 scenarios in the bench suite (see Performance above) — powered by the Spineless incremental engine. The React layer ships mouse, scroll, focus management, typed errors, and layout devtools.

Contributing

Issues, discussions, and PRs welcome. Start with CONTRIBUTING.md for setup, the test loop, and what the maintainer expects from layout-algorithm changes (oracle-fixture coverage). By participating you agree to follow the Code of Conduct. Security issues: see SECURITY.md for the private disclosure channel.

License

MIT © Zhijie Wang.