From 70f6a2d202881926aa2ac03863f82cc4a64874b7 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Tue, 23 Dec 2025 00:30:23 +0900 Subject: [PATCH 1/3] tty-mode intro --- README.md | 22 ++++- docs/api-reference.md | 2 +- examples/counter.ts | 6 +- src/layout/index.ts | 2 +- src/runtime/lifecycle.ts | 2 +- src/runtime/platform-adapter.ts | 10 ++- src/terminal/io.ts | 24 +++--- src/terminal/raw.ts | 42 ++++++---- src/terminal/tty-streams.ts | 57 +++++++++++++ tests/e2e/runtime-exit.test.ts | 84 ++++++++++++------- .../units/layout-engine/ffi-boundary.test.ts | 1 - 11 files changed, 186 insertions(+), 66 deletions(-) create mode 100644 src/terminal/tty-streams.ts diff --git a/README.md b/README.md index 3ba8a60..77bef93 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,29 @@ await app.mount(); - `reactivity`: `ref/computed/effect/watch` による状態管理 - `layout-engine`: Flexbox ライクなレイアウト(Rust FFI) - `renderer`: バッファ描画 + 差分レンダリング(`renderDiff` は文字列を返す純粋関数) -- `terminal`: raw mode / 入力 / stdout 書き込み +- `terminal`: raw mode / 入力 / TTY へのUI描画 - `btuin`: それらを束ねる “アプリ実行” と View API +## パイプ/ヘッドレスでの実行 + +- UI描画は可能ならTTYへ直接出力し、`setExitOutput()` で指定した内容だけを標準出力に流します(`fzf`の挙動に近い)。 +- ヘッドレス環境では `Bun.Terminal`(PTY) でプロセスを起動すると、PTY越しにUIへアクセスできます。 + +```ts +const terminal = new Bun.Terminal({ + cols: 80, + rows: 24, + data(_term, data) { + // UIの描画(ANSI)が流れてくる + process.stdout.write(data); + }, +}); + +const proc = Bun.spawn(["bun", "run", "examples/counter.ts"], { terminal }); +terminal.write("q"); // キー入力 +await proc.exited; +``` + ## アダプタ(テスト/差し替え用) 通常はそのまま `createApp()` を使えば動きます。必要なら I/O を差し替えできます。 diff --git a/docs/api-reference.md b/docs/api-reference.md index bed55fa..5eb4931 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -86,7 +86,7 @@ init({ onExit }) { ### `setExitOutput(output: string | (() => string))` -アプリケーションが終了した後に、コンソールに表示されるメッセージを設定します。`onExit`の処理結果を反映させたい場合など、動的にメッセージを生成したい場合は、関数を渡すことができます。 +アプリケーションが終了した後に、標準出力へ流す文字列を設定します(パイプ/リダイレクト向け)。`onExit`の処理結果を反映させたい場合など、動的にメッセージを生成したい場合は、関数を渡すことができます。 **使用例:** diff --git a/examples/counter.ts b/examples/counter.ts index d2d6a52..e108fdd 100644 --- a/examples/counter.ts +++ b/examples/counter.ts @@ -21,11 +21,11 @@ const app = createApp({ Text("Counter"), // Text(String(count.value)), ]) - // .width("100%") - // .height("100%") + .width("100%") + .height("100%") .justify("center") .align("center"); }, }); -await app.mount({ inline: true, "inlineCleanupOnExit": true}); +await app.mount(); diff --git a/src/layout/index.ts b/src/layout/index.ts index 53b7a9b..a56bdb6 100644 --- a/src/layout/index.ts +++ b/src/layout/index.ts @@ -39,7 +39,7 @@ export interface LayoutContainerSize { } export interface LayoutOptions { - inline?: boolean + inline?: boolean; } function isPercent(value: unknown): value is string { diff --git a/src/runtime/lifecycle.ts b/src/runtime/lifecycle.ts index 8ba4f2e..8d8e286 100644 --- a/src/runtime/lifecycle.ts +++ b/src/runtime/lifecycle.ts @@ -80,7 +80,7 @@ export class LifecycleManager { terminal.clearScreen(); } if (output) { - terminal.write(output.endsWith("\n") ? output : `${output}\n`); + process.stdout.write(output.endsWith("\n") ? output : `${output}\n`); } platform.exit(code); diff --git a/src/runtime/platform-adapter.ts b/src/runtime/platform-adapter.ts index 7c6ff2f..c9179b9 100644 --- a/src/runtime/platform-adapter.ts +++ b/src/runtime/platform-adapter.ts @@ -1,3 +1,5 @@ +import { getUiOutputStream } from "@/terminal/tty-streams"; + export interface PlatformAdapter { onStdoutResize(handler: () => void): () => void; onExit(handler: () => void): void; @@ -10,8 +12,12 @@ export interface PlatformAdapter { export function createDefaultPlatformAdapter(): PlatformAdapter { return { onStdoutResize: (handler) => { - process.stdout.on("resize", handler); - return () => process.stdout.off("resize", handler); + const stream = getUiOutputStream() as any; + if (typeof stream?.on !== "function" || typeof stream?.off !== "function") { + return () => {}; + } + stream.on("resize", handler); + return () => stream.off("resize", handler); }, onExit: (handler) => { process.once("exit", handler); diff --git a/src/terminal/io.ts b/src/terminal/io.ts index faebadc..f5a9fc6 100644 --- a/src/terminal/io.ts +++ b/src/terminal/io.ts @@ -1,4 +1,4 @@ -import { getOriginalStdout } from "./capture"; +import { getUiOutputStream } from "./tty-streams"; /** * Gets the current terminal size. @@ -7,36 +7,38 @@ import { getOriginalStdout } from "./capture"; * @returns Object with cols and rows properties */ export function getTerminalSize(): { cols: number; rows: number } { - const cols = process.stdout.columns || 80; - const rows = process.stdout.rows || 24; + const stream = getUiOutputStream(); + const cols = stream.columns || 80; + const rows = stream.rows || 24; return { cols, rows }; } export function clearScreen() { - getOriginalStdout().write("\x1b[2J"); - getOriginalStdout().write("\x1b[H"); + const out = getUiOutputStream(); + out.write("\x1b[2J"); + out.write("\x1b[H"); } export function moveCursor(row: number, col: number) { - getOriginalStdout().write(`\x1b[${row};${col}H`); + getUiOutputStream().write(`\x1b[${row};${col}H`); } export function write(str: string) { - getOriginalStdout().write(str); + getUiOutputStream().write(str); } export function hideCursor() { - getOriginalStdout().write("\x1b[?25l"); + getUiOutputStream().write("\x1b[?25l"); } export function showCursor() { - getOriginalStdout().write("\x1b[?25h"); + getUiOutputStream().write("\x1b[?25h"); } export function enableBracketedPaste() { - getOriginalStdout().write("\x1b[?2004h"); + getUiOutputStream().write("\x1b[?2004h"); } export function disableBracketedPaste() { - getOriginalStdout().write("\x1b[?2004l"); + getUiOutputStream().write("\x1b[?2004l"); } diff --git a/src/terminal/raw.ts b/src/terminal/raw.ts index fed193f..4f5dea4 100644 --- a/src/terminal/raw.ts +++ b/src/terminal/raw.ts @@ -1,6 +1,7 @@ import { AnsiInputParser } from "./parser/ansi"; import type { InputParser } from "./parser/types"; import type { KeyHandler } from "./types"; +import { getUiInputStream } from "./tty-streams"; const ESCAPE_KEY_TIMEOUT_MS = 30; @@ -44,6 +45,7 @@ class TerminalState { const terminalState = new TerminalState(); let escapeFlushTimer: ReturnType | null = null; +let activeInputStream: ReturnType | null = null; function clearEscapeFlushTimer() { if (escapeFlushTimer) { @@ -108,12 +110,16 @@ export function setInputParser(parser: InputParser) { */ export function setupRawMode() { if (terminalState.isRawModeActive()) return; - if (!process.stdin.isTTY) return; - process.stdin.setRawMode(true); - process.stdin.resume(); - process.stdin.setEncoding("utf8"); - process.stdin.on("data", handleData); + const input = getUiInputStream(); + activeInputStream = input; + + if (!input.isTTY || typeof input.setRawMode !== "function") return; + + input.setRawMode(true); + input.resume(); + input.setEncoding("utf8"); + input.on("data", handleData); process.once("exit", cleanupWithoutClear); terminalState.setRawModeActive(true); @@ -145,15 +151,18 @@ export function cleanupWithoutClear() { terminalState.setRawModeActive(false); - if (process.stdin.listenerCount("data") > 0) { - process.stdin.off("data", handleData); + const input = activeInputStream ?? getUiInputStream(); + activeInputStream = null; + + if (input.listenerCount("data") > 0) { + input.off("data", handleData); } - if (process.stdin.isTTY) { - process.stdin.setRawMode(false); + if (input.isTTY && typeof input.setRawMode === "function") { + input.setRawMode(false); } - process.stdin.pause(); + input.pause(); } /** @@ -170,13 +179,16 @@ export function cleanup() { terminalState.setRawModeActive(false); - if (process.stdin.listenerCount("data") > 0) { - process.stdin.off("data", handleData); + const input = activeInputStream ?? getUiInputStream(); + activeInputStream = null; + + if (input.listenerCount("data") > 0) { + input.off("data", handleData); } - if (process.stdin.isTTY) { - process.stdin.setRawMode(false); + if (input.isTTY && typeof input.setRawMode === "function") { + input.setRawMode(false); } - process.stdin.pause(); + input.pause(); } diff --git a/src/terminal/tty-streams.ts b/src/terminal/tty-streams.ts new file mode 100644 index 0000000..834b8a3 --- /dev/null +++ b/src/terminal/tty-streams.ts @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import tty from "node:tty"; +import { getOriginalStderr, getOriginalStdout } from "./capture"; + +type UiOutput = NodeJS.WriteStream & { isTTY?: boolean; columns?: number; rows?: number }; +type UiInput = NodeJS.ReadStream & { isTTY?: boolean; setRawMode?: (enabled: boolean) => void }; + +let cachedDevTty: { + input: UiInput; + output: UiOutput; +} | null = null; + +function ensureDevTty(): { input: UiInput; output: UiOutput } | null { + if (cachedDevTty) return cachedDevTty; + + if (process.platform === "win32") return null; + + try { + const inputFd = fs.openSync("/dev/tty", "r"); + const outputFd = fs.openSync("/dev/tty", "w"); + const input = new tty.ReadStream(inputFd) as UiInput; + const output = new tty.WriteStream(outputFd) as UiOutput; + + process.once("exit", () => { + try { + input.destroy(); + } catch {} + try { + output.destroy(); + } catch {} + }); + + cachedDevTty = { input, output }; + return cachedDevTty; + } catch { + return null; + } +} + +export function getUiOutputStream(): UiOutput { + if (process.stdout.isTTY) return getOriginalStdout() as UiOutput; + if (process.stderr.isTTY) return getOriginalStderr() as UiOutput; + + const devTty = ensureDevTty(); + if (devTty) return devTty.output; + + return getOriginalStdout() as UiOutput; +} + +export function getUiInputStream(): UiInput { + if (process.stdin.isTTY) return process.stdin as UiInput; + + const devTty = ensureDevTty(); + if (devTty) return devTty.input; + + return process.stdin as UiInput; +} diff --git a/tests/e2e/runtime-exit.test.ts b/tests/e2e/runtime-exit.test.ts index 76af866..1b32205 100644 --- a/tests/e2e/runtime-exit.test.ts +++ b/tests/e2e/runtime-exit.test.ts @@ -9,46 +9,70 @@ describe("Runtime exit integration", () => { const terminal = createMockTerminal(); const platform = createMockPlatform(); - const app = createApp({ - terminal, - platform, - init({ runtime }) { - runtime.setExitOutput("bye"); - runtime.exit(0); - return {}; - }, - render: () => Block(Text("ignored")), - }); + const originalStdoutWrite = process.stdout.write; + let stdout = ""; + process.stdout.write = ((chunk: any) => { + stdout += typeof chunk === "string" ? chunk : chunk.toString(); + return true; + }) as any; - await app.mount(); - await Bun.sleep(50); + try { + const app = createApp({ + terminal, + platform, + init({ runtime }) { + runtime.setExitOutput("bye"); + runtime.exit(0); + return {}; + }, + render: () => Block(Text("ignored")), + }); - expect(platform.state.exitCode).toBe(0); - expect(sanitizeAnsi(terminal.output)).toContain("bye"); - app.unmount(); + await app.mount(); + await Bun.sleep(50); + + expect(platform.state.exitCode).toBe(0); + expect(stdout).toContain("bye"); + expect(sanitizeAnsi(terminal.output)).not.toContain("bye"); + app.unmount(); + } finally { + process.stdout.write = originalStdoutWrite; + } }); it("does not write exit output on sigint exit", async () => { const terminal = createMockTerminal(); const platform = createMockPlatform(); - const app = createApp({ - terminal, - platform, - init({ runtime }) { - runtime.setExitOutput("should-not-print"); - runtime.exit(0, "sigint"); - return {}; - }, - render: () => Block(Text("ignored")), - }); + const originalStdoutWrite = process.stdout.write; + let stdout = ""; + process.stdout.write = ((chunk: any) => { + stdout += typeof chunk === "string" ? chunk : chunk.toString(); + return true; + }) as any; - await app.mount(); - await Bun.sleep(50); + try { + const app = createApp({ + terminal, + platform, + init({ runtime }) { + runtime.setExitOutput("should-not-print"); + runtime.exit(0, "sigint"); + return {}; + }, + render: () => Block(Text("ignored")), + }); - expect(platform.state.exitCode).toBe(0); - expect(sanitizeAnsi(terminal.output)).not.toContain("should-not-print"); - app.unmount(); + await app.mount(); + await Bun.sleep(50); + + expect(platform.state.exitCode).toBe(0); + expect(stdout).not.toContain("should-not-print"); + expect(sanitizeAnsi(terminal.output)).not.toContain("should-not-print"); + app.unmount(); + } finally { + process.stdout.write = originalStdoutWrite; + } }); it("triggers sigint exit on unhandled ctrl+c", async () => { diff --git a/tests/units/layout-engine/ffi-boundary.test.ts b/tests/units/layout-engine/ffi-boundary.test.ts index b53ad26..1c41ac0 100644 --- a/tests/units/layout-engine/ffi-boundary.test.ts +++ b/tests/units/layout-engine/ffi-boundary.test.ts @@ -78,4 +78,3 @@ describe("Layout Engine FFI boundary", () => { ); }); }); - From cd4f09556ba7a073b3e531ec4baa6ce4064056f6 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Tue, 23 Dec 2025 02:06:33 +0900 Subject: [PATCH 2/3] Add bypass stdout/stderr and cached UI streams Provide bypassStdoutWrite and bypassStderrWrite that write directly or record output in test mode. Add a write proxy and cache UI input/output streams so UI rendering is routed through the bypass functions and dev TTY handles are reused. --- src/terminal/capture.ts | 36 ++++++++++++++++++++++++++ src/terminal/tty-streams.ts | 50 +++++++++++++++++++++++++++++++------ 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/terminal/capture.ts b/src/terminal/capture.ts index 6063a64..efe19d8 100644 --- a/src/terminal/capture.ts +++ b/src/terminal/capture.ts @@ -186,6 +186,24 @@ export function getOriginalStdout(): typeof process.stdout { return process.stdout; } +/** + * Write to stdout bypassing capture (for UI rendering). + * In test mode, this records output instead of writing. + */ +export const bypassStdoutWrite: WriteFunction = (chunk: any, encoding?: any, callback?: any) => { + if (state.testModeEnabled) { + const text = typeof chunk === "string" ? chunk : chunk.toString(); + state.capturedOutput.push(text); + if (typeof encoding === "function") encoding(); + if (typeof callback === "function") callback(); + return true; + } + + const writeFn = state.isCapturing ? state.originalStdoutWrite : process.stdout.write; + if (!writeFn) return true; + return writeFn.call(process.stdout, chunk, encoding as any, callback as any); +}; + /** * Get the original stderr write function. * This allows error output directly to the terminal without being captured. @@ -197,6 +215,24 @@ export function getOriginalStderr(): typeof process.stderr { return process.stderr; } +/** + * Write to stderr bypassing capture (for UI rendering). + * In test mode, this records output instead of writing. + */ +export const bypassStderrWrite: WriteFunction = (chunk: any, encoding?: any, callback?: any) => { + if (state.testModeEnabled) { + const text = typeof chunk === "string" ? chunk : chunk.toString(); + state.capturedOutput.push(text); + if (typeof encoding === "function") encoding(); + if (typeof callback === "function") callback(); + return true; + } + + const writeFn = state.isCapturing ? state.originalStderrWrite : process.stderr.write; + if (!writeFn) return true; + return writeFn.call(process.stderr, chunk, encoding as any, callback as any); +}; + /** * Patch console methods to route through stdout/stderr. * This ensures console.log, console.error, etc. are captured. diff --git a/src/terminal/tty-streams.ts b/src/terminal/tty-streams.ts index 834b8a3..0c7c0d7 100644 --- a/src/terminal/tty-streams.ts +++ b/src/terminal/tty-streams.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import tty from "node:tty"; -import { getOriginalStderr, getOriginalStdout } from "./capture"; +import { bypassStderrWrite, bypassStdoutWrite } from "./capture"; type UiOutput = NodeJS.WriteStream & { isTTY?: boolean; columns?: number; rows?: number }; type UiInput = NodeJS.ReadStream & { isTTY?: boolean; setRawMode?: (enabled: boolean) => void }; @@ -10,6 +10,20 @@ let cachedDevTty: { output: UiOutput; } | null = null; +let cachedUiOutput: UiOutput | null = null; +let cachedUiInput: UiInput | null = null; + +function createWriteBypassProxy( + target: T, + writeFn: typeof bypassStdoutWrite, +): UiOutput { + const proxy = Object.create(target) as UiOutput; + proxy.write = function (chunk: any, encoding?: any, callback?: any) { + return writeFn(chunk, encoding, callback); + } as any; + return proxy; +} + function ensureDevTty(): { input: UiInput; output: UiOutput } | null { if (cachedDevTty) return cachedDevTty; @@ -38,20 +52,40 @@ function ensureDevTty(): { input: UiInput; output: UiOutput } | null { } export function getUiOutputStream(): UiOutput { - if (process.stdout.isTTY) return getOriginalStdout() as UiOutput; - if (process.stderr.isTTY) return getOriginalStderr() as UiOutput; + if (cachedUiOutput) return cachedUiOutput; + + if (process.stdout.isTTY) { + cachedUiOutput = createWriteBypassProxy(process.stdout, bypassStdoutWrite); + return cachedUiOutput; + } + if (process.stderr.isTTY) { + cachedUiOutput = createWriteBypassProxy(process.stderr, bypassStderrWrite); + return cachedUiOutput; + } const devTty = ensureDevTty(); - if (devTty) return devTty.output; + if (devTty) { + cachedUiOutput = devTty.output; + return cachedUiOutput; + } - return getOriginalStdout() as UiOutput; + cachedUiOutput = createWriteBypassProxy(process.stdout, bypassStdoutWrite); + return cachedUiOutput; } export function getUiInputStream(): UiInput { - if (process.stdin.isTTY) return process.stdin as UiInput; + if (cachedUiInput) return cachedUiInput; + if (process.stdin.isTTY) { + cachedUiInput = process.stdin as UiInput; + return cachedUiInput; + } const devTty = ensureDevTty(); - if (devTty) return devTty.input; + if (devTty) { + cachedUiInput = devTty.input; + return cachedUiInput; + } - return process.stdin as UiInput; + cachedUiInput = process.stdin as UiInput; + return cachedUiInput; } From dac81361c17669ac3abe6b6d8b7da2800d4b9d5f Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Wed, 24 Dec 2025 11:06:43 +0900 Subject: [PATCH 3/3] Add bilingual docs and architecture assets Introduce Japanese README and translated English README Add architecture docs (EN/JA) and diagram assets (d2 + svg) Remove several outdated detailed doc pages --- README.ja.md | 87 +++++++++++ README.md | 160 ++++++-------------- docs/advanced-guides.md | 279 ----------------------------------- docs/api-reference.md | 159 -------------------- docs/architecture.ja.md | 57 +++++++ docs/architecture.md | 57 +++++++ docs/assets/architecture.d2 | 93 ++++++++++++ docs/assets/architecture.svg | 117 +++++++++++++++ docs/philosophy.md | 72 --------- docs/roadmap.ja.md | 29 ++++ docs/roadmap.md | 29 ++++ docs/state-management.md | 127 ---------------- docs/ui-components.md | 119 --------------- 13 files changed, 516 insertions(+), 869 deletions(-) create mode 100644 README.ja.md delete mode 100644 docs/advanced-guides.md delete mode 100644 docs/api-reference.md create mode 100644 docs/architecture.ja.md create mode 100644 docs/architecture.md create mode 100644 docs/assets/architecture.d2 create mode 100644 docs/assets/architecture.svg delete mode 100644 docs/philosophy.md create mode 100644 docs/roadmap.ja.md create mode 100644 docs/roadmap.md delete mode 100644 docs/state-management.md delete mode 100644 docs/ui-components.md diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 0000000..3f93024 --- /dev/null +++ b/README.ja.md @@ -0,0 +1,87 @@ +# btuin + +Bunランタイム向けの宣言的なTUIフレームワーク。 + +## 特徴 + +- **きめ細かなリアクティビティ**: 仮想DOMは使用しません。状態の変更に依存するコンポーネントのみが再描画されます。 +- **Flexboxベースのレイアウト**: Rust製のエンジンがFlexboxのサブセットを実装し、レスポンシブなレイアウトを実現します。 +- **Bunネイティブ**: Bunの高速なTTY、FFI、疑似ターミナルAPIと統合されています。 +- **型安全**: TypeScriptで書かれています。 + +## インストール + +```bash +bun add btuin +``` + +## 使い方 + +```ts +import { createApp, ref, ui } from "btuin"; + +const app = createApp({ + // init: 状態とイベントリスナーをセットアップ + init({ onKey, runtime }) { + const count = ref(0); + + onKey((keyEvent) => { + if (keyEvent.name === "up") count.value++; + if (keyEvent.name === "down") count.value--; + if (keyEvent.name === "q") runtime.exit(0); + }); + + return { count }; + }, + + // render: UIツリーを返す。状態が変化すると再実行される。 + render({ count }) { + return ui + .VStack([ui.Text("Counter"), ui.Text(String(count.value))]) + .width("100%") + .height("100%") + .justify("center") // 子要素を垂直方向に中央揃え + .align("center"); // 子要素を水平方向に中央揃え + }, +}); + +await app.mount(); +``` + +## API概要 + +- `createApp(options)`: アプリケーションインスタンスを作成します。 + - `options.init`: 状態を初期化し、リスナーを登録する関数。 + - `options.render`: UIコンポーネントツリーを返す関数。 +- `ref(value)`: リアクティブな状態変数を作成します。 +- `computed(() => ...)`: 派生リアクティブ値を作成します。 +- `watch(ref, () => ...)`: refが変更されたときに副作用を実行します。 +- `ui`: プリミティブコンポーネントを含むオブジェクト (`Text`, `Block`, `VStack`など)。 + +## リンク + +- [**ドキュメント**](./docs/) (アーキテクチャ, ロードマップ) +- [**GitHub**](https://github.com/HALQME/btuin) (ソースコード, Issue) + +## 言語 + +- [English (英語)](./README.md) + +## コントリビューション + +コントリビューションを歓迎します。 + +> このリポジトリはmiseでツールを管理しています (`mise install`)。 + +### 開発セットアップ + +```bash +# 依存関係をインストール +mise exec -- bun install --frozen-lockfile + +# ネイティブのレイアウトエンジンをビルド +mise run build:ffi + +# テストを実行 +mise run test +``` diff --git a/README.md b/README.md index 77bef93..9f7fbab 100644 --- a/README.md +++ b/README.md @@ -1,153 +1,87 @@ # btuin -**btuin** (pronounced _between_) は、Bun ランタイム向けに設計された、モダンで高速な TUIフレームワークです。 -Vue.js の Composition API を意識したリアクティビティシステムとSwiftUIライクな表現を採用しており、宣言的かつ直感的に CLI アプリケーションを構築できます。 - -## 目的 - -Bunのパフォーマンスを最大限に活かし、快適な開発体験をターミナルアプリケーション開発にもたらすことを目的としています。 - -複雑になりがちなカーソル制御や描画更新ロジックを隠蔽し、開発者が状態とビューの定義に集中できる環境を提供します。 - -## 利点 - -- **Vue ライクなリアクティビティ**: `ref`, `computed`, `watch`, `effect` などを備えた独自のリアクティビティシステムを搭載。状態の変化に応じて画面の差分だけを自動的に効率よく再描画します。 -- **宣言的レイアウト**: `VStack`, `HStack`, `ZStack` などのレイアウトプリミティブを提供。絶対座標を計算することなく、Flexbox のように柔軟な UI を構築できます。 -- **Bun ネイティブ**: Bun の高速な起動と実行速度、Bunのネイティブ API を活用し、軽量かつハイパフォーマンスに動作します。 -- **コンポーネント指向**: `defineComponent` による再利用可能なコンポーネント設計が可能。現状は `Text` / `Block` / `Spacer` などのプリミティブ中心で、実用コンポーネントは順次追加予定です。 -- **入力・実行基盤**: raw mode / 差分描画 / stdout capture など、TUI を成立させる足回りを内蔵しています。 - -## ロードマップ - -- [x] 入力 - - [x] 入力パーサーをステートフル化(チャンク分割耐性): `src/terminal/parser/ansi.ts` - - [x] `ESC` 単体 vs `Alt+Key` の曖昧さを解消 - - [x] ブラケットペーストを「1イベント」に正規化: `src/terminal/parser/ansi.ts` - - [x] ブラケットペーストの on/off をランタイムへ統合 -- [ ] マウス - - [ ] マウス入力(SGR など)をランタイムへ統合(有効化/無効化・イベント形式の確定) - - [ ] ヒットテスト(`ComputedLayout` と座標の照合、重なり順の決定) - - [ ] バブリング/伝播(子→親、キャンセル可能なイベントモデル) -- [ ] Developer Tools - - [ ] シェル統合 - - [x] stdout/stderr capture 基盤(listener/console patch/テストモード): `src/terminal/capture.ts` - - [ ] `useLog`(capture → reactive state)でログUIを簡単にする - - [ ] デバッグ - - [ ] インスペクターモード(境界線/座標/サイズ可視化) -- [x] 配布 - - [x] GitHub Release 用 tarball 生成(`src/layout-engine/native/` 同梱): `.github/workflows/release.yml` - - [x] `npm pack` の成果物を展開し、`src/layout-engine/native/` と `src/layout-engine/index.ts` の解決が噛み合うことを自動チェック -- [ ] Inline モード -- [ ] コンポーネント - - [ ] `TextInput` を実用レベルへ(編集・カーソル移動・IME確定後の反映) - - [ ] `ScrollView` / `ListView`(必要に応じて仮想スクロール、マウスホイール連動) -- [x] 安全性 - - [x] FFI 境界の同期テスト(Rust 定数/構造体 ↔ JS 定義)を CI に追加 -- [ ] ドキュメント / スターター - - [ ] `examples/` の拡充 - -## クイックスタート - -> **前提**: `mise` がインストール済みであること(このリポジトリは `mise.toml` でツールを管理します)。 - -### セットアップ +Declarative TUI framework for the Bun runtime. -```bash -mise install -mise exec -- pnpm install --frozen-lockfile - -# Layout Engine (FFI) をビルド(初回/更新時) -mise run build:ffi -``` +## Features -### テスト - -```bash -mise run test -``` +- **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. +- **Type-Safe**: Written in TypeScript. -### Profiling / Perf Regression +## Installation ```bash -# 大量要素のストレス -mise run profiler:stress -- --n=10000 --frames=120 --io=off --out=profiles/stress.json - -# パフォーマンス上限テスト -mise run profiler:limit +bun add btuin ``` -## 使い方 +## Usage ```ts import { createApp, ref, ui } from "btuin"; const app = createApp({ + // init: setup state and event listeners. init({ onKey, runtime }) { const count = ref(0); - onKey((k) => { - if (k.name === "up") count.value++; - if (k.name === "down") count.value--; - if (k.name === "q") runtime.exit(0); + + onKey((keyEvent) => { + if (keyEvent.name === "up") count.value++; + if (keyEvent.name === "down") count.value--; + if (keyEvent.name === "q") runtime.exit(0); }); + return { count }; }, + + // render: returns the UI tree. Re-runs when state changes. render({ count }) { return ui .VStack([ui.Text("Counter"), ui.Text(String(count.value))]) .width("100%") .height("100%") - .justify("center") - .align("center"); + .justify("center") // Center children vertically + .align("center"); // Center children horizontally }, }); await app.mount(); ``` -## 責務 +## API Overview -- `reactivity`: `ref/computed/effect/watch` による状態管理 -- `layout-engine`: Flexbox ライクなレイアウト(Rust FFI) -- `renderer`: バッファ描画 + 差分レンダリング(`renderDiff` は文字列を返す純粋関数) -- `terminal`: raw mode / 入力 / TTY へのUI描画 -- `btuin`: それらを束ねる “アプリ実行” と View API +- `createApp(options)`: Creates an application instance. + - `options.init`: Function to initialize state and register listeners. + - `options.render`: Function that returns the UI component tree. +- `ref(value)`: Creates a reactive state variable. +- `computed(() => ...)`: Creates a derived reactive value. +- `watch(ref, () => ...)`: Runs a side effect when a ref changes. +- `ui`: Object with primitive components (`Text`, `Block`, `VStack`, etc.). -## パイプ/ヘッドレスでの実行 +## Links -- UI描画は可能ならTTYへ直接出力し、`setExitOutput()` で指定した内容だけを標準出力に流します(`fzf`の挙動に近い)。 -- ヘッドレス環境では `Bun.Terminal`(PTY) でプロセスを起動すると、PTY越しにUIへアクセスできます。 +- [**Documentation**](./docs/) (Architecture, Roadmap) +- [**GitHub**](https://github.com/HALQME/btuin) (Source Code, Issues) -```ts -const terminal = new Bun.Terminal({ - cols: 80, - rows: 24, - data(_term, data) { - // UIの描画(ANSI)が流れてくる - process.stdout.write(data); - }, -}); +## Language -const proc = Bun.spawn(["bun", "run", "examples/counter.ts"], { terminal }); -terminal.write("q"); // キー入力 -await proc.exited; -``` +- [日本語 (Japanese)](./README.ja.md) -## アダプタ(テスト/差し替え用) +## Contributing -通常はそのまま `createApp()` を使えば動きます。必要なら I/O を差し替えできます。 +Contributions are welcome. -```ts -import { createApp } from "btuin"; +> This repository uses `mise` for tool management (`mise install`). -createApp({ - terminal: { - // write/onKey/getTerminalSize... など - }, - platform: { - // resize/exit/signal... など - }, - setup() { - return () => /* view */; - }, -}); +### Development Setup + +```bash +# Install dependencies +mise exec -- bun install --frozen-lockfile + +# Build the native layout-engine +mise run build:ffi + +# Run tests +mise run test ``` diff --git a/docs/advanced-guides.md b/docs/advanced-guides.md deleted file mode 100644 index 0add3be..0000000 --- a/docs/advanced-guides.md +++ /dev/null @@ -1,279 +0,0 @@ -# btuin 高度なガイド - -基本的なAPIに慣れたら、より複雑で再利用可能なコンポーネントや、堅牢なアプリケーションを構築するための高度な機能を活用できます。 - ---- - -## 1. カスタムコンポーネントの作成 - -アプリケーションが複雑になるにつれて、UIの一部を自己完結したコンポーネントとして切り出すことが重要になります。btuinでは、状態、ロジック、描画をカプセル化した再利用可能なコンポーネントを簡単に作成できます。 - -コンポーネントの定義には、主に2つの方法があります。 - -1. **高レベルな `setup` 方式**: `props` と `setup` 関数を使ってコンポーネントを定義する方法。再利用性が高く、ほとんどのケースで推奨されます。 -2. **低レベルな `init`/`render` 方式**: `createApp` に渡すオブジェクトと似た構造で、より直接的にコンポーネントを定義する方法。 - ---- - -### 方法1: 高レベルな `setup` 方式(推奨) - -コンポーネントは `defineComponent` 関数を使って定義します。これは `props`(コンポーネントが受け取るデータ)と `setup`(コンポーネントのロジック)を持つオブジェクトを引数に取ります。 - -- **`props`**: 親コンポーネントから渡されるプロパティを定義します。型、必須要件、デフォルト値などを指定できます。 -- **`setup(props)`**: コンポーネントの主要なロジックを記述します。`props`オブジェクトを引数として受け取り、**描画関数**を返す必要があります。リアクティブな状態の定義やライフサイクルメソッドの登録もここで行います。 - -#### 使用例: `Counter` コンポーネント - -`initialValue` というプロパティを受け取り、内部でカウンターを管理するコンポーネントを作成してみましょう。 - -```typescript -import { defineComponent, ref, computed, Text, HStack, Spacer } from "btuin"; - -export const Counter = defineComponent({ - props: { - initialValue: { type: Number, default: 0 }, - }, - setup(props) { - const count = ref(props.initialValue); - const double = computed(() => count.value * 2); - const increment = () => count.value++; - - return () => - HStack([ - Text(`Count: ${count.value} (Double: ${double.value})`), - Spacer(), - Text("[+]").onKey("enter", increment), - ]); - }, -}); -``` - -#### カスタムコンポーネントの使用 - -定義したコンポーネントは、他のコンポーネントと同様に `render` 関数内で使用できます。プロパティは第2引数にオブジェクトとして渡します。 - -```typescript -// app.ts -import { createApp, VStack } from "btuin"; - -createApp({ - render() { - return VStack([ - Counter({ initialValue: 5 }), // propsを渡して使用 - Counter(), // デフォルト値が使われる - ]); - }, -}); -``` - -### 方法2: 低レベルな `init`/`render` 方式 - -こちらは、`createApp` に渡すアプリケーション定義と非常によく似た構造を持つ、より基本的なコンポーネント定義方法です。 - -- **`init(context)`**: コンポーネントのインスタンスが作成されるときに一度だけ実行されます。ここで状態を初期化し、`render` 関数に渡す `state` オブジェクトを返します。 -- **`render(state)`**: `init` から返された `state` を受け取り、UIを描画します。 - -この方式は `props` を直接受け取るための組み込みの仕組みを持ちません。しかし、コンポーネントを返すファクトリ関数を作成し、クロージャを利用することで `props` を渡すことが可能です。 - -#### 使用例: `Label` コンポーネント - -`label` という文字列をプロパティとして受け取り表示するコンポーネント。 - -```typescript -import { defineComponent, Text, type Component } from "btuin"; - -// Propsを型として定義 -interface LabelProps { - label: string; -} - -// コンポーネントを返すファクトリ関数を作成 -export function Label(props: LabelProps): Component { - return defineComponent({ - // このinitはライフサイクルイベントの登録などに使う - init() { - // 低レベルなコンポーネントなので、状態はinitの外(クロージャ)で管理 - }, - // render関数はpropsにアクセスできる - render() { - return Text(props.label); - }, - }); -} -``` - -#### カスタムコンポーネントの使用 - -ファクトリ関数を呼び出す形で使用します。 - -```typescript -// app.ts -import { createApp, VStack } from "btuin"; - -createApp({ - render() { - return VStack([ - Label({ label: "First Label" }), - Label({ label: "Second Label" }), - ]); - }, -}); -``` - ---- - -### どちらを使うべきか? - -- **`setup` 方式**は、`props` の定義、デフォルト値、バリデーションなど、再利用可能なコンポーネントを作成するための豊富な機能を提供するため、**ほとんどの場面で推奨**されます。 -- **`init`/`render` 方式**は、`createApp` の構造と似ていて理解しやすく、非常にシンプルなコンポーネントや、`props` の仕組みを必要としない場合に手早く使えます。また、クロージャの動作を熟知していれば、より低レベルで柔軟なコンポーネント設計が可能です。 - ---- - -## 2. ライフサイクルイベント - -コンポーネントが画面に表示されたり、画面から消えたりする特定のタイミングで処理を実行したい場合があります。そのためにライフサイクルフックが用意されています。これらは `setup` 関数(またはルートの `init` 関数)内で使用します。 - -- **`onMounted(handler)`**: コンポーネントが最初に描画され、DOMにマウントされた直後に呼び出されます。データの取得やタイマーのセットアップに適しています。 -- **`onUnmounted(handler)`**: コンポーネントがアンマウントされる(画面から削除される)直前に呼び出されます。`onMounted` で登録したイベントリスナーやタイマーをクリーンアップするのに最適です。 - -### 使用例: `Clock` コンポーネント - -1秒ごとに現在時刻を更新する時計コンポーネント。 - -```typescript -// components/Clock.ts -import { btuin, ref, onMounted, onUnmounted, Text } from "btuin"; - -export const Clock = defineComponent({ - setup() { - const time = ref(new Date().toLocaleTimeString()); - let timerId: any; - - // マウント時にタイマーを開始 - onMounted(() => { - timerId = setInterval(() => { - time.value = new Date().toLocaleTimeString(); - }, 1000); - }); - - // アンマウント時にタイマーを停止 - onUnmounted(() => { - clearInterval(timerId); - }); - - return () => Text(time.value); - }, -}); -``` - ---- - -## 3. エラーハンドリング - -アプリケーション全体で発生した予期せぬエラーを捕捉し、適切に処理するために、`createApp` に `onError` ハンドラを渡すことができます。 - -`onError` ハンドラは2つの引数を取ります。 - -- **`error`**: 捕捉された `Error` オブジェクト。 -- **`phase`**: エラーが発生したアプリケーションのフェーズ(例: `"mount"`, `"render"`, `"key"`, `"unmount"`)。 - -これにより、エラーの原因を特定しやすくなります。 - -### 使用例 - -```typescript -import { createApp, ... } from "btuin"; - -createApp({ - init() { - // ... - }, - render() { - if (Math.random() > 0.9) { - // レンダリング中にエラーを発生させてみる - throw new Error("レンダリング中に問題が発生しました!"); - } - return Text("OK"); - }, -}, { // createAppの第2引数にオプションとして渡す - onError(error, phase) { - // ここでエラーを集中的に処理 - console.error(`[${phase}] フェーズでエラーが発生しました:`, error.message); - // ログファイルに書き出すなどの処理も可能 - - // エラー発生時はアプリケーションを終了させるなどの判断もできる - // runtime.exit(1); - }, -}); -``` - ---- - -## 4. パフォーマンス・プロファイリング - -アプリケーションのパフォーマンス・ボトルネックを特定するために、組み込みのプロファイラを使用できます。 - -プロファイラは `createApp` の `profile` オプションを通じて有効化・設定します。 - -- **`enabled: boolean`**: `true`に設定するとプロファイラが有効になります。 -- **`hud: boolean`**: `true`に設定すると、画面の右上にパフォーマンス情報(フレーム時間、メモリ使用量など)がオーバーレイ表示されます。デバッグに非常に便利です。 -- **`outputFile: string`**: 指定したパスに、アプリケーション終了時に詳細なパフォーマンスデータをJSON形式で出力します。このファイルを使って、フレームごとのレンダリング時間などを詳しく分析できます。 - -### 使用例 - -```typescript -import { createApp, ... } from "btuin"; - -createApp( - { /* ... app definition ... */ }, - { - profile: { - enabled: true, - hud: true, - outputFile: "./profile-results.json", - }, - } -); -``` - -この設定でアプリケーションを実行すると、画面右上にHUDが表示され、終了後に`profile-results.json`が生成されます。 - ---- - -## 5. コンポーネントのテスト - -btuinプロジェクトでは、`bun:test` を利用してテストを記述します。これにより、コンポーネントが期待通りに描画され、機能することを確認できます。 - -テストの基本的な構造は `describe` でテストスイートを定義し、`it` で個別のテストケースを記述します。アサーションには `expect` を使用します。 - -### 使用例: `Text` コンポーネントのテスト - -```typescript -// tests/text.test.ts -import { describe, it, expect } from "bun:test"; -import { Text } from "../src/view/primitives/text"; - -describe("Text Primitive", () => { - it("should create a TextElement with the correct content", () => { - // 1. テスト対象のコンポーネントを作成 - const textView = Text("hello world"); - - // 2. 内部的なbuildメソッドなどで描画結果の内部表現を取得 - const element = textView.build(); - - // 3. expectを使って結果を検証 - expect(element.type).toBe("text"); - expect(element.content).toBe("hello world"); - }); -}); -``` - -### テストの実行 - -テストを実行するには、プロジェクトのルートディレクトリで以下のコマンドを実行します。 - -```sh -bun test -``` - -これにより、`tests` ディレクトリ以下の `*.test.ts` や `*.spec.ts` ファイルが自動的に検索・実行されます。 diff --git a/docs/api-reference.md b/docs/api-reference.md deleted file mode 100644 index 5eb4931..0000000 --- a/docs/api-reference.md +++ /dev/null @@ -1,159 +0,0 @@ -# btuin APIリファレンス - -`btuin`でアプリケーションを作成する際、`createApp`の`init`関数を通じて、アプリケーションのライフサイクルやイベントを管理するための様々なAPIにアクセスできます。 - -```typescript -import { createApp } from "btuin"; - -createApp({ - init(context) { - // ここでcontextオブジェクトを通じて各種APIを使用します - // context: { onKey, onExit, setExitOutput, runtime, getSize, onResize, getEnv } - }, - render(state) { - // ... - }, -}); -``` - -以下は`init`のコンテキストオブジェクトから利用可能な主なAPIです。 - ---- - -### `onKey(handler: (key: KeyEvent) => void)` - -キーボード入力を購読します。ユーザーがキーを押すたびに、登録したハンドラ関数が呼び出されます。 - -**`KeyEvent`オブジェクトの主なプロパティ:** - -- `name`: キーの名前(例: 'up', 'down', 'a', 'b') -- `ctrl`: Ctrlキーが同時に押されたか (boolean) -- `meta`: Metaキー(Commandキー on macOS)が同時に押されたか (boolean) -- `shift`: Shiftキーが同時に押されたか (boolean) - -**使用例:** - -```typescript -init({ onKey, runtime }) { - onKey((key) => { - // 'q'キーが押されたらアプリケーションを終了 - if (key.name === 'q') { - runtime.exit(); - } - }); -} -``` - ---- - -### `runtime.exit(code: number = 0)` - -アプリケーションを正常に終了させます。終了コードを指定することもできます。 - -**使用例:** - -```typescript -init({ onKey, runtime }) { - onKey((key) => { - if (key.name === 'c' && key.ctrl) { - // Ctrl+Cで終了コード1で終了 - runtime.exit(1); - } - }); -} -``` - ---- - -### `onExit(handler: () => void)` - -アプリケーションが終了する際に、クリーンアップ処理などの副作用を実行するためのコールバック関数を登録します。 - -**注意:** このハンドラ内での`console.log`などの画面出力は、その後の画面クリア処理によって表示されないことがあります。画面にメッセージを表示したい場合は `setExitOutput` を使用してください。 - -**使用例:** - -```typescript -init({ onExit }) { - onExit(() => { - // 例: データベース接続を閉じる、一時ファイルを削除するなどの処理 - console.log("アプリケーションのクリーンアップ処理を実行しました。"); - }); -} -``` - ---- - -### `setExitOutput(output: string | (() => string))` - -アプリケーションが終了した後に、標準出力へ流す文字列を設定します(パイプ/リダイレクト向け)。`onExit`の処理結果を反映させたい場合など、動的にメッセージを生成したい場合は、関数を渡すことができます。 - -**使用例:** - -```typescript -init({ onKey, runtime, onExit, setExitOutput }) { - let message = "ユーザーによって終了されました。"; - - onExit(() => { - // onExitでの処理に応じてメッセージを変更 - message = "設定を保存して終了しました。"; - }); - - // 終了時に表示するメッセージをセット - setExitOutput(() => message); - - onKey((key) => { - if (key.name === 'q') { - runtime.exit(); - } - }); -} -``` - ---- - -### `getSize(): { rows: number, cols: number }` - -現在のターミナルのサイズ(行数と列数)を取得します。 - -**使用例:** - -```typescript -init({ getSize }) { - const { rows, cols } = getSize(); - console.log(`現在のターミナルサイズ: ${cols}x${rows}`); -} -``` - ---- - -### `onResize(handler: () => void)` - -ターミナルのウィンドウサイズが変更されたときに呼び出されるハンドラを登録します。 - -**使用例:** - -```typescript -init({ onResize, getSize }) { - onResize(() => { - const { cols, rows } = getSize(); - console.log(`リサイズ後のターミナルサイズ: ${cols}x${rows}`); - // ここでUIの再描画などをトリガーできます - }); -} -``` - ---- - -### `getEnv(name: string): string | undefined` - -指定された名前の環境変数の値を取得します。 - -**使用例:** - -```typescript -init({ getEnv }) { - const logLevel = getEnv("LOG_LEVEL") ?? "info"; - console.log(`ログレベル: ${logLevel}`); -} -``` diff --git a/docs/architecture.ja.md b/docs/architecture.ja.md new file mode 100644 index 0000000..12822d7 --- /dev/null +++ b/docs/architecture.ja.md @@ -0,0 +1,57 @@ +# アーキテクチャ + +## コア設計 + +btuinは、状態・レイアウト・レンダリング・I/Oの4つの関心を個別のモジュールに分離しています。UIは宣言的にコンポーネントツリーとして定義され、フレームワークがレイアウト、レンダリング、更新を処理します。 + +## モジュール + +- **`reactivity`**: きめ細かなリアクティビティシステムを提供します。`ref`が変更されると、依存する`computed`や`effect`関数のみが再評価され、仮想DOMを回避します。 + +- **`layout-engine`**: Rustで構築されたレイアウトエンジンであるTaffyをFFI経由で実行され、高性能なレイアウト計算を実現します。 + +- **`renderer`**: ダブルバッファシステムを使用。前回と現在のUI状態を比較し、ターミナル更新のための最小限のANSIエスケープコードを生成することで、ちらつきを削減します。 + +- **`terminal`**: 低レベルのターミナルI/Oを処理します。キー入力などのイベントのためにANSIエスケープシーケンスを解析し、rawモードを管理します。 + +- **`btuin` (ランタイム)**: 他のすべての部分を統合する最上位モジュール。`createApp`を提供し、アプリケーションのライフサイクルを管理し、コンポーネントAPIを公開します。 + +## ヘッドレス実行 + +I/Oが分離されているため、btuinはヘッドレス環境(例: CI)で実行できます。UIはTTYインターフェースに描画され、結果は`runtime.setExitOutput()`で`stdout`に送られます。`Bun.Terminal`はプログラムによるテストに使用できます。 + +```ts +// ヘッドレス実行の例 +import { Bun } from "bun"; + +const terminal = new Bun.Terminal({ + cols: 80, + rows: 24, + data(_term, data) { + // このストリームからのUI出力をアサートする + }, +}); +const proc = Bun.spawn(["bun", "run", "my-app.ts"], { terminal }); +terminal.write("q"); // キー入力をシミュレート +await proc.exited; +``` + +## アダプタ + +アダプタパターンは、プラットフォームとターミナルの詳細を抽象化し、移植性とテスト容易性を向上させます。カスタムアダプタを`createApp`に渡すことで、I/Oをモックできます。 + +```ts +import { createApp, type TerminalAdapter } from "btuin"; + +// モックアダプタの例 +const myMockTerminalAdapter: Partial = { + write: (data) => { + /* 書き込みをモック */ + }, + onKey: (listener) => { + /* リスナーをモック */ + }, +}; + +createApp({ terminal: myMockTerminalAdapter /* ... */ }); +``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..61fecb4 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,57 @@ +# Architecture + +## Core Design + +btuin separates concerns into four distinct modules: state, layout, rendering, and I/O. The UI is defined declaratively as a component tree. The framework then handles layout, rendering, and updates. + +## Modules + +- **`reactivity`**: Provides a fine-grained reactivity system. When a `ref` changes, only dependent `computed` and `effect` functions are re-evaluated, avoiding a virtual DOM. + +- **`layout-engine`**: Taffy, a Rust-based layout engine. It runs via FFI for high-performance layout calculations. + +- **`renderer`**: Uses a double-buffer system. It compares the previous and current UI states to generate a minimal set of ANSI escape codes for terminal updates, which reduces flicker. + +- **`terminal`**: Handles low-level terminal I/O. It parses ANSI escape sequences for events like key presses and manages raw mode. + +- **`btuin` (Runtime)**: The top-level module that integrates all other parts. It provides `createApp`, manages the application lifecycle, and exposes the component API. + +## Headless Execution + +The I/O separation allows btuin to run in headless environments (e.g., CI). The UI renders to a TTY interface, while results can be directed to `stdout` via `runtime.setExitOutput()`. `Bun.Terminal` can be used for programmatic testing. + +```ts +// Example of headless execution +import { Bun } from "bun"; + +const terminal = new Bun.Terminal({ + cols: 80, + rows: 24, + data(_term, data) { + // Assert UI output from this stream + }, +}); +const proc = Bun.spawn(["bun", "run", "my-app.ts"], { terminal }); +terminal.write("q"); // Simulate key press +await proc.exited; +``` + +## Adapters + +The adapter pattern abstracts platform and terminal details, improving portability and testability. Custom adapters can be provided to `createApp` to mock I/O. + +```ts +import { createApp, type TerminalAdapter } from "btuin"; + +// Example of a mock adapter +const myMockTerminalAdapter: Partial = { + write: (data) => { + /* Mock write */ + }, + onKey: (listener) => { + /* Mock listener */ + }, +}; + +createApp({ terminal: myMockTerminalAdapter /* ... */ }); +``` diff --git a/docs/assets/architecture.d2 b/docs/assets/architecture.d2 new file mode 100644 index 0000000..0a61a4b --- /dev/null +++ b/docs/assets/architecture.d2 @@ -0,0 +1,93 @@ +direction: left + +classes: { + step: { + style.fill: "#F8FAFC" + style.stroke: "#334155" + } + file: { + style.fill: "#E8F3FF" + style.stroke: "#3B82F6" + } + io: { + style.fill: "#FFF7ED" + style.stroke: "#F97316" + } +} + +entry: "User\nawait app.mount()" { + class: step +} + +app_ts: "src/runtime/app.ts\nmount()" { + class: file +} + +terminal_setup: "Terminal setup\n(1) patchConsole()\n(2) startCapture()\n(3) setupRawMode()\n(4) setBracketedPaste(true)\n(5) clearScreen() [fullscreen]" { + class: step +} + +mount_component: "mountComponent(root)\n-> init(ctx)\n-> returns state" { + class: step +} + +loop_start: "LoopManager.start()\n- terminal.onKey(...)\n- createRenderer(...)\n- renderOnce(true)\n- render() effect" { + class: step +} + +signals: "Platform hooks\nonExit / SIGINT / SIGTERM\n-> app.exit(...)" { + class: step +} + +steady: "Steady state\n- key events -> handleComponentKey\n- resize -> rerender" { + class: step +} + +reactive: "Reactivity\n(init/setup creates refs)\nrender() reads -> deps tracked\nstate change -> rerender" { + class: step +} + +layout: "Layout\nlayout-engine (Rust FFI)\nView tree -> computed layout\n(x/y/w/h)" { + class: step +} + +diff: "Diff + buffer\nrenderDiff(prev, next)\n-> minimal ANSI" { + class: step +} + +write_path: "UI write path\nterminal.write(str)\n-> src/terminal/io.ts\n-> UI TTY (stdout/stderr/devtty)\n(bypass capture)" { + class: io +} + +exit_path: "Exit path\nLifecycleManager.exit()\n- run onExit handlers\n- unmount()\n- if normal: compute exitOutput\n- UI cleanup to TTY\n- exitOutput -> stdout only\n- platform.exit(code)" { + class: step +} + +stdout: "stdout\n(pipe/redirect)" { + shape: cylinder + class: io +} + +tty: "TTY\n(stdout/stderr/devtty)" { + shape: cylinder + class: io +} + +entry -> app_ts: "call" +app_ts -> terminal_setup: "in order" +app_ts -> mount_component: "mount root" +app_ts -> loop_start: "start loop" +app_ts -> signals: "register handlers" +loop_start -> steady: "runs until exit" + +mount_component -> reactive: "init/setup" +loop_start -> reactive: "effect(render)" +reactive -> layout: "layout(view)" +layout -> diff: "render to buffer" +diff -> write_path: "ANSI" +write_path -> tty: "UI" + +signals -> exit_path: "trigger exit" +steady -> exit_path: "runtime.exit()" +exit_path -> tty: "cleanup sequences" +exit_path -> stdout: "exitOutput only" diff --git a/docs/assets/architecture.svg b/docs/assets/architecture.svg new file mode 100644 index 0000000..4743d68 --- /dev/null +++ b/docs/assets/architecture.svg @@ -0,0 +1,117 @@ +Userawait app.mount()src/runtime/app.tsmount()Terminal setup(1) patchConsole()(2) startCapture()(3) setupRawMode()(4) setBracketedPaste(true)(5) clearScreen() [fullscreen]mountComponent(root)-> init(ctx)-> returns stateLoopManager.start()- terminal.onKey(...)- createRenderer(...)- renderOnce(true)- render() effectPlatform hooksonExit / SIGINT / SIGTERM-> app.exit(...)Steady state- key events -> handleComponentKey- resize -> rerenderReactivity(init/setup creates refs)render() reads -> deps trackedstate change -> rerenderLayoutlayout-engine (Rust FFI)View tree -> computed layout(x/y/w/h)Diff + bufferrenderDiff(prev, next)-> minimal ANSIUI write pathterminal.write(str)-> src/terminal/io.ts-> UI TTY (stdout/stderr/devtty)(bypass capture)Exit pathLifecycleManager.exit()- run onExit handlers- unmount()- if normal: compute exitOutput- UI cleanup to TTY- exitOutput -> stdout only- platform.exit(code)stdout(pipe/redirect)TTY(stdout/stderr/devtty) callin ordermount rootstart loopregister handlersruns until exitinit/setupeffect(render)layout(view)render to bufferANSIUItrigger exitruntime.exit()cleanup sequencesexitOutput only + + + + + + + + + + + + + + + + + + diff --git a/docs/philosophy.md b/docs/philosophy.md deleted file mode 100644 index d42bee1..0000000 --- a/docs/philosophy.md +++ /dev/null @@ -1,72 +0,0 @@ -# 設計思想 - -このドキュメントでは、`btuin`がどのような考えに基づいて設計されているかを解説します。APIの表面的な使い方だけでなく、その背景にある「なぜ」を知ることで、ライブラリをより深く、効果的に活用できるようになるでしょう。 - ---- - -## すべてはコンポーネント - -`btuin`におけるUI構築の最も中心的な思想は、「すべてはコンポーネントである」というものです。画面に表示されるテキスト、レイアウトを構成するコンテナ、そしてアプリケーション全体に至るまで、あらゆるものが「コンポーネント」という統一された概念で扱われます。 - -`createApp`に渡すアプリケーション定義オブジェクトも、コンポーネントツリーの頂点に立つ、最も外側の特別な**「ルートコンポーネント」**と見なすことができます。 - -この一貫性により、開発者はアプリケーションのどの部分を構築しているかに関わらず、常に同じ設計原則と思考モデルを適用できます。 - ---- - -## コア原則: 状態/ロジックと描画の分離 - -すべてのコンポーネントは、その複雑さに関わらず、2つの基本的な責務に基づいています。 - -1. **状態とロジックの管理**: コンポーネントがどのようなデータを持ち、どのように振る舞うかを定義します。 -2. **描画**: 上記の状態に基づいて、UIがどのように見えるべきかを宣言的に記述します。 - -この責務の分離は、`btuin`におけるコンポーネント定義の構造に直接反映されています。 - -``` -コンポーネント = 状態/ロジック (init / setup) + 描画 (render / 描画関数) -``` - -この原則により、各部分の役割が明確になり、コードの見通しが良くなり、テストもしやすくなるというメリットが生まれます。 - ---- - -## 2つのコンポーネント定義方法、その意図 - -`btuin`には、コンポーネントを定義する方法として、意図的に2つの方式が用意されています。 - -### 1. 低レベルな `init`/`render` 方式 - -これは、前述の「状態/ロジックと描画の分離」というコア原則を最も直接的に表現した、基本的なコンポーネント形式です。`createApp`に渡すルートコンポーネントの構造と一貫性があり、ライブラリの基盤となるビルディングブロックと言えます。 - -### 2. 高レベルな `setup` 方式 - -`init/render`方式はシンプルですが、コンポーネントを部品として再利用する際には、親から子へデータを渡す仕組み(props)などが欲しくなります。 - -`setup`方式は、この再利用性を高めるために、Vue.jsのようなモダンなフレームワークのアイデアを取り入れて設計された、より高機能なAPIです。これは単なる`init/render`のラッパー(糖衣構文)と考えることができます。 - -`setup`方式の特筆すべき点は、`setup`関数が**描画関数を返す**という構造です。 - -```typescript -defineComponent({ - setup() { - // 状態とロジック - const count = ref(0); - - // 描画関数(クロージャ) - return () => Text(`Count is ${count.value}`); - }, -}); -``` - -`setup`と`render`を別のプロパティとして定義するのではなく、`setup`内で描画関数を定義して返す形式になっているのは、**クロージャ**の力を最大限に活用するためです。これにより、`setup`スコープで定義された変数や関数を、描画関数内で`state.`のような接頭辞なしに直接利用でき、より少ない「お決まりのコード」で簡潔にコンポーネントを記述できます。 - ---- - -## btuinが目指すもの - -これらの設計思想は、`btuin`が目指す以下の目標に集約されます。 - -- **宣言的なUI**: ターミナルで「カーソルをどう動かすか」といった命令的な操作を考えるのではなく、「UIがどうあるべきか」を宣言的に記述することに集中できます。 -- **コンポーネントベースのアーキテクチャ**: UIを独立して再利用可能な部品に分割し、整理・管理することで、大規模なアプリケーションでも破綻しにくい構造を促進します。 -- **優れた開発者体験**: `setup`方式やリアクティビティシステムに見られるように、直感的で、より少ないコードで快適に開発できる体験を提供することを目指しています。 diff --git a/docs/roadmap.ja.md b/docs/roadmap.ja.md new file mode 100644 index 0000000..5a4158c --- /dev/null +++ b/docs/roadmap.ja.md @@ -0,0 +1,29 @@ +# ロードマップ + +- [x] 入力 + - [x] 入力パーサーをステートフル化(チャンク分割耐性): `src/terminal/parser/ansi.ts` + - [x] `ESC` 単体 vs `Alt+Key` の曖昧さを解消 + - [x] ブラケットペーストを「1イベント」に正規化: `src/terminal/parser/ansi.ts` + - [x] ブラケットペーストの on/off をランタイムへ統合 +- [ ] マウス + - [ ] マウス入力(SGR など)をランタイムへ統合(有効化/無効化・イベント形式の確定) + - [ ] ヒットテスト(`ComputedLayout` と座標の照合、重なり順の決定) + - [ ] バブリング/伝播(子→親、キャンセル可能なイベントモデル) +- [ ] Developer Tools + - [ ] シェル統合 + - [x] stdout/stderr capture 基盤(listener/console patch/テストモード): `src/terminal/capture.ts` + - [ ] `useLog`(capture → reactive state)でログUIを作る + - [ ] デバッグ + - [ ] インスペクターモード(境界線/座標/サイズ可視化) + - [ ] ホットリロード +- [x] 配布 + - [x] GitHub Release 用 tarball 生成(`src/layout-engine/native/` 同梱): `.github/workflows/release.yml` + - [x] `npm pack` の成果物を展開し、`src/layout-engine/native/` と `src/layout-engine/index.ts` の解決が噛み合うことを自動チェック +- [ ] Inline モード +- [ ] コンポーネント + - [ ] `TextInput` を実用レベルへ(編集・カーソル移動・IME確定後の反映) + - [ ] `ScrollView` / `ListView`(必要に応じて仮想スクロール、マウスホイール連動) +- [x] 安全性 + - [x] FFI 境界の同期テスト(Rust 定数/構造体 ↔ JS 定義)を CI に追加 +- [ ] ドキュメント / スターター + - [ ] `examples/` の拡充 diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..fc9b1e0 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,29 @@ +# Roadmap + +- [x] Input + - [x] Make input parser stateful (resistant to chunk splitting): `src/terminal/parser/ansi.ts` + - [x] Resolve ambiguity between standalone `ESC` vs `Alt+Key` + - [x] Normalize bracketed paste into a single event: `src/terminal/parser/ansi.ts` + - [x] Integrate bracketed paste on/off into the runtime +- [ ] Mouse + - [ ] Integrate mouse input (e.g., SGR) into the runtime (enable/disable, finalize event format) + - [ ] Hit testing (matching coordinates with `ComputedLayout`, determining z-order) + - [ ] Event bubbling/propagation (child to parent, cancellable event model) +- [ ] Developer Tools + - [ ] Shell Integration + - [x] stdout/stderr capture infrastructure (listener/console patch/test mode): `src/terminal/capture.ts` + - [ ] Create a log UI with `useLog` (capture -> reactive state) + - [ ] Debug + - [ ] Inspector mode (visualize borders/coordinates/sizes) + - [ ] Hot Reload +- [x] Distribution + - [x] Generate tarball for GitHub Release (including `src/layout-engine/native/`): `.github/workflows/release.yml` + - [x] Automated check to ensure `npm pack` artifacts work with `src/layout-engine/native/` and `src/layout-engine/index.ts` resolution +- [ ] Inline Mode +- [ ] Components + - [ ] Bring `TextInput` to a practical level (editing, cursor movement, IME finalization) + - [ ] `ScrollView` / `ListView` (virtual scrolling as needed, mouse wheel support) +- [x] Safety + - [x] Add FFI boundary sync tests (Rust constants/structs ↔ JS definitions) to CI +- [ ] Documentation / Starter + - [ ] Expand `examples/` diff --git a/docs/state-management.md b/docs/state-management.md deleted file mode 100644 index 2f18019..0000000 --- a/docs/state-management.md +++ /dev/null @@ -1,127 +0,0 @@ -# btuin 状態管理リファレンス - -btuinでは、宣言的にUIを記述するためにリアクティブな状態管理システムが提供されています。`createApp`の`init`関数でアプリケーションの状態を定義し、その状態が変更されるとUIが自動的に更新されます。 - -このシステムはVue.jsのComposition APIに強く影響を受けています。 - -## 基本的なデータフロー - -1. **`init(context)`**: アプリケーションの初期状態をオブジェクトとして返します。ここで`ref`や`computed`を使ってリアクティブな状態を定義します。 -2. **`render(state)`**: `init`から返された状態オブジェクトを引数として受け取り、現在の状態に基づいたUI(ViewElement)を返します。 - -```typescript -import { createApp, Text, ref } from "btuin"; - -createApp({ - init({ onKey }) { - // 1. リアクティブな状態を定義 - const count = ref(0); - - onKey(() => { - // 状態を変更すると... - count.value++; - }); - - // stateオブジェクトとして返す - return { count }; - }, - render({ count }) { - // 2. ...UIが自動的に更新される - return Text(`Count: ${count.value}`); - }, -}); -``` - ---- - -## 基本的な状態の定義: `ref` - -`ref()` は、任意の値を受け取り、その値を`.value`プロパティに持つリアクティブなオブジェクトを返します。これにより、プリミティブな値(数値や文字列など)でもリアクティブに扱うことができます。 - -```typescript -import { ref } from "btuin"; - -// refオブジェクトを作成 -const count = ref(0); - -// 値へのアクセスと変更は .value プロパティを経由する -console.log(count.value); // 0 - -count.value++; - -console.log(count.value); // 1 -``` - ---- - -## 派生状態の作成: `computed` - -`computed()` は、リアクティブな状態から派生した値を計算するために使用します。元の状態が変更されると、`computed`の値も自動的に更新されます。結果はキャッシュされ、依存関係が変更された場合にのみ再計算されるため効率的です。 - -```typescript -import { ref, computed } from "btuin"; - -const count = ref(1); - -// countから派生したcomputedを作成 -const double = computed(() => count.value * 2); - -console.log(double.value); // 2 - -count.value = 5; - -console.log(double.value); // 10 -``` - -`computed`はデフォルトで読み取り専用ですが、`get`と`set`を持つオブジェクトを渡すことで、書き込み可能な`computed`も作成できます。 - ---- - -## 副作用の実行: `watch` と `watchEffect` - -リアクティブな状態の変更を監視し、副作用(コンソール出力、非同期リクエストなど)を実行するために使用します。 - -### `watchEffect` - -`watchEffect` は、渡された関数を即座に実行し、その実行中にアクセスされたすべてのリアクティブな依存関係を自動的に追跡します。いずれかの依存関係が変更されると、関数が再実行されます。 - -**使用例:** - -```typescript -import { ref, watchEffect } from "btuin"; - -const count = ref(0); - -// count.valueが変更されるたびに実行される -watchEffect(() => { - console.log(`現在のカウント: ${count.value}`); -}); -// -> "現在のカウント: 0" が即座に出力される - -count.value++; -// -> "現在のカウント: 1" が出力される -``` - -### `watch` - -`watch` は、特定の一つまたは複数のデータソースを監視します。`watchEffect`とは異なり、以下の特徴があります。 - -- 副作用を即座には実行しません(`immediate: true` オプションで変更可能)。 -- どのデータソースを監視するかを明示的に指定します。 -- 変更後の値と変更前の値の両方にアクセスできます。 - -**使用例:** - -```typescript -import { ref, watch } from "btuin"; - -const count = ref(0); - -// 'count' refを監視 -watch(count, (newValue, oldValue) => { - console.log(`カウントが ${oldValue} から ${newValue} に変わりました。`); -}); - -count.value = 10; -// -> "カウントが 0 から 10 に変わりました。" と出力される -``` diff --git a/docs/ui-components.md b/docs/ui-components.md deleted file mode 100644 index b8d40c6..0000000 --- a/docs/ui-components.md +++ /dev/null @@ -1,119 +0,0 @@ -# btuin UIコンポーネントリファレンス - -btuinのUIは、再利用可能なコンポーネントを組み合わせて構築されます。すべてのコンポーネントは、メソッドチェーンを使ってスタイルを適用できる共通のベースビューを継承しています。 - -## 基本的なスタイリング - -ほとんどのUIコンポーネントでは、以下のメソッドをチェーンしてスタイルを適用できます。 - -- `.width(value: number | string)`: 幅を指定します(例: `10`, `"50%"`)。 -- `.height(value: number | string)`: 高さを指定します。 -- `.padding(value: number)`: 内側の余白を指定します。 -- `.border(style: "single" | "double" | "round" = "single")`: 境界線を設定します。 -- `.color(name: string)`: 前景色(テキストの色など)を設定します。 -- `.background(name: string)`: 背景色を設定します。 - ---- - -## レイアウトコンテナ - -アプリケーションのレイアウト構造を定義するためのコンポーネントです。 - -### `VStack(children: ViewElement[])` - -子要素を垂直方向(上から下)に並べます。`flex-direction: column` を持つ `Block` のショートカットです。 - -```typescript -import { VStack, Text, Block } from "btuin"; - -VStack([ - Text("Line 1"), - Text("Line 2"), - Block().height(1).background("red"), // 1行の高さの赤い線 - Text("Line 3"), -]); -``` - -### `HStack(children: ViewElement[])` - -子要素を水平方向(左から右)に並べます。`flex-direction: row` を持つ `Block` のショートカットです。 - -```typescript -import { HStack, Text } from "btuin"; - -HStack([Text("Left"), Text("Center"), Text("Right")]); -``` - -### `ZStack(children: ViewElement[])` - -子要素をZ軸方向(手前から奥)に重ねて配置します。すべての子要素は同じ開始位置から描画されます。 - -```typescript -import { ZStack, Text } from "btuin"; - -ZStack([Text("背景のテキスト"), Text("前面のテキスト").color("red")]); -``` - ---- - -## 基本的なビルディングブロック - -より複雑なレイアウトを構築するための基本的な要素です。 - -### `Block(...children: ViewElement[])` - -最も基本的なレイアウトコンポーネントです。デフォルトでは子要素を垂直に並べますが、`.direction()` メソッドでレイアウトの方向を変更できます。`VStack` や `HStack` は `Block` を使って作られています。 - -**主なメソッド:** - -- `.direction(dir: "row" | "column")`: 子要素の配置方向を `水平` または `垂直` に設定します。 -- `.justify(value: "flex-start" | "center" | "space-between" | "flex-end")`: 主軸(`direction`で指定した方向)に沿った子要素の配置方法を定義します。 -- `.align(value: "flex-start" | "center" | "flex-end" | "stretch")`: 交差軸に沿った子要素の配置方法を定義します。 - -**使用例:** - -```typescript -import { Block, Text } from "btuin"; - -// 要素を中央に配置するコンテナ -Block(Text("中央に表示")) - .width("100%") - .height("100%") - .justify("center") - .align("center"); -``` - ---- - -## プリミティブ要素 - -UIを構成する最も基本的な要素です。 - -### `Text(content: string)` - -文字列を表示するためのコンポーネントです。 - -**使用例:** - -```typescript -import { Text } from "btuin"; - -Text("こんにちは、世界!"); -``` - -### `Spacer(grow: number = 1)` - -レイアウト内で利用可能な余白を埋めるための柔軟な空きスペースを作成します。`HStack` や `VStack` 内で要素間のスペースを空けたり、特定の位置に要素を配置したりするのに便利です。 - -**使用例:** - -```typescript -import { HStack, Text, Spacer } from "btuin"; - -// 左寄せと右寄せのテキストを作成 -HStack([ - Text("左側"), - Spacer(), // 中央の空きスペースをすべて埋める - Text("右側"), -]).width("100%"); -```