diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index afa9d76..e3bded8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,25 +12,19 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + - name: Setup mise + uses: jdx/mise-action@v2 with: - toolchain: stable + cache: true - - uses: jetli/wasm-pack-action@v0.4.0 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false + - name: Install toolchains + run: mise install - name: Install dependencies - run: pnpm install --frozen-lockfile + run: mise exec -- pnpm install --frozen-lockfile + + - name: Type Check + run: mise run check - - name: Build - run: pnpm run build + - name: Build Layout Engine (FFI) + run: mise run build:ffi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48f0146..63e5e1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,50 +14,28 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + - name: Setup mise + uses: jdx/mise-action@v2 with: - toolchain: stable + cache: true - - uses: jetli/wasm-pack-action@v0.4.0 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - uses: actions/cache@v3 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- + - name: Install toolchains + run: mise install - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build Layout Engine (WASM) - run: pnpm run build + run: mise exec -- pnpm install --frozen-lockfile - name: Check Formatting - run: pnpm run format --check + run: mise run format -- --check . - name: Lint - run: pnpm run lint + run: mise run lint -- . - name: Type Check - run: pnpm run check + run: mise run check + + - name: Build Layout Engine (FFI) + run: mise run build:ffi - name: Run Tests - run: pnpm run test + run: mise run test diff --git a/.github/workflows/profiler.yml b/.github/workflows/profiler.yml index a645e8c..b58d83b 100644 --- a/.github/workflows/profiler.yml +++ b/.github/workflows/profiler.yml @@ -19,45 +19,23 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + - name: Setup mise + uses: jdx/mise-action@v2 with: - toolchain: stable + cache: true - - uses: jetli/wasm-pack-action@v0.4.0 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - uses: actions/cache@v3 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- + - name: Install toolchains + run: mise install - name: Install dependencies - run: pnpm install --frozen-lockfile + run: mise exec -- pnpm install --frozen-lockfile - - name: Build Layout Engine (WASM) - run: pnpm run build + - name: Build Layout Engine (FFI) + run: mise run build:ffi - name: Run Limit Test run: | - pnpm run profiler:limit | tee benchmark_result.txt + mise run profiler:limit | tee benchmark_result.txt - name: Extract Report id: extract_report diff --git a/README.md b/README.md index 3fe67be..30185f6 100644 --- a/README.md +++ b/README.md @@ -32,78 +32,67 @@ Bunのパフォーマンスを最大限に活かし、快適な開発体験を ## クイックスタート -> **前提**: Bun(`bun` コマンド)がインストール済みであること。 +> **前提**: `mise` がインストール済みであること(このリポジトリは `mise.toml` でツールを管理します)。 ### セットアップ ```bash -pnpm install -``` - -### テスト +mise install +mise exec -- pnpm install --frozen-lockfile -```bash -pnpm test +# Layout Engine (FFI) をビルド(初回/更新時) +mise run build:ffi ``` -### すぐ動くサンプル(showcase) +### テスト ```bash -# 矢印キーでカウント / q で終了 -bun packages/showcase/counter.ts - -# ダッシュボード(↑/↓でページ移動 / space で一時停止 / q で終了) -bun packages/showcase/dashboard.ts +mise run test ``` ### Profiling / Perf Regression ```bash -# 大量要素のストレス(JSON出力、--io=off で stdout を捨てて純粋な計算寄りに) -bun run profile:stress --n=10000 --frames=120 --io=off --out=profiles/stress.json - -# パフォーマンス予算テスト(将来的にCIで回帰検知に使う想定) -# 例: まずは計測してから budget を詰めるのがおすすめ -bun run perf:budget --task=frame --n=10000 --iterations=30 --out=profiles/budget.json -bun run perf:budget --task=diff --rows=200 --cols=400 --iterations=20 --out=profiles/budget-diff.json +# 大量要素のストレス +mise run profiler:stress -- --n=10000 --frames=120 --io=off --out=profiles/stress.json -# bun test に載せる場合(CI or BTUIN_PERF=1 のときのみ実行) -CI=1 bun run test:perf -# 予算/サイズは env で上書き可能(例: BTUIN_BUDGET_FRAME_P95=120 など) +# パフォーマンス上限テスト +mise run profiler:limit ``` -## 使い方(最小例) +## 使い方 ```ts -import { createApp, VStack, Text, ref, onKey } from "btuin"; +import { createApp, VStack, Text, ref } from "btuin"; const app = createApp({ - setup() { + 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") process.exit(0); + if (k.name === "q") runtime.exit(0); }); - - return () => - VStack([Text("Counter"), Text(String(count.value))]) - .width("100%") - .height("100%") - .justify("center") - .align("center"); + return { count }; + }, + render({ count }) { + return VStack([Text("Counter"), Text(String(count.value))]) + .width("100%") + .height("100%") + .justify("center") + .align("center"); }, }); await app.mount(); ``` -## 設計メモ(ざっくり) +## 責務 -- `@btuin/reactivity`: `ref/computed/effect/watch` による状態管理 -- `@btuin/layout-engine`: Flexbox ライクなレイアウト(WASM) -- `@btuin/renderer`: バッファ描画 + 差分レンダリング(`renderDiff` は文字列を返す純粋関数) -- `@btuin/terminal`: raw mode / 入力 / stdout 書き込み +- `reactivity`: `ref/computed/effect/watch` による状態管理 +- `layout-engine`: Flexbox ライクなレイアウト(Rust FFI) +- `renderer`: バッファ描画 + 差分レンダリング(`renderDiff` は文字列を返す純粋関数) +- `terminal`: raw mode / 入力 / stdout 書き込み - `btuin`: それらを束ねる “アプリ実行” と View API ## アダプタ(テスト/差し替え用) diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..bfff7b2 --- /dev/null +++ b/bun.lock @@ -0,0 +1,64 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "btuin-monorepo", + "devDependencies": { + "@types/bun": "latest", + "oxfmt": "^0.17.0", + "oxlint": "^1.32.0", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@oxfmt/darwin-arm64": ["@oxfmt/darwin-arm64@0.17.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OMv0tOb+xiwSZKjYbM6TwMSP5QwFJlBGQmEsk98QJ30sHhdyC//0UvGKuR0KZuzZW4E0+k0rHDmos1Z5DmBEkA=="], + + "@oxfmt/darwin-x64": ["@oxfmt/darwin-x64@0.17.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-trzidyzryKIdL/cLCYU9IwprgJegVBUrz1rqzOMe5is+qdgH/RxTCvhYUNFzxRHpil3g4QUYd2Ja831tc5Nehg=="], + + "@oxfmt/linux-arm64-gnu": ["@oxfmt/linux-arm64-gnu@0.17.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-KlwzidgvHznbUaaglZT1goTS30osTV553pfbKve9B1PyTDkluNDfm/polOaf3SVLN7wL/NNLFZRMupvJ1eJXAw=="], + + "@oxfmt/linux-arm64-musl": ["@oxfmt/linux-arm64-musl@0.17.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tbYJTocF4BNLaQQbc/xrBWTNgiU6zmYeF4NvRDxuuQjDOnmUZPn0EED3PZBRJyg4/YllhplHDo8x+gfcb9G3A=="], + + "@oxfmt/linux-x64-gnu": ["@oxfmt/linux-x64-gnu@0.17.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pEmv7zJIw2HpnA4Tn1xrfJNGi2wOH2+usT14Pkvf/c5DdB+pOir6k/5jzfe70+V3nEtmtV9Lm+spndN/y6+X7A=="], + + "@oxfmt/linux-x64-musl": ["@oxfmt/linux-x64-musl@0.17.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+DrFSCZWyFdtEAWR5xIBTV8GX0RA9iB+y7ZlJPRAXrNG8TdBY9vc7/MIGolIgrkMPK4mGMn07YG/qEyPY+iKaw=="], + + "@oxfmt/win32-arm64": ["@oxfmt/win32-arm64@0.17.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FoUZRR7mVpTYIaY/qz2BYwzqMnL+HsUxmMWAIy6nl29UEkDgxNygULJ4rIGY4/Axne41fhtldLrSGBOpwNm3jA=="], + + "@oxfmt/win32-x64": ["@oxfmt/win32-x64@0.17.0", "", { "os": "win32", "cpu": "x64" }, "sha512-fBIcUpHmCwf3leWlo0cYwLb9Pd2mzxQlZYJX9dD9nylPvsxOnsy9fmsaflpj34O0JbQJN3Y0SRkoaCcHHlxFww=="], + + "@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yrqPmZYu5Qb+49h0P5EXVIq8VxYkDDM6ZQrWzlh16+UGFcD8HOXs4oF3g9RyfaoAbShLCXooSQsM/Ifwx8E/eQ=="], + + "@oxlint/darwin-x64": ["@oxlint/darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-pQRZrJG/2nAKc3IuocFbaFFbTDlQsjz2WfivRsMn0hw65EEsSuM84WMFMiAfLpTGyTICeUtHZLHlrM5lzVr36A=="], + + "@oxlint/linux-arm64-gnu": ["@oxlint/linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tyomSmU2DzwcTmbaWFmStHgVfRmJDDvqcIvcw4fRB1YlL2Qg/XaM4NJ0m2bdTap38gxD5FSxSgCo0DkQ8GTolg=="], + + "@oxlint/linux-arm64-musl": ["@oxlint/linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0W46dRMaf71OGE4+Rd+GHfS1uF/UODl5Mef6871pMhN7opPGfTI2fKJxh9VzRhXeSYXW/Z1EuCq9yCfmIJq+5Q=="], + + "@oxlint/linux-x64-gnu": ["@oxlint/linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5+6myVCBOMvM62rDB9T3CARXUvIwhGqte6E+HoKRwYaqsxGUZ4bh3pItSgSFwHjLGPrvADS11qJUkk39eQQBzQ=="], + + "@oxlint/linux-x64-musl": ["@oxlint/linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-qwQlwYYgVIC6ScjpUwiKKNyVdUlJckrfwPVpIjC9mvglIQeIjKuuyaDxUZWIOc/rEzeCV/tW6tcbehLkfEzqsw=="], + + "@oxlint/win32-arm64": ["@oxlint/win32-arm64@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-7qYZF9CiXGtdv8Z/fBkgB5idD2Zokht67I5DKWH0fZS/2R232sDqW2JpWVkXltk0+9yFvmvJ0ouJgQRl9M3S2g=="], + + "@oxlint/win32-x64": ["@oxlint/win32-x64@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-XW1xqCj34MEGJlHteqasTZ/LmBrwYIgluhNW0aP+XWkn90+stKAq3W/40dvJKbMK9F7o09LPCuMVtUW7FIUuiA=="], + + "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], + + "@types/node": ["@types/node@25.0.2", "", { "dependencies": { "undici-types": "7.16.0" } }, "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA=="], + + "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "25.0.2" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], + + "oxfmt": ["oxfmt@0.17.0", "", { "optionalDependencies": { "@oxfmt/darwin-arm64": "0.17.0", "@oxfmt/darwin-x64": "0.17.0", "@oxfmt/linux-arm64-gnu": "0.17.0", "@oxfmt/linux-arm64-musl": "0.17.0", "@oxfmt/linux-x64-gnu": "0.17.0", "@oxfmt/linux-x64-musl": "0.17.0", "@oxfmt/win32-arm64": "0.17.0", "@oxfmt/win32-x64": "0.17.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-12Rmq2ub61rUZ3Pqnsvmo99rRQ6hQJwQsjnFnbvXYLMrlIsWT6SFVsrjAkBBrkXXSHv8ePIpKQ0nZph5KDrOqw=="], + + "oxlint": ["oxlint@1.32.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.32.0", "@oxlint/darwin-x64": "1.32.0", "@oxlint/linux-arm64-gnu": "1.32.0", "@oxlint/linux-arm64-musl": "1.32.0", "@oxlint/linux-x64-gnu": "1.32.0", "@oxlint/linux-x64-musl": "1.32.0", "@oxlint/win32-arm64": "1.32.0", "@oxlint/win32-x64": "1.32.0" }, "bin": { "oxlint": "bin/oxlint", "oxc_language_server": "bin/oxc_language_server" } }, "sha512-HYDQCga7flsdyLMUIxTgSnEx5KBxpP9VINB8NgO+UjV80xBiTQXyVsvjtneMT3ZBLMbL0SlG/Dm03XQAsEshMA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..5ad9fd8 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +root = "tests" diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..d312191 --- /dev/null +++ b/mise.toml @@ -0,0 +1,18 @@ +[tools] +bun="1.3.5" +pnpm="10.26.0" +rust="stable" + +[tasks] +"start"="bun run" +"build:ffi" = "cd src/layout-engine && cargo build --release" +"lint"="pnpm exec oxlint" +"lint:fix"="pnpm exec oxlint --fix" +"format"="pnpm exec oxfmt" +"check"="bunx tsc --noEmit" +"test"="bun test" +"test:watch"="bun test --watch" +"profiler"="bun test ./scripts/profiler*.spec.ts" +"profiler:stress"="bun test ./scripts/profiler-stress.spec.ts" +"profiler:layout"="bun test ./scripts/profiler-layout.spec.ts" +"profiler:limit"="bun test ./scripts/profiler-limit.spec.ts" diff --git a/package.json b/package.json index 4372ceb..e7e2fd6 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,11 @@ { "name": "btuin-monorepo", + "version": "0.1.0", "private": true, "type": "module", "workspaces": [ "packages/*" ], - "scripts": { - "start": "bun run", - "build": "pnpm --filter @btuin/layout-engine build", - "lint": "oxlint packages/*", - "lint:fix": "oxlint packages/* --fix", - "format": "oxfmt packages/*", - "test": "bun test packages/btuin packages/layout-engine packages/reactivity packages/renderer packages/terminal packages/showcase", - "test:watch": "pnpm test --watch", - "test:all": "bun test packages", - "profiler": "bun test scripts/profiler*.spec.ts", - "profiler:stress": "bun test scripts/profiler-stress.test.ts", - "profiler:layout": "bun test scripts/profiler-layout.test.ts", - "profiler:limit": "bun test scripts/profiler-limit.spec.ts", - "check": "bunx tsc --noEmit" - }, "devDependencies": { "@types/bun": "latest", "oxfmt": "^0.17.0", @@ -29,7 +15,7 @@ "typescript": "^5" }, "engines": { - "bun": "latest" + "bun": "^1.3.4" }, "packageManager": "pnpm@10.25.0" } diff --git a/packages/btuin/package.json b/packages/btuin/package.json deleted file mode 100644 index 79e2111..0000000 --- a/packages/btuin/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "btuin", - "version": "0.1.0", - "description": "Fast and modern TUI framework for Bun runtime", - "type": "module", - "module": "./src/index.ts", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": "./src/index.ts", - "./types": "./src/types/index.ts" - }, - "files": [ - "src", - "README.md", - "LICENSE" - ], - "keywords": [ - "tui", - "terminal", - "cli", - "bun", - "ui", - "framework", - "terminal-ui", - "text-based-userinterface", - "console" - ], - "engines": { - "bun": ">=1.3.4" - }, - "devDependencies": { - "@types/bun": "latest" - }, - "dependencies": { - "@btuin/layout-engine": "workspace:*", - "@btuin/reactivity": "workspace:*", - "@btuin/renderer": "workspace:*", - "@btuin/terminal": "workspace:*" - }, - "peerDependencies": { - "typescript": "^5" - } -} diff --git a/packages/btuin/src/runtime/app.ts b/packages/btuin/src/runtime/app.ts deleted file mode 100644 index f377046..0000000 --- a/packages/btuin/src/runtime/app.ts +++ /dev/null @@ -1,343 +0,0 @@ -import type { KeyEvent } from "@btuin/terminal"; -import { Block } from "../view/primitives"; -import { initLayoutEngine } from "../layout"; -import { - defineComponent, - mountComponent, - unmountComponent, - renderComponent, - handleComponentKey, - type Component, - type MountedComponent, -} from "../view/components"; -import { effect, stop, type ReactiveEffect } from "@btuin/reactivity"; -import { createRenderer } from "./render-loop"; -import { createErrorHandler, createErrorContext } from "./error-boundary"; -import { createDefaultTerminalAdapter, type TerminalAdapter } from "./terminal-adapter"; -import { createDefaultPlatformAdapter, type PlatformAdapter } from "./platform-adapter"; -import { Profiler, type ProfileOptions } from "./profiler"; - -export interface AppConfig { - /** - * Setup function where component logic is defined. - * Returns a render function that produces the view. - * - * @example - * ```typescript - * setup() { - * const count = ref(0); - * - * onKey((key) => { - * if (key.name === 'up') count.value++; - * }); - * - * return () => Paragraph({ text: `Count: ${count.value}` }); - * } - * ``` - */ - setup: Component["options"]["setup"]; - - /** - * Optional error handler for the app. - * Called when errors occur during rendering or lifecycle. - */ - onError?: (error: Error, phase: string) => void; - - /** - * Optional file path for logging errors. - */ - errorLog?: string; - - /** - * Optional handler called when the app is about to exit. - */ - onExit?: () => void; - - /** - * Optional terminal adapter (for tests/custom IO). - */ - terminal?: TerminalAdapter; - - /** - * Optional platform adapter (process hooks/exit). - */ - platform?: PlatformAdapter; - - /** - * Optional profiler configuration. - */ - profile?: ProfileOptions; -} - -export interface AppInstance { - /** - * Mounts the app to the terminal. - * - * @param options - Mount options - * @returns Promise for chaining - */ - mount(options?: MountOptions): Promise; - - /** - * Unmounts the app and cleans up resources. - */ - unmount(): void; - - /** - * Gets the root component instance. - */ - getComponent(): MountedComponent | null; -} - -export interface MountOptions { - /** - * Number of rows in the terminal display. - * Set to 0 for auto-sizing based on terminal. - * @default 0 - */ - rows?: number; - - /** - * Number of columns in the terminal display. - * Set to 0 for auto-sizing based on terminal. - * @default 0 - */ - cols?: number; -} - -/** - * Creates a btuin TUI application. - * - * @example - * ```typescript - * import { createApp, ref, onKey, Paragraph } from 'btuin'; - * - * const app = createApp({ - * setup() { - * const count = ref(0); - * - * onKey((key) => { - * if (key.name === 'up') count.value++; - * if (key.name === 'q') process.exit(0); - * }); - * - * return () => Paragraph({ - * text: `Count: ${count.value}`, - * align: 'center' - * }); - * } - * }); - * - * app.mount(); - * ``` - * - * @param config - Application configuration - * @returns App instance - */ -export function createApp(config: AppConfig): AppInstance { - let mounted: MountedComponent | null = null; - let renderEffect: ReactiveEffect | null = null; - let isMounted = false; - let isUnmounting = false; - const term = config.terminal ?? createDefaultTerminalAdapter(); - const platform = config.platform ?? createDefaultPlatformAdapter(); - const profiler = new Profiler(config.profile ?? {}); - - // Convert config to component definition - const rootComponent = defineComponent({ - name: "App", - setup: config.setup, - }); - - const appInstance: AppInstance = { - async mount(options: MountOptions = {}) { - // asyncに変更 - if (isMounted) { - console.warn("App is already mounted"); - return appInstance; - } - - // Wasmレイアウトエンジンの初期化待機 - await initLayoutEngine(); - - const rows = options.rows ?? 0; - const cols = options.cols ?? 0; - - // Patch console to prevent output interference - term.patchConsole(); - term.startCapture(); - - // Setup terminal - term.setupRawMode(); - term.clearScreen(); - - // Terminal size resolver - const getSize = () => { - const termSize = term.getTerminalSize(); - return { - rows: rows === 0 ? termSize.rows : rows, - cols: cols === 0 ? termSize.cols : cols, - }; - }; - - // Error handler - const handleError = createErrorHandler( - config.onError - ? (context) => { - config.onError!(context.error, context.phase); - } - : undefined, - config.errorLog, - ); - - // Buffer key events that may arrive before the app finishes mounting. - const pendingKeyEvents: KeyEvent[] = []; - - // Setup keyboard event handler early to avoid losing initial input. - term.onKey((event: KeyEvent) => { - if (!mounted) { - pendingKeyEvents.push(event); - return; - } - - try { - handleComponentKey(mounted, event); - } catch (error) { - handleError(createErrorContext("key", error, { keyEvent: event })); - } - }); - - try { - // Mount root component - mounted = mountComponent(rootComponent, {}); - - // Create renderer once - const renderer = createRenderer({ - getSize, - write: term.write, - view: () => { - if (!mounted) return Block(); - return renderComponent(mounted); - }, - getState: () => ({}), - handleError, - profiler: profiler.isEnabled() ? profiler : undefined, - }); - - // Create reactive render effect - renderEffect = effect(() => { - if (!mounted) return; - - try { - renderer.render(); - } catch (error) { - handleError(createErrorContext("render", error)); - } - }); - - // Ensure at least one frame is rendered during mount, even if no reactive - // dependencies change afterward. - renderer.render(true); - - // Flush any key events received during mount. - if (pendingKeyEvents.length && mounted) { - for (const event of pendingKeyEvents.splice(0)) { - try { - handleComponentKey(mounted, event); - } catch (error) { - handleError(createErrorContext("key", error, { keyEvent: event })); - } - } - // Ensure a render after applying buffered events. - renderEffect.run(); - } - - // Setup resize handler if auto-sizing - if (rows === 0 || cols === 0) { - platform.onStdoutResize(() => { - try { - term.clearScreen(); - if (renderEffect) { - renderEffect.run(); - } - } catch (error) { - handleError(createErrorContext("resize", error)); - } - }); - } - - isMounted = true; - - // Setup exit handler - const exitHandler = () => { - if (isUnmounting) return; - appInstance.unmount(); - }; - - platform.onExit(exitHandler); - platform.onSigint(() => { - exitHandler(); - profiler.flushSync(); - platform.exit(0); - }); - platform.onSigterm(() => { - exitHandler(); - profiler.flushSync(); - platform.exit(0); - }); - } catch (error) { - handleError(createErrorContext("mount", error)); - } - - return appInstance; - }, - - unmount() { - if (!isMounted || isUnmounting) { - return; - } - - isUnmounting = true; - - try { - // Call onExit handler - if (config.onExit) { - config.onExit(); - } - - // Stop render effect - if (renderEffect) { - stop(renderEffect); - renderEffect = null; - } - - // Unmount component - if (mounted) { - unmountComponent(mounted); - mounted = null; - } - - // Dispose console capture - term.disposeSingletonCapture(); - - // Persist profile results (if enabled) - profiler.flushSync(); - - // Clean up terminal - term.cleanupWithoutClear(); - - isMounted = false; - } catch (error) { - console.error("Error during unmount:", error); - } finally { - isUnmounting = false; - } - }, - - getComponent() { - return mounted; - }, - }; - - return appInstance; -} diff --git a/packages/btuin/src/view/components/component.ts b/packages/btuin/src/view/components/component.ts deleted file mode 100644 index 2243fbe..0000000 --- a/packages/btuin/src/view/components/component.ts +++ /dev/null @@ -1,312 +0,0 @@ -/** - * Component Definition and Setup System - * - * Provides Vue-like component definition with setup() function, - * props, emits, and render function. - */ -import type { ViewElement } from "../types/elements"; -import { isBlock } from "../types/elements"; -import type { KeyEvent } from "@btuin/terminal"; -import { - createComponentInstance, - setCurrentInstance, - invokeHooks, - invokeKeyHooks, - startTickTimers, - unmountInstance, - type ComponentInstance, -} from "./lifecycle"; - -export interface SetupContext { - emit: (event: string, ...args: any[]) => void; - expose: (exposed: Record) => void; -} - -export type RenderFunction = () => ViewElement; - -export interface ComponentOptions { - name?: string; - props?: Record; - emits?: string[]; - setup: (props: any, context: SetupContext) => RenderFunction | void; -} - -export interface PropOptions { - type?: any; - default?: any; - required?: boolean; - validator?: (value: any) => boolean; -} - -export interface Component { - options: ComponentOptions; - instances: WeakMap; -} - -export interface MountedComponent { - instance: ComponentInstance; - props: any; - render: RenderFunction; - emitCallbacks: Map void>>; - exposed: Record; - renderEffect: any; - lastElement: ViewElement | null; -} - -/** - * Defines a new component with Vue-like API. - * - * @example - * ```typescript - * const Counter = defineComponent({ - * name: 'Counter', - * props: { - * initial: { type: Number, default: 0 } - * }, - * setup(props, { emit }) { - * const count = ref(props.initial); - * - * onKey((key) => { - * if (key.name === 'up') { - * count.value++; - * emit('change', count.value); - * } - * }); - * - * return () => Paragraph({ - * text: `Count: ${count.value}` - * }); - * } - * }); - * ``` - * - * @param options - Component options - * @returns Component definition - */ -export function defineComponent(options: ComponentOptions): Component { - return { - options, - instances: new WeakMap(), - }; -} - -/** - * Mounts a component and creates its instance. - * - * @internal - * @param component - Component to mount - * @param props - Props to pass to component - * @param key - Unique key for this mount (optional) - * @returns Mounted component - */ -export function mountComponent(component: Component, props: any = {}, key?: any): MountedComponent { - // Check if already mounted with this key - const mountKey = key ?? Symbol(); - let mounted = component.instances.get(mountKey); - - if (mounted) { - // Update props if changed - if (props !== mounted.props) { - mounted.props = props; - } - return mounted; - } - - // Create component instance - const instance = createComponentInstance(); - - // Validate and normalize props - const normalizedProps = normalizeProps(component.options.props || {}, props); - - // Setup emit callbacks - const emitCallbacks = new Map void>>(); - - const context: SetupContext = { - emit: (event: string, ...args: any[]) => { - const callbacks = emitCallbacks.get(event); - if (callbacks) { - for (const callback of callbacks) { - callback(...args); - } - } - }, - expose: (exposed: Record) => { - mounted!.exposed = exposed; - }, - }; - - // Run setup with current instance context - setCurrentInstance(instance); - let render: RenderFunction; - - try { - const setupResult = component.options.setup(normalizedProps, context); - - if (typeof setupResult === "function") { - render = setupResult; - } else { - throw new Error(`Component setup() must return a render function`); - } - } finally { - setCurrentInstance(null); - } - - // Create mounted component - mounted = { - instance, - props: normalizedProps, - render, - emitCallbacks, - exposed: {}, - renderEffect: null, - lastElement: null, - }; - - // Store in instances map - component.instances.set(mountKey, mounted); - - // Mark as mounted and run mounted hooks - instance.isMounted = true; - invokeHooks(instance.mountedHooks); - - // Start tick timers - startTickTimers(instance); - - return mounted; -} - -/** - * Unmounts a component and cleans up its instance. - * - * @internal - * @param mounted - Mounted component to unmount - */ -export function unmountComponent(mounted: MountedComponent) { - unmountInstance(mounted.instance); - - // Stop render effect - if (mounted.renderEffect && mounted.renderEffect.effect) { - mounted.renderEffect.effect.stop(); - } -} - -/** - * Renders a component and returns the view element. - * - * @internal - * @param mounted - Mounted component - * @returns View element - */ -export function renderComponent(mounted: MountedComponent): ViewElement { - const { instance, render } = mounted; - - // Run before update hooks - if (instance.isMounted) { - invokeHooks(instance.beforeUpdateHooks); - } - - // Render the component - let element: ViewElement = render(); - - mounted.lastElement = element; - - // Run updated hooks - if (instance.isMounted) { - invokeHooks(instance.updatedHooks); - } - - return element; -} -function traverseKeyHandlers( - element: ViewElement, - visitor: (element: ViewElement) => boolean, -): boolean { - if (isBlock(element)) { - for (let i = element.children.length - 1; i >= 0; i--) { - const child = element.children[i]!; - if (traverseKeyHandlers(child, visitor)) { - return true; - } - } - } - - if (element.keyHooks.length > 0 && visitor(element)) { - return true; - } - - return false; -} -/** - * Handles key events for a component. - * Returns true if the event was handled and should stop propagation. - * - * @internal - * @param mounted - Mounted component - * @param event - Key event - * @returns True if event was handled - */ -export function handleComponentKey(mounted: MountedComponent, event: KeyEvent): boolean { - if (mounted.lastElement) { - const handled = traverseKeyHandlers(mounted.lastElement, (element) => - invokeKeyHooks(element.keyHooks, event), - ); - if (handled) { - return true; - } - } - - return invokeKeyHooks(mounted.instance.keyHooks, event); -} - -/** - * Normalizes and validates props. - * - * @internal - */ -function normalizeProps(propOptions: Record, rawProps: any): any { - const normalized: any = {}; - - for (const key in propOptions) { - const option = propOptions[key]; - if (!option) continue; - - let value = rawProps[key]; - - // Use default value if not provided - if (value === undefined) { - if (option.default !== undefined) { - value = typeof option.default === "function" ? option.default() : option.default; - } - } - - // Check required - if (option.required && value === undefined) { - console.warn(`Prop "${key}" is required but not provided`); - } - - // Validate - if (option.validator && value !== undefined) { - if (!option.validator(value)) { - console.warn(`Prop "${key}" failed validation`); - } - } - - normalized[key] = value; - } - - // Copy other props that aren't in options - for (const key in rawProps) { - if (!(key in normalized)) { - normalized[key] = rawProps[key]; - } - } - - return normalized; -} - -/** - * Helper to check if a value is a component - */ -export function isComponent(value: any): value is Component { - return value && typeof value === "object" && "options" in value && "instances" in value; -} diff --git a/packages/btuin/tests/runtime/app.test.ts b/packages/btuin/tests/runtime/app.test.ts deleted file mode 100644 index a9f33d9..0000000 --- a/packages/btuin/tests/runtime/app.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, it, expect, afterEach, mock, afterAll } from "bun:test"; -import { createApp, type AppInstance } from "../../src/runtime/app"; -import { ref } from "@btuin/reactivity"; -import * as terminal from "@btuin/terminal"; -import { Block, Text } from "../../src/view/primitives"; - -// Mock dependencies -const keyHandlers: any[] = []; - -mock.module("@btuin/terminal", () => ({ - setupRawMode: () => {}, - clearScreen: () => {}, - cleanupWithoutClear: () => {}, - patchConsole: () => {}, - startCapture: () => {}, - write: (_output: string) => {}, - onKey: (callback: any) => { - keyHandlers.push(callback); - (global as any).__btuin_onKeyCallback = (event: any) => { - for (const handler of keyHandlers) { - handler(event); - } - }; - }, - getTerminalSize: () => ({ rows: 24, cols: 80 }), - disposeSingletonCapture: () => {}, -})); - -mock.module("../../../src/layout", () => ({ - initLayoutEngine: async () => {}, -})); - -describe("createApp", () => { - let app: AppInstance; - const platform = { - onStdoutResize: () => {}, - onExit: () => {}, - onSigint: () => {}, - onSigterm: () => {}, - exit: () => {}, - }; - - afterAll(() => { - mock.restore(); - }); - - afterEach(() => { - if (app) { - app.unmount(); - } - }); - - it("should create an app instance", () => { - app = createApp({ - platform, - setup() { - return () => Block(Text("test")); - }, - }); - expect(app).toBeDefined(); - expect(app.mount).toBeInstanceOf(Function); - expect(app.unmount).toBeInstanceOf(Function); - expect(app.getComponent).toBeInstanceOf(Function); - }); - - it("should mount and unmount the app", async () => { - let setupCalled = false; - app = createApp({ - platform, - setup() { - setupCalled = true; - return () => Block(Text("test")); - }, - }); - - await app.mount(); - expect(setupCalled).toBe(true); - expect(app.getComponent()).toBeDefined(); - - app.unmount(); - expect(app.getComponent()).toBe(null); - }); - - it("should handle key events", async () => { - let keyValue = ""; - app = createApp({ - platform, - setup() { - const key = ref(""); - terminal.onKey((k) => { - key.value = k.name; - keyValue = k.name; - }); - return () => Block(Text(key.value)); - }, - }); - - await app.mount(); - - // Manually trigger the key event - const onKeyCallback = (global as any).__btuin_onKeyCallback; - if (onKeyCallback) { - onKeyCallback({ name: "a", sequence: "a", ctrl: false, meta: false, shift: false }); - } - - // We can't directly test the rendered output without a full render cycle, - // but we can check if the key event was processed. - expect(keyValue).toBe("a"); - }); -}); diff --git a/packages/layout-engine/package.json b/packages/layout-engine/package.json deleted file mode 100644 index 0227a6b..0000000 --- a/packages/layout-engine/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@btuin/layout-engine", - "files": [ - "pkg" - ], - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "build": "wasm-pack build --target web --out-dir pkg" - } -} diff --git a/packages/layout-engine/src/index.ts b/packages/layout-engine/src/index.ts deleted file mode 100644 index e46766d..0000000 --- a/packages/layout-engine/src/index.ts +++ /dev/null @@ -1,300 +0,0 @@ -import type { LayoutElementShape, ComputedLayout } from "./types"; - -export * from "./types"; - -let wasmInitialized = false; - -type LayoutEngineWasmModule = { - default: (module_or_path?: unknown) => Promise; - init_layout_engine: () => void; - update_nodes: (nodes: unknown) => void; - remove_nodes: (keys: unknown) => void; - compute_layout: (root_key: string) => unknown; -}; - -let wasmModule: LayoutEngineWasmModule | null = null; -let wasmImportPromise: Promise | null = null; - -async function loadWasmModule(): Promise { - if (wasmModule) return wasmModule; - if (wasmImportPromise) return wasmImportPromise; - - wasmImportPromise = (async () => { - try { - const specifier = new URL("../pkg/layout_engine.js", import.meta.url).href; - const mod = (await import(specifier)) as LayoutEngineWasmModule; - wasmModule = mod; - return mod; - } catch (cause) { - wasmImportPromise = null; - throw new Error( - [ - "Failed to load @btuin/layout-engine WASM module.", - "The generated files are missing.", - "Run: pnpm --filter @btuin/layout-engine build", - ].join(" "), - { cause }, - ); - } - })(); - - return wasmImportPromise; -} - -const layoutState = createLayoutState(); - -export async function initLayoutEngine() { - const mod = await loadWasmModule(); - if (!wasmInitialized) { - await mod.default(); - wasmInitialized = true; - } - mod.init_layout_engine(); - layoutState.reset(); -} - -// ---------------------------------------------------------------------------- -// Type Definitions -// ---------------------------------------------------------------------------- - -export type Dimension = number | string | "auto"; - -export interface LayoutStyle { - display?: "flex" | "none"; - position?: "relative" | "absolute"; - - width?: Dimension; - height?: Dimension; - minWidth?: Dimension; - minHeight?: Dimension; - maxWidth?: Dimension; - maxHeight?: Dimension; - layoutBoundary?: boolean; - - padding?: number | [number, number, number, number]; - margin?: number | [number, number, number, number]; - - flexDirection?: "row" | "column" | "row-reverse" | "column-reverse"; - flexWrap?: "nowrap" | "wrap" | "wrap-reverse"; - flexGrow?: number; - flexShrink?: number; - flexBasis?: Dimension; - - justifyContent?: - | "flex-start" - | "flex-end" - | "center" - | "space-between" - | "space-around" - | "space-evenly"; - alignItems?: "flex-start" | "flex-end" | "center" | "baseline" | "stretch"; - alignSelf?: "auto" | "flex-start" | "flex-end" | "center" | "baseline" | "stretch"; - - gap?: number | { width?: number; height?: number }; -} - -export interface LayoutInputNode extends LayoutElementShape, LayoutStyle { - key?: string; - type: string; - measuredSize?: { width: number; height: number }; - children?: LayoutInputNode[]; -} - -interface BridgeStyle { - display?: string; - position?: string; - width?: Dimension; - height?: Dimension; - min_width?: Dimension; - min_height?: Dimension; - max_width?: Dimension; - max_height?: Dimension; - padding?: number[]; - margin?: number[]; - flex_direction?: string; - flex_wrap?: string; - flex_grow?: number; - flex_shrink?: number; - flex_basis?: Dimension; - justify_content?: string; - align_items?: string; - align_self?: string; - gap?: { width: number; height: number }; -} - -// ---------------------------------------------------------------------------- -// Implementation -// ---------------------------------------------------------------------------- - -export function computeLayout(root: LayoutInputNode): ComputedLayout { - if (!wasmInitialized || !wasmModule) { - throw new Error("Layout engine not initialized. Call initLayoutEngine() first."); - } - try { - return layoutState.compute(root); - } catch (cause) { - if (cause instanceof Error && /not initialized/i.test(cause.message)) { - throw cause; - } - throw new Error("failed to compute layout", { cause }); - } -} - -interface BridgeNodePayload { - key: string; - style: BridgeStyle; - children: string[]; - measure?: { width: number; height: number }; -} - -function createLayoutState() { - let signatures = new Map(); - - function reset() { - signatures = new Map(); - } - - function compute(root: LayoutInputNode): ComputedLayout { - const module = getReadyModule(); - const nodes: BridgeNodePayload[] = []; - flattenBridgeNodes(root, nodes); - - if (nodes.length === 0 || !nodes[0]) { - throw new Error("Layout tree must contain at least one node."); - } - - const newSignatures = new Map(); - const currentKeys = new Set(); - const changedNodes: BridgeNodePayload[] = []; - - for (const node of nodes) { - const signature = createSignature(node); - newSignatures.set(node.key, signature); - currentKeys.add(node.key); - if (signatures.get(node.key) !== signature) { - changedNodes.push(node); - } - } - - const removedKeys = [...signatures.keys()].filter((key) => !currentKeys.has(key)); - signatures = newSignatures; - - if (removedKeys.length > 0) { - module.remove_nodes(removedKeys); - } - - if (changedNodes.length > 0) { - module.update_nodes(changedNodes); - } - - const rootKey = nodes[0].key; - const result = module.compute_layout(rootKey); - return normalizeComputedLayout(result); - } - - return { - reset, - compute, - }; -} - -function normalizeComputedLayout(value: unknown): ComputedLayout { - if (!value) return {}; - const maybeMap = value as { entries?: unknown; get?: unknown }; - const isMapLike = - value instanceof Map || - (typeof maybeMap === "object" && - maybeMap !== null && - typeof maybeMap.entries === "function" && - typeof maybeMap.get === "function"); - - if (isMapLike) { - const out: ComputedLayout = {}; - for (const [key, rect] of (value as Map).entries()) { - if (typeof key === "string") { - out[key] = rect as ComputedLayout[string]; - } - } - return out; - } - return value as ComputedLayout; -} - -function flattenBridgeNodes(node: LayoutInputNode, nodes: BridgeNodePayload[]): string { - const key = node.key ?? `node-${nodes.length}`; - const index = nodes.length; - nodes.push({ - key, - style: extractStyle(node), - children: [], - measure: node.measuredSize, - }); - - if (node.children && node.children.length > 0) { - const childKeys = node.children.map((child) => flattenBridgeNodes(child, nodes)); - nodes[index]!.children = childKeys; - } - - return key; -} - -function createSignature(node: BridgeNodePayload): string { - const styleJson = JSON.stringify(node.style); - const childrenKey = node.children.join(","); - const measureKey = node.measure ? `${node.measure.width}:${node.measure.height}` : ""; - return `${styleJson}|${childrenKey}|${measureKey}`; -} - -function getReadyModule(): LayoutEngineWasmModule { - if (!wasmInitialized) { - throw new Error("Layout engine not initialized. Call initLayoutEngine() first."); - } - if (!wasmModule) { - throw new Error("Layout engine module not loaded. Call initLayoutEngine() first."); - } - return wasmModule; -} - -function extractStyle(node: LayoutInputNode): BridgeStyle { - const s: BridgeStyle = {}; - - if (node.width !== undefined) s.width = node.width; - if (node.height !== undefined) s.height = node.height; - if (node.minWidth !== undefined) s.min_width = node.minWidth; - if (node.minHeight !== undefined) s.min_height = node.minHeight; - if (node.maxWidth !== undefined) s.max_width = node.maxWidth; - if (node.maxHeight !== undefined) s.max_height = node.maxHeight; - - if (node.display) s.display = node.display; - if (node.position) s.position = node.position; - - if (node.padding !== undefined) { - s.padding = Array.isArray(node.padding) - ? node.padding - : [node.padding, node.padding, node.padding, node.padding]; - } - if (node.margin !== undefined) { - s.margin = Array.isArray(node.margin) - ? node.margin - : [node.margin, node.margin, node.margin, node.margin]; - } - - if (node.flexDirection) s.flex_direction = node.flexDirection; - if (node.flexWrap) s.flex_wrap = node.flexWrap; - if (node.flexGrow !== undefined) s.flex_grow = node.flexGrow; - if (node.flexShrink !== undefined) s.flex_shrink = node.flexShrink; - if (node.flexBasis !== undefined) s.flex_basis = node.flexBasis; - - if (node.justifyContent) s.justify_content = node.justifyContent; - if (node.alignItems) s.align_items = node.alignItems; - if (node.alignSelf) s.align_self = node.alignSelf; - - if (node.gap !== undefined) { - s.gap = - typeof node.gap === "number" - ? { width: node.gap, height: node.gap } - : { width: node.gap.width ?? 0, height: node.gap.height ?? 0 }; - } - - return s; -} diff --git a/packages/layout-engine/src/lib.rs b/packages/layout-engine/src/lib.rs deleted file mode 100644 index b879c88..0000000 --- a/packages/layout-engine/src/lib.rs +++ /dev/null @@ -1,355 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::cell::RefCell; -use std::collections::{HashMap, HashSet, hash_map::Entry}; -use taffy::prelude::*; -use wasm_bindgen::prelude::*; - -#[derive(Deserialize)] -struct JsNodeUpdate { - key: String, - style: JsStyle, - #[serde(default)] - children: Vec, -} - -#[derive(Deserialize)] -struct JsSize { - width: f32, - height: f32, -} - -#[derive(Deserialize, Default)] -struct JsStyle { - display: Option, - position: Option, - - width: Option, - height: Option, - min_width: Option, - min_height: Option, - max_width: Option, - max_height: Option, - size: Option, - - padding: Option>, // [left, right, top, bottom] - margin: Option>, // [left, right, top, bottom] - - flex_direction: Option, - flex_wrap: Option, - flex_grow: Option, - flex_shrink: Option, - flex_basis: Option, - - justify_content: Option, - align_items: Option, - align_self: Option, - - gap: Option, -} - -// JSからの入力値 ("auto", 100, "50%") を受け取るEnum -#[derive(Deserialize)] -#[serde(untagged)] -enum JsDimension { - Auto(String), // "auto" - Points(f32), // 100 - Str(String), // "50%" -} - -impl JsDimension { - // Dimension (width, height, flex_basis 等) への変換 - fn to_dimension(&self) -> Dimension { - match self { - JsDimension::Points(v) => length(*v), - JsDimension::Auto(s) if s == "auto" => auto(), - JsDimension::Str(s) if s == "auto" => auto(), - JsDimension::Str(s) if s.ends_with("%") => { - let v = s.trim_end_matches('%').parse::().unwrap_or(0.0); - percent(v / 100.0) - } - _ => auto(), - } - } -} - -#[derive(Serialize)] -struct LayoutOutput { - x: f32, - y: f32, - width: f32, - height: f32, -} - -impl From<&JsStyle> for Style { - fn from(js: &JsStyle) -> Self { - let mut style = Style::default(); - - // Display & Position - if let Some(d) = js.display.as_deref() { - style.display = match d { - "none" => Display::None, - _ => Display::Flex, - }; - } - if let Some(p) = js.position.as_deref() { - style.position = match p { - "absolute" => Position::Absolute, - _ => Position::Relative, - }; - } - - // Size - if let Some(s) = &js.size { - style.size = Size { - width: length(s.width), - height: length(s.height), - }; - } else { - if let Some(d) = &js.width { - style.size.width = d.to_dimension(); - } - if let Some(d) = &js.height { - style.size.height = d.to_dimension(); - } - } - if let Some(d) = &js.min_width { - style.min_size.width = d.to_dimension(); - } - if let Some(d) = &js.min_height { - style.min_size.height = d.to_dimension(); - } - if let Some(d) = &js.max_width { - style.max_size.width = d.to_dimension(); - } - if let Some(d) = &js.max_height { - style.max_size.height = d.to_dimension(); - } - - // Spacing (Padding) - if let Some(p) = &js.padding { - if p.len() == 4 { - style.padding = Rect { - left: length(p[0]), - right: length(p[1]), - top: length(p[2]), - bottom: length(p[3]), - }; - } - } - - // Spacing (Margin) - if let Some(m) = &js.margin { - if m.len() == 4 { - style.margin = Rect { - left: length(m[0]), - right: length(m[1]), - top: length(m[2]), - bottom: length(m[3]), - }; - } - } - - // Flex - if let Some(dir) = js.flex_direction.as_deref() { - style.flex_direction = match dir { - "row" => FlexDirection::Row, - "column" => FlexDirection::Column, - "row-reverse" => FlexDirection::RowReverse, - "column-reverse" => FlexDirection::ColumnReverse, - _ => FlexDirection::Row, - }; - } - if let Some(wrap) = js.flex_wrap.as_deref() { - style.flex_wrap = match wrap { - "nowrap" => FlexWrap::NoWrap, - "wrap" => FlexWrap::Wrap, - "wrap-reverse" => FlexWrap::WrapReverse, - _ => FlexWrap::NoWrap, - }; - } - if let Some(g) = js.flex_grow { - style.flex_grow = g; - } - if let Some(s) = js.flex_shrink { - style.flex_shrink = s; - } - if let Some(b) = &js.flex_basis { - style.flex_basis = b.to_dimension(); - } - - // Alignment - if let Some(jc) = js.justify_content.as_deref() { - style.justify_content = match jc { - "flex-start" => Some(JustifyContent::FlexStart), - "flex-end" => Some(JustifyContent::FlexEnd), - "center" => Some(JustifyContent::Center), - "space-between" => Some(JustifyContent::SpaceBetween), - "space-around" => Some(JustifyContent::SpaceAround), - "space-evenly" => Some(JustifyContent::SpaceEvenly), - _ => None, - }; - } - if let Some(ai) = js.align_items.as_deref() { - style.align_items = match ai { - "flex-start" => Some(AlignItems::FlexStart), - "flex-end" => Some(AlignItems::FlexEnd), - "center" => Some(AlignItems::Center), - "baseline" => Some(AlignItems::Baseline), - "stretch" => Some(AlignItems::Stretch), - _ => None, - }; - } - if let Some(as_) = js.align_self.as_deref() { - style.align_self = match as_ { - "auto" => None, - "flex-start" => Some(AlignSelf::FlexStart), - "flex-end" => Some(AlignSelf::FlexEnd), - "center" => Some(AlignSelf::Center), - "baseline" => Some(AlignSelf::Baseline), - "stretch" => Some(AlignSelf::Stretch), - _ => None, - }; - } - - // Gap - if let Some(gap) = &js.gap { - style.gap.width = length(gap.width); - style.gap.height = length(gap.height); - } - - style - } -} - -struct LayoutEngineState { - taffy: TaffyTree>, - nodes: HashMap, -} - -struct NodeInfo { - id: NodeId, -} - -impl LayoutEngineState { - fn new() -> Self { - Self { - taffy: TaffyTree::new(), - nodes: HashMap::new(), - } - } - - fn update_nodes(&mut self, nodes: Vec) -> Result<(), String> { - for node in &nodes { - let style: Style = (&node.style).into(); - match self.nodes.entry(node.key.clone()) { - Entry::Occupied(entry) => { - self.taffy - .set_style(entry.get().id, style) - .map_err(|e| e.to_string())?; - } - Entry::Vacant(entry) => { - let id = self.taffy.new_leaf(style).map_err(|e| e.to_string())?; - entry.insert(NodeInfo { id }); - } - } - } - - for node in &nodes { - if let Some(info) = self.nodes.get(&node.key) { - let child_ids: Vec = node - .children - .iter() - .filter_map(|child_key| { - self.nodes.get(child_key).map(|child_info| child_info.id) - }) - .collect(); - self.taffy - .set_children(info.id, &child_ids) - .map_err(|e| e.to_string())?; - } - } - - Ok(()) - } - - fn remove_nodes(&mut self, keys: Vec) -> Result<(), String> { - let key_set: HashSet = keys.into_iter().collect(); - for key in &key_set { - if let Some(info) = self.nodes.remove(key) { - self.taffy.remove(info.id).map_err(|e| e.to_string())?; - } - } - Ok(()) - } - - fn compute_layout(&mut self, root_key: &str) -> Result, String> { - let root = self - .nodes - .get(root_key) - .ok_or_else(|| format!("root node not found: {}", root_key))?; - - self.taffy - .compute_layout(root.id, Size::MAX_CONTENT) - .map_err(|e| e.to_string())?; - - let mut outputs = HashMap::with_capacity(self.nodes.len()); - for (key, info) in &self.nodes { - let layout = self.taffy.layout(info.id).map_err(|e| e.to_string())?; - outputs.insert( - key.clone(), - LayoutOutput { - x: layout.location.x, - y: layout.location.y, - width: layout.size.width, - height: layout.size.height, - }, - ); - } - - Ok(outputs) - } -} - -thread_local! { - static ENGINE_STATE: RefCell> = RefCell::new(None); -} - -fn with_state( - f: impl FnOnce(&mut LayoutEngineState) -> Result, -) -> Result { - ENGINE_STATE.with(|cell| { - let mut guard = cell.borrow_mut(); - let state = guard - .as_mut() - .ok_or_else(|| JsValue::from_str("Layout engine not initialized"))?; - f(state).map_err(|e| JsValue::from_str(&e)) - }) -} - -#[wasm_bindgen] -pub fn init_layout_engine() -> Result<(), JsValue> { - ENGINE_STATE.with(|cell| { - let mut guard = cell.borrow_mut(); - *guard = Some(LayoutEngineState::new()); - Ok(()) - }) -} - -#[wasm_bindgen] -pub fn update_nodes(nodes_js: JsValue) -> Result<(), JsValue> { - let nodes: Vec = serde_wasm_bindgen::from_value(nodes_js)?; - with_state(|state| state.update_nodes(nodes)) -} - -#[wasm_bindgen] -pub fn remove_nodes(keys_js: JsValue) -> Result<(), JsValue> { - let keys: Vec = serde_wasm_bindgen::from_value(keys_js)?; - with_state(|state| state.remove_nodes(keys)) -} - -#[wasm_bindgen] -pub fn compute_layout(root_key: &str) -> Result { - with_state(|state| state.compute_layout(root_key)).and_then(|outputs| { - serde_wasm_bindgen::to_value(&outputs).map_err(|e| JsValue::from_str(&e.to_string())) - }) -} diff --git a/packages/layout-engine/src/types/index.ts b/packages/layout-engine/src/types/index.ts deleted file mode 100644 index 505f5a8..0000000 --- a/packages/layout-engine/src/types/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type * from "./geometry"; -export type * from "./layout"; diff --git a/packages/layout-engine/tests/index.test.ts b/packages/layout-engine/tests/index.test.ts deleted file mode 100644 index 2885b60..0000000 --- a/packages/layout-engine/tests/index.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { computeLayout, initLayoutEngine, type LayoutInputNode } from "../src/index"; - -describe("@btuin/layout-engine", () => { - test("computeLayout throws before initLayoutEngine", () => { - expect(() => computeLayout({ type: "block" } as LayoutInputNode)).toThrow(/not initialized/i); - }); - - test("computeLayout returns stable positions after initLayoutEngine", async () => { - await initLayoutEngine(); - - const root: LayoutInputNode = { - key: "root", - type: "root", - display: "flex", - flexDirection: "row", - width: 20, - height: 5, - children: [ - { key: "a", type: "leaf", width: 5, height: 1 }, - { key: "b", type: "leaf", width: 7, height: 1 }, - ], - }; - - const computed = computeLayout(root); - expect(computed.root).toEqual({ x: 0, y: 0, width: 20, height: 5 }); - expect(computed.a).toEqual({ x: 0, y: 0, width: 5, height: 1 }); - expect(computed.b).toEqual({ x: 5, y: 0, width: 7, height: 1 }); - }); - - test("gap affects layout after initLayoutEngine", async () => { - await initLayoutEngine(); - - const root: LayoutInputNode = { - key: "root", - type: "root", - display: "flex", - flexDirection: "row", - gap: 2, - width: 20, - height: 5, - children: [ - { key: "a", type: "leaf", width: 5, height: 1 }, - { key: "b", type: "leaf", width: 7, height: 1 }, - ], - }; - - const computed = computeLayout(root); - expect(computed.a).toEqual({ x: 0, y: 0, width: 5, height: 1 }); - expect(computed.b).toEqual({ x: 7, y: 0, width: 7, height: 1 }); - }); - - test("computeLayout throws when given invalid dimensions", async () => { - await initLayoutEngine(); - - const root: LayoutInputNode = { - key: "root", - type: "root", - display: "flex", - flexDirection: "row", - // @ts-expect-error - purposely invalid input to verify error propagation - width: { invalid: true }, - height: 5, - children: [{ key: "a", type: "leaf", width: 1, height: 1 }], - }; - - expect(() => computeLayout(root)).toThrow(/failed/i); - }); -}); diff --git a/packages/reactivity/package.json b/packages/reactivity/package.json deleted file mode 100644 index 63eaba1..0000000 --- a/packages/reactivity/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@btuin/reactivity", - "type": "module", - "exports": { - "default": "./src/index.ts" - } -} diff --git a/packages/renderer/package.json b/packages/renderer/package.json deleted file mode 100644 index 7e80c75..0000000 --- a/packages/renderer/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@btuin/renderer", - "type": "module", - "dependencies": { - "@btuin/terminal": "workspace:*" - }, - "exports": { - ".": "./src/index.ts", - "./types": "./src/types/index.ts" - } -} diff --git a/packages/renderer/src/grapheme.ts b/packages/renderer/src/grapheme.ts deleted file mode 100644 index 4d19dfc..0000000 --- a/packages/renderer/src/grapheme.ts +++ /dev/null @@ -1,102 +0,0 @@ -const graphemeSegmenter = (() => { - try { - if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") { - return new Intl.Segmenter(undefined, { granularity: "grapheme" }); - } - } catch { - /* fall back to manual segmentation */ - } - return null; -})(); - -const COMBINING_RANGES: [number, number][] = [ - [0x0300, 0x036f], - [0x1ab0, 0x1aff], - [0x1dc0, 0x1dff], - [0x20d0, 0x20ff], - [0xfe20, 0xfe2f], - [0x200c, 0x200d], -]; - -const WIDE_RANGES: [number, number][] = [ - [0x1100, 0x115f], - [0x2329, 0x232a], - [0x2e80, 0xa4cf], - [0xac00, 0xd7a3], - [0xf900, 0xfaff], - [0xfe10, 0xfe19], - [0xfe30, 0xfe6f], - [0xff00, 0xff60], - [0xffe0, 0xffe6], - [0x1f300, 0x1f64f], - [0x1f900, 0x1f9ff], - [0x20000, 0x2fffd], - [0x30000, 0x3fffd], -]; - -function inRange(code: number, ranges: [number, number][]): boolean { - for (const [start, end] of ranges) { - if (code >= start && code <= end) { - return true; - } - } - return false; -} - -function isCombining(code: number): boolean { - return inRange(code, COMBINING_RANGES); -} - -function isWide(code: number): boolean { - return inRange(code, WIDE_RANGES); -} - -function isControl(code: number): boolean { - return (code >= 0 && code < 32) || (code >= 0x7f && code < 0xa0); -} - -export function segmentGraphemes(text: string): string[] { - if (!text) return []; - - if (graphemeSegmenter) { - const segments: string[] = []; - for (const segment of graphemeSegmenter.segment(text)) { - if (segment.segment) { - segments.push(segment.segment); - } - } - return segments; - } - - const fallback: string[] = []; - let buffer = ""; - - for (const char of text) { - const code = char.codePointAt(0) ?? 0; - if (buffer && isCombining(code)) { - buffer += char; - continue; - } - if (buffer) { - fallback.push(buffer); - } - buffer = char; - } - - if (buffer) { - fallback.push(buffer); - } - - return fallback; -} - -export function measureGraphemeWidth(cluster: string): number { - for (const char of cluster) { - const code = char.codePointAt(0); - if (code === undefined) continue; - if (isControl(code)) return 0; - if (isCombining(code)) continue; - return isWide(code) ? 2 : 1; - } - return cluster ? 1 : 0; -} diff --git a/packages/showcase/counter.ts b/packages/showcase/counter.ts deleted file mode 100644 index 094a924..0000000 --- a/packages/showcase/counter.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { createApp, HStack, onKey, ref, Text, VStack, type KeyEvent } from "btuin"; - -const app = createApp({ - setup() { - const count = ref(0); - onKey((key: KeyEvent) => { - switch (key.name) { - case "up": - count.value++; - break; - case "down": - count.value--; - break; - case "r": - count.value = 0; - break; - case "q": - process.exit(0); - } - }); - - return () => - VStack([ - Text("Counter"), - HStack([Text("Count: "), Text(`${count.value}`).foreground("red")]), - Text(count.value.toString()), - ]) - .outline({ color: "blue" }) - .width("100%") - .height("100%") - .justify("center") - .align("center"); - }, -}); - -await app.mount(); - -const exitAfterMs = Number(process.env.BTUIN_EXIT_AFTER_MS ?? ""); -if (Number.isFinite(exitAfterMs) && exitAfterMs > 0) { - setTimeout(() => process.exit(0), exitAfterMs); -} diff --git a/packages/showcase/dashboard.ts b/packages/showcase/dashboard.ts deleted file mode 100644 index ed5ae90..0000000 --- a/packages/showcase/dashboard.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { - Block, - HStack, - Spacer, - VStack, - ZStack, - Text, - createApp, - computed, - onKey, - onTick, - ref, - type KeyEvent, -} from "btuin"; - -type Page = "Overview" | "Activity" | "Help"; - -const PAGES: Page[] = ["Overview", "Activity", "Help"]; - -function pad2(n: number) { - return String(n).padStart(2, "0"); -} - -function formatClock(d: Date) { - return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; -} - -function clamp(n: number, min: number, max: number) { - return Math.min(max, Math.max(min, n)); -} - -function titleBar(title: string, right?: string) { - return HStack([ - Text(title).foreground("cyan"), - Spacer(), - ...(right ? [Text(right).foreground("gray")] : []), - ]) - .gap(1) - .height(1); -} - -function card(lines: string[], accent: string) { - return VStack(lines.map((l) => Text(l))) - .gap(0) - .outline({ style: "single", color: accent }); -} - -const app = createApp({ - setup() { - const now = ref(new Date()); - const pageIndex = ref(0); - const paused = ref(false); - const showOverlay = ref(false); - - const termRows = ref(process.stdout.rows ?? 24); - const termCols = ref(process.stdout.columns ?? 80); - - const page = computed(() => PAGES[clamp(pageIndex.value, 0, PAGES.length - 1)]!); - const clock = computed(() => formatClock(now.value)); - const compact = computed(() => termRows.value < 24 || termCols.value < 80); - - const activity = ref([]); - const activitySeq = ref(0); - - function pushActivity(message: string) { - const id = activitySeq.value++; - const line = `${pad2(id % 100)} ${clock.value} ${message}`; - const keep = Math.max(10, (process.stdout.rows ?? 24) - 11); - activity.value = [...activity.value, line].slice(-keep); - } - - onTick(() => { - now.value = new Date(); - - const nextRows = process.stdout.rows ?? termRows.value; - const nextCols = process.stdout.columns ?? termCols.value; - if (nextRows !== termRows.value) termRows.value = nextRows; - if (nextCols !== termCols.value) termCols.value = nextCols; - - if (!paused.value && now.value.getMilliseconds() < 50) { - const messages = [ - "Sample Log A", - "Sample Log B", - "Sample Log C", - "Sample Log D", - "Sample Log E", - ]; - pushActivity(messages[Math.floor(Math.random() * messages.length)]!); - } - }, 50); - - onKey((key: KeyEvent) => { - switch (key.name) { - case "q": - process.exit(0); - case "up": - pageIndex.value = clamp(pageIndex.value - 1, 0, PAGES.length - 1); - return true; - case "down": - pageIndex.value = clamp(pageIndex.value + 1, 0, PAGES.length - 1); - return true; - case " ": - case "space": - paused.value = !paused.value; - pushActivity(paused.value ? "Paused activity stream" : "Resumed activity stream"); - return true; - case "r": - activity.value = []; - activitySeq.value = 0; - pushActivity("Reset activity stream"); - return true; - case "z": - // Ensure overlay size uses up-to-date terminal dimensions even before the first tick. - termRows.value = process.stdout.rows ?? termRows.value; - termCols.value = process.stdout.columns ?? termCols.value; - showOverlay.value = !showOverlay.value; - pushActivity(showOverlay.value ? "Opened floating overlay" : "Closed floating overlay"); - return true; - } - }); - - const sidebar = () => - VStack([ - titleBar("Pages"), - ...PAGES.map((p, idx) => { - const active = idx === pageIndex.value; - const label = active ? `> ${p}` : ` ${p}`; - return Text(label).foreground(active ? "yellow" : "gray"); - }), - Block().height(1), - titleBar("Controls"), - Text("↑/↓ navigate").foreground("gray"), - Text("space pause").foreground("gray"), - Text("r reset").foreground("gray"), - Text("z overlay").foreground("gray"), - Text("q quit").foreground("gray"), - ]) - .gap(0) - .outline({ style: "single", color: "blue" }) - .width(24) - .shrink(0); - - const overview = () => - VStack([ - titleBar("Overview", clock.value), - HStack([ - card( - [ - "Runtime: Bun", - "Renderer: diff-based", - `Page: ${page.value}`, - `Paused: ${paused.value ? "yes" : "no"}`, - ], - "magenta", - ).grow(1), - ]).gap(2), - titleBar("Highlights"), - Text("• Vue-like reactivity (ref/computed/effect)").foreground("gray"), - Text("• Flexbox-ish layout engine (WASM)").foreground("gray"), - ...(compact.value ? [] : [Text("• Floating overlay via ZStack").foreground("gray")]), - ]) - .gap(compact.value ? 0 : 1) - .outline({ style: "single", color: "green" }) - .grow(1); - - const activityView = () => - VStack([ - titleBar("Activity", paused.value ? "paused" : "live"), - ...activity.value.map((l) => Text(l).foreground("gray")), - ]) - .gap(0) - .outline({ style: "single", color: "green" }) - .grow(1); - - const help = () => - VStack([ - titleBar("Help", clock.value), - Text("This is a small showcase app for btuin.").foreground("gray"), - Text("Try resizing your terminal, then toggle overlay with z.").foreground("gray"), - ]) - .gap(1) - .outline({ style: "single", color: "green" }) - .grow(1); - - const main = () => { - switch (page.value) { - case "Overview": - return overview(); - case "Activity": - return activityView(); - case "Help": - return help(); - } - }; - - const header = () => - HStack([ - Text("btuin").foreground("white"), - Text("showcase").foreground("gray"), - Spacer(), - Text("q:quit").foreground("gray"), - ]) - .gap(1) - .outline({ style: "single", color: "blue" }); - - const footer = () => { - const status = paused.value ? "PAUSED" : "LIVE"; - return HStack([ - Text(`[${status}]`).foreground(paused.value ? "yellow" : "green"), - Text("↑/↓ pages z overlay space pause r reset q quit").foreground("gray"), - ]) - .gap(1) - .outline({ style: "single", color: "blue" }); - }; - - const floatingOverlay = () => { - const maxWidth = Math.max(20, termCols.value - 6); - const maxHeight = Math.max(7, termRows.value - 8); - const modalWidth = Math.min(60, maxWidth); - const modalHeight = Math.min(9, maxHeight); - - const modal = VStack([ - titleBar("Overlay", "z to close"), - Text("Floating window via ZStack").foreground("gray"), - Text("This clears its own area (bg).").foreground("gray"), - ...(modalHeight >= 9 - ? [Block().height(1), Text("Resize and toggle.").foreground("gray")] - : []), - ]) - .width(modalWidth) - .height(modalHeight) - .background("black") - .outline({ style: "double", color: "yellow" }) - .justify("flex-start") - .align("stretch"); - - return Block(modal).width("100%").height("100%").justify("center").align("center"); - }; - - const baseApp = () => - VStack([header(), HStack([sidebar(), main()]).gap(1).align("stretch").grow(1), footer()]) - .width("100%") - .justify("flex-start") - .align("stretch"); - - return () => - ZStack([baseApp(), ...(showOverlay.value ? [floatingOverlay()] : [])]) - .width("100%") - .height("100%"); - }, -}); - -await app.mount(); - -const exitAfterMs = Number(process.env.BTUIN_EXIT_AFTER_MS ?? ""); -if (Number.isFinite(exitAfterMs) && exitAfterMs > 0) { - setTimeout(() => process.exit(0), exitAfterMs); -} diff --git a/packages/showcase/package.json b/packages/showcase/package.json deleted file mode 100644 index fe8111b..0000000 --- a/packages/showcase/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "showcase", - "dependencies": { - "btuin": "workspace:*" - }, - "scripts": { - "counter": "BTUIN_EXIT_AFTER_MS=0 bun run counter.ts", - "dashboard": "BTUIN_EXIT_AFTER_MS=0 bun run dashboard.ts" - } -} diff --git a/packages/showcase/tests/showcase-smoke.test.ts b/packages/showcase/tests/showcase-smoke.test.ts deleted file mode 100644 index 43b2617..0000000 --- a/packages/showcase/tests/showcase-smoke.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect } from "bun:test"; -function stripCsi(input: string): string { - // Strip CSI sequences like: ESC[12;34H, ESC[31m, etc. - return input.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); -} - -describe("showcase smoke", () => { - it("counter renders at least once", async () => { - const proc = Bun.spawn(["bun", "run", "packages/showcase/counter.ts"], { - stdout: "pipe", - stderr: "pipe", - env: { - ...process.env, - BTUIN_EXIT_AFTER_MS: "50", - }, - }); - - const [exitCode, stdout, stderr] = await Promise.all([ - proc.exited, - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); - - expect(exitCode).toBe(0); - expect(stderr).toBe(""); - expect(stripCsi(stdout)).toContain("Counter"); - }); - - it("dashboard renders at least once", async () => { - const proc = Bun.spawn(["bun", "run", "packages/showcase/dashboard.ts"], { - stdout: "pipe", - stderr: "pipe", - env: { - ...process.env, - BTUIN_EXIT_AFTER_MS: "75", - }, - }); - - const [exitCode, stdout, stderr] = await Promise.all([ - proc.exited, - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); - - expect(exitCode).toBe(0); - expect(stderr).toBe(""); - const text = stripCsi(stdout); - expect(text).toContain("btuin"); - expect(text).toContain("showcase"); - }); -}); diff --git a/packages/terminal/package.json b/packages/terminal/package.json deleted file mode 100644 index c65239d..0000000 --- a/packages/terminal/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@btuin/terminal", - "type": "module", - "private": false, - "exports": { - ".": "./src/index.ts", - "./types": "./src/types/index.ts" - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5662182..c6b48b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,18 +1,17 @@ -lockfileVersion: '9.0' +lockfileVersion: "9.0" settings: autoInstallPeers: true excludeLinksFromLockfile: false importers: - .: dependencies: typescript: specifier: ^5 version: 5.9.3 devDependencies: - '@types/bun': + "@types/bun": specifier: latest version: 1.3.4 oxfmt: @@ -22,243 +21,270 @@ importers: specifier: ^1.32.0 version: 1.32.0 - packages/btuin: - dependencies: - '@btuin/layout-engine': - specifier: workspace:* - version: link:../layout-engine - '@btuin/reactivity': - specifier: workspace:* - version: link:../reactivity - '@btuin/renderer': - specifier: workspace:* - version: link:../renderer - '@btuin/terminal': - specifier: workspace:* - version: link:../terminal - typescript: - specifier: ^5 - version: 5.9.3 - devDependencies: - '@types/bun': - specifier: latest - version: 1.3.4 - - packages/layout-engine: {} - - packages/reactivity: {} - - packages/renderer: - dependencies: - '@btuin/terminal': - specifier: workspace:* - version: link:../terminal - - packages/showcase: - dependencies: - btuin: - specifier: workspace:* - version: link:../btuin - - packages/terminal: {} - packages: - - '@oxfmt/darwin-arm64@0.17.0': - resolution: {integrity: sha512-OMv0tOb+xiwSZKjYbM6TwMSP5QwFJlBGQmEsk98QJ30sHhdyC//0UvGKuR0KZuzZW4E0+k0rHDmos1Z5DmBEkA==} + "@oxfmt/darwin-arm64@0.17.0": + resolution: + { + integrity: sha512-OMv0tOb+xiwSZKjYbM6TwMSP5QwFJlBGQmEsk98QJ30sHhdyC//0UvGKuR0KZuzZW4E0+k0rHDmos1Z5DmBEkA==, + } cpu: [arm64] os: [darwin] - '@oxfmt/darwin-x64@0.17.0': - resolution: {integrity: sha512-trzidyzryKIdL/cLCYU9IwprgJegVBUrz1rqzOMe5is+qdgH/RxTCvhYUNFzxRHpil3g4QUYd2Ja831tc5Nehg==} + "@oxfmt/darwin-x64@0.17.0": + resolution: + { + integrity: sha512-trzidyzryKIdL/cLCYU9IwprgJegVBUrz1rqzOMe5is+qdgH/RxTCvhYUNFzxRHpil3g4QUYd2Ja831tc5Nehg==, + } cpu: [x64] os: [darwin] - '@oxfmt/linux-arm64-gnu@0.17.0': - resolution: {integrity: sha512-KlwzidgvHznbUaaglZT1goTS30osTV553pfbKve9B1PyTDkluNDfm/polOaf3SVLN7wL/NNLFZRMupvJ1eJXAw==} + "@oxfmt/linux-arm64-gnu@0.17.0": + resolution: + { + integrity: sha512-KlwzidgvHznbUaaglZT1goTS30osTV553pfbKve9B1PyTDkluNDfm/polOaf3SVLN7wL/NNLFZRMupvJ1eJXAw==, + } cpu: [arm64] os: [linux] - '@oxfmt/linux-arm64-musl@0.17.0': - resolution: {integrity: sha512-+tbYJTocF4BNLaQQbc/xrBWTNgiU6zmYeF4NvRDxuuQjDOnmUZPn0EED3PZBRJyg4/YllhplHDo8x+gfcb9G3A==} + "@oxfmt/linux-arm64-musl@0.17.0": + resolution: + { + integrity: sha512-+tbYJTocF4BNLaQQbc/xrBWTNgiU6zmYeF4NvRDxuuQjDOnmUZPn0EED3PZBRJyg4/YllhplHDo8x+gfcb9G3A==, + } cpu: [arm64] os: [linux] - '@oxfmt/linux-x64-gnu@0.17.0': - resolution: {integrity: sha512-pEmv7zJIw2HpnA4Tn1xrfJNGi2wOH2+usT14Pkvf/c5DdB+pOir6k/5jzfe70+V3nEtmtV9Lm+spndN/y6+X7A==} + "@oxfmt/linux-x64-gnu@0.17.0": + resolution: + { + integrity: sha512-pEmv7zJIw2HpnA4Tn1xrfJNGi2wOH2+usT14Pkvf/c5DdB+pOir6k/5jzfe70+V3nEtmtV9Lm+spndN/y6+X7A==, + } cpu: [x64] os: [linux] - '@oxfmt/linux-x64-musl@0.17.0': - resolution: {integrity: sha512-+DrFSCZWyFdtEAWR5xIBTV8GX0RA9iB+y7ZlJPRAXrNG8TdBY9vc7/MIGolIgrkMPK4mGMn07YG/qEyPY+iKaw==} + "@oxfmt/linux-x64-musl@0.17.0": + resolution: + { + integrity: sha512-+DrFSCZWyFdtEAWR5xIBTV8GX0RA9iB+y7ZlJPRAXrNG8TdBY9vc7/MIGolIgrkMPK4mGMn07YG/qEyPY+iKaw==, + } cpu: [x64] os: [linux] - '@oxfmt/win32-arm64@0.17.0': - resolution: {integrity: sha512-FoUZRR7mVpTYIaY/qz2BYwzqMnL+HsUxmMWAIy6nl29UEkDgxNygULJ4rIGY4/Axne41fhtldLrSGBOpwNm3jA==} + "@oxfmt/win32-arm64@0.17.0": + resolution: + { + integrity: sha512-FoUZRR7mVpTYIaY/qz2BYwzqMnL+HsUxmMWAIy6nl29UEkDgxNygULJ4rIGY4/Axne41fhtldLrSGBOpwNm3jA==, + } cpu: [arm64] os: [win32] - '@oxfmt/win32-x64@0.17.0': - resolution: {integrity: sha512-fBIcUpHmCwf3leWlo0cYwLb9Pd2mzxQlZYJX9dD9nylPvsxOnsy9fmsaflpj34O0JbQJN3Y0SRkoaCcHHlxFww==} + "@oxfmt/win32-x64@0.17.0": + resolution: + { + integrity: sha512-fBIcUpHmCwf3leWlo0cYwLb9Pd2mzxQlZYJX9dD9nylPvsxOnsy9fmsaflpj34O0JbQJN3Y0SRkoaCcHHlxFww==, + } cpu: [x64] os: [win32] - '@oxlint/darwin-arm64@1.32.0': - resolution: {integrity: sha512-yrqPmZYu5Qb+49h0P5EXVIq8VxYkDDM6ZQrWzlh16+UGFcD8HOXs4oF3g9RyfaoAbShLCXooSQsM/Ifwx8E/eQ==} + "@oxlint/darwin-arm64@1.32.0": + resolution: + { + integrity: sha512-yrqPmZYu5Qb+49h0P5EXVIq8VxYkDDM6ZQrWzlh16+UGFcD8HOXs4oF3g9RyfaoAbShLCXooSQsM/Ifwx8E/eQ==, + } cpu: [arm64] os: [darwin] - '@oxlint/darwin-x64@1.32.0': - resolution: {integrity: sha512-pQRZrJG/2nAKc3IuocFbaFFbTDlQsjz2WfivRsMn0hw65EEsSuM84WMFMiAfLpTGyTICeUtHZLHlrM5lzVr36A==} + "@oxlint/darwin-x64@1.32.0": + resolution: + { + integrity: sha512-pQRZrJG/2nAKc3IuocFbaFFbTDlQsjz2WfivRsMn0hw65EEsSuM84WMFMiAfLpTGyTICeUtHZLHlrM5lzVr36A==, + } cpu: [x64] os: [darwin] - '@oxlint/linux-arm64-gnu@1.32.0': - resolution: {integrity: sha512-tyomSmU2DzwcTmbaWFmStHgVfRmJDDvqcIvcw4fRB1YlL2Qg/XaM4NJ0m2bdTap38gxD5FSxSgCo0DkQ8GTolg==} + "@oxlint/linux-arm64-gnu@1.32.0": + resolution: + { + integrity: sha512-tyomSmU2DzwcTmbaWFmStHgVfRmJDDvqcIvcw4fRB1YlL2Qg/XaM4NJ0m2bdTap38gxD5FSxSgCo0DkQ8GTolg==, + } cpu: [arm64] os: [linux] - '@oxlint/linux-arm64-musl@1.32.0': - resolution: {integrity: sha512-0W46dRMaf71OGE4+Rd+GHfS1uF/UODl5Mef6871pMhN7opPGfTI2fKJxh9VzRhXeSYXW/Z1EuCq9yCfmIJq+5Q==} + "@oxlint/linux-arm64-musl@1.32.0": + resolution: + { + integrity: sha512-0W46dRMaf71OGE4+Rd+GHfS1uF/UODl5Mef6871pMhN7opPGfTI2fKJxh9VzRhXeSYXW/Z1EuCq9yCfmIJq+5Q==, + } cpu: [arm64] os: [linux] - '@oxlint/linux-x64-gnu@1.32.0': - resolution: {integrity: sha512-5+6myVCBOMvM62rDB9T3CARXUvIwhGqte6E+HoKRwYaqsxGUZ4bh3pItSgSFwHjLGPrvADS11qJUkk39eQQBzQ==} + "@oxlint/linux-x64-gnu@1.32.0": + resolution: + { + integrity: sha512-5+6myVCBOMvM62rDB9T3CARXUvIwhGqte6E+HoKRwYaqsxGUZ4bh3pItSgSFwHjLGPrvADS11qJUkk39eQQBzQ==, + } cpu: [x64] os: [linux] - '@oxlint/linux-x64-musl@1.32.0': - resolution: {integrity: sha512-qwQlwYYgVIC6ScjpUwiKKNyVdUlJckrfwPVpIjC9mvglIQeIjKuuyaDxUZWIOc/rEzeCV/tW6tcbehLkfEzqsw==} + "@oxlint/linux-x64-musl@1.32.0": + resolution: + { + integrity: sha512-qwQlwYYgVIC6ScjpUwiKKNyVdUlJckrfwPVpIjC9mvglIQeIjKuuyaDxUZWIOc/rEzeCV/tW6tcbehLkfEzqsw==, + } cpu: [x64] os: [linux] - '@oxlint/win32-arm64@1.32.0': - resolution: {integrity: sha512-7qYZF9CiXGtdv8Z/fBkgB5idD2Zokht67I5DKWH0fZS/2R232sDqW2JpWVkXltk0+9yFvmvJ0ouJgQRl9M3S2g==} + "@oxlint/win32-arm64@1.32.0": + resolution: + { + integrity: sha512-7qYZF9CiXGtdv8Z/fBkgB5idD2Zokht67I5DKWH0fZS/2R232sDqW2JpWVkXltk0+9yFvmvJ0ouJgQRl9M3S2g==, + } cpu: [arm64] os: [win32] - '@oxlint/win32-x64@1.32.0': - resolution: {integrity: sha512-XW1xqCj34MEGJlHteqasTZ/LmBrwYIgluhNW0aP+XWkn90+stKAq3W/40dvJKbMK9F7o09LPCuMVtUW7FIUuiA==} + "@oxlint/win32-x64@1.32.0": + resolution: + { + integrity: sha512-XW1xqCj34MEGJlHteqasTZ/LmBrwYIgluhNW0aP+XWkn90+stKAq3W/40dvJKbMK9F7o09LPCuMVtUW7FIUuiA==, + } cpu: [x64] os: [win32] - '@types/bun@1.3.4': - resolution: {integrity: sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA==} + "@types/bun@1.3.4": + resolution: + { + integrity: sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA==, + } - '@types/node@25.0.2': - resolution: {integrity: sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==} + "@types/node@25.0.2": + resolution: + { + integrity: sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==, + } bun-types@1.3.4: - resolution: {integrity: sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ==} + resolution: + { + integrity: sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ==, + } oxfmt@0.17.0: - resolution: {integrity: sha512-12Rmq2ub61rUZ3Pqnsvmo99rRQ6hQJwQsjnFnbvXYLMrlIsWT6SFVsrjAkBBrkXXSHv8ePIpKQ0nZph5KDrOqw==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { + integrity: sha512-12Rmq2ub61rUZ3Pqnsvmo99rRQ6hQJwQsjnFnbvXYLMrlIsWT6SFVsrjAkBBrkXXSHv8ePIpKQ0nZph5KDrOqw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } hasBin: true oxlint@1.32.0: - resolution: {integrity: sha512-HYDQCga7flsdyLMUIxTgSnEx5KBxpP9VINB8NgO+UjV80xBiTQXyVsvjtneMT3ZBLMbL0SlG/Dm03XQAsEshMA==} - engines: {node: ^20.19.0 || >=22.12.0} + resolution: + { + integrity: sha512-HYDQCga7flsdyLMUIxTgSnEx5KBxpP9VINB8NgO+UjV80xBiTQXyVsvjtneMT3ZBLMbL0SlG/Dm03XQAsEshMA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } hasBin: true peerDependencies: - oxlint-tsgolint: '>=0.8.1' + oxlint-tsgolint: ">=0.8.1" peerDependenciesMeta: oxlint-tsgolint: optional: true typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} + resolution: + { + integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, + } + engines: { node: ">=14.17" } hasBin: true undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + resolution: + { + integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==, + } snapshots: - - '@oxfmt/darwin-arm64@0.17.0': + "@oxfmt/darwin-arm64@0.17.0": optional: true - '@oxfmt/darwin-x64@0.17.0': + "@oxfmt/darwin-x64@0.17.0": optional: true - '@oxfmt/linux-arm64-gnu@0.17.0': + "@oxfmt/linux-arm64-gnu@0.17.0": optional: true - '@oxfmt/linux-arm64-musl@0.17.0': + "@oxfmt/linux-arm64-musl@0.17.0": optional: true - '@oxfmt/linux-x64-gnu@0.17.0': + "@oxfmt/linux-x64-gnu@0.17.0": optional: true - '@oxfmt/linux-x64-musl@0.17.0': + "@oxfmt/linux-x64-musl@0.17.0": optional: true - '@oxfmt/win32-arm64@0.17.0': + "@oxfmt/win32-arm64@0.17.0": optional: true - '@oxfmt/win32-x64@0.17.0': + "@oxfmt/win32-x64@0.17.0": optional: true - '@oxlint/darwin-arm64@1.32.0': + "@oxlint/darwin-arm64@1.32.0": optional: true - '@oxlint/darwin-x64@1.32.0': + "@oxlint/darwin-x64@1.32.0": optional: true - '@oxlint/linux-arm64-gnu@1.32.0': + "@oxlint/linux-arm64-gnu@1.32.0": optional: true - '@oxlint/linux-arm64-musl@1.32.0': + "@oxlint/linux-arm64-musl@1.32.0": optional: true - '@oxlint/linux-x64-gnu@1.32.0': + "@oxlint/linux-x64-gnu@1.32.0": optional: true - '@oxlint/linux-x64-musl@1.32.0': + "@oxlint/linux-x64-musl@1.32.0": optional: true - '@oxlint/win32-arm64@1.32.0': + "@oxlint/win32-arm64@1.32.0": optional: true - '@oxlint/win32-x64@1.32.0': + "@oxlint/win32-x64@1.32.0": optional: true - '@types/bun@1.3.4': + "@types/bun@1.3.4": dependencies: bun-types: 1.3.4 - '@types/node@25.0.2': + "@types/node@25.0.2": dependencies: undici-types: 7.16.0 bun-types@1.3.4: dependencies: - '@types/node': 25.0.2 + "@types/node": 25.0.2 oxfmt@0.17.0: optionalDependencies: - '@oxfmt/darwin-arm64': 0.17.0 - '@oxfmt/darwin-x64': 0.17.0 - '@oxfmt/linux-arm64-gnu': 0.17.0 - '@oxfmt/linux-arm64-musl': 0.17.0 - '@oxfmt/linux-x64-gnu': 0.17.0 - '@oxfmt/linux-x64-musl': 0.17.0 - '@oxfmt/win32-arm64': 0.17.0 - '@oxfmt/win32-x64': 0.17.0 + "@oxfmt/darwin-arm64": 0.17.0 + "@oxfmt/darwin-x64": 0.17.0 + "@oxfmt/linux-arm64-gnu": 0.17.0 + "@oxfmt/linux-arm64-musl": 0.17.0 + "@oxfmt/linux-x64-gnu": 0.17.0 + "@oxfmt/linux-x64-musl": 0.17.0 + "@oxfmt/win32-arm64": 0.17.0 + "@oxfmt/win32-x64": 0.17.0 oxlint@1.32.0: optionalDependencies: - '@oxlint/darwin-arm64': 1.32.0 - '@oxlint/darwin-x64': 1.32.0 - '@oxlint/linux-arm64-gnu': 1.32.0 - '@oxlint/linux-arm64-musl': 1.32.0 - '@oxlint/linux-x64-gnu': 1.32.0 - '@oxlint/linux-x64-musl': 1.32.0 - '@oxlint/win32-arm64': 1.32.0 - '@oxlint/win32-x64': 1.32.0 + "@oxlint/darwin-arm64": 1.32.0 + "@oxlint/darwin-x64": 1.32.0 + "@oxlint/linux-arm64-gnu": 1.32.0 + "@oxlint/linux-arm64-musl": 1.32.0 + "@oxlint/linux-x64-gnu": 1.32.0 + "@oxlint/linux-x64-musl": 1.32.0 + "@oxlint/win32-arm64": 1.32.0 + "@oxlint/win32-x64": 1.32.0 typescript@5.9.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dee51e9..924b55f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,2 @@ packages: - - "packages/*" + - packages/* diff --git a/scripts/profiler-core.ts b/scripts/profiler-core.ts index adeb728..d384f6e 100644 --- a/scripts/profiler-core.ts +++ b/scripts/profiler-core.ts @@ -1,4 +1,4 @@ -import type { TerminalAdapter } from "../packages/btuin/src/runtime"; +import type { TerminalAdapter } from "@/runtime/terminal-adapter"; type FrameMetrics = { id: number; @@ -48,9 +48,13 @@ export function createNullTerminalAdapter(size: { rows: number; cols: number }): return { setupRawMode() {}, clearScreen() {}, + moveCursor() {}, cleanupWithoutClear() {}, - patchConsole() {}, + patchConsole() { + return () => {}; + }, startCapture() {}, + stopCapture() {}, onKey() {}, getTerminalSize() { return size; diff --git a/scripts/profiler-layout.spec.ts b/scripts/profiler-layout.spec.ts index f6166f3..504baf9 100644 --- a/scripts/profiler-layout.spec.ts +++ b/scripts/profiler-layout.spec.ts @@ -1,7 +1,7 @@ import { test, describe, expect } from "bun:test"; import { existsSync } from "node:fs"; -import { createApp, ref, Block, Text } from "../packages/btuin"; +import { createApp, ref, Block, Text } from "@/index"; import { createNullTerminalAdapter, printSummary, type ProfilerLog } from "./profiler-core"; // This test intentionally mutates layout-relevant props every frame to stress: @@ -62,7 +62,7 @@ function buildTree(t: number) { } const app = createApp({ - setup() { + init() { let produced = 0; const timer = setInterval(() => { tick.value++; @@ -72,8 +72,10 @@ const app = createApp({ resolveFinished?.(); } }, INTERVAL_MS); - - return () => buildTree(tick.value); + return {}; + }, + render() { + return buildTree(tick.value); }, terminal: createNullTerminalAdapter({ rows: 40, cols: 120 }), profile: { @@ -87,10 +89,10 @@ const app = createApp({ describe("Many Layout Change Test", async () => { Bun.gc(true); - const appInstance = await app.mount(); - expect(appInstance.getComponent()).not.toBeNull(); + await app.mount(); + expect(app.getComponent()).not.toBeNull(); await finished; - appInstance.unmount(); + app.unmount(); expect(existsSync(OUTPUT_FILE)).toBe(true); const log = (await import(OUTPUT_FILE, { with: { type: "json" } })) as ProfilerLog; printSummary(log); diff --git a/scripts/profiler-limit.spec.ts b/scripts/profiler-limit.spec.ts index cca1486..bbf10d0 100644 --- a/scripts/profiler-limit.spec.ts +++ b/scripts/profiler-limit.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { Block, Text, createApp, ref } from "../packages/btuin"; +import { Block, Text, createApp, ref } from "@/index"; import { createNullTerminalAdapter, type ProfilerLog } from "./profiler-core"; // ---------------------------------------------------------------------------- @@ -22,7 +22,7 @@ async function runSingleIteration(iterationIndex: number): Promise }); const app = createApp({ - setup() { + init() { let produced = 0; const timer = setInterval(() => { tick.value++; @@ -32,22 +32,23 @@ async function runSingleIteration(iterationIndex: number): Promise resolveFinished?.(); } }, INTERVAL_MS); - - return () => { - const count = START_NODES + tick.value * STEP_NODES; - const root = Block().direction("column"); - - const children = new Array(count); - for (let i = 0; i < count; i++) { - children[i] = Text(`item ${i}`).foreground(i % 2 === 0 ? "white" : "gray"); - } - root.children = children; - - root.children.unshift( - Text(`Iter: ${iterationIndex} Frame: ${tick.value} | Nodes: ${count}`).foreground("cyan"), - ); - return root; - }; + return {}; + }, + render() { + const count = START_NODES + tick.value * STEP_NODES; + const root = Block().direction("column"); + + const children: ReturnType[] = []; + children.length = count; + for (let i = 0; i < count; i++) { + children[i] = Text(`item ${i}`).foreground(i % 2 === 0 ? "white" : "gray"); + } + root.children = children; + + root.children.unshift( + Text(`Iter: ${iterationIndex} Frame: ${tick.value} | Nodes: ${count}`).foreground("cyan"), + ); + return root; }, terminal: createNullTerminalAdapter({ rows: 40, cols: 120 }), profile: { @@ -58,9 +59,9 @@ async function runSingleIteration(iterationIndex: number): Promise }, }); - const appInstance = await app.mount(); + await app.mount(); await finished; - appInstance.unmount(); + app.unmount(); if (!existsSync(OUTPUT_FILE)) { throw new Error(`Profile output file not found: ${OUTPUT_FILE}`); diff --git a/scripts/profiler-stress.spec.ts b/scripts/profiler-stress.spec.ts index 7414e59..24521e6 100644 --- a/scripts/profiler-stress.spec.ts +++ b/scripts/profiler-stress.spec.ts @@ -1,7 +1,7 @@ import { test, describe, expect } from "bun:test"; import { existsSync } from "node:fs"; -import { createApp, ref, Block, Text, type TerminalAdapter } from "../packages/btuin"; +import { createApp, ref, Block, Text } from "@/index"; import { createNullTerminalAdapter, printSummary, type ProfilerLog } from "./profiler-core"; const N = 10_000; @@ -24,7 +24,7 @@ root.add(header); for (const item of items) root.add(item); const app = createApp({ - setup() { + init() { let produced = 0; const timer = setInterval(() => { tick.value++; @@ -34,11 +34,11 @@ const app = createApp({ resolveFinished?.(); } }, INTERVAL_MS); - - return () => { - header.content = `stress n=${N} tick=${tick.value}`; - return root; - }; + return {}; + }, + render() { + header.content = `stress n=${N} tick=${tick.value}`; + return root; }, terminal: createNullTerminalAdapter({ rows: 40, cols: 120 }), profile: { @@ -52,10 +52,10 @@ const app = createApp({ describe("Multi Element Stress Test", async () => { Bun.gc(true); - const appInstance = await app.mount(); - expect(appInstance.getComponent()).not.toBeNull(); + await app.mount(); + expect(app.getComponent()).not.toBeNull(); await finished; - appInstance.unmount(); + app.unmount(); expect(existsSync(OUTPUT_FILE)).toBe(true); const log = (await import(OUTPUT_FILE, { with: { type: "json" } })) as ProfilerLog; printSummary(log); diff --git a/src/buffer.ts b/src/buffer.ts new file mode 100644 index 0000000..5b29f71 --- /dev/null +++ b/src/buffer.ts @@ -0,0 +1 @@ +export * from "./renderer/buffer"; diff --git a/src/colors.ts b/src/colors.ts new file mode 100644 index 0000000..0961b4d --- /dev/null +++ b/src/colors.ts @@ -0,0 +1 @@ +export * from "./renderer/colors"; diff --git a/src/component.ts b/src/component.ts new file mode 100644 index 0000000..68a9a96 --- /dev/null +++ b/src/component.ts @@ -0,0 +1,68 @@ +import type { KeyEvent } from "./terminal"; +import type { ViewElement } from "./view/types/elements"; + +export type KeyHandler = (key: KeyEvent) => void | boolean; +export type TickHandler = () => void; + +export type ExitReason = "normal" | "sigint" | "sigterm"; + +export interface RuntimeContext { + exit: (code?: number, reason?: ExitReason) => void; + getSize: () => { rows: number; cols: number }; + onResize: (handler: () => void) => () => void; + getEnv: (name: string) => string | undefined; + onExit: (handler: (info: { code: number; reason: ExitReason }) => void) => () => void; + setExitOutput: (output: string | (() => string)) => void; +} + +export interface ComponentInitContext { + onKey: (fn: KeyHandler) => void; + onTick: (fn: TickHandler, interval?: number) => void; + onMounted: (fn: () => void) => void; + onUnmounted: (fn: () => void) => void; + runtime: RuntimeContext; + cleanup: (fn: () => void) => void; + exit: (code?: number, reason?: ExitReason) => void; + getSize: () => { rows: number; cols: number }; + onResize: (handler: () => void) => void; + getEnv: (name: string) => string | undefined; + onExit: (handler: (info: { code: number; reason: ExitReason }) => void) => void; + setExitOutput: (output: string | (() => string)) => void; +} + +export interface Component { + __type: "Component"; + init?: (ctx: ComponentInitContext) => State; + render: (state: State) => ViewElement; +} + +export type ComponentDefinition = { + init?: (ctx: ComponentInitContext) => State; + render: (state: State) => ViewElement; +}; + +type AssertSync = T extends PromiseLike ? never : T; + +/** + * Defines a reusable UI Component. + * + * This only defines a component. Nothing is executed until a Runtime mounts it. + */ +export function btuin( + definition: Omit, "init"> & { init?: undefined }, +): Component; +export function btuin any>( + definition: AssertSync> extends never + ? never + : { + init: Init; + render: (state: ReturnType) => ViewElement; + }, +): Component>; +export function btuin(definition: ComponentDefinition): Component { + return { + __type: "Component", + init: definition.init, + render: definition.render, + }; +} diff --git a/src/computed.ts b/src/computed.ts new file mode 100644 index 0000000..9ac86c6 --- /dev/null +++ b/src/computed.ts @@ -0,0 +1 @@ +export * from "./reactivity/computed"; diff --git a/src/diff.ts b/src/diff.ts new file mode 100644 index 0000000..fc7f403 --- /dev/null +++ b/src/diff.ts @@ -0,0 +1 @@ +export * from "./renderer/diff"; diff --git a/src/effect.ts b/src/effect.ts new file mode 100644 index 0000000..92822d8 --- /dev/null +++ b/src/effect.ts @@ -0,0 +1 @@ +export * from "./reactivity/effect"; diff --git a/src/grapheme.ts b/src/grapheme.ts new file mode 100644 index 0000000..9fe68e5 --- /dev/null +++ b/src/grapheme.ts @@ -0,0 +1 @@ +export * from "./renderer/grapheme"; diff --git a/src/grid.ts b/src/grid.ts new file mode 100644 index 0000000..c6fa198 --- /dev/null +++ b/src/grid.ts @@ -0,0 +1 @@ +export * from "./renderer/grid"; diff --git a/packages/btuin/src/index.ts b/src/index.ts similarity index 57% rename from packages/btuin/src/index.ts rename to src/index.ts index 1d97de8..b57cceb 100644 --- a/packages/btuin/src/index.ts +++ b/src/index.ts @@ -2,17 +2,18 @@ * btuin core entry point */ +export * from "./component"; export * from "./runtime"; -export * from "./view/components"; - export * from "./view/base"; export * from "./view/layout"; export * from "./view/primitives"; export * from "./layout"; +export * from "./renderer"; +export * from "./grapheme"; -export * from "@btuin/reactivity"; +export * from "./reactivity"; export * from "./types"; -export type { KeyEvent } from "@btuin/terminal"; +export type { KeyEvent } from "./terminal"; diff --git a/packages/layout-engine/Cargo.lock b/src/layout-engine/Cargo.lock similarity index 55% rename from packages/layout-engine/Cargo.lock rename to src/layout-engine/Cargo.lock index b454e48..577fb52 100644 --- a/packages/layout-engine/Cargo.lock +++ b/src/layout-engine/Cargo.lock @@ -8,49 +8,26 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "bumpalo" -version = "3.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - [[package]] name = "grid" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9e2d4c0a8296178d8802098410ca05d86b17a10bb5ab559b3fb404c1f948220" -[[package]] -name = "js-sys" -version = "0.3.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - [[package]] name = "layout-engine" version = "0.1.0" dependencies = [ + "libc", "serde", - "serde-wasm-bindgen", "taffy", - "wasm-bindgen", ] [[package]] -name = "once_cell" -version = "1.21.3" +name = "libc" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "proc-macro2" @@ -70,12 +47,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - [[package]] name = "serde" version = "1.0.228" @@ -86,17 +57,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-wasm-bindgen" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" -dependencies = [ - "js-sys", - "serde", - "wasm-bindgen", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -160,48 +120,3 @@ name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasm-bindgen" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" -dependencies = [ - "unicode-ident", -] diff --git a/packages/layout-engine/Cargo.toml b/src/layout-engine/Cargo.toml similarity index 62% rename from packages/layout-engine/Cargo.toml rename to src/layout-engine/Cargo.toml index b763d2c..eaddded 100644 --- a/packages/layout-engine/Cargo.toml +++ b/src/layout-engine/Cargo.toml @@ -8,9 +8,5 @@ crate-type = ["cdylib", "rlib"] [dependencies] serde = { version = "1.0.228", features = ["derive"] } -serde-wasm-bindgen = "0.6.5" taffy = "0.9.2" -wasm-bindgen = "0.2.106" - -[package.metadata.wasm-pack.profile.release] -wasm-opt = false +libc = "0.2" diff --git a/src/layout-engine/index.ts b/src/layout-engine/index.ts new file mode 100644 index 0000000..b7c0b4a --- /dev/null +++ b/src/layout-engine/index.ts @@ -0,0 +1,252 @@ +import { dlopen, FFIType, suffix, ptr, toArrayBuffer } from "bun:ffi"; +import path from "node:path"; +import type { LayoutInputNode, ComputedLayout, Dimension, LayoutStyle } from "./types"; + +export * from "./types"; + +// --- Data Layout Constants (must match Rust) --- +// prettier-ignore +enum StyleProp { + Display, PositionType, FlexDirection, FlexWrap, + JustifyContent, AlignItems, AlignSelf, + FlexGrow, FlexShrink, FlexBasis, + Width, Height, MinWidth, MinHeight, MaxWidth, MaxHeight, + MarginLeft, MarginRight, MarginTop, MarginBottom, + PaddingLeft, PaddingRight, PaddingTop, PaddingBottom, + GapRow, GapColumn, + ChildrenCount, ChildrenOffset, + TotalProps, +} +const STYLE_STRIDE = StyleProp.TotalProps; + +// --- Helper Functions for Serialization --- + +function dimToFloat(dim: Dimension | undefined): number { + if (typeof dim === "number") return dim; + return NaN; // Represents 'auto' +} + +function serializeTree(root: LayoutInputNode): { + flatNodes: LayoutInputNode[]; + nodesBuffer: Float32Array; + childrenBuffer: Uint32Array; +} { + const flatNodes: LayoutInputNode[] = []; + const nodeMap = new Map(); + + function traverse(node: LayoutInputNode, idCounter = { count: 0 }) { + if (nodeMap.has(node)) return; + const id = idCounter.count++; + nodeMap.set(node, id); + flatNodes[id] = node; // Ensure order by ID + if (node.children) { + for (const child of node.children) { + traverse(child, idCounter); + } + } + } + traverse(root); + + const nodeCount = flatNodes.length; + const nodesBuffer = new Float32Array(nodeCount * STYLE_STRIDE); + const childrenBufferData: number[] = []; + + for (let i = 0; i < nodeCount; i++) { + const node = flatNodes[i]; + if (!node) continue; + const style: LayoutStyle = node; + const offset = i * STYLE_STRIDE; + + nodesBuffer[offset + StyleProp.FlexGrow] = style.flexGrow ?? 0; + nodesBuffer[offset + StyleProp.FlexShrink] = style.flexShrink ?? 1; + + const flexDirectionMap: Record = { + row: 0, + column: 1, + "row-reverse": 2, + "column-reverse": 3, + }; + nodesBuffer[offset + StyleProp.FlexDirection] = + flexDirectionMap[style.flexDirection ?? "row"] ?? 0; + + const gap = style.gap ?? 0; + const gapArr = Array.isArray(gap) ? gap : [gap, gap]; + nodesBuffer[offset + StyleProp.GapRow] = gapArr[0]; + nodesBuffer[offset + StyleProp.GapColumn] = gapArr[1]; + + const justifyContentMap: Record = { + "flex-start": 0, + "flex-end": 1, + center: 2, + "space-between": 3, + "space-around": 4, + "space-evenly": 5, + }; + nodesBuffer[offset + StyleProp.JustifyContent] = + justifyContentMap[style.justifyContent ?? "flex-start"] ?? 0; + + const alignItemsMap: Record = { + "flex-start": 0, + "flex-end": 1, + center: 2, + baseline: 3, + stretch: 4, + }; + nodesBuffer[offset + StyleProp.AlignItems] = alignItemsMap[style.alignItems ?? "stretch"] ?? 4; + + const positionTypeMap: Record = { + relative: 0, + absolute: 1, + }; + nodesBuffer[offset + StyleProp.PositionType] = + positionTypeMap[style.position ?? "relative"] ?? 0; + + nodesBuffer[offset + StyleProp.Width] = dimToFloat(style.width); + nodesBuffer[offset + StyleProp.Height] = dimToFloat(style.height); + + const margin = style.margin ?? 0; + const marginArr = Array.isArray(margin) ? margin : [margin, margin, margin, margin]; + nodesBuffer.set(marginArr, offset + StyleProp.MarginLeft); + + const padding = style.padding ?? 0; + const paddingArr = Array.isArray(padding) ? padding : [padding, padding, padding, padding]; + nodesBuffer.set(paddingArr, offset + StyleProp.PaddingLeft); + + const children = node.children ?? []; + nodesBuffer[offset + StyleProp.ChildrenOffset] = childrenBufferData.length; + nodesBuffer[offset + StyleProp.ChildrenCount] = children.length; + for (const child of children) { + const childId = nodeMap.get(child); + if (childId === undefined) throw new Error("Child node not found in map."); + childrenBufferData.push(childId); + } + } + + const childrenBuffer = new Uint32Array(childrenBufferData); + return { flatNodes, nodesBuffer, childrenBuffer }; +} + +// --- FFI setup --- + +const libName = "liblayout_engine"; +const libPath = path.join(import.meta.dir, "target", "release", `${libName}.${suffix}`); + +const { symbols } = dlopen(libPath, { + create_engine: { args: [], returns: FFIType.ptr }, + destroy_engine: { args: [FFIType.ptr], returns: FFIType.void }, + compute_layout_from_buffers: { + args: [FFIType.ptr, FFIType.ptr, FFIType.u64, FFIType.ptr, FFIType.u64], + returns: FFIType.i32, + }, + get_results_ptr: { args: [FFIType.ptr], returns: FFIType.ptr }, + get_results_len: { args: [FFIType.ptr], returns: FFIType.u64 }, +}); + +// --- Memory Management --- + +const registry = new FinalizationRegistry((enginePtr: import("bun:ffi").Pointer) => { + console.log(`[FFI] Finalizing engine at ${enginePtr}`); + symbols.destroy_engine(enginePtr); +}); + +// --- FFI Wrapper Class --- + +class LayoutEngineJS { + private enginePtr: import("bun:ffi").Pointer | null; + + constructor() { + this.enginePtr = symbols.create_engine(); + if (!this.enginePtr) throw new Error("Failed to create layout engine."); + registry.register(this, this.enginePtr, this); + } + + compute(root: LayoutInputNode): ComputedLayout { + if (!this.enginePtr) throw new Error("Layout engine has been destroyed."); + const { flatNodes, nodesBuffer, childrenBuffer } = serializeTree(root); + + const status = symbols.compute_layout_from_buffers( + this.enginePtr, + nodesBuffer.length > 0 ? ptr(nodesBuffer) : null, + nodesBuffer.length, + childrenBuffer.length > 0 ? ptr(childrenBuffer) : null, + childrenBuffer.length, + ); + + if (status !== 0) { + throw new Error(`Layout computation failed with status: ${status}`); + } + + const resultsPtr = symbols.get_results_ptr(this.enginePtr); + const resultsLenU64 = symbols.get_results_len(this.enginePtr); + const resultsLen = Number(resultsLenU64); + if (!Number.isSafeInteger(resultsLen)) { + throw new Error(`Unexpected results length (u64): ${resultsLenU64.toString()}`); + } + + if (!resultsPtr || resultsLen === 0) return {}; + + const resultsArrayBuffer = toArrayBuffer( + resultsPtr, + 0, + resultsLen * Float32Array.BYTES_PER_ELEMENT, + ); + const resultsBuffer = new Float32Array(resultsArrayBuffer); + + const computedLayout: ComputedLayout = {}; + const resultStride = 5; // js_id, x, y, width, height + + const snap = (value: number): number => { + if (!Number.isFinite(value)) return value; + const rounded = Math.round(value); + return Math.abs(value - rounded) < 1e-4 ? rounded : value; + }; + + for (let i = 0; i < resultsLen; i += resultStride) { + const jsId = resultsBuffer[i]!; + const node = flatNodes[jsId]; + if (!node) continue; + + const key = node.key ?? node.identifier; + if (key) { + computedLayout[key] = { + x: snap(resultsBuffer[i + 1]!), + y: snap(resultsBuffer[i + 2]!), + width: snap(resultsBuffer[i + 3]!), + height: snap(resultsBuffer[i + 4]!), + }; + } + } + + return computedLayout; + } + + destroy() { + if (this.enginePtr) { + symbols.destroy_engine(this.enginePtr); + registry.unregister(this); + this.enginePtr = null; + } + } +} + +// --- Public API --- + +let engineInstance: LayoutEngineJS | null = null; + +function getEngine(): LayoutEngineJS { + if (!engineInstance) { + engineInstance = new LayoutEngineJS(); + } + return engineInstance; +} + +export function computeLayout(root: LayoutInputNode): ComputedLayout { + return getEngine().compute(root); +} + +export function cleanupLayoutEngine() { + if (engineInstance) { + engineInstance.destroy(); + engineInstance = null; + } +} diff --git a/src/layout-engine/src/lib.rs b/src/layout-engine/src/lib.rs new file mode 100644 index 0000000..c95855f --- /dev/null +++ b/src/layout-engine/src/lib.rs @@ -0,0 +1,233 @@ +#![allow(dead_code)] +use std::collections::HashMap; +use taffy::prelude::*; + +#[repr(C)] +enum StyleProp { + Display, + PositionType, + FlexDirection, + FlexWrap, + JustifyContent, + AlignItems, + AlignSelf, + FlexGrow, + FlexShrink, + FlexBasis, + Width, + Height, + MinWidth, + MinHeight, + MaxWidth, + MaxHeight, + MarginLeft, + MarginRight, + MarginTop, + MarginBottom, + PaddingLeft, + PaddingRight, + PaddingTop, + PaddingBottom, + GapRow, + GapColumn, + ChildrenCount, + ChildrenOffset, + TotalProps, +} +const STYLE_STRIDE: usize = StyleProp::TotalProps as usize; + +pub struct LayoutEngineState { + taffy: TaffyTree, + nodes: HashMap, + node_id_map: HashMap, + results_buffer: Vec, +} + +impl LayoutEngineState { + fn new() -> Self { + Self { + taffy: TaffyTree::with_capacity(15000), + nodes: HashMap::with_capacity(15000), + node_id_map: HashMap::with_capacity(15000), + results_buffer: Vec::with_capacity(15000 * 5), + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn create_engine() -> *mut LayoutEngineState { + Box::into_raw(Box::new(LayoutEngineState::new())) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn destroy_engine(ptr: *mut LayoutEngineState) { + if ptr.is_null() { + return; + } + unsafe { + drop(Box::from_raw(ptr)); + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn compute_layout_from_buffers( + engine_ptr: *mut LayoutEngineState, + nodes_buffer_ptr: *const f32, + nodes_buffer_len: usize, + children_buffer_ptr: *const u32, + children_buffer_len: usize, +) -> i32 { + if engine_ptr.is_null() { + return -1; + } + + let (engine, nodes_buffer, children_buffer) = unsafe { + ( + &mut *engine_ptr, + std::slice::from_raw_parts(nodes_buffer_ptr, nodes_buffer_len), + std::slice::from_raw_parts(children_buffer_ptr, children_buffer_len), + ) + }; + + let node_count = nodes_buffer_len / STYLE_STRIDE; + if nodes_buffer_len % STYLE_STRIDE != 0 { + return -2; + } + + engine.nodes.clear(); + engine.node_id_map.clear(); + engine.taffy.clear(); + + for i in 0..node_count { + let node_id = i as u32; + let style_slice = &nodes_buffer[i * STYLE_STRIDE..(i + 1) * STYLE_STRIDE]; + let mut style = Style::default(); + + let width = style_slice[StyleProp::Width as usize]; + if !width.is_nan() { + style.size.width = length(width); + } + + + let height = style_slice[StyleProp::Height as usize]; + if !height.is_nan() { + style.size.height = length(height); + } + + style.flex_direction = match style_slice[StyleProp::FlexDirection as usize] as i32 { + 1 => FlexDirection::Column, + 2 => FlexDirection::RowReverse, + 3 => FlexDirection::ColumnReverse, + _ => FlexDirection::Row, + }; + + style.gap = Size { + width: length(style_slice[StyleProp::GapColumn as usize]), + height: length(style_slice[StyleProp::GapRow as usize]), + }; + + style.justify_content = Some(match style_slice[StyleProp::JustifyContent as usize] as i32 { + 1 => JustifyContent::FlexEnd, + 2 => JustifyContent::Center, + 3 => JustifyContent::SpaceBetween, + 4 => JustifyContent::SpaceAround, + 5 => JustifyContent::SpaceEvenly, + _ => JustifyContent::FlexStart, + }); + + style.align_items = Some(match style_slice[StyleProp::AlignItems as usize] as i32 { + 0 => AlignItems::FlexStart, + 1 => AlignItems::FlexEnd, + 2 => AlignItems::Center, + 3 => AlignItems::Baseline, + _ => AlignItems::Stretch, + }); + + style.position = match style_slice[StyleProp::PositionType as usize] as i32 { + 1 => Position::Absolute, + _ => Position::Relative, + }; + + style.flex_grow = style_slice[StyleProp::FlexGrow as usize]; + style.flex_shrink = style_slice[StyleProp::FlexShrink as usize]; + + style.margin = Rect { + left: length(style_slice[StyleProp::MarginLeft as usize]), + right: length(style_slice[StyleProp::MarginRight as usize]), + top: length(style_slice[StyleProp::MarginTop as usize]), + bottom: length(style_slice[StyleProp::MarginBottom as usize]), + }; + style.padding = Rect { + left: length(style_slice[StyleProp::PaddingLeft as usize]), + right: length(style_slice[StyleProp::PaddingRight as usize]), + top: length(style_slice[StyleProp::PaddingTop as usize]), + bottom: length(style_slice[StyleProp::PaddingBottom as usize]), + }; + + let taffy_node = engine.taffy.new_leaf(style).unwrap(); + engine.nodes.insert(node_id, taffy_node); + engine.node_id_map.insert(taffy_node, node_id); + } + + for i in 0..node_count { + let node_id = i as u32; + let style_slice = &nodes_buffer[i * STYLE_STRIDE..(i + 1) * STYLE_STRIDE]; + let children_count = style_slice[StyleProp::ChildrenCount as usize] as usize; + if children_count > 0 { + let children_offset = style_slice[StyleProp::ChildrenOffset as usize] as usize; + let children_ids_slice = + &children_buffer[children_offset..children_offset + children_count]; + let taffy_children: Vec = children_ids_slice + .iter() + .filter_map(|child_id| engine.nodes.get(child_id)) + .map(|id| *id) + .collect(); + if let Some(taffy_node) = engine.nodes.get(&node_id) { + engine + .taffy + .set_children(*taffy_node, &taffy_children) + .unwrap(); + } + } + } + + if let Some(root_node) = engine.nodes.get(&0) { + engine + .taffy + .compute_layout(*root_node, Size::MAX_CONTENT) + .unwrap(); + } else { + return -3; + } + + engine.results_buffer.clear(); + for (taffy_id, js_id) in &engine.node_id_map { + if let Ok(layout) = engine.taffy.layout(*taffy_id) { + engine.results_buffer.push(*js_id as f32); + engine.results_buffer.push(layout.location.x); + engine.results_buffer.push(layout.location.y); + engine.results_buffer.push(layout.size.width); + engine.results_buffer.push(layout.size.height); + } + } + + 0 +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn get_results_ptr(engine_ptr: *mut LayoutEngineState) -> *const f32 { + if engine_ptr.is_null() { + return std::ptr::null(); + } + let engine = unsafe { &*engine_ptr }; + engine.results_buffer.as_ptr() +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn get_results_len(engine_ptr: *mut LayoutEngineState) -> usize { + if engine_ptr.is_null() { + return 0; + } + let engine = unsafe { &*engine_ptr }; + engine.results_buffer.len() +} diff --git a/packages/layout-engine/src/types/geometry.ts b/src/layout-engine/types/geometry.ts similarity index 100% rename from packages/layout-engine/src/types/geometry.ts rename to src/layout-engine/types/geometry.ts diff --git a/src/layout-engine/types/index.ts b/src/layout-engine/types/index.ts new file mode 100644 index 0000000..621d079 --- /dev/null +++ b/src/layout-engine/types/index.ts @@ -0,0 +1,49 @@ +import type { LayoutElementShape, ComputedLayout as LayoutComputationResult, Rect } from "./layout"; + +export type { Rect }; + +export type Dimension = number | string | "auto"; + +export interface LayoutStyle { + display?: "flex" | "none"; + position?: "relative" | "absolute"; + + width?: Dimension; + height?: Dimension; + minWidth?: Dimension; + minHeight?: Dimension; + maxWidth?: Dimension; + maxHeight?: Dimension; + layoutBoundary?: boolean; + + padding?: number | [number, number, number, number]; + margin?: number | [number, number, number, number]; + + flexDirection?: "row" | "column" | "row-reverse" | "column-reverse"; + flexWrap?: "nowrap" | "wrap" | "wrap-reverse"; + flexGrow?: number; + flexShrink?: number; + flexBasis?: Dimension; + + justifyContent?: + | "flex-start" + | "flex-end" + | "center" + | "space-between" + | "space-around" + | "space-evenly"; + alignItems?: "flex-start" | "flex-end" | "center" | "baseline" | "stretch"; + alignSelf?: "auto" | "flex-start" | "flex-end" | "center" | "baseline" | "stretch"; + + gap?: number | { width?: number; height?: number }; +} + +export interface LayoutInputNode extends LayoutElementShape, LayoutStyle { + key?: string; + identifier?: string; + type: string; + measuredSize?: { width: number; height: number }; + children?: LayoutInputNode[]; +} + +export type ComputedLayout = LayoutComputationResult; diff --git a/packages/layout-engine/src/types/layout.ts b/src/layout-engine/types/layout.ts similarity index 94% rename from packages/layout-engine/src/types/layout.ts rename to src/layout-engine/types/layout.ts index fd8e278..c8f9512 100644 --- a/packages/layout-engine/src/types/layout.ts +++ b/src/layout-engine/types/layout.ts @@ -1,4 +1,5 @@ import type { Rect } from "./geometry"; +export type { Rect }; export interface LayoutElementShape { type: string; diff --git a/packages/btuin/src/layout/focus.ts b/src/layout/focus.ts similarity index 85% rename from packages/btuin/src/layout/focus.ts rename to src/layout/focus.ts index 7aeaa77..e87eeab 100644 --- a/packages/btuin/src/layout/focus.ts +++ b/src/layout/focus.ts @@ -1,5 +1,5 @@ import type { ViewElement } from "../view/types/elements"; -import type { ComputedLayout } from "@btuin/layout-engine"; +import type { ComputedLayout } from "../layout-engine"; import type { FocusTarget } from "../view/types/focus"; import { isBlock } from "../view/types/elements"; @@ -34,7 +34,8 @@ function visitFocusTargets( for (let i = 0; i < element.children.length; i++) { const child = element.children[i]!; - const childKey = child.key ?? (effectiveKey ? `${effectiveKey}/${child.type}-${i}` : undefined); + const childKey = + child.identifier ?? (effectiveKey ? `${effectiveKey}/${child.type}-${i}` : undefined); visitFocusTargets(child, layoutMap, absX, absY, childKey, visit); } } @@ -47,7 +48,7 @@ export function collectFocusTargets( layoutMap: ComputedLayout, parentX = 0, parentY = 0, - effectiveKey: string | undefined = element.key, + effectiveKey: string | undefined = element.identifier, ): FocusTarget[] { const targets: FocusTarget[] = []; visitFocusTargets(element, layoutMap, parentX, parentY, effectiveKey, (t) => targets.push(t)); @@ -60,7 +61,7 @@ export function collectFocusTargetMap( layoutMap: ComputedLayout, parentX = 0, parentY = 0, - effectiveKey: string | undefined = element.key, + effectiveKey: string | undefined = element.identifier, ): Map { const map = new Map(); visitFocusTargets(element, layoutMap, parentX, parentY, effectiveKey, (t) => diff --git a/packages/btuin/src/layout/index.ts b/src/layout/index.ts similarity index 94% rename from packages/btuin/src/layout/index.ts rename to src/layout/index.ts index 964b5d0..c5544b1 100644 --- a/packages/btuin/src/layout/index.ts +++ b/src/layout/index.ts @@ -1,28 +1,33 @@ import { - initLayoutEngine as initWasm, computeLayout as computeLayoutWasm, type LayoutInputNode, type ComputedLayout, type Dimension, -} from "@btuin/layout-engine"; +} from "../layout-engine"; import { isBlock, isText, type ViewElement, type BlockView } from "../view/types/elements"; export { renderElement } from "./renderer"; export interface LayoutEngine { - initLayoutEngine(): Promise; computeLayout(root: LayoutInputNode): ComputedLayout; } function ensureKeys(element: ViewElement, prefix: string) { - if (!element.key) { + if (!element.identifier && !element.key) { + element.identifier = prefix; element.key = prefix; + } else { + const k = element.key ?? element.identifier; + if (k) { + element.key = k; + element.identifier = k; + } } if (isBlock(element)) { for (let i = 0; i < element.children.length; i++) { const child = element.children[i]!; - ensureKeys(child, `${element.key}/${child.type}-${i}`); + ensureKeys(child, `${element.identifier}/${child.type}-${i}`); } } } @@ -137,10 +142,11 @@ function viewElementToLayoutNode( parentSize?: LayoutContainerSize, isRoot = false, ): LayoutInputNode { - const { key, style } = element; + const { identifier, style } = element; const node: LayoutInputNode = { - key, + key: element.key ?? identifier, + identifier, type: element.type, ...style, }; @@ -224,7 +230,6 @@ function viewElementToLayoutNode( export function createLayout(engine: LayoutEngine = wasmLayoutEngine()) { return { - initLayoutEngine: () => engine.initLayoutEngine(), layout: (root: ViewElement, containerSize?: LayoutContainerSize): ComputedLayout => { ensureKeys(root, "root"); const layoutNode = viewElementToLayoutNode(root, containerSize, true); @@ -235,9 +240,8 @@ export function createLayout(engine: LayoutEngine = wasmLayoutEngine()) { function wasmLayoutEngine(): LayoutEngine { return { - initLayoutEngine: () => initWasm(), computeLayout: (root: LayoutInputNode) => computeLayoutWasm(root), }; } -export const { initLayoutEngine, layout } = createLayout(); +export const { layout } = createLayout(); diff --git a/packages/btuin/src/layout/renderer.ts b/src/layout/renderer.ts similarity index 94% rename from packages/btuin/src/layout/renderer.ts rename to src/layout/renderer.ts index c6e1ac8..daded27 100644 --- a/packages/btuin/src/layout/renderer.ts +++ b/src/layout/renderer.ts @@ -1,6 +1,6 @@ import { type ViewElement, isBlock, isText } from "../view/types/elements"; -import type { ComputedLayout } from "@btuin/layout-engine"; -import { type Buffer2D, drawText, fillRect } from "@btuin/renderer"; +import type { ComputedLayout } from "../layout-engine"; +import { type Buffer2D, drawText, fillRect } from "../renderer"; /** * Draw the element tree to the buffer. @@ -12,7 +12,7 @@ export function renderElement( _parentX = 0, _parentY = 0, ) { - const key = element.key; + const key = element.identifier; if (!key) return; const layout = layoutMap[key]; diff --git a/src/pool.ts b/src/pool.ts new file mode 100644 index 0000000..73ef77c --- /dev/null +++ b/src/pool.ts @@ -0,0 +1 @@ +export * from "./renderer/pool"; diff --git a/packages/reactivity/src/computed.ts b/src/reactivity/computed.ts similarity index 100% rename from packages/reactivity/src/computed.ts rename to src/reactivity/computed.ts diff --git a/packages/reactivity/src/effect.ts b/src/reactivity/effect.ts similarity index 100% rename from packages/reactivity/src/effect.ts rename to src/reactivity/effect.ts diff --git a/packages/reactivity/src/index.ts b/src/reactivity/index.ts similarity index 100% rename from packages/reactivity/src/index.ts rename to src/reactivity/index.ts diff --git a/packages/reactivity/src/reactive.ts b/src/reactivity/reactive.ts similarity index 100% rename from packages/reactivity/src/reactive.ts rename to src/reactivity/reactive.ts diff --git a/packages/reactivity/src/ref.ts b/src/reactivity/ref.ts similarity index 100% rename from packages/reactivity/src/ref.ts rename to src/reactivity/ref.ts diff --git a/packages/reactivity/src/watch.ts b/src/reactivity/watch.ts similarity index 100% rename from packages/reactivity/src/watch.ts rename to src/reactivity/watch.ts diff --git a/src/ref.ts b/src/ref.ts new file mode 100644 index 0000000..6b6c463 --- /dev/null +++ b/src/ref.ts @@ -0,0 +1 @@ +export * from "./reactivity/ref"; diff --git a/packages/renderer/src/buffer.ts b/src/renderer/buffer.ts similarity index 100% rename from packages/renderer/src/buffer.ts rename to src/renderer/buffer.ts diff --git a/packages/renderer/src/colors.ts b/src/renderer/colors.ts similarity index 100% rename from packages/renderer/src/colors.ts rename to src/renderer/colors.ts diff --git a/packages/renderer/src/diff.ts b/src/renderer/diff.ts similarity index 100% rename from packages/renderer/src/diff.ts rename to src/renderer/diff.ts diff --git a/src/renderer/grapheme.ts b/src/renderer/grapheme.ts new file mode 100644 index 0000000..1f132cd --- /dev/null +++ b/src/renderer/grapheme.ts @@ -0,0 +1,213 @@ +const graphemeSegmenter = (() => { + try { + if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") { + return new Intl.Segmenter(undefined, { granularity: "grapheme" }); + } + } catch { + /* fall back to manual segmentation */ + } + return null; +})(); + +const COMBINING_RANGES: [number, number][] = [ + [0x0300, 0x036f], + [0x1ab0, 0x1aff], + [0x1dc0, 0x1dff], + [0x20d0, 0x20ff], + [0xfe20, 0xfe2f], + [0x200c, 0x200d], +]; + +const WIDE_RANGES: [number, number][] = [ + [0x1100, 0x115f], + [0x2329, 0x232a], + [0x2e80, 0xa4cf], + [0xac00, 0xd7a3], + [0xf900, 0xfaff], + [0xfe10, 0xfe19], + [0xfe30, 0xfe6f], + [0xff00, 0xff60], + [0xffe0, 0xffe6], + [0x1f300, 0x1f64f], + [0x1f900, 0x1f9ff], + [0x20000, 0x2fffd], + [0x30000, 0x3fffd], +]; + +function inRange(code: number, ranges: [number, number][]): boolean { + for (const [start, end] of ranges) { + if (code >= start && code <= end) { + return true; + } + } + return false; +} + +function isCombining(code: number): boolean { + return inRange(code, COMBINING_RANGES); +} + +function isWide(code: number): boolean { + return inRange(code, WIDE_RANGES); +} + +function isControl(code: number): boolean { + return (code >= 0 && code < 32) || (code >= 0x7f && code < 0xa0); +} + +export function segmentGraphemes(text: string): string[] { + if (!text) return []; + + if (graphemeSegmenter) { + const segments: string[] = []; + for (const segment of graphemeSegmenter.segment(text)) { + if (segment.segment) { + segments.push(segment.segment); + } + } + return segments; + } + + const fallback: string[] = []; + let buffer = ""; + + for (const char of text) { + const code = char.codePointAt(0) ?? 0; + if (buffer && isCombining(code)) { + buffer += char; + continue; + } + if (buffer) { + fallback.push(buffer); + } + buffer = char; + } + + if (buffer) { + fallback.push(buffer); + } + + return fallback; +} + +export function measureGraphemeWidth(cluster: string): number { + for (const char of cluster) { + const code = char.codePointAt(0); + if (code === undefined) continue; + if (isControl(code)) return 0; + if (isCombining(code)) continue; + return isWide(code) ? 2 : 1; + } + return cluster ? 1 : 0; +} + +export function measureTextWidth(text: string): number { + if (!text) return 0; + let width = 0; + for (const cluster of segmentGraphemes(text)) { + width += measureGraphemeWidth(cluster); + } + return width; +} + +export function truncateTextWidth(text: string, maxWidth: number, ellipsis = "…"): string { + const cap = Math.max(0, Math.floor(maxWidth)); + if (cap === 0) return ""; + if (!text) return ""; + + if (measureTextWidth(text) <= cap) return text; + + const ellWidth = measureTextWidth(ellipsis); + if (ellWidth >= cap) { + // Return a truncated ellipsis that fits into cap. + let out = ""; + let used = 0; + for (const cluster of segmentGraphemes(ellipsis)) { + const w = measureGraphemeWidth(cluster); + if (used + w > cap) break; + used += w; + out += cluster; + } + return out; + } + + const target = cap - ellWidth; + let out = ""; + let used = 0; + for (const cluster of segmentGraphemes(text)) { + const w = measureGraphemeWidth(cluster); + if (used + w > target) break; + used += w; + out += cluster; + } + return out + ellipsis; +} + +export function wrapTextWidth(text: string, maxWidth: number): string[] { + const cap = Math.max(1, Math.floor(maxWidth)); + if (!text) return []; + + const out: string[] = []; + const rawLines = text.split("\n"); + + for (const rawLine of rawLines) { + const line = rawLine.replace(/\s+$/g, ""); + if (line === "") { + out.push(""); + continue; + } + + const words = line.split(/\s+/g).filter(Boolean); + let current = ""; + let currentWidth = 0; + + for (const word of words) { + const wordWidth = measureTextWidth(word); + const sep = current ? " " : ""; + const sepWidth = current ? 1 : 0; + + if (currentWidth + sepWidth + wordWidth <= cap) { + current += `${sep}${word}`; + currentWidth += sepWidth + wordWidth; + continue; + } + + if (current) { + out.push(current); + current = ""; + currentWidth = 0; + } + + if (wordWidth <= cap) { + current = word; + currentWidth = wordWidth; + continue; + } + + // Hard-wrap a single long token by grapheme width. + let chunk = ""; + let chunkWidth = 0; + for (const cluster of segmentGraphemes(word)) { + const w = measureGraphemeWidth(cluster); + if (chunkWidth + w > cap && chunk) { + out.push(chunk); + chunk = ""; + chunkWidth = 0; + } + if (chunkWidth + w > cap) { + continue; + } + chunk += cluster; + chunkWidth += w; + } + if (chunk) { + current = chunk; + currentWidth = chunkWidth; + } + } + + if (current) out.push(current); + } + + return out; +} diff --git a/packages/renderer/src/grid.ts b/src/renderer/grid.ts similarity index 100% rename from packages/renderer/src/grid.ts rename to src/renderer/grid.ts diff --git a/packages/renderer/src/index.ts b/src/renderer/index.ts similarity index 100% rename from packages/renderer/src/index.ts rename to src/renderer/index.ts diff --git a/packages/renderer/src/pool.ts b/src/renderer/pool.ts similarity index 100% rename from packages/renderer/src/pool.ts rename to src/renderer/pool.ts diff --git a/packages/renderer/src/sanitize.ts b/src/renderer/sanitize.ts similarity index 100% rename from packages/renderer/src/sanitize.ts rename to src/renderer/sanitize.ts diff --git a/packages/renderer/src/types/buffer.ts b/src/renderer/types/buffer.ts similarity index 100% rename from packages/renderer/src/types/buffer.ts rename to src/renderer/types/buffer.ts diff --git a/packages/renderer/src/types/color.ts b/src/renderer/types/color.ts similarity index 100% rename from packages/renderer/src/types/color.ts rename to src/renderer/types/color.ts diff --git a/packages/renderer/src/types/index.ts b/src/renderer/types/index.ts similarity index 100% rename from packages/renderer/src/types/index.ts rename to src/renderer/types/index.ts diff --git a/packages/renderer/src/types/styling.ts b/src/renderer/types/styling.ts similarity index 100% rename from packages/renderer/src/types/styling.ts rename to src/renderer/types/styling.ts diff --git a/src/runtime/app.ts b/src/runtime/app.ts new file mode 100644 index 0000000..533b24b --- /dev/null +++ b/src/runtime/app.ts @@ -0,0 +1,368 @@ +import type { KeyEvent } from "../terminal"; +import { effect, stop, type ReactiveEffect } from "../reactivity"; +import type { Component, ComponentInitContext, ExitReason } from "../component"; +import { Block } from "@/view/primitives"; +import type { ViewElement } from "@/view/types/elements"; +import { + handleComponentKey, + mountComponent, + renderComponent, + unmountComponent, + type MountedComponent, +} from "../view/components"; +import { createRenderer } from "./render-loop"; +import { createErrorContext, createErrorHandler } from "./error-boundary"; +import { createDefaultTerminalAdapter, type TerminalAdapter } from "./terminal-adapter"; +import { createDefaultPlatformAdapter, type PlatformAdapter } from "./platform-adapter"; +import { Profiler, type ProfileOptions } from "./profiler"; + +export interface App { + mount(options?: MountOptions): Promise; + unmount(): void; + exit(code?: number, reason?: ExitReason): void; + getSize(): { rows: number; cols: number }; + onResize(handler: () => void): () => void; + getEnv(name: string): string | undefined; + onExit(handler: (info: { code: number; reason: ExitReason }) => void): () => void; + setExitOutput(output: string | (() => string)): void; + getComponent(): MountedComponent | null; +} + +export interface MountOptions { + rows?: number; + cols?: number; +} + +export type CreateAppOptions = { + onError?: (error: Error, phase: string) => void; + errorLog?: string; + onExit?: () => void; + terminal?: TerminalAdapter; + platform?: PlatformAdapter; + profile?: ProfileOptions; +}; + +let processHasActiveMount = false; + +export function App(root: Component, options: CreateAppOptions = {}): App { + let mounted: MountedComponent | null = null; + let renderEffect: ReactiveEffect | null = null; + let isMounted = false; + let isUnmounting = false; + let disposeResize: (() => void) | null = null; + let unpatchConsole: (() => void) | null = null; + const exitHandlers = new Set<(info: { code: number; reason: ExitReason }) => void>(); + let exitOutput: string | (() => string) | null = null; + let isExiting = false; + + const terminal = options.terminal ?? createDefaultTerminalAdapter(); + const platform = options.platform ?? createDefaultPlatformAdapter(); + const profiler = new Profiler(options.profile ?? {}); + + const mount = async (mountOptions: MountOptions = {}) => { + if (isMounted) return; + + if (processHasActiveMount) { + throw new Error("Only one app may be mounted at a time per process."); + } + processHasActiveMount = true; + + let handleError: ReturnType = (context) => { + const message = context.error.stack ?? context.error.message; + process.stderr.write(`[btuin] error(${context.phase}): ${message}\n`); + }; + + try { + handleError = createErrorHandler( + options.onError ? (context) => options.onError?.(context.error, context.phase) : undefined, + options.errorLog, + ); + + const rows = mountOptions.rows ?? 0; + const cols = mountOptions.cols ?? 0; + + unpatchConsole = terminal.patchConsole(); + terminal.startCapture(); + terminal.setupRawMode(); + terminal.clearScreen(); + + const getSize = () => { + const termSize = terminal.getTerminalSize(); + return { + rows: rows === 0 ? termSize.rows : rows, + cols: cols === 0 ? termSize.cols : cols, + }; + }; + + const pendingKeyEvents: KeyEvent[] = []; + + terminal.onKey((event: KeyEvent) => { + if (!mounted) { + pendingKeyEvents.push(event); + return; + } + + try { + const handled = handleComponentKey(mounted, event); + if (!handled && (event.sequence === "\x03" || (event.ctrl && event.name === "c"))) { + app.exit(0, "sigint"); + } + } catch (error) { + handleError(createErrorContext("key", error, { keyEvent: event })); + } + }); + + mounted = mountComponent(root, undefined, { + exit: (code = 0, reason = "normal") => app.exit(code, reason), + getSize: () => terminal.getTerminalSize(), + onResize: (handler) => platform.onStdoutResize(handler), + getEnv: (name) => platform.getEnv?.(name), + onExit: (handler) => { + exitHandlers.add(handler); + return () => exitHandlers.delete(handler); + }, + setExitOutput: (output) => { + exitOutput = output; + }, + }); + + const renderer = createRenderer({ + getSize, + write: terminal.write, + view: (): ViewElement => { + if (!mounted) return Block(); + return renderComponent(mounted); + }, + getState: () => ({}), + handleError, + profiler: profiler.isEnabled() ? profiler : undefined, + }); + + renderEffect = effect(() => { + if (!mounted) return; + try { + renderer.render(); + } catch (error) { + handleError(createErrorContext("render", error)); + } + }); + + renderer.render(true); + + if (pendingKeyEvents.length && mounted) { + for (const event of pendingKeyEvents.splice(0)) { + try { + handleComponentKey(mounted, event); + } catch (error) { + handleError(createErrorContext("key", error, { keyEvent: event })); + } + } + renderEffect.run(); + } + + if (rows === 0 || cols === 0) { + disposeResize = platform.onStdoutResize(() => { + try { + terminal.clearScreen(); + renderEffect?.run(); + } catch (error) { + handleError(createErrorContext("resize", error)); + } + }); + } + + isMounted = true; + + const exitHandler = () => { + if (isUnmounting) return; + app.unmount(); + }; + + platform.onExit(exitHandler); + platform.onSigint(() => { + app.exit(0, "sigint"); + }); + platform.onSigterm(() => { + app.exit(0, "sigterm"); + }); + } catch (error) { + handleError(createErrorContext("mount", error)); + } finally { + if (!isMounted && disposeResize) { + disposeResize(); + disposeResize = null; + } + if (!isMounted) { + try { + terminal.stopCapture(); + } catch { + // ignore + } + try { + unpatchConsole?.(); + } catch { + // ignore + } + unpatchConsole = null; + } + if (!isMounted) { + processHasActiveMount = false; + } + } + }; + + const unmount = () => { + if (!isMounted || isUnmounting) return; + + isUnmounting = true; + try { + options.onExit?.(); + + if (renderEffect) { + stop(renderEffect); + renderEffect = null; + } + + if (mounted) { + unmountComponent(mounted); + mounted = null; + } + + if (disposeResize) { + disposeResize(); + disposeResize = null; + } + + terminal.stopCapture(); + if (unpatchConsole) { + unpatchConsole(); + unpatchConsole = null; + } + terminal.disposeSingletonCapture(); + profiler.flushSync(); + terminal.cleanupWithoutClear(); + + isMounted = false; + } finally { + isUnmounting = false; + processHasActiveMount = false; + } + }; + + const exit = (code = 0, reason: ExitReason = "normal") => { + if (isUnmounting || isExiting) return; + isExiting = true; + + for (const handler of exitHandlers) { + try { + handler({ code, reason }); + } catch { + // ignore + } + } + + app.unmount(); + + let output: string | null = null; + if (reason === "normal") { + try { + output = typeof exitOutput === "function" ? exitOutput() : exitOutput; + } catch { + output = null; + } + } + + const { rows } = terminal.getTerminalSize(); + terminal.moveCursor(rows, 1); + terminal.write("\n"); + terminal.clearScreen(); + if (output) { + terminal.write(output.endsWith("\n") ? output : `${output}\n`); + } + + platform.exit(code); + isExiting = false; + }; + + const getSize = () => terminal.getTerminalSize(); + + const onResize = (handler: () => void) => platform.onStdoutResize(handler); + const getEnv = (name: string) => platform.getEnv?.(name); + const onExit = (handler: (info: { code: number; reason: ExitReason }) => void) => { + exitHandlers.add(handler); + return () => exitHandlers.delete(handler); + }; + const setExitOutput = (output: string | (() => string)) => { + exitOutput = output; + }; + + const getComponent = () => mounted; + + const app: App = { + mount, + unmount, + exit, + getSize, + onResize, + getEnv, + onExit, + setExitOutput, + getComponent, + }; + return app; +} + +export type AppConfig = { + platform?: Partial; + terminal?: TerminalAdapter; + onError?: (error: Error, phase: string) => void; + errorLog?: string; + onExit?: () => void; + profile?: ProfileOptions; + init: (ctx: ComponentInitContext) => State; + render: (state: State) => ViewElement; +}; + +function normalizePlatformAdapter( + platform?: Partial, +): PlatformAdapter | undefined { + if (!platform) return undefined; + return { + onStdoutResize: (handler) => { + const maybeDispose = platform.onStdoutResize?.(handler); + return typeof maybeDispose === "function" ? maybeDispose : () => {}; + }, + onExit: (handler) => platform.onExit?.(handler) ?? undefined, + onSigint: (handler) => platform.onSigint?.(handler) ?? undefined, + onSigterm: (handler) => platform.onSigterm?.(handler) ?? undefined, + exit: (code) => (platform.exit ? platform.exit(code) : process.exit(code)), + getEnv: (name) => platform.getEnv?.(name), + } satisfies PlatformAdapter; +} + +/** + * Back-compat helper that accepts `init/render` and returns an `App` instance. + */ +export function app any>( + config: Omit>, "init" | "render"> & { + init: Init; + render: (state: ReturnType) => ViewElement; + }, +): App { + const root: Component> = { + __type: "Component", + init: config.init, + render: config.render, + }; + + return App(root, { + terminal: config.terminal, + platform: normalizePlatformAdapter(config.platform) ?? undefined, + onError: config.onError, + errorLog: config.errorLog, + onExit: config.onExit, + profile: config.profile, + }); +} + +export const createApp = app; diff --git a/packages/btuin/src/runtime/error-boundary.ts b/src/runtime/error-boundary.ts similarity index 98% rename from packages/btuin/src/runtime/error-boundary.ts rename to src/runtime/error-boundary.ts index 0ac6a00..5bba847 100644 --- a/packages/btuin/src/runtime/error-boundary.ts +++ b/src/runtime/error-boundary.ts @@ -29,7 +29,7 @@ export type ErrorHandler = (context: ErrorContext) => void; * @returns Error handling function */ import { createWriteStream } from "fs"; -import { getOriginalStderr } from "@btuin/terminal"; +import { getOriginalStderr } from "../terminal"; // ... diff --git a/packages/btuin/src/runtime/index.ts b/src/runtime/index.ts similarity index 100% rename from packages/btuin/src/runtime/index.ts rename to src/runtime/index.ts diff --git a/packages/btuin/src/runtime/platform-adapter.ts b/src/runtime/platform-adapter.ts similarity index 76% rename from packages/btuin/src/runtime/platform-adapter.ts rename to src/runtime/platform-adapter.ts index c527a25..7c6ff2f 100644 --- a/packages/btuin/src/runtime/platform-adapter.ts +++ b/src/runtime/platform-adapter.ts @@ -1,15 +1,17 @@ export interface PlatformAdapter { - onStdoutResize(handler: () => void): void; + onStdoutResize(handler: () => void): () => void; onExit(handler: () => void): void; onSigint(handler: () => void): void; onSigterm(handler: () => void): void; exit(code: number): void; + getEnv?(name: string): string | undefined; } export function createDefaultPlatformAdapter(): PlatformAdapter { return { onStdoutResize: (handler) => { process.stdout.on("resize", handler); + return () => process.stdout.off("resize", handler); }, onExit: (handler) => { process.once("exit", handler); @@ -23,5 +25,6 @@ export function createDefaultPlatformAdapter(): PlatformAdapter { exit: (code) => { process.exit(code); }, + getEnv: (name) => process.env[name], }; } diff --git a/packages/btuin/src/runtime/profiler.ts b/src/runtime/profiler.ts similarity index 99% rename from packages/btuin/src/runtime/profiler.ts rename to src/runtime/profiler.ts index ad3e673..2420362 100644 --- a/packages/btuin/src/runtime/profiler.ts +++ b/src/runtime/profiler.ts @@ -1,4 +1,4 @@ -import type { Buffer2D } from "@btuin/renderer"; +import type { Buffer2D } from "../renderer"; import path from "node:path"; import { mkdirSync, writeFileSync } from "node:fs"; diff --git a/packages/btuin/src/runtime/render-loop.ts b/src/runtime/render-loop.ts similarity index 72% rename from packages/btuin/src/runtime/render-loop.ts rename to src/runtime/render-loop.ts index 59a8786..916da7d 100644 --- a/packages/btuin/src/runtime/render-loop.ts +++ b/src/runtime/render-loop.ts @@ -10,13 +10,40 @@ import { renderDiff, type Buffer2D, type DiffStats, -} from "@btuin/renderer"; -import { layout, renderElement } from "../layout"; -import type { ViewElement } from "../view/types/elements"; -import { isBlock } from "../view/types/elements"; +} from "@/renderer"; +import { layout, renderElement } from "@/layout"; +import type { ViewElement } from "@/view/types/elements"; +import { isBlock } from "@/view/types/elements"; import { createErrorContext } from "./error-boundary"; import type { Profiler } from "./profiler"; -import type { ComputedLayout } from "@btuin/layout-engine"; +import type { ComputedLayout } from "@/layout-engine"; + +export interface BufferPoolLike { + acquire(): Buffer2D; + release(buffer: Buffer2D): void; +} + +export interface RenderLoopDeps { + FlatBuffer: typeof FlatBuffer; + getGlobalBufferPool: (rows: number, cols: number) => BufferPoolLike; + renderDiff: (prev: Buffer2D, next: Buffer2D, stats?: DiffStats) => string; + layout: (root: ViewElement, containerSize?: { width: number; height: number }) => ComputedLayout; + renderElement: ( + element: ViewElement, + buffer: Buffer2D, + layoutMap: ComputedLayout, + parentX?: number, + parentY?: number, + ) => void; +} + +const defaultDeps: RenderLoopDeps = { + FlatBuffer, + getGlobalBufferPool, + renderDiff, + layout, + renderElement, +}; /** * Terminal size configuration @@ -42,6 +69,8 @@ export interface RenderLoopConfig { handleError: (context: import("./error-boundary").ErrorContext) => void; /** Optional profiler */ profiler?: Profiler; + /** Optional dependency overrides (avoid `mock.module()` leakage between tests) */ + deps?: Partial; } /** @@ -59,11 +88,13 @@ interface RenderLoopState { * @returns Object containing render function and state getter */ export function createRenderer(config: RenderLoopConfig) { + const deps: RenderLoopDeps = { ...defaultDeps, ...config.deps }; + // Initialize with current size const initialSize = config.getSize(); // Buffer pool tied to current terminal size - let pool = getGlobalBufferPool(initialSize.rows, initialSize.cols); + let pool = deps.getGlobalBufferPool(initialSize.rows, initialSize.cols); let state: RenderLoopState = { currentSize: initialSize, @@ -88,7 +119,7 @@ export function createRenderer(config: RenderLoopConfig) { if (sizeChanged || forceFullRedraw) { // When size changes, re-create a pool bound to the new dimensions state.currentSize = newSize; - pool = getGlobalBufferPool(state.currentSize.rows, state.currentSize.cols); + pool = deps.getGlobalBufferPool(state.currentSize.rows, state.currentSize.cols); // Return previous buffer to the old pool (if any) and acquire a fresh one pool.release(state.prevBuffer); @@ -111,12 +142,12 @@ export function createRenderer(config: RenderLoopConfig) { !sizeChanged ? prevLayoutResult : (config.profiler?.measure(frame, "layoutMs", () => - layout(rootElement, { + deps.layout(rootElement, { width: state.currentSize.cols, height: state.currentSize.rows, }), ) ?? - layout(rootElement, { + deps.layout(rootElement, { width: state.currentSize.cols, height: state.currentSize.rows, })); @@ -130,15 +161,15 @@ export function createRenderer(config: RenderLoopConfig) { // Ensure prev/next buffers differ; diffing the same instance yields no output. buf = pool.acquire(); if (buf === state.prevBuffer) { - buf = new FlatBuffer(state.currentSize.rows, state.currentSize.cols); + buf = new deps.FlatBuffer(state.currentSize.rows, state.currentSize.cols); } } if (config.profiler && frame) { config.profiler.measure(frame, "renderMs", () => { - renderElement(rootElement, buf, layoutResult, 0, 0); + deps.renderElement(rootElement, buf, layoutResult, 0, 0); }); } else { - renderElement(rootElement, buf, layoutResult, 0, 0); + deps.renderElement(rootElement, buf, layoutResult, 0, 0); } config.profiler?.drawHud(buf); @@ -157,15 +188,19 @@ export function createRenderer(config: RenderLoopConfig) { : undefined; const prevForDiff = forceFullRedraw - ? new FlatBuffer(state.currentSize.rows, state.currentSize.cols) + ? new deps.FlatBuffer(state.currentSize.rows, state.currentSize.cols) : state.prevBuffer; const output = - config.profiler?.measure(frame, "diffMs", () => renderDiff(prevForDiff, buf, diffStats)) ?? - renderDiff(prevForDiff, buf); + config.profiler?.measure(frame, "diffMs", () => + deps.renderDiff(prevForDiff, buf, diffStats), + ) ?? deps.renderDiff(prevForDiff, buf); const safeOutput = output === "" - ? renderDiff(new FlatBuffer(state.currentSize.rows, state.currentSize.cols), buf) + ? deps.renderDiff( + new deps.FlatBuffer(state.currentSize.rows, state.currentSize.cols), + buf, + ) : output; if (frame && diffStats) { config.profiler?.recordDiffStats(frame, diffStats); diff --git a/packages/btuin/src/runtime/terminal-adapter.ts b/src/runtime/terminal-adapter.ts similarity index 74% rename from packages/btuin/src/runtime/terminal-adapter.ts rename to src/runtime/terminal-adapter.ts index 6d53584..cadfe6f 100644 --- a/packages/btuin/src/runtime/terminal-adapter.ts +++ b/src/runtime/terminal-adapter.ts @@ -1,12 +1,14 @@ -import type { KeyEvent } from "@btuin/terminal"; -import * as terminal from "@btuin/terminal"; +import type { KeyEvent } from "@/terminal"; +import * as terminal from "@/terminal"; export interface TerminalAdapter { setupRawMode(): void; clearScreen(): void; + moveCursor(row: number, col: number): void; cleanupWithoutClear(): void; - patchConsole(): void; + patchConsole(): () => void; startCapture(): void; + stopCapture(): void; onKey(handler: (event: KeyEvent) => void): void; getTerminalSize(): { rows: number; cols: number }; disposeSingletonCapture(): void; @@ -17,9 +19,11 @@ export function createDefaultTerminalAdapter(): TerminalAdapter { return { setupRawMode: terminal.setupRawMode, clearScreen: terminal.clearScreen, + moveCursor: terminal.moveCursor, cleanupWithoutClear: terminal.cleanupWithoutClear, patchConsole: terminal.patchConsole, startCapture: terminal.startCapture, + stopCapture: terminal.stopCapture, onKey: terminal.onKey, getTerminalSize: terminal.getTerminalSize, disposeSingletonCapture: terminal.disposeSingletonCapture, diff --git a/src/sanitize.ts b/src/sanitize.ts new file mode 100644 index 0000000..9f984e1 --- /dev/null +++ b/src/sanitize.ts @@ -0,0 +1 @@ +export * from "./renderer/sanitize"; diff --git a/packages/terminal/src/capture.ts b/src/terminal/capture.ts similarity index 100% rename from packages/terminal/src/capture.ts rename to src/terminal/capture.ts diff --git a/packages/terminal/src/index.ts b/src/terminal/index.ts similarity index 100% rename from packages/terminal/src/index.ts rename to src/terminal/index.ts diff --git a/packages/terminal/src/io.ts b/src/terminal/io.ts similarity index 100% rename from packages/terminal/src/io.ts rename to src/terminal/io.ts diff --git a/packages/terminal/src/raw.ts b/src/terminal/raw.ts similarity index 98% rename from packages/terminal/src/raw.ts rename to src/terminal/raw.ts index 014e066..69fe4f3 100644 --- a/packages/terminal/src/raw.ts +++ b/src/terminal/raw.ts @@ -219,12 +219,6 @@ function handleData(chunk: string) { for (const handler of terminalState.getKeyHandlers()) { handler(event); } - - // Handle Ctrl+C for graceful exit - if (event.sequence === "\x03" || (event.ctrl && event.name === "c")) { - cleanup(); - process.exit(0); - } } /** @@ -244,7 +238,7 @@ export function setupRawMode() { process.stdin.resume(); process.stdin.setEncoding("utf8"); process.stdin.on("data", handleData); - process.once("exit", cleanup); + process.once("exit", cleanupWithoutClear); hideCursor(); terminalState.setRawModeActive(true); diff --git a/packages/terminal/src/types/index.ts b/src/terminal/types/index.ts similarity index 100% rename from packages/terminal/src/types/index.ts rename to src/terminal/types/index.ts diff --git a/packages/terminal/src/types/key-event.ts b/src/terminal/types/key-event.ts similarity index 100% rename from packages/terminal/src/types/key-event.ts rename to src/terminal/types/key-event.ts diff --git a/packages/btuin/src/types/index.ts b/src/types/index.ts similarity index 100% rename from packages/btuin/src/types/index.ts rename to src/types/index.ts diff --git a/packages/btuin/src/view/base.ts b/src/view/base.ts similarity index 77% rename from packages/btuin/src/view/base.ts rename to src/view/base.ts index e4eda26..380dc76 100644 --- a/packages/btuin/src/view/base.ts +++ b/src/view/base.ts @@ -1,11 +1,18 @@ import type { KeyEventHook } from "./components/lifecycle"; -import type { KeyEvent } from "@btuin/terminal"; -import type { OutlineOptions } from "@btuin/renderer"; -import type { Dimension, LayoutStyle } from "@btuin/layout-engine"; +import type { KeyEvent } from "../terminal"; +import type { OutlineOptions } from "../renderer"; +import type { Dimension, LayoutStyle } from "../layout-engine"; // 1. 基本的なプロパティ定義(スタイリング以外) export interface ViewProps { + /** + * Stable identifier for layout, focus, diffing, etc. + * + * Historically this was called `key`; during refactors it was also exposed as + * `identifier`. Both are supported for compatibility. + */ key?: string; + identifier?: string; focusKey?: string; onFocus?: (e: KeyEvent) => void; // Taffy用のスタイル定義を含める @@ -24,12 +31,17 @@ export abstract class BaseView implements ViewProps { // 実際のデータ保持場所 public style: NonNullable = {}; public key?: string; + public identifier?: string; public focusKey?: string; public keyHooks: KeyEventHook[] = []; constructor(props: ViewProps = {}) { this.style = { ...props.style }; - if (props.key) this.key = props.key; + const key = props.key ?? props.identifier; + if (key) { + this.key = key; + this.identifier = key; + } if (props.focusKey) this.focusKey = props.focusKey; } @@ -92,6 +104,13 @@ export abstract class BaseView implements ViewProps { setKey(value: string): this { this.key = value; + this.identifier = value; + return this; + } + + setIdentifier(value: string): this { + this.key = value; + this.identifier = value; return this; } diff --git a/src/view/components/component.ts b/src/view/components/component.ts new file mode 100644 index 0000000..57795de --- /dev/null +++ b/src/view/components/component.ts @@ -0,0 +1,293 @@ +import type { KeyEvent } from "../../terminal"; +import type { Component, ComponentInitContext, ExitReason, RuntimeContext } from "../../component"; +import type { ViewElement } from "../types/elements"; +import { isBlock } from "../types/elements"; +import { + createComponentInstance, + invokeHooks, + invokeKeyHooks, + onKey, + onMounted, + onTick, + onUnmounted, + setCurrentInstance, + startTickTimers, + unmountInstance, + type ComponentInstance, +} from "./lifecycle"; + +const INSTANCES = Symbol.for("btuin.component.instances"); + +export type { Component }; + +export type RenderFunction = () => ViewElement; + +export type PropDefinition = + | StringConstructor + | NumberConstructor + | BooleanConstructor + | ObjectConstructor + | ArrayConstructor + | FunctionConstructor + | { + type?: any; + required?: boolean; + default?: any; + validator?: (value: any) => boolean; + }; + +export type PropsOptions = Record; + +export interface DefineComponentOptions = Record> { + name?: string; + props?: PropsOptions; + setup: (props: Props) => RenderFunction; +} + +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function normalizeProps(options: PropsOptions | undefined, raw: unknown): Record { + const rawProps = isPlainRecord(raw) ? raw : {}; + if (!options) return { ...rawProps }; + + const out: Record = { ...rawProps }; + + for (const [name, defRaw] of Object.entries(options)) { + const def = + typeof defRaw === "function" + ? ({ type: defRaw } as const) + : (defRaw as Exclude); + + const hasValue = Object.prototype.hasOwnProperty.call(rawProps, name); + let value = hasValue ? rawProps[name] : undefined; + + if (value === undefined) { + if (def && typeof def === "object" && "default" in def && def.default !== undefined) { + value = typeof def.default === "function" ? def.default() : def.default; + } + } + + if ( + def && + typeof def === "object" && + "required" in def && + def.required && + value === undefined + ) { + throw new Error(`Missing required prop: ${name}`); + } + + if ( + def && + typeof def === "object" && + "validator" in def && + def.validator && + value !== undefined + ) { + if (!def.validator(value)) { + throw new Error(`Invalid prop: ${name}`); + } + } + + out[name] = value; + } + + return out; +} + +/** + * Vue-like defineComponent helper. + * This returns a core `Component` that can be mounted by the runtime, + * while keeping the original options available for inspection/testing. + */ +export function btuin = Record>( + options: DefineComponentOptions, +): Component<{ render: RenderFunction }> & { options: DefineComponentOptions } { + return { + __type: "Component", + options, + init: (ctx: ComponentInitContext) => { + const props = normalizeProps(options.props, (ctx as any).props); + const render = options.setup(props as Props); + return { render }; + }, + render: ({ render }) => render(), + }; +} + +export interface MountedComponent { + instance: ComponentInstance; + render: () => ViewElement; + renderEffect: any; + lastElement: ViewElement | null; +} + +function getInstanceStore(component: Component): WeakMap { + const anyComponent = component as any; + if (!anyComponent[INSTANCES]) { + anyComponent[INSTANCES] = new WeakMap(); + } + return anyComponent[INSTANCES] as WeakMap; +} + +/** + * Mounts a component and creates its instance. + * + * @internal + */ +export function mountComponent( + component: Component, + keyOrProps?: any, + runtime?: RuntimeContext, +): MountedComponent { + if (!isComponent(component)) { + throw new Error("mountComponent() expects a Component."); + } + + const maybeOptions = (component as any).options as DefineComponentOptions | undefined; + const treatSecondArgAsProps = + runtime === undefined && !!maybeOptions?.props && isPlainRecord(keyOrProps); + const mountKey = treatSecondArgAsProps ? Symbol() : (keyOrProps ?? Symbol()); + const rawProps = treatSecondArgAsProps ? keyOrProps : undefined; + + const store = getInstanceStore(component); + const existing = store.get(mountKey); + if (existing) return existing; + + const instance = createComponentInstance(); + const safeRuntime = + runtime ?? + ({ + exit: () => {}, + getSize: () => ({ rows: 0, cols: 0 }), + onResize: () => () => {}, + getEnv: () => undefined, + onExit: (_handler) => () => {}, + setExitOutput: () => {}, + } satisfies RuntimeContext); + + const initContext: ComponentInitContext = { + onKey: (fn) => onKey(fn), + onTick: (fn, interval) => onTick(fn, interval), + onMounted: (fn) => onMounted(fn), + onUnmounted: (fn) => onUnmounted(fn), + runtime: safeRuntime, + cleanup: (fn) => { + instance.effects.push(fn); + }, + exit: (code, reason: ExitReason | undefined) => safeRuntime.exit(code, reason), + getSize: () => safeRuntime.getSize(), + onResize: (handler) => { + instance.effects.push(safeRuntime.onResize(handler)); + }, + getEnv: (name) => safeRuntime.getEnv(name), + onExit: (handler) => { + instance.effects.push(safeRuntime.onExit(handler)); + }, + setExitOutput: (output) => safeRuntime.setExitOutput(output), + }; + + (initContext as any).props = rawProps; + + setCurrentInstance(instance); + let state: any = undefined; + try { + state = component.init?.(initContext); + } finally { + setCurrentInstance(null); + } + + const mounted: MountedComponent = { + instance, + render: () => component.render(state), + renderEffect: null, + lastElement: null, + }; + + store.set(mountKey, mounted); + + instance.isMounted = true; + invokeHooks(instance.mountedHooks); + startTickTimers(instance); + + return mounted; +} + +/** + * Unmounts a component and cleans up its instance. + * + * @internal + */ +export function unmountComponent(mounted: MountedComponent) { + unmountInstance(mounted.instance); + if (mounted.renderEffect && mounted.renderEffect.effect) { + mounted.renderEffect.effect.stop(); + } +} + +/** + * Renders a component and returns the view element. + * + * @internal + */ +export function renderComponent(mounted: MountedComponent): ViewElement { + const { instance, render } = mounted; + + if (instance.isMounted) { + invokeHooks(instance.beforeUpdateHooks); + } + + const element = render(); + mounted.lastElement = element; + + if (instance.isMounted) { + invokeHooks(instance.updatedHooks); + } + + return element; +} + +function traverseKeyHandlers( + element: ViewElement, + visitor: (element: ViewElement) => boolean, +): boolean { + if (isBlock(element)) { + for (let i = element.children.length - 1; i >= 0; i--) { + const child = element.children[i]!; + if (traverseKeyHandlers(child, visitor)) { + return true; + } + } + } + + if (element.keyHooks.length > 0 && visitor(element)) { + return true; + } + + return false; +} + +/** + * Handles key events for a component. + * Returns true if the event was handled and should stop propagation. + * + * @internal + */ +export function handleComponentKey(mounted: MountedComponent, event: KeyEvent): boolean { + if (mounted.lastElement) { + const handled = traverseKeyHandlers(mounted.lastElement, (element) => + invokeKeyHooks(element.keyHooks, event), + ); + if (handled) return true; + } + + return invokeKeyHooks(mounted.instance.keyHooks, event); +} + +export function isComponent(value: any): value is Component { + return value && typeof value === "object" && value.__type === "Component"; +} diff --git a/packages/btuin/src/view/components/index.ts b/src/view/components/index.ts similarity index 100% rename from packages/btuin/src/view/components/index.ts rename to src/view/components/index.ts diff --git a/packages/btuin/src/view/components/lifecycle.ts b/src/view/components/lifecycle.ts similarity index 95% rename from packages/btuin/src/view/components/lifecycle.ts rename to src/view/components/lifecycle.ts index 63d47fe..fa34012 100644 --- a/packages/btuin/src/view/components/lifecycle.ts +++ b/src/view/components/lifecycle.ts @@ -5,7 +5,7 @@ * These hooks are called during different phases of a component's lifecycle. */ -import type { KeyEvent } from "@btuin/terminal"; +import type { KeyEvent } from "../../terminal"; export type LifecycleHook = () => void; export type KeyEventHook = (event: KeyEvent) => void | boolean; @@ -92,7 +92,7 @@ function injectHook( hook: LifecycleHook, ) { if (!currentInstance) { - console.warn(`${type} hook called outside of component setup()`); + console.warn(`${type} hook called outside of component init()`); return; } currentInstance[type].push(hook); @@ -103,7 +103,7 @@ function injectHook( * * @example * ```typescript - * setup() { + * init() { * onMounted(() => { * console.log('Component mounted!'); * }); @@ -122,7 +122,7 @@ export function onMounted(hook: LifecycleHook) { * * @example * ```typescript - * setup() { + * init() { * const timer = setInterval(() => {}, 1000); * * onUnmounted(() => { @@ -142,7 +142,7 @@ export function onUnmounted(hook: LifecycleHook) { * * @example * ```typescript - * setup() { + * init() { * onUpdated(() => { * console.log('Component updated!'); * }); @@ -160,7 +160,7 @@ export function onUpdated(hook: LifecycleHook) { * * @example * ```typescript - * setup() { + * init() { * onBeforeUpdate(() => { * console.log('About to update...'); * }); @@ -180,7 +180,7 @@ export function onBeforeUpdate(hook: LifecycleHook) { * * @example * ```typescript - * setup() { + * init() { * onKey((event) => { * if (event.name === 'up') { * count.value++; @@ -194,7 +194,7 @@ export function onBeforeUpdate(hook: LifecycleHook) { */ export function onKey(hook: KeyEventHook) { if (!currentInstance) { - console.warn("onKey called outside of component setup()"); + console.warn("onKey called outside of component init()"); return; } currentInstance.keyHooks.push(hook); @@ -206,7 +206,7 @@ export function onKey(hook: KeyEventHook) { * * @example * ```typescript - * setup() { + * init() { * const count = ref(0); * * // Increment every second @@ -221,7 +221,7 @@ export function onKey(hook: KeyEventHook) { */ export function onTick(hook: TickHook, interval = 1000) { if (!currentInstance) { - console.warn("onTick called outside of component setup()"); + console.warn("onTick called outside of component init()"); return; } currentInstance.tickHooks.push({ hook, interval }); diff --git a/packages/btuin/src/view/layout.ts b/src/view/layout.ts similarity index 100% rename from packages/btuin/src/view/layout.ts rename to src/view/layout.ts diff --git a/packages/btuin/src/view/primitives/block.ts b/src/view/primitives/block.ts similarity index 100% rename from packages/btuin/src/view/primitives/block.ts rename to src/view/primitives/block.ts diff --git a/packages/btuin/src/view/primitives/index.ts b/src/view/primitives/index.ts similarity index 100% rename from packages/btuin/src/view/primitives/index.ts rename to src/view/primitives/index.ts diff --git a/packages/btuin/src/view/primitives/spacer.ts b/src/view/primitives/spacer.ts similarity index 100% rename from packages/btuin/src/view/primitives/spacer.ts rename to src/view/primitives/spacer.ts diff --git a/packages/btuin/src/view/primitives/text.ts b/src/view/primitives/text.ts similarity index 100% rename from packages/btuin/src/view/primitives/text.ts rename to src/view/primitives/text.ts diff --git a/packages/btuin/src/view/types/elements.ts b/src/view/types/elements.ts similarity index 100% rename from packages/btuin/src/view/types/elements.ts rename to src/view/types/elements.ts diff --git a/packages/btuin/src/view/types/focus.ts b/src/view/types/focus.ts similarity index 88% rename from packages/btuin/src/view/types/focus.ts rename to src/view/types/focus.ts index 5e35d1f..b268e6e 100644 --- a/packages/btuin/src/view/types/focus.ts +++ b/src/view/types/focus.ts @@ -1,6 +1,6 @@ -import type { KeyEvent } from "@btuin/terminal"; +import type { KeyEvent } from "../../terminal"; import type { ViewElement } from "./elements"; -import type { Rect } from "@btuin/layout-engine"; +import type { Rect } from "../../layout-engine"; export interface FocusContext { focusables: FocusTarget[]; diff --git a/src/watch.ts b/src/watch.ts new file mode 100644 index 0000000..8649b4c --- /dev/null +++ b/src/watch.ts @@ -0,0 +1 @@ +export * from "./reactivity/watch"; diff --git a/tests/layout-engine/index.spec.ts b/tests/layout-engine/index.spec.ts new file mode 100644 index 0000000..44acad2 --- /dev/null +++ b/tests/layout-engine/index.spec.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "bun:test"; +import { computeLayout, type LayoutInputNode } from "@/layout-engine"; + +describe("Layout Engine", () => { + it("should compute a simple layout", () => { + const root: LayoutInputNode = { + identifier: "root", + type: "block", + width: 100, + height: 100, + children: [ + { + identifier: "child1", + type: "block", + width: 50, + height: 50, + }, + { + identifier: "child2", + type: "block", + width: 50, + height: 50, + flexGrow: 1, + }, + ], + }; + + const layout = computeLayout(root); + + expect(layout.root).toBeDefined(); + expect(layout.root?.width).toBe(100); + expect(layout.root?.height).toBe(100); + expect(layout.root?.x).toBe(0); + expect(layout.root?.y).toBe(0); + + expect(layout.child1).toBeDefined(); + expect(layout.child1?.width).toBe(50); + expect(layout.child1?.height).toBe(50); + expect(layout.child1?.x).toBe(0); + expect(layout.child1?.y).toBe(0); + + expect(layout.child2).toBeDefined(); + // The default flexDirection is row. + // So child2 will be to the right of child1. + expect(layout.child2?.width).toBe(50); + expect(layout.child2?.height).toBe(50); + expect(layout.child2?.x).toBe(50); + expect(layout.child2?.y).toBe(0); + }); + + it("should compute flex layout", () => { + const root: LayoutInputNode = { + identifier: "root", + type: "block", + width: 200, + height: 100, + flexDirection: "row", + padding: 10, + gap: 10, + children: [ + { + identifier: "child1", + type: "block", + width: 50, + height: 50, + }, + { + identifier: "child2", + type: "block", + flexGrow: 1, + height: 50, + }, + ], + }; + + const layout = computeLayout(root); + + expect(layout.root).toBeDefined(); + expect(layout.root?.width).toBe(200); + expect(layout.root?.height).toBe(100); + + expect(layout.child1).toBeDefined(); + expect(layout.child1?.width).toBe(50); + expect(layout.child1?.height).toBe(50); + expect(layout.child1?.x).toBe(10); + expect(layout.child1?.y).toBe(10); + + // child2 should be to the right of child1, with a gap. + // The available width for children is 200 - 2*10 (padding) = 180. + // child1 takes 50. Gap is 10. Remaining space is 180 - 50 - 10 = 120. + // child2 has flexGrow: 1, so it takes all remaining space. + expect(layout.child2).toBeDefined(); + expect(layout.child2?.width).toBe(120); + expect(layout.child2?.height).toBe(50); + expect(layout.child2?.x).toBe(10 + 50 + 10); // root.padding + child1.width + gap + expect(layout.child2?.y).toBe(10); + }); +}); diff --git a/packages/btuin/tests/layout/focus.test.ts b/tests/layout/focus.test.ts similarity index 97% rename from packages/btuin/tests/layout/focus.test.ts rename to tests/layout/focus.test.ts index 4816df0..46220ad 100644 --- a/packages/btuin/tests/layout/focus.test.ts +++ b/tests/layout/focus.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "bun:test"; import { collectFocusTargets } from "../../src/layout/focus"; import { Block, Text } from "../../src/view/primitives"; import type { ViewElement } from "../../src/view/types/elements"; -import type { ComputedLayout } from "@btuin/layout-engine"; +import type { ComputedLayout } from "@/layout-engine"; describe("collectFocusTargets", () => { it("should collect focusable elements from a simple tree", () => { diff --git a/packages/btuin/tests/layout/index.test.ts b/tests/layout/index.test.ts similarity index 93% rename from packages/btuin/tests/layout/index.test.ts rename to tests/layout/index.test.ts index 2b86edf..ebfdb2b 100644 --- a/packages/btuin/tests/layout/index.test.ts +++ b/tests/layout/index.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "bun:test"; import { createLayout } from "../../src/layout"; import { Block, Text } from "../../src/view/primitives"; import { LayoutBoundary } from "../../src/view/layout"; -import type { LayoutInputNode, ComputedLayout } from "@btuin/layout-engine"; +import type { LayoutInputNode, ComputedLayout } from "@/layout-engine"; // Mock the layout engine let receivedLayoutNode: LayoutInputNode | null = null; @@ -15,7 +15,6 @@ const mockComputedLayout: ComputedLayout = { describe("layout", () => { it("should convert ViewElement tree to LayoutInputNode tree and compute layout", () => { const { layout } = createLayout({ - initLayoutEngine: async () => {}, computeLayout: (node: LayoutInputNode): ComputedLayout => { receivedLayoutNode = node; return mockComputedLayout; @@ -46,7 +45,6 @@ describe("layout", () => { it("should assign keys to elements without them", () => { const { layout } = createLayout({ - initLayoutEngine: async () => {}, computeLayout: (node: LayoutInputNode): ComputedLayout => { receivedLayoutNode = node; return mockComputedLayout; @@ -65,7 +63,6 @@ describe("layout", () => { it("should resolve root size", () => { const { layout } = createLayout({ - initLayoutEngine: async () => {}, computeLayout: (node: LayoutInputNode): ComputedLayout => { receivedLayoutNode = node; return mockComputedLayout; @@ -81,7 +78,6 @@ describe("layout", () => { it("should resolve nested percent dimensions", () => { const { layout } = createLayout({ - initLayoutEngine: async () => {}, computeLayout: (node: LayoutInputNode): ComputedLayout => { receivedLayoutNode = node; return mockComputedLayout; @@ -100,7 +96,6 @@ describe("layout", () => { it("should trim children that exceed the layout boundary", () => { receivedLayoutNode = null; const { layout } = createLayout({ - initLayoutEngine: async () => {}, computeLayout: (node: LayoutInputNode): ComputedLayout => { receivedLayoutNode = node; expect(receivedLayoutNode?.children?.length).toBe(2); diff --git a/packages/btuin/tests/layout/justify.test.ts b/tests/layout/justify.test.ts similarity index 90% rename from packages/btuin/tests/layout/justify.test.ts rename to tests/layout/justify.test.ts index 7f12dbd..0b2b99e 100644 --- a/packages/btuin/tests/layout/justify.test.ts +++ b/tests/layout/justify.test.ts @@ -1,12 +1,10 @@ import { describe, expect, test } from "bun:test"; import { HStack, VStack } from "../../src/view/layout"; import { Text } from "../../src/view/primitives"; -import { initLayoutEngine, layout } from "../../src/layout/index"; +import { layout } from "../../src/layout/index"; describe("btuin layout centering", () => { test("justify:center centers children vertically in a column", async () => { - await initLayoutEngine(); - const root = VStack([Text("Counter"), Text("Count: 0")]) .width("100%") .height("100%") @@ -27,8 +25,6 @@ describe("btuin layout centering", () => { }); test("blocks shrink to their content when not sized explicitly", async () => { - await initLayoutEngine(); - const root = VStack([HStack([Text("A"), Text("B")]).gap(1)]) .width("100%") .height(5) diff --git a/packages/btuin/tests/layout/renderer.test.ts b/tests/layout/renderer.test.ts similarity index 92% rename from packages/btuin/tests/layout/renderer.test.ts rename to tests/layout/renderer.test.ts index 2c7d648..6afd2b6 100644 --- a/packages/btuin/tests/layout/renderer.test.ts +++ b/tests/layout/renderer.test.ts @@ -1,10 +1,8 @@ import { describe, expect, test, beforeAll } from "bun:test"; import { renderElement } from "../../src/layout/renderer"; -import { layout, initLayoutEngine } from "../../src/layout"; +import { layout } from "../../src/layout"; import { Block, Text } from "../../src/view/primitives"; -import { createBuffer } from "@btuin/renderer"; -import type { Buffer2D } from "@btuin/renderer"; -import { resolveColor } from "@btuin/renderer"; +import { createBuffer, resolveColor, type Buffer2D } from "@/renderer"; // Helper to visualize the buffer function bufferToString(buf: Buffer2D): string { @@ -20,9 +18,7 @@ function bufferToString(buf: Buffer2D): string { } describe("renderElement", () => { - beforeAll(async () => { - await initLayoutEngine(); - }); + beforeAll(async () => {}); test("should render a simple text element", () => { const root = Text({ value: "Hello" }).setKey("root").build(); diff --git a/packages/btuin/tests/layout/zstack.test.ts b/tests/layout/zstack.test.ts similarity index 81% rename from packages/btuin/tests/layout/zstack.test.ts rename to tests/layout/zstack.test.ts index b45a42f..1910fd2 100644 --- a/packages/btuin/tests/layout/zstack.test.ts +++ b/tests/layout/zstack.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, beforeAll } from "bun:test"; -import { layout, initLayoutEngine, renderElement } from "../../src/layout"; +import { layout, renderElement } from "../../src/layout"; import { ZStack, Text } from "../../src"; -import { createBuffer } from "@btuin/renderer"; +import { createBuffer } from "@/renderer"; function bufferToString(buf: ReturnType): string { let out = ""; @@ -15,9 +15,7 @@ function bufferToString(buf: ReturnType): string { } describe("ZStack", () => { - beforeAll(async () => { - await initLayoutEngine(); - }); + beforeAll(async () => {}); test("should overlay children at the same origin", () => { const root = ZStack([Text("Hello"), Text("X")]) diff --git a/packages/reactivity/tests/computed.test.ts b/tests/reactivity/computed.test.ts similarity index 96% rename from packages/reactivity/tests/computed.test.ts rename to tests/reactivity/computed.test.ts index ac34fb8..5959498 100644 --- a/packages/reactivity/tests/computed.test.ts +++ b/tests/reactivity/computed.test.ts @@ -1,7 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { computed } from "../src/computed"; -import { ref } from "../src/ref"; -import { effect } from "../src/effect"; +import { computed, effect, ref } from "@/reactivity"; describe("computed", () => { it("should return an unwrapped value", () => { diff --git a/packages/reactivity/tests/effect.test.ts b/tests/reactivity/effect.test.ts similarity index 97% rename from packages/reactivity/tests/effect.test.ts rename to tests/reactivity/effect.test.ts index 6237085..70e1806 100644 --- a/packages/reactivity/tests/effect.test.ts +++ b/tests/reactivity/effect.test.ts @@ -1,14 +1,14 @@ import { describe, it, expect } from "bun:test"; import { effect, + reactive, stop, track, trigger, pauseTracking, resetTracking, type ReactiveEffect, -} from "../src/effect"; -import { reactive } from "../src/reactive"; +} from "@/reactivity"; describe("effect", () => { it("should run the passed function once", () => { diff --git a/packages/reactivity/tests/reactive.test.ts b/tests/reactivity/reactive.test.ts similarity index 91% rename from packages/reactivity/tests/reactive.test.ts rename to tests/reactivity/reactive.test.ts index 03bdce0..10c85ca 100644 --- a/packages/reactivity/tests/reactive.test.ts +++ b/tests/reactivity/reactive.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { computed, effect, ref } from "../src/index"; +import { computed, effect, ref } from "@/reactivity"; describe("@btuin/reactivity", () => { test("ref triggers effect", () => { diff --git a/packages/reactivity/tests/ref.test.ts b/tests/reactivity/ref.test.ts similarity index 95% rename from packages/reactivity/tests/ref.test.ts rename to tests/reactivity/ref.test.ts index e70d712..bcc2cf7 100644 --- a/packages/reactivity/tests/ref.test.ts +++ b/tests/reactivity/ref.test.ts @@ -1,7 +1,15 @@ import { describe, it, expect } from "bun:test"; -import { ref, isRef, unref, toRef, toRefs, shallowRef, customRef } from "../src/ref"; -import { effect } from "../src/effect"; -import { reactive } from "../src/reactive"; +import { + customRef, + effect, + isRef, + reactive, + ref, + shallowRef, + toRef, + toRefs, + unref, +} from "@/reactivity"; describe("ref", () => { it("should hold a value", () => { diff --git a/packages/reactivity/tests/watch.test.ts b/tests/reactivity/watch.test.ts similarity index 96% rename from packages/reactivity/tests/watch.test.ts rename to tests/reactivity/watch.test.ts index 099a62e..c32cd45 100644 --- a/packages/reactivity/tests/watch.test.ts +++ b/tests/reactivity/watch.test.ts @@ -1,7 +1,5 @@ import { describe, it, expect, mock } from "bun:test"; -import { watch, watchEffect } from "../src/watch"; -import { ref } from "../src/ref"; -import { reactive } from "../src/reactive"; +import { reactive, ref, watch, watchEffect } from "@/reactivity"; describe("watch", () => { it("should watch a single ref", () => { diff --git a/packages/renderer/tests/buffer.test.ts b/tests/renderer/buffer.test.ts similarity index 96% rename from packages/renderer/tests/buffer.test.ts rename to tests/renderer/buffer.test.ts index 78b4038..e2900f8 100644 --- a/packages/renderer/tests/buffer.test.ts +++ b/tests/renderer/buffer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { FlatBuffer } from "../src/buffer"; +import { FlatBuffer } from "@/renderer/buffer"; describe("FlatBuffer", () => { const rows = 5; diff --git a/packages/renderer/tests/colors.test.ts b/tests/renderer/colors.test.ts similarity index 97% rename from packages/renderer/tests/colors.test.ts rename to tests/renderer/colors.test.ts index 06da70f..ff73b6f 100644 --- a/packages/renderer/tests/colors.test.ts +++ b/tests/renderer/colors.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { resolveColor } from "../src/colors"; +import { resolveColor } from "@/renderer/colors"; describe("resolveColor", () => { // Test named colors diff --git a/packages/renderer/tests/diff.test.ts b/tests/renderer/diff.test.ts similarity index 95% rename from packages/renderer/tests/diff.test.ts rename to tests/renderer/diff.test.ts index 4e5d199..dc29b3d 100644 --- a/packages/renderer/tests/diff.test.ts +++ b/tests/renderer/diff.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, mock, beforeEach } from "bun:test"; -import { renderDiff } from "../src/diff"; -import { FlatBuffer } from "../src/buffer"; -import type { Buffer2D } from "../src/types"; -import type { DiffStats } from "../src/diff"; +import { renderDiff } from "@/renderer/diff"; +import { FlatBuffer } from "@/renderer/buffer"; +import type { Buffer2D } from "@/renderer/types"; +import type { DiffStats } from "@/renderer/diff"; function setCharAt(buf: Buffer2D, idx: number, ch: string) { const row = Math.floor(idx / buf.cols); diff --git a/packages/renderer/tests/grapheme.test.ts b/tests/renderer/grapheme.test.ts similarity index 59% rename from packages/renderer/tests/grapheme.test.ts rename to tests/renderer/grapheme.test.ts index 9a7930d..0a299de 100644 --- a/packages/renderer/tests/grapheme.test.ts +++ b/tests/renderer/grapheme.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { segmentGraphemes, measureGraphemeWidth } from "../src/grapheme"; +import { + measureGraphemeWidth, + measureTextWidth, + segmentGraphemes, + truncateTextWidth, + wrapTextWidth, +} from "@/renderer/grapheme"; describe("grapheme helpers", () => { it("segments ascii and combining sequences", () => { @@ -28,4 +34,18 @@ describe("grapheme helpers", () => { it("reports width 1 for normal latin glyphs", () => { expect(measureGraphemeWidth("A")).toBe(1); }); + + it("measures text width by grapheme display width", () => { + expect(measureTextWidth("a饅b")).toBe(4); + }); + + it("truncates by display width", () => { + // Each Kanji is width 2, ellipsis is width 1 -> 2 + 2 + 1 = 5 + expect(truncateTextWidth("饅饅饅", 5)).toBe("饅饅…"); + }); + + it("wraps text by display width", () => { + expect(wrapTextWidth("Hello world", 5)).toEqual(["Hello", "world"]); + expect(wrapTextWidth("饅饅饅", 2)).toEqual(["饅", "饅", "饅"]); + }); }); diff --git a/packages/renderer/tests/grid.test.ts b/tests/renderer/grid.test.ts similarity index 88% rename from packages/renderer/tests/grid.test.ts rename to tests/renderer/grid.test.ts index 154d395..208a2dd 100644 --- a/packages/renderer/tests/grid.test.ts +++ b/tests/renderer/grid.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createBuffer, drawText, fillRect } from "../src/grid"; +import { createBuffer, drawText, fillRect } from "@/renderer/grid"; describe("@btuin/renderer grid", () => { test("fillRect floors non-integer coordinates", () => { diff --git a/packages/renderer/tests/pool.test.ts b/tests/renderer/pool.test.ts similarity index 97% rename from packages/renderer/tests/pool.test.ts rename to tests/renderer/pool.test.ts index 0f7146c..64bb5f4 100644 --- a/packages/renderer/tests/pool.test.ts +++ b/tests/renderer/pool.test.ts @@ -4,8 +4,8 @@ import { getGlobalBufferPool, setGlobalBufferPool, resetGlobalBufferPool, -} from "../src/pool"; -import { FlatBuffer } from "../src/buffer"; +} from "@/renderer/pool"; +import { FlatBuffer } from "@/renderer/buffer"; describe("BufferPool", () => { const rows = 10; diff --git a/packages/renderer/tests/sanitize.test.ts b/tests/renderer/sanitize.test.ts similarity index 98% rename from packages/renderer/tests/sanitize.test.ts rename to tests/renderer/sanitize.test.ts index 10bc533..484eef7 100644 --- a/packages/renderer/tests/sanitize.test.ts +++ b/tests/renderer/sanitize.test.ts @@ -7,7 +7,7 @@ import { escapeSpecial, truncateInput, createSanitizer, -} from "../src/sanitize"; +} from "@/renderer/sanitize"; describe("Sanitization Utilities", () => { describe("sanitizeAnsi", () => { diff --git a/tests/runtime/app.test.ts b/tests/runtime/app.test.ts new file mode 100644 index 0000000..055367c --- /dev/null +++ b/tests/runtime/app.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, afterEach, beforeAll } from "bun:test"; +import type { App } from "@/runtime/app"; +import { ref } from "@/reactivity"; +import { Block, Text } from "@/view/primitives"; +import type { TerminalAdapter } from "@/runtime/terminal-adapter"; + +const keyHandlers: any[] = []; + +describe("createApp", () => { + let appInstance: App; + let app: typeof import("@/runtime/app").app; + const terminal: TerminalAdapter = { + setupRawMode: () => {}, + clearScreen: () => {}, + moveCursor: () => {}, + cleanupWithoutClear: () => {}, + patchConsole: () => () => {}, + startCapture: () => {}, + stopCapture: () => {}, + onKey: (callback: any) => { + keyHandlers.push(callback); + (global as any).__btuin_onKeyCallback = (event: any) => { + for (const handler of keyHandlers) { + handler(event); + } + }; + }, + getTerminalSize: () => ({ rows: 24, cols: 80 }), + disposeSingletonCapture: () => {}, + write: (_output: string) => {}, + }; + const platform = { + onStdoutResize: () => () => {}, + onExit: () => {}, + onSigint: () => {}, + onSigterm: () => {}, + exit: (_code?: number) => {}, + }; + + beforeAll(async () => { + ({ app } = await import("@/runtime/app")); + }); + + afterEach(() => { + if (appInstance) { + appInstance.unmount(); + } + keyHandlers.length = 0; + delete (global as any).__btuin_onKeyCallback; + }); + + it("should create an app instance", () => { + appInstance = app({ + terminal, + platform, + init() { + return {}; + }, + render: () => Block(Text("test")), + }); + expect(appInstance).toBeDefined(); + expect(appInstance.mount).toBeInstanceOf(Function); + expect(appInstance.unmount).toBeInstanceOf(Function); + expect(appInstance.getComponent).toBeInstanceOf(Function); + }); + + it("should mount and unmount the app", async () => { + let initCalled = false; + appInstance = app({ + terminal, + platform, + init() { + initCalled = true; + return { ready: true }; + }, + render({ ready }) { + return Block(Text(String(ready))); + }, + }); + + await appInstance.mount(); + expect(initCalled).toBe(true); + expect(appInstance.getComponent()).toBeDefined(); + + appInstance.unmount(); + expect(appInstance.getComponent()).toBe(null); + }); + + it("should handle key events", async () => { + let keyValue = ""; + appInstance = app({ + terminal, + platform, + init({ onKey }) { + const key = ref(""); + onKey((k) => { + key.value = k.name; + keyValue = k.name; + }); + return { key }; + }, + render({ key }) { + return Block(Text(key.value)); + }, + }); + + await appInstance.mount(); + + // Manually trigger the key event + const onKeyCallback = (global as any).__btuin_onKeyCallback; + if (onKeyCallback) { + onKeyCallback({ name: "a", sequence: "a", ctrl: false, meta: false, shift: false }); + } + + // We can't directly test the rendered output without a full render cycle, + // but we can check if the key event was processed. + expect(keyValue).toBe("a"); + }); +}); diff --git a/packages/btuin/tests/runtime/error-boundary.test.ts b/tests/runtime/error-boundary.test.ts similarity index 98% rename from packages/btuin/tests/runtime/error-boundary.test.ts rename to tests/runtime/error-boundary.test.ts index beee1c2..fe314ba 100644 --- a/packages/btuin/tests/runtime/error-boundary.test.ts +++ b/tests/runtime/error-boundary.test.ts @@ -3,7 +3,7 @@ import { createErrorHandler, createErrorContext, type ErrorContext, -} from "../../src/runtime/error-boundary"; +} from "@/runtime/error-boundary"; import { type WriteStream } from "fs"; // Mock the 'fs' module diff --git a/packages/btuin/tests/runtime/render-loop.test.ts b/tests/runtime/render-loop.test.ts similarity index 75% rename from packages/btuin/tests/runtime/render-loop.test.ts rename to tests/runtime/render-loop.test.ts index afae37d..7f740c2 100644 --- a/packages/btuin/tests/runtime/render-loop.test.ts +++ b/tests/runtime/render-loop.test.ts @@ -1,14 +1,9 @@ -import { describe, it, expect, mock } from "bun:test"; -import { createRenderer } from "../../src/runtime/render-loop"; -import { Block } from "../../src/view/primitives"; -import { FlatBuffer, type Buffer2D } from "@btuin/renderer"; +import { describe, it, expect } from "bun:test"; +import { createRenderer } from "@/runtime/render-loop"; +import { Block } from "@/view/primitives"; +import { FlatBuffer, type Buffer2D } from "@/renderer"; -// Mocks const mockLayoutResult = { root: { x: 0, y: 0, width: 80, height: 24 } }; -mock.module("../../../src/layout", () => ({ - layout: () => mockLayoutResult, - renderElement: () => {}, -})); const mockBufferA: Buffer2D = new FlatBuffer(24, 80); const mockBufferB: Buffer2D = new FlatBuffer(24, 80); @@ -21,11 +16,6 @@ const mockPool = { }, release: () => {}, }; -mock.module("@btuin/renderer", () => ({ - FlatBuffer, - getGlobalBufferPool: () => mockPool, - renderDiff: () => "x", -})); describe("createRenderer", () => { it("should create a renderer and perform a render cycle", () => { @@ -37,6 +27,13 @@ describe("createRenderer", () => { view: () => Block(), getState: () => ({}), handleError: (e) => console.error(e), + deps: { + FlatBuffer, + getGlobalBufferPool: () => mockPool, + renderDiff: () => "x", + layout: () => mockLayoutResult, + renderElement: () => {}, + }, }); // Initial state @@ -69,6 +66,13 @@ describe("createRenderer", () => { handleError: (ctx) => { errorCaught = ctx.error as Error; }, + deps: { + FlatBuffer, + getGlobalBufferPool: () => mockPool, + renderDiff: () => "x", + layout: () => mockLayoutResult, + renderElement: () => {}, + }, }); renderer.render(); diff --git a/packages/btuin/tests/runtime/terminal-size.test.ts b/tests/runtime/terminal-size.test.ts similarity index 86% rename from packages/btuin/tests/runtime/terminal-size.test.ts rename to tests/runtime/terminal-size.test.ts index c9d1bf4..cecdb67 100644 --- a/packages/btuin/tests/runtime/terminal-size.test.ts +++ b/tests/runtime/terminal-size.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { getTerminalSize } from "@btuin/terminal"; +import { getTerminalSize } from "@/terminal"; import { VStack } from "../../src/view/layout"; import { Text } from "../../src/view/primitives"; -import { initLayoutEngine, layout } from "../../src/layout"; +import { layout } from "../../src/layout"; describe("runtime terminal size propagation", () => { test("getTerminalSize returns usable cols/rows in tests", () => { @@ -14,7 +14,6 @@ describe("runtime terminal size propagation", () => { }); test("layout root 100% resolves to terminal cols/rows", async () => { - await initLayoutEngine(); const { cols, rows } = getTerminalSize(); const root = VStack([Text("Counter"), Text("Count: 0")]) diff --git a/packages/terminal/tests/capture.test.ts b/tests/terminal/capture.test.ts similarity index 99% rename from packages/terminal/tests/capture.test.ts rename to tests/terminal/capture.test.ts index ebd0234..7136efa 100644 --- a/packages/terminal/tests/capture.test.ts +++ b/tests/terminal/capture.test.ts @@ -12,7 +12,7 @@ import { createConsoleCapture, getConsoleCaptureInstance, disposeSingletonCapture, -} from "../src/capture"; +} from "@/terminal/capture"; import { expect, describe, it, beforeEach, afterEach } from "bun:test"; describe("Output Capture", () => { diff --git a/packages/terminal/tests/io.test.ts b/tests/terminal/io.test.ts similarity index 84% rename from packages/terminal/tests/io.test.ts rename to tests/terminal/io.test.ts index a1cd10a..8dad48a 100644 --- a/packages/terminal/tests/io.test.ts +++ b/tests/terminal/io.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { getTerminalSize } from "../src/io"; +import { getTerminalSize } from "@/terminal/io"; describe("@btuin/terminal", () => { test("getTerminalSize returns positive numbers", () => { diff --git a/packages/terminal/tests/raw.test.ts b/tests/terminal/raw.test.ts similarity index 81% rename from packages/terminal/tests/raw.test.ts rename to tests/terminal/raw.test.ts index b9d5193..1d4bbc1 100644 --- a/packages/terminal/tests/raw.test.ts +++ b/tests/terminal/raw.test.ts @@ -1,9 +1,8 @@ -import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"; -import { setupRawMode, onKey, cleanup, cleanupWithoutClear, resetKeyHandlers } from "../src/raw"; -import type { KeyHandler, KeyEvent } from "../src/types"; +import { describe, it, expect, mock, beforeAll, beforeEach, afterEach } from "bun:test"; +import type { KeyEvent } from "@/terminal/types"; // Mock the 'io' module -mock.module("../../src/io", () => ({ +mock.module("@/terminal/io", () => ({ clearScreen: () => {}, hideCursor: () => {}, showCursor: () => {}, @@ -40,6 +39,17 @@ const mockStdin = { Object.defineProperty(process, "stdin", { value: mockStdin }); describe("Raw Mode and Key Handling", () => { + let setupRawMode: typeof import("@/terminal/raw").setupRawMode; + let onKey: typeof import("@/terminal/raw").onKey; + let cleanup: typeof import("@/terminal/raw").cleanup; + let cleanupWithoutClear: typeof import("@/terminal/raw").cleanupWithoutClear; + let resetKeyHandlers: typeof import("@/terminal/raw").resetKeyHandlers; + + beforeAll(async () => { + ({ setupRawMode, onKey, cleanup, cleanupWithoutClear, resetKeyHandlers } = + await import("@/terminal/raw")); + }); + beforeEach(() => { cleanupWithoutClear(); resetKeyHandlers(); diff --git a/packages/btuin/tests/view/base.test.ts b/tests/view/base.test.ts similarity index 97% rename from packages/btuin/tests/view/base.test.ts rename to tests/view/base.test.ts index 1a7bc15..8513f06 100644 --- a/packages/btuin/tests/view/base.test.ts +++ b/tests/view/base.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "bun:test"; import { BaseView } from "../../src/view/base"; -import type { OutlineOptions } from "@btuin/renderer"; +import type { OutlineOptions } from "@/renderer/types"; // A concrete class for testing the abstract BaseView class ConcreteView extends BaseView { diff --git a/packages/btuin/tests/view/components/component.test.ts b/tests/view/components/component.test.ts similarity index 87% rename from packages/btuin/tests/view/components/component.test.ts rename to tests/view/components/component.test.ts index 55faffc..8df8bb2 100644 --- a/packages/btuin/tests/view/components/component.test.ts +++ b/tests/view/components/component.test.ts @@ -1,20 +1,20 @@ import { describe, it, expect, beforeEach } from "bun:test"; import { - defineComponent, + btuin, isComponent, mountComponent, unmountComponent, renderComponent, handleComponentKey, -} from "../../../src/view/components/component"; -import type { Component, RenderFunction } from "../../../src/view/components/component"; +} from "@/view/components/component"; +import type { Component, RenderFunction } from "@/view/components/component"; import { onKey } from "../../../src/view/components/lifecycle"; -import { ref } from "@btuin/reactivity"; +import { ref } from "@/reactivity"; import { Block, Text } from "../../../src/view/primitives"; describe("defineComponent", () => { it("should define a component", () => { - const component = defineComponent({ + const component = btuin({ name: "TestComponent", setup() { return () => Text("Hello"); @@ -26,7 +26,7 @@ describe("defineComponent", () => { }); it("should return a render function from setup", () => { - const component = defineComponent({ + const component = btuin({ setup() { return () => Text("Hello"); }, @@ -44,7 +44,7 @@ describe("defineComponent", () => { describe("mountComponent", () => { it("should mount and unmount a component", () => { - const component = defineComponent({ + const component = btuin({ setup() { return () => Text("Hello"); }, @@ -62,7 +62,7 @@ describe("mountComponent", () => { describe("handleComponentKey", () => { it("should handle key events", () => { let keyPressed = ""; - const component = defineComponent({ + const component = btuin({ setup() { onKey((key) => { keyPressed = key.name; @@ -86,7 +86,7 @@ describe("handleComponentKey", () => { it("should traverse view hierarchy and honor stopPropagation", () => { const order: string[] = []; - const component = defineComponent({ + const component = btuin({ setup() { onKey(() => { order.push("component"); @@ -124,7 +124,7 @@ describe("handleComponentKey", () => { describe("normalizeProps", () => { it("should normalize props", () => { - const component = defineComponent({ + const component = btuin({ props: { name: { type: String, required: true }, age: { type: Number, default: 20 }, diff --git a/packages/btuin/tests/view/components/lifecycle.test.ts b/tests/view/components/lifecycle.test.ts similarity index 98% rename from packages/btuin/tests/view/components/lifecycle.test.ts rename to tests/view/components/lifecycle.test.ts index 2b399f0..2d7782b 100644 --- a/packages/btuin/tests/view/components/lifecycle.test.ts +++ b/tests/view/components/lifecycle.test.ts @@ -13,7 +13,7 @@ import { stopTickTimers, unmountInstance, type ComponentInstance, -} from "../../../src/view/components/lifecycle"; +} from "@/view/components/lifecycle"; describe("Component Lifecycle", () => { let instance: ComponentInstance; diff --git a/packages/btuin/tests/view/layout.test.ts b/tests/view/layout.test.ts similarity index 100% rename from packages/btuin/tests/view/layout.test.ts rename to tests/view/layout.test.ts diff --git a/packages/btuin/tests/view/primitives/block.test.ts b/tests/view/primitives/block.test.ts similarity index 92% rename from packages/btuin/tests/view/primitives/block.test.ts rename to tests/view/primitives/block.test.ts index d9f4ca5..25602e5 100644 --- a/packages/btuin/tests/view/primitives/block.test.ts +++ b/tests/view/primitives/block.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "bun:test"; -import { Block } from "../../../src/view/primitives/block"; -import { Text } from "../../../src/view/primitives/text"; +import { Block } from "@/view/primitives/block"; +import { Text } from "@/view/primitives/text"; describe("Block Primitive", () => { it("should create a BlockElement", () => { diff --git a/packages/btuin/tests/view/primitives/spacer.test.ts b/tests/view/primitives/spacer.test.ts similarity index 100% rename from packages/btuin/tests/view/primitives/spacer.test.ts rename to tests/view/primitives/spacer.test.ts diff --git a/packages/btuin/tests/view/primitives/text.test.ts b/tests/view/primitives/text.test.ts similarity index 100% rename from packages/btuin/tests/view/primitives/text.test.ts rename to tests/view/primitives/text.test.ts diff --git a/packages/btuin/tests/view/types/elements.test.ts b/tests/view/types/elements.test.ts similarity index 100% rename from packages/btuin/tests/view/types/elements.test.ts rename to tests/view/types/elements.test.ts diff --git a/tsconfig.json b/tsconfig.json index ee848d8..b1488c3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,7 @@ { "compilerOptions": { // Environment setup & latest features - "lib": [ - "ESNext" - ], + "lib": ["ESNext"], "target": "ESNext", "module": "Preserve", "moduleDetection": "force", @@ -22,6 +20,11 @@ // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } + "noPropertyAccessFromIndexSignature": false, + // alias + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + }, + }, }