Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ tests/
├── ansi.test.ts # ANSI integration tests
└── *.test.ts # Additional test suites
scratchpad/ # Development playground
docs/ # Module documentation
```

## Development Workflow
Expand Down Expand Up @@ -174,7 +173,8 @@ This creates a markdown file in `.changeset/`.

## Getting Help

- Check the [documentation](./docs/) for module guides
- Review inline TSDoc comments in `src/` for API documentation
- Check the [API Reference](https://effect-boxes.lloydrichards.dev/api/box) for module guides
- Review [AGENTS.md](./AGENTS.md) for detailed coding patterns (useful for AI
assistants)
- Open an issue for bugs or feature requests
128 changes: 38 additions & 90 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Effect Boxes

[![npm version](https://img.shields.io/npm/v/effect-boxes)](https://www.npmjs.com/package/effect-boxes)
[![License: BSD-3-Clause](https://img.shields.io/npm/l/effect-boxes)](./LICENSE)
[![TypeScript](https://img.shields.io/badge/TypeScript-6.0+-blue)](https://www.typescriptlang.org/)
[![Effect](https://img.shields.io/badge/power%20up-Effect-black)](https://effect.website)

A functional layout system for terminal applications built with Effect.js.
Create TUIs with composable boxes, ANSI styling, and reactive components.

Expand All @@ -8,20 +13,18 @@ Create TUIs with composable boxes, ANSI styling, and reactive components.
## What is Effect Boxes?

Effect Boxes is a TypeScript library inspired by Haskell's
`Text.PrettyPrint.Boxes`, providing a flex-style layout system for terminal
applications within the Effect ecosystem. It started from the original box model
and function naming, then grew into its own implementation with Effect
integration, annotations, ANSI styling, and reactive rendering support. Think of
it as a CSS flexbox system, but built specifically for functional composition of
elements in terminal UIs, ASCII art, and structured text output.
`Text.PrettyPrint.Boxes`, providing a flex-style layout system for terminal applications within the Effect ecosystem. Think of it as CSS flexbox, but built for functional composition of elements in terminal UIs, ASCII art, and structured text output.

## Key Features
## Features

- **Flex-y Layout System**: Horizontal and vertical composition with alignment
- **Flex-y Layout System** Horizontal and vertical composition with alignment
control
- **Text Flow**: Automatic paragraph wrapping and column layout
- **ANSI Color Support**: Rich terminal styling with colors and text attributes
- **Reactive Components**: Create dynamic UIs with efficient partial updates
- **Text Flow** — Automatic paragraph wrapping and column layout
- **ANSI Color Support** — Rich terminal styling with colors and text attributes
- **Reactive Components** — Dynamic UIs with efficient partial updates
- **Effect Integration** — Pipeable, Equal, Hash, and dual-function APIs
- **Unicode Aware** — Correct width calculations for full-width characters and
emoji

## Installation

Expand All @@ -31,111 +34,56 @@ npm install effect-boxes
bun add effect-boxes
# or
pnpm add effect-boxes
# or
yarn add effect-boxes
```

## Quick Start
## Quick Example

```typescript
import { Box, Ansi } from "effect-boxes";
// Alternative import patterns:
// import * as Box from "effect-boxes/Box";
// import * as Ansi from "effect-boxes/Ansi";

// Create a simple box with colored text and positioning
const myBox = Box.hsep(
[
Box.text("Hello").pipe(Box.annotate(Ansi.green)),
Box.text("Hello").pipe(Box.annotate(Ansi.green)),
Box.text("\nEffect").pipe(Box.annotate(Ansi.bold)),
Box.text("Boxes!").pipe(Box.annotate(Ansi.blue)),
],
],
1,
Box.left
);

// Render to string (with ANSI colors)
console.log(Box.renderPrettySync(myBox));
/**
* Hello Boxes!
* Effect
*/
```

## Example: Creating a Table
## Documentation

```typescript
import { pipe } from "effect";
import { Box } from "effect-boxes";

// Create a simple table layout
const createTable = (headers: string[], rows: string[][]) => {
const headerRow = Box.punctuateH(
headers.map((h) => Box.text(h).pipe(Box.alignHoriz(Box.center1, 12))),
Box.top,
Box.text(" | ")
);

const separator = Box.text("-".repeat(headerRow.cols));

const dataRows = rows.map((row) =>
Box.punctuateH(
row.map((cell) => Box.text(cell).pipe(Box.alignHoriz(Box.left, 12))),
Box.top,
Box.text(" | ")
)
);

return Box.vcat([headerRow, separator, ...dataRows], Box.left);
};

const table = createTable(
["Name", "Age", "City"],
[
["Alice", "30", "New York"],
["Bob", "25", "London"],
["Charlie", "35", "Tokyo"],
]
);
Full documentation, guides, and API reference are available at
[effect-boxes.lloydrichards.dev](https://effect-boxes.lloydrichards.dev).

console.log(Box.renderPlainSync(table));
/**
* Name | Age | City
* ------------------------------------------
* Alice | 30 | New York
* Bob | 25 | London
* Charlie | 35 | Tokyo
*/
```
For library-specific details (modules, usage guides, common patterns), see the
[package README](./packages/effect-boxes/README.md).

## Documentation
## Repository Structure

This project is organized as a monorepo:

| Module | Description |
| -------------- | ------------------------------------------------------------------------- |
| **Box** | Core box creation and composition (`hcat`, `vcat`, `text`, `align`, etc.) |
| **Annotation** | Attach metadata/annotations to boxes for styling and semantics |
| **ANSI** | Terminal styling with colors and text attributes |
| **Cmd** | Terminal control commands (cursor movement, screen clearing) |
| **Reactive** | Position tracking for interactive terminal interfaces |
| **Width** | Unicode and ANSI-aware text width calculations |

### Guides

- [Box Module](./docs/using-box.md) - Core layout primitives and composition
- [Layout Module](./docs/using-layout.md) - Higher-level Flex, Container, and Grid layouts
- [Annotation Module](./docs/using-annotation.md) - Adding metadata to boxes
- [ANSI Module](./docs/using-ansi.md) - Terminal colors and styling
- [Cmd Module](./docs/using-cmd.md) - Terminal control sequences
- [Reactive Module](./docs/using-reactive.md) - Building interactive UIs
- [Common Patterns](./docs/common-patterns.md) - Reusable patterns and examples
| Path | Description |
| --- | --- |
| [`packages/effect-boxes`](./packages/effect-boxes) | Core library (published to npm) |
| [`apps/docs`](./apps/docs) | Documentation website |
| [`apps/scratchpad`](./apps/scratchpad) | Development playground |

## Contributing

Interested in contributing? See [CONTRIBUTING.md](./CONTRIBUTING.md) for
development setup, commands, and release process.
See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and workflow.

```bash
bun install # Install dependencies
turbo run build # Build all packages
turbo run test # Run all tests
```

## License

BSD-3-Clause

This library is inspired by Haskell's `Text.PrettyPrint.Boxes` by Brent Yorgey.
Inspired by Haskell's `Text.PrettyPrint.Boxes` by Brent Yorgey.
106 changes: 67 additions & 39 deletions apps/scratchpad/01-progress-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,26 @@ import {
Ref,
Schedule,
Stream,
Terminal,
} from "effect";
import * as Ansi from "effect-boxes/Ansi";
import * as Box from "effect-boxes/Box";
import * as Cmd from "effect-boxes/Cmd";
import { Container, Flex } from "effect-boxes/Layout";
import * as Reactive from "effect-boxes/Reactive";

const display = (msg: string) => Effect.sync(() => process.stdout.write(msg));

const StatusBar = (status: string, counter: number, time: string) =>
pipe(
const StatusBar = (status: string, counter: number, time: string, width: number) =>
Flex.row(
[
Box.text(`Status: ${status}`),
Box.text(`Counter: ${counter}`),
Box.text(`⏰ ${time}`),
Flex.fixed(Box.text(`Status: ${status}`)),
Flex.fixed(Box.text(" | ")),
Flex.fixed(Box.text(`Counter: ${counter}`)),
Flex.fixed(Box.text(" | ")),
Flex.fixed(Box.text(`⏰ ${time}`)),
],
Box.punctuateH(Box.left, Box.text(" | "))
width
);

const ProgressBar = (progress: number, total: number, width: number) => {
Expand Down Expand Up @@ -53,11 +57,16 @@ const formatTime = (timestamp: number): string => {

export const main = Effect.gen(function* () {
// Clear screen and hide cursor for cleaner output
yield* display(Box.renderPrettySync(Cmd.clearScreen));
yield* display(Box.renderPrettySync(Cmd.cursorHide));
yield* display(Box.renderPrettySync(Box.combine(Cmd.clearScreen,Cmd.cursorHide)));

const terminal = yield* Terminal.Terminal

const Complete = 1000;
const ProgressBarWidth = 69;
const ContainerWidth = (yield* terminal.columns)-2
// For reactive updates, pre-compute the progress bar width matching Container's inner math
// Container padding [1,2] => innerWidth = 85 - 2(border) - 2*2(padding) = 79
// percentBox = 5 + 2(border) = 7, gap = 2
const ProgressBarInnerWidth = ContainerWidth - 2 - 4 - 7 - 2;

const counterRef = yield* Ref.make(0);

Expand All @@ -68,40 +77,59 @@ export const main = Effect.gen(function* () {
const timeStr = formatTime(timestamp);
const status = counter >= Complete ? "completed" : "running";

const top = Box.hcat(
[
ProgressBar(counter, Complete, ProgressBarWidth).pipe(
Reactive.makeReactive("progress-bar"),
Box.border("single")
),
Box.text(`${percentage.toString().padStart(3)}%`).pipe(
const top = Container.make(
{ width: ContainerWidth, padding: [1, 2] },
(ctx) => {
const percentBox = Box.text(
`${percentage.toString().padStart(3)}%`
).pipe(
Box.alignHoriz(Box.right, 5),
Box.annotate(percentage === 100 ? Ansi.green : Ansi.blue),
Reactive.makeReactive("percentage"),
Box.border("single"),
Box.annotate(Ansi.green)
),
],
Box.center1
).pipe(Box.pad(1), Box.border("single"));

const bottom = StatusBar(status, counter, timeStr).pipe(
Box.alignHoriz(Box.center1, 79),
Reactive.makeReactive("status-bar"),
Box.border("single")
);
Box.border("rounded", {
annotation: Ansi.green,
sides:{left:false}}),
);
// progress bar fills remaining space after percentage box
const barWidth = ctx.innerWidth - Box.cols(percentBox) - 2;
return Flex.row(
[
Flex.fixed(
ProgressBar(counter, Complete, barWidth).pipe(
Reactive.makeReactive("progress-bar"),
Box.border("single")
)
),
Flex.fixed(percentBox),
],
ctx.innerWidth,
{ align: Box.center1 }
);
}
).pipe(Box.border("rounded", { sides: { bottom: false } }));

const bottom = Container.make(
{ width: ContainerWidth },
(ctx) =>
StatusBar(status, counter, timeStr, ctx.innerWidth).pipe(
Box.alignHoriz(Box.center1, ctx.innerWidth),
Reactive.makeReactive("status-bar")
)
).pipe(Box.border("single"));

const footer = Box.punctuateH(
const footer = Flex.row(
[
Box.text("Press"),
Box.text("Ctrl+C").pipe(Box.annotate(Ansi.blue)),
Box.text("to stop..."),
Flex.fixed(Box.text("Press")),
Flex.fixed(Box.text(" ")),
Flex.fixed(Box.text("Ctrl+C").pipe(Box.annotate(Ansi.blue))),
Flex.fixed(Box.text(" ")),
Flex.fixed(Box.text("to stop...")),
],
Box.left,
Box.text(" ")
ContainerWidth
);

return Box.punctuateV([top, bottom, footer], Box.top, Box.char(" "));
const layout = Box.vcat([top, bottom], Box.left);
return Box.punctuateV([layout, footer], Box.top, Box.char(" "));
};

// Display the initial layout
Expand Down Expand Up @@ -137,7 +165,7 @@ export const main = Effect.gen(function* () {
Reactive.cursorToReactive("progress-bar"),
Option.map((cursorCmd) => [
cursorCmd,
ProgressBar(counter, Complete, ProgressBarWidth),
ProgressBar(counter, Complete, ProgressBarInnerWidth),
])
),
pipe(
Expand All @@ -157,9 +185,9 @@ export const main = Effect.gen(function* () {
Reactive.cursorToReactive("status-bar"),
Option.map((cursorCmd) => [
cursorCmd,
StatusBar(status, counter, timeStr).pipe(
Box.truncate(79, Box.center1),
Box.alignHoriz(Box.center1, 79)
StatusBar(status, counter, timeStr, ContainerWidth - 2).pipe(
Box.truncate(ContainerWidth - 2, Box.center1),
Box.alignHoriz(Box.center1, ContainerWidth - 2)
),
])
),
Expand Down
Loading
Loading