# ashlar

Terminal UI framework built on Solid 2.0 reactivity, a pure-TypeScript Yoga
flexbox engine, and Bun's native terminal primitives. Renders into a frame
buffer with differential ANSI output — only changed cells are written to
stdout.

---

## Quick Start

```tsx
import { createSignal } from "solid-js";
import { mount, useInput } from "ashlar";

function App() {
  const [count, setCount] = createSignal(0);

  useInput((key) => {
    if (key.name === "up") setCount((c) => c + 1);
    if (key.name === "down") setCount((c) => Math.max(0, c - 1));
    if (key.name === "c" && key.ctrl) {
      screen.unmount();
      process.exit(0);
    }
  });

  return (
    <box flexDirection="column" padding={1}>
      <text bold color="#ff6600">
        My App
      </text>
      <box borderStyle="round" color="#666">
        <text bold>{`  Count: ${count()}`}</text>
      </box>
    </box>
  );
}

const screen = mount(() => <App />, { fps: 30 });
```

---

## Demos

The `demo/` workspace is an interactive gallery. Launch the menu and pick a
demo with the arrow keys:

```sh
bun demo          # from the repo root
```

The launcher (`demo/src/main.tsx`) runs each selected demo in-process — every
demo exposes a `run()` that mounts its own screen and resolves on Ctrl+C, which
returns you to the menu.

| Demo             | What it shows                                                    |
| ---------------- | ---------------------------------------------------------------- |
| `alt-screen`     | Counter with keyboard input in the alternate screen buffer       |
| `input`          | Single- and multi-line `<input>` with submission                 |
| `mouse`          | Hover/click buttons via mouse tracking                           |
| `scroll`         | Scrollable list — keyboard navigation + mouse wheel              |
| `hyperlinks`     | OSC 8 web and file links with capability fallback                |
| `selection`      | Click-drag text selection copied to clipboard via OSC 52         |
| `commit`         | `screen.commit()` finalizing output into native scrollback       |
| `stream-spinner` | Animated shimmer spinner + token counter on a simulated stream   |
| `select`         | Keyboard- and mouse-navigable `<Select>` menu with custom rows   |
| `progress`       | Determinate `<ProgressBar>` — auto-advancing, manual, and styled |

---

## Build Configuration

**bunfig.toml** — preloads the Solid transform plugin so `.tsx`/`.jsx` is
lowered through `babel-preset-solid` in universal mode:

```toml
preload = ["./scripts/preload.ts"]

[test]
preload = ["./scripts/preload.ts"]
```

The demo workspace preloads the same plugin via the package's `./preload`
export (see `demo/bunfig.toml`).

**tsconfig.json** — JSX set to `preserve` with `jsxImportSource: "ashlar"` so
JSX type-checks against this package's `jsx-runtime.d.ts` and runtime calls
resolve to its `createElement`.

**Bun plugin** (`scripts/solid-plugin.ts`) transforms `.tsx`/`.jsx` via
`babel-preset-solid` with `generate: "universal"` and module name `ashlar`. It
also redirects `solid-js/server.js` to the client runtime under Bun's `node`
condition.

### Package Exports

| Export                               | Path                      | Purpose                                                      |
| ------------------------------------ | ------------------------- | ------------------------------------------------------------ |
| `.`                                  | `src/index.ts`            | Main API                                                     |
| `./animation`                        | `src/animation/index.ts`  | Linked animation groups + effects (shimmer, wave, pulse)     |
| `./ui`                               | `src/ui/index.ts`         | Higher-level components (`Spinner`, `Select`, `ProgressBar`) |
| `./preload`                          | `scripts/preload.ts`      | Bun plugin loader                                            |
| `./bun-plugin`                       | `scripts/solid-plugin.ts` | Plugin source                                                |
| `./jsx-runtime`, `./jsx-dev-runtime` | `jsx-runtime.d.ts`        | JSX type definitions                                         |

The default workflow runs straight from `src/` via the preload plugin — no
build step. `bun run build` produces a publishable `dist/` (universal `.js`, a
JSX-preserved `.jsx` tree for the `solid` condition, and `.d.ts`); see
`scripts/build.ts`.

---

## Architecture

```
Solid 2.0 Signals (batched, settle on flush())
        │
        ▼
Frame Scheduler (setInterval at target fps, calls flush())
        │
        ▼
Solid Universal Renderer (targeted node mutations)
        │
        ▼
Yoga Layout Engine (pure TS, recalculates dirty subtree)
        │
        ▼
Frame Buffer (2D cell grid, paint positioned nodes)
        │
        ▼
Diff Engine (compare current vs previous frame)
        │
        ▼
ANSI Serializer (minimal escape sequences, SGR delta tracking)
        │
        ▼
stdout.write() (wrapped in DEC 2026 synchronized output)
```

Signal updates queue without propagating. The frame scheduler calls `flush()`
at the target frame rate (default 30fps), settling the entire reactive graph in
one pass. This produces exactly one layout + paint + diff + serialize cycle per
frame regardless of how many signals changed between frames.

Key events schedule a render on the next microtask via `queueMicrotask` — rapid
keypresses within the same event-loop turn are coalesced into a single render.
Mouse events are dispatched to handlers but render on the next scheduled frame.

---

## mount() and Screen

`mount()` is the top-level entry point. It creates the root `TermNode`, starts
the frame scheduler, sets up input handling, and manages the terminal
lifecycle.

```typescript
function mount(code: () => TermNode, options?: ScreenOptions): Screen;
```

### ScreenOptions

| Option          | Type                     | Default                | Description                     |
| --------------- | ------------------------ | ---------------------- | ------------------------------- |
| `altScreen`     | `boolean`                | `false`                | Use alternate screen buffer     |
| `cols`          | `number`                 | `stdout.columns`       | Terminal columns                |
| `rows`          | `number`                 | `stdout.rows`          | Terminal rows                   |
| `fps`           | `number`                 | `30`                   | Target frame rate               |
| `enableInput`   | `boolean`                | `true`                 | Enable input handling           |
| `mouse`         | `boolean`                | `false`                | Enable mouse tracking           |
| `kittyKeyboard` | `boolean`                | auto                   | Enable kitty keyboard protocol  |
| `syncOutput`    | `boolean`                | auto                   | Wrap frames in DEC 2026 markers |
| `write`         | `(data: string) => void` | `process.stdout.write` | Custom output function          |

### Screen Interface

```typescript
interface Screen {
  root: TermNode;
  input: InputBus;
  mouse: boolean;
  commit(content: () => TermNode): void;
  refresh(): void;
  resize(cols: number, rows: number): void;
  setMouse(enabled: boolean): void;
  unmount(): void;
}
```

- **`commit(content)`** — renders a component once to ANSI, writes it into
  scrollback above the active region, then disposes the tree. Used to finalize
  completed output. Primary buffer only.
- **`setMouse(enabled)`** — toggle mouse tracking at runtime.
- **`refresh()`** — force an immediate render, bypassing the scheduler.
- **`resize(cols, rows)`** — update dimensions and re-render (also hooked to
  `process.stdout` resize events automatically).
- **`unmount()`** — stop the frame loop, dispose the Solid tree, restore the
  terminal. Also registered on SIGINT/SIGTERM/exit so the terminal is restored
  on unexpected process death.

### Primary vs Alternate Buffer

By default `mount()` renders in the **primary buffer**: the UI occupies the
bottom N rows and output committed via `screen.commit()` scrolls into native
terminal scrollback (preserving search, selection, copy-paste). With
`altScreen: true` the renderer takes over the full screen; `commit()` is a
no-op.

---

## Intrinsic Elements

All elements are JSX intrinsics. Colors accept any `Bun.ColorInput` value (CSS
names, hex, rgb, etc.). Full prop reference lives in `jsx-runtime.d.ts`.

- **`<box>`** — flexbox container with layout, sizing, spacing, visual, and
  mouse-event props, plus `ref`.
- **`<text>`** — styled text with wrapping/truncation and the usual SGR styling
  props.
- **`<input>`** — controlled text input with built-in keyboard editing; the
  screen routes keypresses to the focused `<input>`.
- **`<hyperlink>`** — OSC 8 terminal hyperlink with plain-text fallback.

---

## Animation (`ashlar/animation`)

A linked animation is a **paint-time color field**: `<Animated>` runs an
`Effect` as a filter over the region its children paint, recoloring glyph cells
by screen position _after_ the subtree paints. A shimmer (or wave, or pulse)
therefore sweeps across heterogeneous children — text, a spinner glyph, nested
boxes — as one continuous sequence, and **the children stay plain** (`<text>`,
`<Spinner>`) with no animation-specific markup or wrappers.

```tsx
import { Shimmer } from "ashlar/animation";
import { Spinner } from "ashlar/ui";

<Shimmer baseColor="#666" highlightColor="#fff">
  <Spinner />
  <text>{` ${verb}…`}</text>
</Shimmer>;
```

**Named wrappers** — the three built-in effects, each running across all of its
children as one linked region:

| Wrapper     | Effect                                                         |
| ----------- | -------------------------------------------------------------- |
| `<Shimmer>` | A highlight crest sweeping by column, soft two-cell falloff    |
| `<Wave>`    | A sine gradient scrolling along the columns between two colors |
| `<Pulse>`   | The whole region breathing between two colors in unison        |

Each takes its own color and timing props; all three also accept `paused`
(freeze at the resting color) and `time` (share an external `useClock` signal).

**`<Animated effect={…}>`** — the generic form. The effect is _data_: the
`shimmer()`, `wave()`, and `pulse()` factories return an `Effect`
— `(env: { t, paused }) => (x, y, w, h) => packedColor | undefined` (return
`undefined` to leave a cell's painted color untouched). New effects are just new
factories; they need no new components. Effect colors may be accessors, so they
can themselves animate — e.g. lerping `baseColor`/`highlightColor` toward red as
a stream stalls (the `stream-spinner` demo does exactly this). `paused` freezes
the effect at its resting color.

**Hooks & helpers** —

- `useClock(ms?)` — a shared tick signal (a `setInterval` that increments a
  signal, auto-cleared on dispose). Components that need time-derived values
  should share one clock rather than each running a timer.
- `useStalled(time, getLength, opts?)` — watches a length accessor against the
  clock and returns `{ isStalled(), intensity() }`, a smoothed 0→1 ramp for
  fading a stalled stream's color (`delayMs`/`rampMs` tune the onset and slope).
- Color utilities — `lerpColor(from, to, t)`, `lighten(color)`, and
  `packColor(color)` (any `Bun.ColorInput` → packed 24-bit int).

Mechanically, `<Animated>` sets a `colorField` on its `<box>`; the paint
pipeline recolors that box's glyph cells each frame (see `applyColorField` in
`pipeline.ts`). Because the field is a normal reactive prop, a clock tick marks
the box dirty and the region repaints.

---

## Components (`ashlar/ui`)

Higher-level components built on the renderer primitives.

- **`<Select>`** — a keyboard- and mouse-navigable list. Uncontrolled by
  default, or pass `index` to drive the highlight from the parent; a `children`
  render prop customizes row rendering.
- **`<ProgressBar>`** — a determinate bar driven by a 0–1 `value`. For an
  indeterminate "busy" state, reach for `<Spinner>` instead.
- **`<Spinner>`** — a bare animated glyph. It carries no animation awareness —
  dropped inside an `<Animated>`/`<Shimmer>`/… region, the group's paint filter
  recolors its glyph along with the text beside it, so an effect sweeps across
  the spinner and its label as one. `glyphs` selects the frame sequence (a
  `SPINNER_GLYPHS` preset or your own).

---

## Input System

`useInput`, `useMouse`, and `usePaste` subscribe to input inside any component
under `mount()`:

```typescript
import { useInput, useMouse, usePaste, scrollBy } from "ashlar";

useInput((key) => {
  if (key.name === "up") scrollBy(ref, -1);
  if (key.name === "c" && key.ctrl) screen.unmount();
});
useMouse((event) => { if (event.type === "up") /* click at event.x,event.y */ });
usePaste((text) => setValue((v) => v + text));
```

`ParsedKey` covers both legacy xterm/CSI sequences and the kitty keyboard
protocol (CSI u). `RawMouseEvent` carries `type`, `button`, `x`/`y` (0-indexed),
`modifiers`, and optional `scroll`. The bus is also exposed directly on
`screen.input` (`addKeyHandler` / `addMouseHandler` / `addPasteHandler`, each
returning an unsubscribe fn) for use outside components.

When mouse is enabled, clicks are hit-tested to the deepest node at the
coordinates (cached `screenRect` per node). Click-and-drag selects frame-buffer
text and copies it to the clipboard via OSC 52 on mouse-up.

---

## Scrolling

Containers with `overflow="scroll"` scroll via mouse wheel (when `mouse: true`)
and programmatically via refs:

```tsx
import { scrollBy, scrollTo, scrollToBottom, type TermNode } from "ashlar";

let ref!: TermNode;
<box overflow="scroll" height={15} ref={ref} borderStyle="round">
  …
</box>;

scrollBy(ref, -1); // up one row
scrollTo(ref, 0); // jump to top
scrollToBottom(ref); // jump to bottom
```

---

## Terminal Capabilities

`terminalCaps` is a synchronous capability object detected on import from
environment variables (`TERM_PROGRAM`, `TERM`, `KITTY_WINDOW_ID`, `WT_SESSION`,
`COLORTERM`, `VTE_VERSION`, tmux): `hyperlinks`, `kittyKeyboard`, `mouse`,
`syncOutput`, `trueColor`. Defaults are conservative — sync output and
hyperlinks are disabled inside tmux.

Several primitives come from Bun directly: `Bun.stringWidth()`,
`Bun.sliceAnsi()`, `Bun.wrapAnsi()`, `Bun.stripANSI()`, `Bun.color()` — no
`string-width`/`slice-ansi`/`wrap-ansi`/`chalk` dependencies, and no native
deps (the Yoga port is pure TypeScript).

---

## Development

```sh
bun install          # install + symlink the demo workspace
bun test             # run the suite
bun run typecheck    # tsgo --noEmit
bun run fix          # biome format + lint --write
bun run demo         # launch the demo gallery
bun run build        # emit dist/ for publishing
```

---

## Dependencies

**Runtime:** `solid-js` (2.0).
**Dev:** `@babel/core`, `babel-preset-solid` (2.0), `@babel/preset-typescript`,
`@biomejs/biome`, `typescript`/`tsgo`, `bun-types`.
