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
110 changes: 96 additions & 14 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@ Bunランタイム向けの宣言的なTUIフレームワーク。

## 特徴

- **きめ細かなリアクティビティ**: 仮想DOMは使用しません。状態の変更に依存するコンポーネントのみが再描画されます。
- **Flexboxベースのレイアウト**: Rust製のエンジンがFlexboxのサブセットを実装し、レスポンシブなレイアウトを実現します。
- **Bunネイティブ**: Bunの高速なTTY、FFI、疑似ターミナルAPIと統合されています。
- **型安全**: TypeScriptで書かれています。
- **宣言的なUI**: コンポーネントのツリーでインターフェースを記述します。
- **リアクティビティモデル**: 依存する状態(`ref`, `computed`)が変更されると、UIが自動的に更新されます。フレームワークは依存関係を追跡し、仮想DOMを使用せずに必要なコンポーネントのみを再描画します。
- **Flexboxベースのレイアウト**: Rustベースのレイアウトエンジンである[Taffy](https://github.com/DioxusLabs/taffy)をFFI経由で使用し、Flexboxのようなレイアウトを計算します。
- **最適化されたレンダリング**: レンダラーは、前回と現在の画面状態との差分を作成することで、TTYへの書き込みを削減します。また、スクロールパフォーマンスを最適化するための部分的な再描画もサポートしています。
- **Bunネイティブ**: Bunランタイム向けに構築されており、その高速なTTY、FFI、および疑似ターミナルAPIを活用しています。
- **型安全**: TypeScriptで記述されています。

## 開発体験

- **ホットリロード**: `btuin dev`コマンドは、ファイルの変更を監視する開発ランナーを提供し、変更時にTUIを自動的に再起動することで、高速なフィードバックループを可能にします。

- **ブラウザベースのDevTools**: 統合されたインスペクターを使用すると、Webブラウザでリアルタイムにコンポーネントツリーの表示、コンポーネントレベルのログの確認、レイアウトとレンダリングのデバッグが可能です。

## インストール

Expand All @@ -19,11 +27,13 @@ bun add btuin

## 使い方

次のコードは、矢印キーで増減するシンプルなカウンターを作成します。

```ts
import { createApp, ref, ui } from "btuin";

const app = createApp({
// init: 状態とイベントリスナーをセットアップ
// `init`は状態とイベントリスナーをセットアップするために一度だけ呼び出されます。
init({ onKey, runtime }) {
const count = ref(0);

Expand All @@ -36,7 +46,7 @@ const app = createApp({
return { count };
},

// render: UIツリーを返す。状態が変化すると再実行される
// `render`はUIツリーを返します。状態が変化するたびに再実行されます
render({ count }) {
return ui
.VStack([ui.Text("Counter"), ui.Text(String(count.value))])
Expand All @@ -50,12 +60,86 @@ const app = createApp({
await app.mount();
```

## Inline モード
## より多くの例

### インラインプログレスバー

ターミナル画面全体をクリアせずにUIをインラインでレンダリングできます。これは、プログレスバー、プロンプト、またはターミナルのスクロールバック履歴を妨げるべきではないインタラクティブツールに役立ちます。

`inline`モードがアクティブな場合、`stdout`と`stderr`は自動的にレンダリングされたUIの上にルーティングされます。

```ts
import { createApp, ref, ui } from "btuin";

const app = createApp({
init({ onKey, onTick, runtime, setExitOutput }) {
const progress = ref(0);

onKey((k) => k.name === "q" && runtime.exit(0));

onTick(() => {
progress.value++;
if (progress.value >= 100) {
setExitOutput("完了!");
runtime.exit(0);
}
}, 25);

return { progress };
},
render({ progress }) {
return ui.Text(`進捗: ${progress.value}%`);
},
});

await app.mount({
inline: true,
// 終了時に画面からUIをクリアする
inlineCleanupOnExit: true,
});
```

### 仮想化リスト

ターミナル全体を消さずに描画します:
`btuin`は、仮想化された`Windowed`コンポーネントを使用して、アイテムの長いリストを効率的にレンダリングできます。表示されているアイテム(および「オーバースキャン」バッファー)のみがレンダリングされるため、何千ものアイテムがあっても高いパフォーマンスが維持されます。

```ts
await app.mount({ inline: true, inlineCleanupOnExit: true });
import { createApp, ref, ui } from "btuin";

const TOTAL = 50_000;
const items = Array.from({ length: TOTAL }, (_, i) => `アイテム ${i}`);

const app = createApp({
init({ onKey, runtime }) {
const scrollIndex = ref(0);

onKey((k) => {
if (k.name === "q") runtime.exit(0);
// 注: `clampWindowedStartIndex`は、スクロールインデックスが
// 有効な範囲内に収まるようにするためのヘルパーです。
if (k.name === "down") scrollIndex.value++;
if (k.name === "up") scrollIndex.value--;
if (k.name === "pagedown") scrollIndex.value += 20;
if (k.name === "pageup") scrollIndex.value -= 20;
});

return { scrollIndex };
},
render({ scrollIndex }) {
const list = ui.Windowed({
items,
startIndex: scrollIndex.value,
renderItem: (item) => ui.Text(item),
});

return ui.VStack([
ui.Text(`${items.length}個のアイテムを表示中(qで終了)`),
list,
]);
},
});

await app.mount();
```

## API概要
Expand All @@ -70,11 +154,9 @@ await app.mount({ inline: true, inlineCleanupOnExit: true });

## リンク

- [**ドキュメント**](./docs/) (アーキテクチャ, ロードマップ)
- [**Inline モード**](./docs/inline-mode.ja.md)
- [**DevTools**](./docs/devtools.ja.md)
- [**ホットリロード**](./docs/hot-reload.ja.md)
- [**GitHub**](https://github.com/HALQME/btuin) (ソースコード, Issue)
- [**アーキテクチャ**](./docs/architecture.ja.md): コア設計、リアクティビティシステム、レンダリングパイプラインについて。
- [**開発ツール**](./docs/devtools.ja.md): ブラウザベースのインスペクタとホットリロードの使い方。
- [**GitHub**](https://github.com/HALQME/btuin): ソースコード、Issue、コントリビューション。

## 言語

Expand Down
108 changes: 95 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@ Declarative TUI framework for the Bun runtime.

## Features

- **Fine-Grained Reactivity**: No virtual DOM. Only components that depend on changed state are re-rendered.
- **Flexbox-based Layout**: A Rust-powered engine that implements a subset of Flexbox for responsive layouts.
- **Bun Native**: Integrated with Bun's fast TTY, FFI, and pseudo-terminal APIs.
- **Declarative UI**: Describe your interface with a tree of components.
- **Reactivity Model**: The UI automatically updates when the state (`ref`, `computed`) it depends on changes. The framework tracks dependencies to re-render only necessary components, without using a Virtual DOM.
- **Flexbox-based Layout**: Uses [Taffy](https://github.com/DioxusLabs/taffy), a Rust-based layout engine, via FFI to calculate Flexbox-like layouts.
- **Optimized Rendering**: The renderer reduces TTY writes by creating a diff between the previous and current screen states. It also supports partial re-rendering for optimized scrolling performance.
- **Bun Native**: Built for the Bun runtime, utilizing its fast TTY, FFI, and pseudo-terminal APIs.
- **Type-Safe**: Written in TypeScript.

## Developer Experience

- **Hot Reloading**: The `btuin dev` command provides a file-watching development runner that automatically restarts your TUI on changes, enabling a fast feedback loop.

- **Browser-Based DevTools**: An integrated inspector allows you to view the component tree, check component-level logs, and debug layout and rendering in real-time in your web browser.

## Installation

```bash
Expand All @@ -19,11 +27,13 @@ Publishing/install details: `docs/github-packages.md`

## Usage

The following code creates a simple counter that increments and decrements with the arrow keys.

```ts
import { createApp, ref, ui } from "btuin";

const app = createApp({
// init: setup state and event listeners.
// `init` is called once to set up state and event listeners.
init({ onKey, runtime }) {
const count = ref(0);

Expand All @@ -36,7 +46,7 @@ const app = createApp({
return { count };
},

// render: returns the UI tree. Re-runs when state changes.
// `render` returns the UI tree. It re-runs whenever state changes.
render({ count }) {
return ui
.VStack([ui.Text("Counter"), ui.Text(String(count.value))])
Expand All @@ -50,12 +60,86 @@ const app = createApp({
await app.mount();
```

## Inline Mode
## More Examples

### Inline Progress Bar

You can render a UI inline without clearing the entire terminal screen. This is useful for progress bars, prompts, or interactive tools that should not disrupt the terminal's scrollback history.

When `inline` mode is active, `stdout` and `stderr` are automatically routed above the rendered UI.

```ts
import { createApp, ref, ui } from "btuin";

const app = createApp({
init({ onKey, onTick, runtime, setExitOutput }) {
const progress = ref(0);

onKey((k) => k.name === "q" && runtime.exit(0));

onTick(() => {
progress.value++;
if (progress.value >= 100) {
setExitOutput("Done!");
runtime.exit(0);
}
}, 25);

return { progress };
},
render({ progress }) {
return ui.Text(`Progress: ${progress.value}%`);
},
});

await app.mount({
inline: true,
// Clear the UI from the screen on exit
inlineCleanupOnExit: true,
});
```

### Virtualized List

Render without clearing the whole screen:
`btuin` can render long lists of items efficiently using a virtualized `Windowed` component. Only the visible items (plus an "overscan" buffer) are rendered, keeping performance high even with thousands of items.

```ts
await app.mount({ inline: true, inlineCleanupOnExit: true });
import { createApp, ref, ui } from "btuin";

const TOTAL = 50_000;
const items = Array.from({ length: TOTAL }, (_, i) => `item ${i}`);

const app = createApp({
init({ onKey, runtime }) {
const scrollIndex = ref(0);

onKey((k) => {
if (k.name === "q") runtime.exit(0);
// NOTE: `clampWindowedStartIndex` is a helper to ensure
// the scroll index stays within valid bounds.
if (k.name === "down") scrollIndex.value++;
if (k.name === "up") scrollIndex.value--;
if (k.name === "pagedown") scrollIndex.value += 20;
if (k.name === "pageup") scrollIndex.value -= 20;
});

return { scrollIndex };
},
render({ scrollIndex }) {
const list = ui.Windowed({
items,
startIndex: scrollIndex.value,
renderItem: (item) => ui.Text(item),
});

return ui.VStack([
ui.Text(`Displaying ${items.length} items (q to quit)`),
list,
]);
},
});

await app.mount();
```

## API Overview
Expand All @@ -70,11 +154,9 @@ await app.mount({ inline: true, inlineCleanupOnExit: true });

## Links

- [**Documentation**](./docs/) (Architecture, Roadmap)
- [**Inline Mode**](./docs/inline-mode.md)
- [**DevTools**](./docs/devtools.md)
- [**Hot Reload**](./docs/hot-reload.md)
- [**GitHub**](https://github.com/HALQME/btuin) (Source Code, Issues)
- [**Architecture**](./docs/architecture.md): Learn about the core design, reactivity system, and rendering pipeline.
- [**Developer Tools**](./docs/devtools.md): See how to use the browser-based inspector and hot reloading.
- [**GitHub**](https://github.com/HALQME/btuin): View the source code, open issues, and contribute.

## Language

Expand Down
12 changes: 6 additions & 6 deletions docs/roadmap.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,19 @@
- [ ] ブラウザ側からのリアクティブ・ステート(Ref)の直接書き換え
- [ ] リモートキーイベント送信(ブラウザ仮想キーボードからの入力注入)
- [ ] **アーキテクチャ・最適化**
- [ ] **FFI通信の効率化**
- [ ] フルシリアライズの回避(Dirty Checking による部分的なレイアウト更新)
- [ ] **大規模描画サポート**
- [ ] 仮想ウィンドウ化(Virtual Scrolling)による数万行のリスト表示
- [ ] スクロールリージョン(DECSTBM)を活用した高速スクロール
- [x] **FFI通信の効率化**
- [x] フルシリアライズの回避(Dirty Checking による部分的なレイアウト更新)
- [x] **大規模描画サポート**
- [x] 仮想ウィンドウ化(Virtual Scrolling)による数万行のリスト表示
- [x] スクロールリージョン(DECSTBM)を活用した高速スクロール
- [ ] **リアクティビティの高度化**
- [ ] Effect Scope の導入(コンポーネントに紐付いた Effect の自動追跡・破棄)
- [x] **開発体験 (DX) / 大規模開発サポート**
- [x] **状態共有パターン**
- [x] `Provide/Inject` または `Context API` 相当の依存注入機能
- [ ] **安全性・堅牢性**
- [x] FFI 境界の同期テスト
- [ ] 致命的エラー時のセーフティネット(パニック時の Raw Mode 強制解除)
- [x] 致命的エラー時のセーフティネット(パニック時の Raw Mode 強制解除)
- [ ] **AI・アクセシビリティ**
- [ ] セマンティック・メタデータのサポート(AIエージェントや将来のA11y支援用)
- [ ] コンポーネント
Expand Down
59 changes: 59 additions & 0 deletions examples/virtual-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { createApp, ref } from "@/index";
import { Text, VStack, Windowed, clampWindowedStartIndex, getWindowedMetrics } from "@/view";

const TOTAL = 50_000;
const items = Array.from({ length: TOTAL }, (_, i) => `item ${i}`);

const app = createApp({
init({ onKey, runtime }) {
const scrollIndex = ref(0);

onKey((k) => {
if (k.name === "q") runtime.exit(0);
if (k.name === "down")
scrollIndex.value = clampWindowedStartIndex({
itemCount: items.length,
startIndex: scrollIndex.value + 1,
});
if (k.name === "up")
scrollIndex.value = clampWindowedStartIndex({
itemCount: items.length,
startIndex: scrollIndex.value - 1,
});
if (k.name === "pagedown")
scrollIndex.value = clampWindowedStartIndex({
itemCount: items.length,
startIndex: scrollIndex.value + 20,
});
if (k.name === "pageup")
scrollIndex.value = clampWindowedStartIndex({
itemCount: items.length,
startIndex: scrollIndex.value - 20,
});
});

return { scrollIndex };
},
render({ scrollIndex }) {
// Reserve 2 rows for header+status and 2 rows for outline padding (1 top + 1 bottom).
const header = Text(`Windowed: ${items.length} items (q to quit)`).foreground("cyan").shrink(0);
const clamped = getWindowedMetrics({
itemCount: items.length,
startIndex: scrollIndex.value,
}).startIndex;
const status = Text(`startIndex=${clamped}`).foreground("gray").shrink(0);

const list = Windowed({
items,
startIndex: clamped,
itemHeight: 1,
overscan: 2,
keyPrefix: "windowed",
renderItem: (item) => Text(item),
});

return VStack([header, status, list]).width("100%").outline({ style: "single", color: "blue" });
},
});

await app.mount();
Loading