diff --git a/.github/workflows/build-core.yml b/.github/workflows/build-core.yml index 6464254a2..348e19850 100644 --- a/.github/workflows/build-core.yml +++ b/.github/workflows/build-core.yml @@ -6,9 +6,9 @@ on: branches: [main] jobs: - build: - name: Core - Build and Test - runs-on: ubuntu-latest + build-native: + name: Build Native (All Platforms) + runs-on: macos-latest steps: - name: Checkout code uses: actions/checkout@v4 @@ -21,17 +21,63 @@ jobs: - name: Setup Zig uses: goto-bus-stop/setup-zig@v2 with: - version: 0.14.1 + version: 0.15.2 - name: Install dependencies run: bun install - - name: Build + - name: Build native for all platforms run: | cd packages/core - bun run build + bun run build:native --all + + - name: Upload native artifacts + uses: actions/upload-artifact@v4 + with: + name: native-all + path: packages/core/node_modules/@opentui/ + retention-days: 1 + + test-ts: + name: Test (${{ matrix.name }}) + needs: build-native + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + name: linux-x64 + - os: ubuntu-latest + name: linux-musl-x64 + container: oven/bun:alpine + - os: macos-latest + name: darwin-arm64 + # darwin-x64 removed: macos-13 is deprecated with no free x64 runner available + # darwin-arm64 provides sufficient macOS coverage + - os: windows-latest + name: win32-x64 + runs-on: ${{ matrix.os }} + container: ${{ matrix.container || '' }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + if: ${{ !matrix.container }} + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Download native artifacts + uses: actions/download-artifact@v4 + with: + name: native-all + path: packages/core/node_modules/@opentui/ - name: Run tests run: | cd packages/core - bun run test + bun run test:js diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index 5aa83d426..212f501e7 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -16,7 +16,7 @@ on: jobs: build-examples: name: Build Example Executables - runs-on: macos-latest + runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index 0c3327524..a87a06cb7 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -14,12 +14,12 @@ on: default: false env: - ZIG_VERSION: 0.14.1 + ZIG_VERSION: 0.15.2 jobs: build-native: name: Build Native Libraries - runs-on: macos-latest + runs-on: ubuntu-latest steps: - name: Checkout code @@ -39,7 +39,10 @@ jobs: run: bun install - name: Build packages (cross-compile for all platforms) - run: bun run build + run: | + cd packages/core + bun run build:native --all + bun run build:lib - name: Verify build outputs run: | diff --git a/.github/workflows/build-react.yml b/.github/workflows/build-react.yml index 68fdd4cfd..d2931a54d 100644 --- a/.github/workflows/build-react.yml +++ b/.github/workflows/build-react.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Zig uses: goto-bus-stop/setup-zig@v2 with: - version: 0.14.1 + version: 0.15.2 - name: Install dependencies run: bun install diff --git a/.github/workflows/build-solid.yml b/.github/workflows/build-solid.yml index a606f2264..3748262b3 100644 --- a/.github/workflows/build-solid.yml +++ b/.github/workflows/build-solid.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Zig uses: goto-bus-stop/setup-zig@v2 with: - version: 0.14.1 + version: 0.15.2 - name: Install dependencies run: bun install diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 60f1c5004..5c0b35b41 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -26,7 +26,7 @@ jobs: - name: Setup Zig uses: goto-bus-stop/setup-zig@v2 with: - version: 0.14.1 + version: 0.15.2 - name: Extract version from tag id: extract_version @@ -52,8 +52,13 @@ jobs: - name: Prepare release versions run: bun run prepare-release "${{ steps.version_scheme.outputs.version }}" - - name: Build packages - run: bun run build + - name: Build packages (cross-compile for all platforms) + run: | + cd packages/core + bun run build:native --all + bun run build:lib + cd ../solid && bun run build + cd ../react && bun run build - name: Publish packages run: bun run publish diff --git a/.zig-version b/.zig-version index 930e3000b..4312e0d0c 100644 --- a/.zig-version +++ b/.zig-version @@ -1 +1 @@ -0.14.1 +0.15.2 diff --git a/bun.lock b/bun.lock index 12061e5b6..3d6bd5bdb 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ }, "packages/core": { "name": "@opentui/core", - "version": "0.1.67", + "version": "0.1.69", "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", @@ -27,12 +27,12 @@ }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", - "@opentui/core-darwin-arm64": "0.1.67", - "@opentui/core-darwin-x64": "0.1.67", - "@opentui/core-linux-arm64": "0.1.67", - "@opentui/core-linux-x64": "0.1.67", - "@opentui/core-win32-arm64": "0.1.67", - "@opentui/core-win32-x64": "0.1.67", + "@opentui/core-darwin-arm64": "0.1.69", + "@opentui/core-darwin-x64": "0.1.69", + "@opentui/core-linux-arm64": "0.1.69", + "@opentui/core-linux-x64": "0.1.69", + "@opentui/core-win32-arm64": "0.1.69", + "@opentui/core-win32-x64": "0.1.69", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0", @@ -43,7 +43,7 @@ }, "packages/react": { "name": "@opentui/react", - "version": "0.1.67", + "version": "0.1.69", "dependencies": { "@opentui/core": "workspace:*", "react-reconciler": "^0.32.0", @@ -71,7 +71,7 @@ }, "packages/solid": { "name": "@opentui/solid", - "version": "0.1.67", + "version": "0.1.69", "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", diff --git a/packages/core/package.json b/packages/core/package.json index f88c2ae76..340bf3640 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,7 +9,7 @@ "types": "src/index.ts", "module": "src/index.ts", "main": "src/index.ts", - "version": "0.1.67", + "version": "0.1.69", "type": "module", "scripts": { "build": "bun run build:native && bun run build:lib", @@ -29,6 +29,7 @@ "@types/bun": "latest", "@types/node": "^24.0.0", "@types/three": "0.177.0", + "bun-pty": "^0.4.2", "commander": "^13.1.0", "typescript": "^5", "web-tree-sitter": "0.25.10" @@ -47,12 +48,12 @@ "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0", - "@opentui/core-darwin-x64": "0.1.67", - "@opentui/core-darwin-arm64": "0.1.67", - "@opentui/core-linux-x64": "0.1.67", - "@opentui/core-linux-arm64": "0.1.67", - "@opentui/core-win32-x64": "0.1.67", - "@opentui/core-win32-arm64": "0.1.67" + "@opentui/core-darwin-x64": "0.1.69", + "@opentui/core-darwin-arm64": "0.1.69", + "@opentui/core-linux-x64": "0.1.69", + "@opentui/core-linux-arm64": "0.1.69", + "@opentui/core-win32-x64": "0.1.69", + "@opentui/core-win32-arm64": "0.1.69" }, "exports": { ".": { diff --git a/packages/core/scripts/build.ts b/packages/core/scripts/build.ts index 624cd5099..a8e7aa265 100644 --- a/packages/core/scripts/build.ts +++ b/packages/core/scripts/build.ts @@ -40,12 +40,15 @@ const args = process.argv.slice(2) const buildLib = args.find((arg) => arg === "--lib") const buildNative = args.find((arg) => arg === "--native") const isDev = args.includes("--dev") +const buildAll = args.includes("--all") // Build for all platforms (requires macOS or cross-compilation setup) const variants: Variant[] = [ { platform: "darwin", arch: "x64" }, { platform: "darwin", arch: "arm64" }, { platform: "linux", arch: "x64" }, { platform: "linux", arch: "arm64" }, + { platform: "linux-musl", arch: "x64" }, + { platform: "linux-musl", arch: "arm64" }, { platform: "win32", arch: "x64" }, { platform: "win32", arch: "arm64" }, ] @@ -56,7 +59,12 @@ if (!buildLib && !buildNative) { } const getZigTarget = (platform: string, arch: string): string => { - const platformMap: Record = { darwin: "macos", win32: "windows", linux: "linux" } + const platformMap: Record = { + darwin: "macos", + win32: "windows", + linux: "linux", + "linux-musl": "linux-musl", + } const archMap: Record = { x64: "x86_64", arm64: "aarch64" } return `${archMap[arch] ?? arch}-${platformMap[platform] ?? platform}` } @@ -78,16 +86,17 @@ if (missingRequired.length > 0) { } if (buildNative) { - console.log(`Building native ${isDev ? "dev" : "prod"} binaries...`) + console.log(`Building native ${isDev ? "dev" : "prod"} binaries${buildAll ? " for all platforms" : ""}...`) - const zigBuild: SpawnSyncReturns = spawnSync( - "zig", - ["build", `-Doptimize=${isDev ? "Debug" : "ReleaseFast"}`], - { - cwd: join(rootDir, "src", "zig"), - stdio: "inherit", - }, - ) + const zigArgs = ["build", `-Doptimize=${isDev ? "Debug" : "ReleaseFast"}`] + if (buildAll) { + zigArgs.push("-Dall") + } + + const zigBuild: SpawnSyncReturns = spawnSync("zig", zigArgs, { + cwd: join(rootDir, "src", "zig"), + stdio: "inherit", + }) if (zigBuild.error) { console.error("Error: Zig is not installed or not in PATH") @@ -124,16 +133,10 @@ if (buildNative) { } if (copiedFiles === 0) { - console.error(`Error: No dynamic libraries found for ${platform}-${arch} in ${libDir}`) - console.error(`Expected to find files like: libopentui.so, libopentui.dylib, opentui.dll`) - console.error(`Found files in ${libDir}:`) - if (existsSync(libDir)) { - const files = spawnSync("ls", ["-la", libDir], { stdio: "pipe" }) - if (files.stdout) console.error(files.stdout.toString()) - } else { - console.error("Directory does not exist") - } - process.exit(1) + // Skip platforms that weren't built (e.g., macOS when cross-compiling from Linux) + console.log(`Skipping ${platform}-${arch}: no libraries found (cross-compilation may not be supported)`) + rmSync(nativeDir, { recursive: true, force: true }) + continue } const indexTsContent = `const module = await import("./${libraryFileName}", { with: { type: "file" } }) diff --git a/packages/core/src/examples/golden-star-demo.ts b/packages/core/src/examples/golden-star-demo.ts index 092b02729..456f4e587 100644 --- a/packages/core/src/examples/golden-star-demo.ts +++ b/packages/core/src/examples/golden-star-demo.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { createCliRenderer, CliRenderer, FrameBufferRenderable, BoxRenderable } from "../index" +import { createCliRenderer, CliRenderer, FrameBufferRenderable, BoxRenderable, OptimizedBuffer } from "../index" import { RGBA } from "../lib" import { ASCIIFontRenderable } from "../renderables/ASCIIFont" import type { ASCIIFontName } from "../lib/ascii.font" @@ -299,6 +299,15 @@ export async function run(renderer: CliRenderer): Promise { const HEIGHT = renderer.terminalHeight const CAM_DISTANCE = 3 + const framebufferRenderable = new FrameBufferRenderable(renderer, { + id: "golden-star-main", + width: WIDTH, + height: HEIGHT, + zIndex: 10, + }) + renderer.root.add(framebufferRenderable) + const framebuffer = framebufferRenderable.frameBuffer + const engine = new ThreeCliRenderer(renderer, { width: WIDTH, height: HEIGHT, @@ -450,6 +459,9 @@ export async function run(renderer: CliRenderer): Promise { const particleSystem = new StarParticleSystem(sceneRoot, 150) const resizeHandler = (width: number, height: number) => { + if (framebuffer) { + framebuffer.resize(width, height) + } if (cameraNode) { cameraNode.aspect = engine.aspectRatio cameraNode.updateProjectionMatrix() @@ -899,7 +911,7 @@ export async function run(renderer: CliRenderer): Promise { char.bottom = Math.round(Math.max(0, jump)) } - engine.drawScene(sceneRoot, renderer.nextRenderBuffer, deltaTime) + engine.drawScene(sceneRoot, framebuffer, deltaTime) }) } diff --git a/packages/core/src/examples/index.ts b/packages/core/src/examples/index.ts index 43122fba8..6483af300 100644 --- a/packages/core/src/examples/index.ts +++ b/packages/core/src/examples/index.ts @@ -9,6 +9,7 @@ import { SelectRenderable, SelectRenderableEvents, BoxRenderable, + TextareaRenderable, type SelectOption, type KeyEvent, ASCIIFontRenderable, @@ -56,6 +57,9 @@ import * as sliderDemo from "./slider-demo" import * as terminalDemo from "./terminal" import * as diffDemo from "./diff-demo" import * as keypressDebugDemo from "./keypress-debug-demo" +import * as linkDemo from "./link-demo" +import * as extmarksDemo from "./extmarks-demo" +import * as opacityExample from "./opacity-example" import { setupCommonDemoKeys } from "./lib/standalone-keys" interface Example { @@ -108,6 +112,24 @@ const examples: Example[] = [ run: styledTextExample.run, destroy: styledTextExample.destroy, }, + { + name: "Link Demo", + description: "Hyperlink support with OSC 8 - clickable links and link inheritance in styled text", + run: linkDemo.run, + destroy: linkDemo.destroy, + }, + { + name: "Extmarks Demo", + description: "Virtual extmarks - text ranges that cursor jumps over, like inline tags and links", + run: extmarksDemo.run, + destroy: extmarksDemo.destroy, + }, + { + name: "Opacity Demo", + description: "Box opacity and transparency effects with animated opacity transitions", + run: opacityExample.run, + destroy: opacityExample.destroy, + }, { name: "TextNode Demo", description: "TextNode API for building complex styled text structures", @@ -326,16 +348,19 @@ class ExampleSelector { private currentExample: Example | null = null private inMenu = true + private menuContainer: BoxRenderable | null = null private title: FrameBufferRenderable | null = null + private filterBox: BoxRenderable | null = null + private filterInput: TextareaRenderable | null = null private instructions: TextRenderable | null = null private selectElement: SelectRenderable | null = null private selectBox: BoxRenderable | null = null private notImplementedText: TextRenderable | null = null + private allExamples: Example[] = examples constructor(renderer: CliRenderer) { this.renderer = renderer - this.createStaticElements() - this.createSelectElement() + this.createLayout() this.setupKeyboardHandling() this.renderer.on("resize", (width: number, height: number) => { @@ -343,14 +368,26 @@ class ExampleSelector { }) } - private createTitle(width: number, height: number): void { + private createLayout(): void { + const width = this.renderer.terminalWidth + + // Menu container with column layout + this.menuContainer = new BoxRenderable(renderer, { + id: "example-menu-container", + flexDirection: "column", + width: "100%", + height: "100%", + }) + this.renderer.root.add(this.menuContainer) + + // Title const titleText = "OPENTUI EXAMPLES" const titleFont = "tiny" const { width: titleWidth } = measureText({ text: titleText, font: titleFont }) const centerX = Math.floor(width / 2) - Math.floor(titleWidth / 2) this.title = new ASCIIFontRenderable(renderer, { - id: "title", + id: "example-index-title", left: centerX, margin: 1, text: titleText, @@ -358,36 +395,47 @@ class ExampleSelector { color: RGBA.fromInts(240, 248, 255, 255), backgroundColor: RGBA.fromInts(15, 23, 42, 255), }) - this.renderer.root.add(this.title) - } - - private createStaticElements(): void { - const width = this.renderer.terminalWidth - const height = this.renderer.terminalHeight + this.menuContainer.add(this.title) - this.createTitle(width, height) - - this.instructions = new TextRenderable(renderer, { - id: "instructions", - marginLeft: 2, - marginRight: 2, - content: - "Use ↑↓ or j/k to navigate, Shift+↑↓ or Shift+j/k for fast scroll, Enter to run, Escape to return, ` for console, ctrl+z to suspend/resume, ctrl+c to quit", - fg: "#94A3B8", + // Filter box with border (grows with content) + this.filterBox = new BoxRenderable(renderer, { + id: "example-index-filter-box", + marginLeft: 1, + marginRight: 1, + flexShrink: 0, + backgroundColor: "transparent", + border: true, + borderStyle: "single", + borderColor: "#475569", }) - this.renderer.root.add(this.instructions) - } + this.menuContainer.add(this.filterBox) - private createSelectElement(): void { - const selectOptions: SelectOption[] = examples.map((example) => ({ - name: example.name, - description: example.description, - value: example, - })) + // Filter input inside the box (transparent bg so box bg shows through) + this.filterInput = new TextareaRenderable(renderer, { + id: "example-index-filter-input", + width: "100%", + height: 1, + placeholder: "Filter examples by title...", + backgroundColor: "transparent", + focusedBackgroundColor: "transparent", + textColor: "#E2E8F0", + focusedTextColor: "#F8FAFC", + wrapMode: "none", + showCursor: true, + cursorColor: "#60A5FA", + onContentChange: () => { + this.filterExamples() + }, + }) + this.filterBox.add(this.filterInput) + this.filterInput.focus() + // Select box (grows to fill remaining space) this.selectBox = new BoxRenderable(renderer, { id: "example-selector-box", - margin: 1, + marginLeft: 1, + marginRight: 1, + marginBottom: 1, flexGrow: 1, borderStyle: "single", borderColor: "#475569", @@ -398,6 +446,14 @@ class ExampleSelector { shouldFill: true, border: true, }) + this.menuContainer.add(this.selectBox) + + // Select element + const selectOptions: SelectOption[] = examples.map((example) => ({ + name: example.name, + description: example.description, + value: example, + })) this.selectElement = new SelectRenderable(renderer, { id: "example-selector", @@ -413,16 +469,49 @@ class ExampleSelector { showScrollIndicator: true, wrapSelection: true, showDescription: true, - fastScrollStep: 5, // Shift+K/J or Shift+Up/Down moves 5 items at once + fastScrollStep: 5, }) + this.selectBox.add(this.selectElement) this.selectElement.on(SelectRenderableEvents.ITEM_SELECTED, (index: number, option: SelectOption) => { this.runSelected(option.value as Example) }) - this.renderer.root.add(this.selectBox) - this.selectBox.add(this.selectElement) - this.selectElement.focus() + // Instructions at the bottom + this.instructions = new TextRenderable(renderer, { + id: "example-index-instructions", + height: 1, + flexShrink: 0, + alignSelf: "center", + content: "Type to filter | ↑↓/j/k navigate | Enter run | Esc clear/return | ctrl+c quit", + fg: "#94A3B8", + }) + this.menuContainer.add(this.instructions) + } + + private filterExamples(): void { + if (!this.filterInput || !this.selectElement) return + + const filterText = this.filterInput.editBuffer.getText().toLowerCase().trim() + + if (filterText === "") { + // Show all examples + const selectOptions: SelectOption[] = this.allExamples.map((example) => ({ + name: example.name, + description: example.description, + value: example, + })) + this.selectElement.options = selectOptions + } else { + // Filter by title only + const filtered = this.allExamples.filter((example) => example.name.toLowerCase().includes(filterText)) + const selectOptions: SelectOption[] = filtered.map((example) => ({ + name: example.name, + description: example.description, + value: example, + })) + this.selectElement.options = selectOptions + } } private handleResize(width: number, height: number): void { @@ -437,13 +526,62 @@ class ExampleSelector { private setupKeyboardHandling(): void { this.renderer.keyInput.on("keypress", (key: KeyEvent) => { + if (key.name === "c" && key.ctrl) { + this.cleanup() + return + } + if (!this.inMenu) { switch (key.name) { case "escape": this.returnToMenu() break } + return + } + + // Forward navigation keys to select even when filter is focused + if (this.filterInput?.focused && this.selectElement) { + // Navigation keys: arrow up/down, j/k, shift variants + if (key.name === "up" || key.name === "k") { + key.preventDefault() + if (key.shift) { + this.selectElement.moveUp(5) + } else { + this.selectElement.moveUp(1) + } + return + } + if (key.name === "down" || key.name === "j") { + key.preventDefault() + if (key.shift) { + this.selectElement.moveDown(5) + } else { + this.selectElement.moveDown(1) + } + return + } + // Enter to select + if (key.name === "return" || key.name === "linefeed") { + key.preventDefault() + this.selectElement.selectCurrent() + return + } + } + + // Handle Escape: clear filter if has content + if (key.name === "escape") { + if (this.filterInput) { + const filterText = this.filterInput.editBuffer.getText() + if (filterText.length > 0) { + key.preventDefault() + this.filterInput.editBuffer.setText("") + this.filterExamples() + return + } + } } + if (key.name === "c" && key.ctrl) { this.cleanup() return @@ -492,25 +630,52 @@ class ExampleSelector { } private hideMenuElements(): void { - if (this.title) this.title.visible = false - if (this.instructions) this.instructions.visible = false + if (this.menuContainer) { + this.menuContainer.visible = false + } + if (this.title) { + this.title.visible = false + } + if (this.filterBox) { + this.filterBox.visible = false + } if (this.selectBox) { this.selectBox.visible = false } + if (this.instructions) { + this.instructions.visible = false + } + if (this.filterInput) { + this.filterInput.blur() + } if (this.selectElement) { this.selectElement.blur() } } private showMenuElements(): void { - if (this.title) this.title.visible = true - if (this.instructions) this.instructions.visible = true + if (this.menuContainer) { + this.menuContainer.visible = true + } + if (this.title) { + this.title.visible = true + } + if (this.filterBox) { + this.filterBox.visible = true + } if (this.selectBox) { this.selectBox.visible = true } - if (this.selectElement) { - this.selectElement.focus() + if (this.instructions) { + this.instructions.visible = true + } + if (this.filterInput) { + // Clear filter when returning to menu + this.filterInput.editBuffer.setText("") + this.filterInput.focus() } + // Reset filter to show all examples + this.filterExamples() } private returnToMenu(): void { @@ -540,9 +705,15 @@ class ExampleSelector { if (this.currentExample) { this.currentExample.destroy?.(this.renderer) } + if (this.filterInput) { + this.filterInput.blur() + } if (this.selectElement) { this.selectElement.blur() } + if (this.menuContainer) { + this.menuContainer.destroy() + } this.renderer.destroy() } } diff --git a/packages/core/src/examples/link-demo.ts b/packages/core/src/examples/link-demo.ts index d6fdeae9d..8dce8028b 100644 --- a/packages/core/src/examples/link-demo.ts +++ b/packages/core/src/examples/link-demo.ts @@ -1,72 +1,212 @@ -import { CliRenderer, createCliRenderer, t, blue, underline, link, BoxRenderable, type KeyEvent } from "../index" -import { TextRenderable } from "../renderables/Text" +import { + CliRenderer, + createCliRenderer, + t, + fg, + underline, + link, + bold, + italic, + BoxRenderable, + RGBA, + TextRenderable, + type MouseEvent, + type RenderContext, +} from "../index" import { setupCommonDemoKeys } from "./lib/standalone-keys" -let parentContainer: BoxRenderable | null = null -let keyboardHandler: ((key: KeyEvent) => void) | null = null +let nextZIndex = 100 +let draggableBoxes: DraggableBox[] = [] +let dragModeEnabled = false -export function run(rendererInstance: CliRenderer): void { - const renderer = rendererInstance - renderer.start() - renderer.setBackgroundColor("#001122") +class DraggableBox extends BoxRenderable { + private isDragging = false + private dragOffsetX = 0 + private dragOffsetY = 0 - parentContainer = new BoxRenderable(renderer, { - id: "link-container", - zIndex: 15, - }) - renderer.root.add(parentContainer) + constructor( + ctx: RenderContext, + id: string, + x: number, + y: number, + width: number, + height: number, + backgroundColor: RGBA, + ) { + super(ctx, { + id, + width, + height, + zIndex: nextZIndex++, + backgroundColor, + position: "absolute", + left: x, + top: y, + borderStyle: "rounded", + borderColor: RGBA.fromHex("#ffffff"), + padding: 1, + flexDirection: "column", + }) + } - // Example with hyperlinks - const linkText = t`${underline(blue(link("https://github.com/sst/opentui")("OpenTUI on GitHub")))} + protected onMouseEvent(event: MouseEvent): void { + if (!dragModeEnabled) return -Visit our ${link("https://opentui.com")("website")} for more info. + switch (event.type) { + case "down": + this.isDragging = true + this.dragOffsetX = event.x - this.x + this.dragOffsetY = event.y - this.y + this.zIndex = nextZIndex++ + event.stopPropagation() + break -Check out the ${underline(link("https://github.com/sst/opentui/blob/main/README.md")("README"))}` + case "drag-end": + if (this.isDragging) { + this.isDragging = false + event.stopPropagation() + } + break - const linkDisplay = new TextRenderable(renderer, { - id: "link-text", - content: linkText, - width: 60, - height: 8, - position: "absolute", - left: 2, - top: 2, - zIndex: 1, - }) - parentContainer.add(linkDisplay) + case "drag": + if (this.isDragging) { + const newX = event.x - this.dragOffsetX + const newY = event.y - this.dragOffsetY - const instructionsText = t`${underline("Hyperlink Demo")} + this.x = Math.max(0, Math.min(newX, this._ctx.width - this.width)) + this.y = Math.max(0, Math.min(newY, this._ctx.height - this.height)) -The text above contains clickable hyperlinks (if your terminal supports OSC 8). -Try clicking on the blue underlined text! + event.stopPropagation() + } + break + } + } +} + +function getHeaderContent(): ReturnType { + const dragStatus = dragModeEnabled ? fg("#34d399")("ON") : fg("#f87171")("OFF") + return t`${bold(fg("#38bdf8")("OpenTUI Interactive Link Demo"))} +${fg("#94a3b8")("Click the links to open them.")} ${fg("#64748b")("Press")} ${bold(fg("#fbbf24")("d"))} ${fg("#64748b")("to toggle drag mode:")} ${dragStatus} +${italic(fg("#64748b")("(Terminal must support OSC 8 hyperlinks)"))}` +} -Press ESC to return to the menu.` +export function run(renderer: CliRenderer): void { + renderer.start() + renderer.setBackgroundColor("#0f172a") // Deep slate blue background + + const container = new BoxRenderable(renderer, { + id: "main-container", + width: "100%", + height: "100%", + }) + renderer.root.add(container) - const instructionsDisplay = new TextRenderable(renderer, { - id: "instructions", - content: instructionsText, - width: 70, - height: 8, + // Header + const header = new TextRenderable(renderer, { + id: "header", + content: getHeaderContent(), position: "absolute", left: 2, - top: 12, - zIndex: 1, + top: 1, + zIndex: 10, + width: 80, + height: 4, }) - parentContainer.add(instructionsDisplay) + container.add(header) - renderer.requestRender() + // Toggle drag mode with 'd' key + renderer.keyInput.on("keypress", (event) => { + if (event.name === "d") { + dragModeEnabled = !dragModeEnabled + header.content = getHeaderContent() + } + }) + + // Card 1: Project Info + createCard( + renderer, + container, + "project-card", + 5, + 6, + 40, + 8, + RGBA.fromHex("#1e293be6"), // Dark slate + t`${bold(fg("#f472b6")("♥ Project Info"))} + +${fg("#e2e8f0")("Source:")} ${link("https://github.com/anomalyco/opentui")(underline(fg("#38bdf8")("GitHub Repository")))} +${fg("#e2e8f0")("Web:")} ${link("https://opentui.com")(underline(fg("#34d399")("Official Website")))} +${fg("#e2e8f0")("License:")} ${link("https://github.com/anomalyco/opentui/blob/main/LICENSE")(underline(fg("#fbbf24")("MIT")))}`, + ) + + // Card 2: Documentation + createCard( + renderer, + container, + "docs-card", + 50, + 8, + 35, + 9, + RGBA.fromHex("#334155e6"), + t`${bold(fg("#a78bfa")("📚 Documentation"))} + +${fg("#cbd5e1")("Get started with:")} +• ${link("https://github.com/anomalyco/opentui#readme")(bold(fg("#fff")("Quick Start")))} +• ${link("https://github.com/anomalyco/opentui/tree/main/packages/core/src/examples")(fg("#fff")("Examples"))} +• ${link("https://github.com/anomalyco/opentui/issues")(fg("#fff")("Known Issues"))}`, + ) + + // Card 3: Socials + createCard( + renderer, + container, + "social-card", + 20, + 16, + 30, + 7, + RGBA.fromHex("#0f766ecc"), // Teal + t`${bold(fg("#2dd4bf")("👋 Connect"))} + +${link("https://x.com/anomalyco")(fg("#60a5fa")("Twitter / X"))} +${link("https://discord.gg/Fc8UPAeV")(fg("#818cf8")("Discord Community"))}`, + ) } -export function destroy(rendererInstance: CliRenderer): void { - if (keyboardHandler) { - rendererInstance.keyInput.off("keypress", keyboardHandler) - keyboardHandler = null - } +function createCard( + renderer: CliRenderer, + container: BoxRenderable, + id: string, + x: number, + y: number, + width: number, + height: number, + bg: RGBA, + content: any, +) { + const card = new DraggableBox(renderer, id, x, y, width, height, bg) + + const text = new TextRenderable(renderer, { + id: `${id}-text`, + content: content, + width: width - 2, // Account for padding + height: height - 2, + }) - if (parentContainer) { - rendererInstance.root.remove("link-container") - parentContainer = null + card.add(text) + container.add(card) + draggableBoxes.push(card) +} + +export function destroy(renderer: CliRenderer): void { + for (const box of draggableBoxes) { + renderer.root.remove(box.id) } + draggableBoxes = [] + dragModeEnabled = false + renderer.root.remove("main-container") + renderer.setCursorPosition(0, 0, false) } if (import.meta.main) { @@ -74,6 +214,7 @@ if (import.meta.main) { exitOnCtrlC: true, targetFps: 60, }) + run(renderer) setupCommonDemoKeys(renderer) } diff --git a/packages/core/src/examples/terminal-interactive-demo.ts b/packages/core/src/examples/terminal-interactive-demo.ts new file mode 100644 index 000000000..a8b678c1e --- /dev/null +++ b/packages/core/src/examples/terminal-interactive-demo.ts @@ -0,0 +1,259 @@ +import { createCliRenderer, TerminalRenderable, BoxRenderable, type CliRenderer, type KeyEvent } from "../index" +import { TextRenderable } from "../renderables/Text" +import { setupCommonDemoKeys } from "./lib/standalone-keys" + +interface Button { + label: string + data: string +} + +const BUTTONS: Button[] = [ + { label: "[1] Send 'hello'", data: "hello" }, + { label: "[2] Send Enter", data: "\r" }, + { label: "[3] Send 'help'", data: "help" }, + { label: "[4] Send Escape", data: "\x1b" }, + { label: "[5] Send Ctrl+C", data: "\x03" }, + { label: "[6] Send '/clear'", data: "/clear" }, +] + +const LEFT_PANEL_WIDTH = 33 +const RIGHT_PANEL_BORDER = 2 +const VERTICAL_OVERHEAD = 3 + +let renderer: CliRenderer | null = null +let pty: any = null +let terminalDisplay: TerminalRenderable | null = null +let statusDisplay: TextRenderable | null = null +let selectedButton = 0 +let status = "Starting..." +let terminalCols = 80 +let terminalRows = 24 + +async function initPty(cols: number, rows: number): Promise { + try { + const { spawn } = await import("bun-pty") + return spawn("opencode", [], { + name: "xterm-256color", + cols, + rows, + cwd: process.cwd(), + }) + } catch (e) { + console.error("Failed to import bun-pty. Make sure it's installed: bun add bun-pty") + throw e + } +} + +function sendData(data: string): void { + if (pty) { + pty.write(data) + if (data === "\r") { + status = "Sent: Enter" + } else if (data === "\x1b") { + status = "Sent: Escape" + } else if (data === "\x03") { + status = "Sent: Ctrl+C" + } else { + status = `Sent: "${data}"` + } + updateStatus() + } +} + +function updateStatus(): void { + if (statusDisplay) { + statusDisplay.content = `Status: ${status} | Size: ${terminalCols}x${terminalRows}` + } +} + +function handleKey(key: KeyEvent): void { + if (key.name === "q" || key.name === "escape") { + if (pty) { + pty.kill() + } + process.exit(0) + } + + if (key.name === "1") sendData(BUTTONS[0].data) + if (key.name === "2") sendData(BUTTONS[1].data) + if (key.name === "3") sendData(BUTTONS[2].data) + if (key.name === "4") sendData(BUTTONS[3].data) + if (key.name === "5") sendData(BUTTONS[4].data) + if (key.name === "6") sendData(BUTTONS[5].data) + + if (key.name === "up") { + selectedButton = selectedButton > 0 ? selectedButton - 1 : BUTTONS.length - 1 + renderer?.requestRender() + } + if (key.name === "down") { + selectedButton = selectedButton < BUTTONS.length - 1 ? selectedButton + 1 : 0 + renderer?.requestRender() + } + if (key.name === "return") { + sendData(BUTTONS[selectedButton].data) + } +} + +export async function run(rendererInstance: CliRenderer): Promise { + renderer = rendererInstance + renderer.setBackgroundColor("#0d1117") + + const width = renderer.width + const height = renderer.height + terminalCols = Math.max(40, width - LEFT_PANEL_WIDTH - RIGHT_PANEL_BORDER) + terminalRows = Math.max(10, height - VERTICAL_OVERHEAD) + + const mainContainer = new BoxRenderable(renderer, { + id: "main-container", + flexDirection: "row", + flexGrow: 1, + }) + renderer.root.add(mainContainer) + + const leftPanel = new BoxRenderable(renderer, { + id: "left-panel", + width: 30, + flexDirection: "column", + padding: 1, + }) + mainContainer.add(leftPanel) + + const commandsTitle = new TextRenderable(renderer, { + id: "commands-title", + content: "Commands", + fg: "#58a6ff", + marginBottom: 1, + }) + leftPanel.add(commandsTitle) + + for (let i = 0; i < BUTTONS.length; i++) { + const btn = BUTTONS[i] + const isSelected = i === selectedButton + const buttonText = new TextRenderable(renderer, { + id: `button-${i}`, + content: btn.label, + fg: isSelected ? "#000" : "#d4d4d4", + bg: isSelected ? "#58a6ff" : undefined, + }) + leftPanel.add(buttonText) + } + + const helpText = new TextRenderable(renderer, { + id: "help-text", + content: "Use arrow keys + Enter\nor number keys 1-6\n\nPress 'q' to quit", + fg: "#8b949e", + marginTop: 2, + }) + leftPanel.add(helpText) + + statusDisplay = new TextRenderable(renderer, { + id: "status-text", + content: `Status: ${status}`, + fg: "#8b949e", + marginTop: 2, + }) + leftPanel.add(statusDisplay) + + const rightPanel = new BoxRenderable(renderer, { + id: "right-panel", + flexGrow: 1, + flexDirection: "column", + marginLeft: 1, + }) + mainContainer.add(rightPanel) + + const terminalTitle = new TextRenderable(renderer, { + id: "terminal-title", + content: "Terminal Output", + fg: "#58a6ff", + height: 1, + paddingLeft: 1, + bg: "#333", + }) + rightPanel.add(terminalTitle) + + try { + pty = await initPty(terminalCols, terminalRows) + + // Create streams from PTY + const readable = new ReadableStream({ + start(controller) { + pty.onData((data: string) => controller.enqueue(data)) + pty.onExit(() => controller.close()) + }, + }) + + const writable = new WritableStream({ + write(chunk) { + pty.write(chunk) + }, + }) + + terminalDisplay = new TerminalRenderable(renderer, { + id: "terminal-display", + cols: terminalCols, + rows: terminalRows, + trimEnd: true, + flexGrow: 1, + readable, + writable, + }) + rightPanel.add(terminalDisplay) + + pty.onExit(({ exitCode }: { exitCode: number }) => { + status = `Process exited with code ${exitCode}` + updateStatus() + }) + + status = "Running opencode" + updateStatus() + } catch (e) { + status = "Failed to start PTY" + updateStatus() + } + + renderer.on("resize", (newWidth: number, newHeight: number) => { + terminalCols = Math.max(40, newWidth - LEFT_PANEL_WIDTH - RIGHT_PANEL_BORDER) + terminalRows = Math.max(10, newHeight - VERTICAL_OVERHEAD) + pty?.resize(terminalCols, terminalRows) + if (terminalDisplay) { + terminalDisplay.cols = terminalCols + terminalDisplay.rows = terminalRows + } + updateStatus() + }) + + rendererInstance.keyInput.on("keypress", handleKey) +} + +export function destroy(rendererInstance: CliRenderer): void { + rendererInstance.keyInput.off("keypress", handleKey) + + if (pty) { + pty.kill() + pty = null + } + + if (terminalDisplay) { + terminalDisplay.destroy() + terminalDisplay = null + } + + if (statusDisplay) { + statusDisplay.destroy() + statusDisplay = null + } + + rendererInstance.root.remove("main-container") + renderer = null +} + +if (import.meta.main) { + const renderer = await createCliRenderer({ + exitOnCtrlC: false, + }) + + await run(renderer) + setupCommonDemoKeys(renderer) + renderer.start() +} diff --git a/packages/core/src/examples/terminal-simple-demo.ts b/packages/core/src/examples/terminal-simple-demo.ts new file mode 100644 index 000000000..e00de6ebe --- /dev/null +++ b/packages/core/src/examples/terminal-simple-demo.ts @@ -0,0 +1,162 @@ +import { + createCliRenderer, + StatelessTerminalRenderable, + BoxRenderable, + type CliRenderer, + type KeyEvent, + ScrollBoxRenderable, +} from "../index" +import { TextRenderable } from "../renderables/Text" +import { setupCommonDemoKeys } from "./lib/standalone-keys" + +let renderer: CliRenderer | null = null +let terminalDisplay: StatelessTerminalRenderable | null = null +let scrollBox: ScrollBoxRenderable | null = null +let statusDisplay: TextRenderable | null = null + +const SAMPLE_ANSI = `\x1b[1;32muser@hostname\x1b[0m:\x1b[1;34m~/projects/my-app\x1b[0m$ ls -la +total 128 +drwxr-xr-x 12 user user 4096 Nov 26 10:30 \x1b[1;34m.\x1b[0m +drwxr-xr-x 5 user user 4096 Nov 25 14:22 \x1b[1;34m..\x1b[0m +-rw-r--r-- 1 user user 234 Nov 26 10:30 .gitignore +drwxr-xr-x 8 user user 4096 Nov 26 10:28 \x1b[1;34m.git\x1b[0m +-rw-r--r-- 1 user user 1842 Nov 26 09:15 package.json + +\x1b[1;32muser@hostname\x1b[0m:\x1b[1;34m~/projects/my-app\x1b[0m$ git status +On branch \x1b[1;36mmain\x1b[0m +Changes to be committed: + \x1b[32mmodified: src/index.ts\x1b[0m + \x1b[32mnew file: src/utils.ts\x1b[0m + +Changes not staged for commit: + \x1b[31mmodified: package.json\x1b[0m + +\x1b[1;32muser@hostname\x1b[0m:\x1b[1;34m~/projects/my-app\x1b[0m$ npm run build +\x1b[1;33m[WARN]\x1b[0m Deprecation warning: 'fs.exists' is deprecated +\x1b[1;36m[INFO]\x1b[0m Compiling TypeScript files... +\x1b[1;32m[SUCCESS]\x1b[0m Build completed in 2.34s + +\x1b[1;32muser@hostname\x1b[0m:\x1b[1;34m~/projects/my-app\x1b[0m$ echo "Style showcase:" +Style showcase: + +\x1b[1mBold text\x1b[0m +\x1b[2mFaint/dim text\x1b[0m +\x1b[3mItalic text\x1b[0m +\x1b[4mUnderlined text\x1b[0m +\x1b[7mInverse/reverse text\x1b[0m +\x1b[9mStrikethrough text\x1b[0m + +\x1b[31mRed\x1b[0m \x1b[32mGreen\x1b[0m \x1b[33mYellow\x1b[0m \x1b[34mBlue\x1b[0m \x1b[35mMagenta\x1b[0m \x1b[36mCyan\x1b[0m +\x1b[38;5;208mOrange (256 color)\x1b[0m +\x1b[38;2;255;105;180mHot Pink (RGB)\x1b[0m +` + +let currentAnsi = SAMPLE_ANSI +let prefixCount = 0 + +export function run(rendererInstance: CliRenderer): void { + renderer = rendererInstance + renderer.setBackgroundColor("#0d1117") + + const container = new BoxRenderable(renderer, { + id: "container", + flexDirection: "column", + flexGrow: 1, + }) + renderer.root.add(container) + + statusDisplay = new TextRenderable(renderer, { + id: "status", + content: "Press 'p' to add prefix | 't' scroll top | 'b' scroll bottom | 'q' to quit", + height: 1, + fg: "#8b949e", + padding: 1, + }) + container.add(statusDisplay) + + scrollBox = new ScrollBoxRenderable(renderer, { + id: "scroll-box", + flexGrow: 1, + padding: 1, + }) + container.add(scrollBox) + + terminalDisplay = new StatelessTerminalRenderable(renderer, { + id: "terminal", + ansi: currentAnsi, + cols: 120, + rows: 100, + trimEnd: true, + }) + scrollBox.add(terminalDisplay) + + rendererInstance.keyInput.on("keypress", handleKey) +} + +function handleKey(key: KeyEvent): void { + if (key.name === "q" || key.name === "escape") { + process.exit(0) + } + + if (key.name === "p" && terminalDisplay) { + prefixCount++ + const prefix = `\x1b[1;35m[PREFIX ${prefixCount}]\x1b[0m\n` + currentAnsi = prefix + currentAnsi + terminalDisplay.ansi = currentAnsi + updateStatus() + } + + if (key.name === "t" && scrollBox) { + scrollBox.scrollTo(0) + } + + if (key.name === "b" && scrollBox && terminalDisplay) { + const lastLine = terminalDisplay.lineCount - 1 + const scrollPos = terminalDisplay.getScrollPositionForLine(lastLine) + scrollBox.scrollTo(scrollPos) + } +} + +function updateStatus(): void { + if (statusDisplay && terminalDisplay) { + statusDisplay.content = `Press 'p' to add prefix | 't' top | 'b' bottom | 'q' quit | Prefixes: ${prefixCount} | Lines: ${terminalDisplay.lineCount}` + } +} + +export function destroy(rendererInstance: CliRenderer): void { + rendererInstance.keyInput.off("keypress", handleKey) + + if (terminalDisplay) { + terminalDisplay.destroy() + terminalDisplay = null + } + + if (scrollBox) { + scrollBox.destroy() + scrollBox = null + } + + if (statusDisplay) { + statusDisplay.destroy() + statusDisplay = null + } + + rendererInstance.root.remove("container") + renderer = null +} + +if (import.meta.main) { + const inputFile = process.argv[2] + if (inputFile) { + const fs = await import("fs") + currentAnsi = fs.readFileSync(inputFile, "utf-8") + } + + const renderer = await createCliRenderer({ + exitOnCtrlC: true, + }) + + run(renderer) + setupCommonDemoKeys(renderer) + renderer.start() +} diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index 71496bbcb..80527ba0b 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -16,3 +16,4 @@ export * from "./tree-sitter" export * from "./data-paths" export * from "./extmarks" export * from "./terminal-palette" +export * from "./vterm-ffi" diff --git a/packages/core/src/lib/tree-sitter/cache.test.ts b/packages/core/src/lib/tree-sitter/cache.test.ts index fefdc0426..872d7c07d 100644 --- a/packages/core/src/lib/tree-sitter/cache.test.ts +++ b/packages/core/src/lib/tree-sitter/cache.test.ts @@ -212,8 +212,12 @@ describe("TreeSitterClient Caching", () => { }) test("should handle directory creation errors gracefully", async () => { - const invalidDataPath = "/invalid/path/that/cannot/be/created" - const client = new TreeSitterClient({ dataPath: invalidDataPath }) + // Use a file path as dataPath - can't create a directory where a file exists + // This works on all platforms (linux, musl, darwin, windows) + const filePath = join(tmpdir(), "tree-sitter-test-file-" + Math.random().toString(36).slice(2)) + await Bun.write(filePath, "test") + + const client = new TreeSitterClient({ dataPath: filePath }) await expect(client.initialize()).rejects.toThrow() diff --git a/packages/core/src/lib/vterm-ffi.ts b/packages/core/src/lib/vterm-ffi.ts new file mode 100644 index 000000000..d5ce1341f --- /dev/null +++ b/packages/core/src/lib/vterm-ffi.ts @@ -0,0 +1,80 @@ +import { StyledText } from "./styled-text" +import { RGBA } from "./RGBA" +import type { TextChunk } from "../text-buffer" +import { TextAttributes } from "../types" + +const DEFAULT_FG = RGBA.fromHex("#d4d4d4") + +export const VTermStyleFlags = { + BOLD: 1, + ITALIC: 2, + UNDERLINE: 4, + STRIKETHROUGH: 8, + INVERSE: 16, + FAINT: 32, +} as const + +export interface VTermSpan { + text: string + fg: string | null + bg: string | null + flags: number + width: number +} + +export interface VTermLine { + spans: VTermSpan[] +} + +export interface VTermData { + cols: number + rows: number + cursor: [number, number] + offset: number + totalLines: number + lines: VTermLine[] +} + +function convertSpanToChunk(span: VTermSpan): TextChunk { + const { text, fg, bg, flags } = span + + let fgColor = fg ? RGBA.fromHex(fg) : DEFAULT_FG + let bgColor = bg ? RGBA.fromHex(bg) : undefined + + if (flags & VTermStyleFlags.INVERSE) { + const temp = fgColor + fgColor = bgColor || DEFAULT_FG + bgColor = temp + } + + let attributes = 0 + if (flags & VTermStyleFlags.BOLD) attributes |= TextAttributes.BOLD + if (flags & VTermStyleFlags.ITALIC) attributes |= TextAttributes.ITALIC + if (flags & VTermStyleFlags.UNDERLINE) attributes |= TextAttributes.UNDERLINE + if (flags & VTermStyleFlags.STRIKETHROUGH) attributes |= TextAttributes.STRIKETHROUGH + if (flags & VTermStyleFlags.FAINT) attributes |= TextAttributes.DIM + + return { __isChunk: true, text, fg: fgColor, bg: bgColor, attributes } +} + +export function vtermDataToStyledText(data: VTermData): StyledText { + const chunks: TextChunk[] = [] + + for (let i = 0; i < data.lines.length; i++) { + const line = data.lines[i] + + if (line.spans.length === 0) { + chunks.push({ __isChunk: true, text: " ", attributes: 0 }) + } else { + for (const span of line.spans) { + chunks.push(convertSpanToChunk(span)) + } + } + + if (i < data.lines.length - 1) { + chunks.push({ __isChunk: true, text: "\n", attributes: 0 }) + } + } + + return new StyledText(chunks) +} diff --git a/packages/core/src/renderables/Code.test.ts b/packages/core/src/renderables/Code.test.ts index 162470e39..a61f357d7 100644 --- a/packages/core/src/renderables/Code.test.ts +++ b/packages/core/src/renderables/Code.test.ts @@ -1692,6 +1692,16 @@ test("CodeRenderable - streaming with conceal and drawUnstyledText=false should currentRenderer.root.add(codeRenderable) + const waitForHighlightingCycle = async (timeout = 2000) => { + const start = Date.now() + await renderOnce() + await new Promise((resolve) => setTimeout(resolve, 10)) + while (codeRenderable.isHighlighting && Date.now() - start < timeout) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + await renderOnce() + } + // Use TestRecorder to capture frames const { TestRecorder } = await import("../testing/test-recorder") const recorder = new TestRecorder(currentRenderer) @@ -1701,13 +1711,13 @@ test("CodeRenderable - streaming with conceal and drawUnstyledText=false should recorder.rec() // Wait for initial highlighting to complete - await new Promise((resolve) => setTimeout(resolve, 100)) + await waitForHighlightingCycle() // Now simulate streaming: add more content including fenced code block codeRenderable.content = `# Example\n\nHere's some code:\n\n\`\`\`typescript\nconst x = 1;\n\`\`\`` // Wait for highlighting to process the update - await new Promise((resolve) => setTimeout(resolve, 150)) + await waitForHighlightingCycle() // Stop everything currentRenderer.stop() diff --git a/packages/core/src/renderables/Terminal.test.ts b/packages/core/src/renderables/Terminal.test.ts new file mode 100644 index 000000000..a892c5ae5 --- /dev/null +++ b/packages/core/src/renderables/Terminal.test.ts @@ -0,0 +1,763 @@ +import { test, expect, beforeEach, afterEach } from "bun:test" +import { TerminalRenderable, StatelessTerminalRenderable } from "./Terminal" +import { createTestRenderer, type TestRenderer } from "../testing" + +let currentRenderer: TestRenderer +let renderOnce: () => Promise +let captureFrame: () => string + +beforeEach(async () => { + const testRenderer = await createTestRenderer({ width: 80, height: 24 }) + currentRenderer = testRenderer.renderer + renderOnce = testRenderer.renderOnce + captureFrame = testRenderer.captureCharFrame +}) + +afterEach(async () => { + if (currentRenderer) { + currentRenderer.destroy() + } +}) + +test("TerminalRenderable - basic construction", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal", + cols: 80, + rows: 24, + }) + + expect(terminal.cols).toBe(80) + expect(terminal.rows).toBe(24) + + terminal.destroy() +}) + +test("TerminalRenderable - feed simple text", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal", + cols: 80, + rows: 24, + }) + + terminal.feed("Hello, World!") + + currentRenderer.root.add(terminal) + await renderOnce() + + const frame = captureFrame() + expect(frame).toContain("Hello, World!") + + terminal.destroy() +}) + +test("TerminalRenderable - feed ANSI colored text", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal", + cols: 80, + rows: 24, + }) + + // Feed red "Hello" and green "World" + terminal.feed("\x1b[31mHello\x1b[0m \x1b[32mWorld\x1b[0m") + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text).toContain("Hello") + expect(text).toContain("World") + + terminal.destroy() +}) + +test("TerminalRenderable - getCursor returns position", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal", + cols: 80, + rows: 24, + }) + + terminal.feed("ABC") + const cursor = terminal.getCursor() + + expect(cursor[0]).toBe(3) // x position after "ABC" + expect(cursor[1]).toBe(0) // y position (first row) + + terminal.destroy() +}) + +test("TerminalRenderable - reset clears content", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal", + cols: 80, + rows: 24, + }) + + terminal.feed("Some text that should be cleared") + terminal.reset() + + const cursor = terminal.getCursor() + expect(cursor[0]).toBe(0) + expect(cursor[1]).toBe(0) + + terminal.destroy() +}) + +test("StatelessTerminalRenderable - basic construction", async () => { + const terminal = new StatelessTerminalRenderable(currentRenderer, { + id: "test-stateless-terminal", + ansi: "Hello, World!", + cols: 80, + rows: 24, + }) + + expect(terminal.cols).toBe(80) + expect(terminal.rows).toBe(24) + + currentRenderer.root.add(terminal) + await renderOnce() + + const frame = captureFrame() + expect(frame).toContain("Hello, World!") +}) + +test("StatelessTerminalRenderable - ANSI colored text", async () => { + const terminal = new StatelessTerminalRenderable(currentRenderer, { + id: "test-stateless-terminal", + ansi: "\x1b[31mRed\x1b[0m \x1b[32mGreen\x1b[0m", + cols: 80, + rows: 24, + }) + + currentRenderer.root.add(terminal) + await renderOnce() + + const frame = captureFrame() + expect(frame).toContain("Red") + expect(frame).toContain("Green") +}) + +test("TerminalRenderable - multiple feeds", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal", + cols: 80, + rows: 24, + }) + + terminal.feed("Line 1\n") + terminal.feed("Line 2\n") + terminal.feed("Line 3") + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text).toContain("Line 1") + expect(text).toContain("Line 2") + expect(text).toContain("Line 3") + + terminal.destroy() +}) + +test("TerminalRenderable - isReady returns correct state", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal", + cols: 80, + rows: 24, + }) + + terminal.feed("Hello") + expect(terminal.isReady()).toBe(true) + + terminal.destroy() +}) + +// Large input tests to reproduce potential segfaults + +function generateLargeAnsi(lineCount: number, lineLength: number = 80): string { + const colors = [31, 32, 33, 34, 35, 36, 37] + let result = "" + for (let i = 0; i < lineCount; i++) { + const color = colors[i % colors.length] + const text = `Line ${i}: ${"x".repeat(lineLength - 10)}` + result += `\x1b[${color}m${text}\x1b[0m\n` + } + return result +} + +function generateComplexAnsi(size: number): string { + let result = "" + const styles = [ + "\x1b[1m", // bold + "\x1b[2m", // dim + "\x1b[3m", // italic + "\x1b[4m", // underline + "\x1b[7m", // inverse + "\x1b[9m", // strikethrough + "\x1b[31m", // red + "\x1b[32m", // green + "\x1b[33m", // yellow + "\x1b[34m", // blue + "\x1b[38;5;208m", // 256 color + "\x1b[38;2;255;105;180m", // RGB color + ] + + let currentSize = 0 + let lineNum = 0 + while (currentSize < size) { + const style = styles[lineNum % styles.length] + const line = `${style}Line ${lineNum}: Some text content here\x1b[0m\n` + result += line + currentSize += line.length + lineNum++ + } + return result +} + +test("TerminalRenderable - large input 1000 lines", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-large", + cols: 120, + rows: 50, + }) + + const largeAnsi = generateLargeAnsi(1000) + terminal.feed(largeAnsi) + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text).toContain("Line 0") + expect(text).toContain("Line 999") + + terminal.destroy() +}) + +test("TerminalRenderable - large input 10000 lines", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-large", + cols: 120, + rows: 50, + }) + + const largeAnsi = generateLargeAnsi(10000) + terminal.feed(largeAnsi) + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text).toContain("Line 0") + expect(text).toContain("Line 9999") + + terminal.destroy() +}) + +test("TerminalRenderable - very large input 100KB", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-100kb", + cols: 120, + rows: 50, + }) + + const largeAnsi = generateComplexAnsi(100 * 1024) + terminal.feed(largeAnsi) + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text.length).toBeGreaterThan(0) + + terminal.destroy() +}) + +test("TerminalRenderable - very large input 200KB", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-200kb", + cols: 120, + rows: 50, + }) + + const largeAnsi = generateComplexAnsi(200 * 1024) + terminal.feed(largeAnsi) + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text.length).toBeGreaterThan(0) + + terminal.destroy() +}) + +test("StatelessTerminalRenderable - large input 1000 lines", async () => { + const largeAnsi = generateLargeAnsi(1000) + + const terminal = new StatelessTerminalRenderable(currentRenderer, { + id: "test-stateless-large", + ansi: largeAnsi, + cols: 120, + rows: 50, + }) + + currentRenderer.root.add(terminal) + await renderOnce() + + const frame = captureFrame() + expect(frame.length).toBeGreaterThan(0) +}) + +test("StatelessTerminalRenderable - large input 200KB", async () => { + const largeAnsi = generateComplexAnsi(200 * 1024) + + const terminal = new StatelessTerminalRenderable(currentRenderer, { + id: "test-stateless-200kb", + ansi: largeAnsi, + cols: 120, + rows: 50, + }) + + currentRenderer.root.add(terminal) + await renderOnce() + + const frame = captureFrame() + expect(frame.length).toBeGreaterThan(0) +}) + +test("TerminalRenderable - create and destroy many terminals", async () => { + for (let i = 0; i < 20; i++) { + const terminal = new TerminalRenderable(currentRenderer, { + id: `test-terminal-${i}`, + cols: 80, + rows: 24, + }) + + terminal.feed(`Terminal ${i}: \x1b[32mSome colored text\x1b[0m\n`) + + currentRenderer.root.add(terminal) + await renderOnce() + + terminal.destroy() + currentRenderer.root.remove(`test-terminal-${i}`) + } + + expect(true).toBe(true) +}) + +test("TerminalRenderable - concurrent terminals", async () => { + const terminals: TerminalRenderable[] = [] + + // Create multiple terminals + for (let i = 0; i < 5; i++) { + const terminal = new TerminalRenderable(currentRenderer, { + id: `test-concurrent-${i}`, + cols: 80, + rows: 24, + }) + terminals.push(terminal) + currentRenderer.root.add(terminal) + } + + // Feed data to all of them + for (let j = 0; j < 10; j++) { + for (let i = 0; i < terminals.length; i++) { + terminals[i].feed(`\x1b[${31 + i}mTerminal ${i}, line ${j}\x1b[0m\n`) + } + await renderOnce() + } + + // Destroy all + for (const terminal of terminals) { + terminal.destroy() + } + + expect(true).toBe(true) +}) + +test("TerminalRenderable - resize during feed", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-resize", + cols: 80, + rows: 24, + }) + + currentRenderer.root.add(terminal) + + for (let i = 0; i < 10; i++) { + terminal.feed(`Line ${i}: Some content\n`) + terminal.cols = 80 + i * 10 + terminal.rows = 24 + i * 2 + await renderOnce() + } + + expect(terminal.cols).toBe(170) + expect(terminal.rows).toBe(42) + + terminal.destroy() +}) + +test("TerminalRenderable - reset and refeed", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-reset", + cols: 80, + rows: 24, + }) + + currentRenderer.root.add(terminal) + + for (let i = 0; i < 5; i++) { + terminal.feed(generateLargeAnsi(100)) + await renderOnce() + terminal.reset() + await renderOnce() + } + + const cursor = terminal.getCursor() + expect(cursor[0]).toBe(0) + expect(cursor[1]).toBe(0) + + terminal.destroy() +}) + +test("TerminalRenderable - special escape sequences", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-special", + cols: 80, + rows: 24, + }) + + // Various special sequences + const sequences = [ + "\x1b[2J", // Clear screen + "\x1b[H", // Home + "\x1b[K", // Clear to end of line + "\x1b[1K", // Clear to beginning of line + "\x1b[2K", // Clear entire line + "\x1b[J", // Clear to end of screen + "\x1b[1J", // Clear to beginning of screen + "\x1b[s", // Save cursor + "\x1b[u", // Restore cursor + "\x1b[?25l", // Hide cursor + "\x1b[?25h", // Show cursor + "\x1b[0m", // Reset attributes + "\x1b[1;1H", // Move to 1,1 + "\x1b[10;20H", // Move to 10,20 + ] + + for (const seq of sequences) { + terminal.feed(seq + "Some text after sequence\n") + } + + currentRenderer.root.add(terminal) + await renderOnce() + + expect(true).toBe(true) + + terminal.destroy() +}) + +test("TerminalRenderable - binary/control characters", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-binary", + cols: 80, + rows: 24, + }) + + // Feed some binary/control characters + let binaryData = "" + for (let i = 0; i < 32; i++) { + if (i !== 27) { + // Skip ESC + binaryData += String.fromCharCode(i) + } + } + binaryData += "Normal text after binary\n" + + terminal.feed(binaryData) + + currentRenderer.root.add(terminal) + await renderOnce() + + expect(true).toBe(true) + + terminal.destroy() +}) + +// Tests to reproduce async/microtask segfaults + +test("TerminalRenderable - rapid async getText calls", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-async", + cols: 80, + rows: 24, + }) + + terminal.feed(generateLargeAnsi(500)) + currentRenderer.root.add(terminal) + + // Make many rapid getText calls with microtask breaks + const results: string[] = [] + for (let i = 0; i < 50; i++) { + const text = terminal.getText() + results.push(text) + await Promise.resolve() // Force microtask break + } + + expect(results.length).toBe(50) + expect(results.every((r) => r.length > 0)).toBe(true) + + terminal.destroy() +}) + +test("TerminalRenderable - rapid async getCursor calls", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-cursor-async", + cols: 80, + rows: 24, + }) + + terminal.feed(generateLargeAnsi(500)) + currentRenderer.root.add(terminal) + + // Make many rapid getCursor calls with microtask breaks + const results: [number, number][] = [] + for (let i = 0; i < 50; i++) { + const cursor = terminal.getCursor() + results.push(cursor) + await Promise.resolve() // Force microtask break + } + + expect(results.length).toBe(50) + + terminal.destroy() +}) + +test("StatelessTerminalRenderable - rapid ansi updates with microtasks", async () => { + const terminal = new StatelessTerminalRenderable(currentRenderer, { + id: "test-stateless-async", + ansi: "Initial", + cols: 80, + rows: 24, + }) + + currentRenderer.root.add(terminal) + + // Rapidly update ansi with microtask breaks + for (let i = 0; i < 100; i++) { + terminal.ansi = generateLargeAnsi(50) + await renderOnce() + await Promise.resolve() // Force microtask break + } + + expect(true).toBe(true) +}) + +test("TerminalRenderable - interleaved feed/getText/render", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-interleaved", + cols: 80, + rows: 24, + }) + + currentRenderer.root.add(terminal) + + for (let i = 0; i < 100; i++) { + terminal.feed(`\x1b[${31 + (i % 7)}mLine ${i}\x1b[0m\n`) + const text = terminal.getText() + await renderOnce() + const cursor = terminal.getCursor() + await Promise.resolve() + } + + terminal.destroy() +}) + +test("TerminalRenderable - parallel getText and feed", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-parallel", + cols: 80, + rows: 24, + }) + + terminal.feed(generateLargeAnsi(100)) + currentRenderer.root.add(terminal) + + // Create multiple promises that read text + const promises: Promise[] = [] + for (let i = 0; i < 20; i++) { + promises.push( + new Promise((resolve) => { + setTimeout(() => { + resolve(terminal.getText()) + }, i * 10) + }), + ) + } + + // Also feed more data while reading + for (let i = 0; i < 10; i++) { + terminal.feed(`\x1b[32mMore data ${i}\x1b[0m\n`) + await Promise.resolve() + } + + const results = await Promise.all(promises) + expect(results.every((r) => typeof r === "string")).toBe(true) + + terminal.destroy() +}) + +test("TerminalRenderable - stress test create/destroy/getText cycle", async () => { + for (let i = 0; i < 50; i++) { + const terminal = new TerminalRenderable(currentRenderer, { + id: `test-terminal-stress-${i}`, + cols: 80, + rows: 24, + }) + + terminal.feed(generateLargeAnsi(100)) + currentRenderer.root.add(terminal) + await renderOnce() + + // Get text multiple times + terminal.getText() + terminal.getText() + terminal.getText() + + await Promise.resolve() + + terminal.destroy() + currentRenderer.root.remove(`test-terminal-stress-${i}`) + + await Promise.resolve() + } + + expect(true).toBe(true) +}) + +test("StatelessTerminalRenderable - stress test rapid creation", async () => { + const terminals: StatelessTerminalRenderable[] = [] + + for (let i = 0; i < 30; i++) { + const terminal = new StatelessTerminalRenderable(currentRenderer, { + id: `test-stateless-stress-${i}`, + ansi: generateLargeAnsi(100), + cols: 80, + rows: 24, + }) + terminals.push(terminal) + currentRenderer.root.add(terminal) + } + + await renderOnce() + + // Access all terminals + for (const terminal of terminals) { + await Promise.resolve() + } + + // Destroy all + for (let i = 0; i < terminals.length; i++) { + currentRenderer.root.remove(`test-stateless-stress-${i}`) + } + + await Promise.resolve() + expect(true).toBe(true) +}) + +test("TerminalRenderable - multiple large feeds", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-multi-feed", + cols: 120, + rows: 50, + }) + + // Feed in chunks + for (let i = 0; i < 10; i++) { + const chunk = generateLargeAnsi(100) + terminal.feed(chunk) + } + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text.length).toBeGreaterThan(0) + + terminal.destroy() +}) + +test("TerminalRenderable - rapid feed and render cycles", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-rapid", + cols: 120, + rows: 50, + }) + + currentRenderer.root.add(terminal) + + // Rapid feed and render + for (let i = 0; i < 50; i++) { + terminal.feed(`\x1b[${31 + (i % 7)}mLine ${i}: Some content here\x1b[0m\n`) + await renderOnce() + } + + const text = terminal.getText() + expect(text).toContain("Line 49") + + terminal.destroy() +}) + +test("TerminalRenderable - feed with cursor movement sequences", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-cursor", + cols: 80, + rows: 24, + }) + + // Various cursor movement and control sequences + const ansi = + `\x1b[2J\x1b[H` + // Clear screen and home + `\x1b[5;10HPosition 5,10` + // Move to row 5, col 10 + `\x1b[10;20HPosition 10,20` + // Move to row 10, col 20 + `\x1b[A\x1b[A\x1b[A` + // Move up 3 times + `After moving up` + + `\x1b[B\x1b[B` + // Move down 2 times + `After moving down` + + `\x1b[C\x1b[C\x1b[C` + // Move right 3 times + `After moving right` + + `\x1b[D\x1b[D` + // Move left 2 times + `After moving left` + + terminal.feed(ansi) + + currentRenderer.root.add(terminal) + await renderOnce() + + const text = terminal.getText() + expect(text).toContain("Position") + + terminal.destroy() +}) + +test("TerminalRenderable - large input with scrollback", async () => { + const terminal = new TerminalRenderable(currentRenderer, { + id: "test-terminal-scrollback", + cols: 80, + rows: 24, + }) + + // Generate more lines than rows to test scrollback + const ansi = generateLargeAnsi(1000, 70) + terminal.feed(ansi) + + currentRenderer.root.add(terminal) + await renderOnce() + + // Check that content exists + const text = terminal.getText() + expect(text.length).toBeGreaterThan(0) + + terminal.destroy() +}) diff --git a/packages/core/src/renderables/Terminal.ts b/packages/core/src/renderables/Terminal.ts new file mode 100644 index 000000000..beb290353 --- /dev/null +++ b/packages/core/src/renderables/Terminal.ts @@ -0,0 +1,388 @@ +import { TextBufferRenderable, type TextBufferOptions } from "./TextBufferRenderable" +import { RGBA } from "../lib/RGBA" +import type { RenderContext } from "../types" +import type { OptimizedBuffer } from "../buffer" +import { resolveRenderLib, type RenderLib } from "../zig" +import { vtermDataToStyledText, type VTermData } from "../lib/vterm-ffi" +import type { MouseEvent } from "../renderer" + +// Re-export types from vterm-ffi for backwards compatibility +export { + VTermStyleFlags, + type VTermSpan, + type VTermLine, + type VTermData, + vtermDataToStyledText, +} from "../lib/vterm-ffi" + +const DEFAULT_FG = RGBA.fromHex("#d4d4d4") + +function trimEmptyLines(data: VTermData): void { + while (data.lines.length > 0) { + const lastLine = data.lines[data.lines.length - 1] + const hasText = lastLine.spans.some((span) => span.text.trim().length > 0) + if (hasText) break + data.lines.pop() + } +} + +export interface StatelessTerminalOptions extends TextBufferOptions { + ansi?: string | Buffer + cols?: number + rows?: number + limit?: number + trimEnd?: boolean +} + +export class StatelessTerminalRenderable extends TextBufferRenderable { + private _ansi: string | Buffer + private _cols: number + private _rows: number + private _limit?: number + private _trimEnd?: boolean + private _needsUpdate: boolean = true + private _lineCount: number = 0 + private _lib: RenderLib + + constructor(ctx: RenderContext, options: StatelessTerminalOptions) { + super(ctx, { ...options, fg: DEFAULT_FG, wrapMode: "none" }) + this._ansi = options.ansi ?? "" + this._cols = options.cols ?? 120 + this._rows = options.rows ?? 40 + this._limit = options.limit + this._trimEnd = options.trimEnd + this._lib = resolveRenderLib() + } + + get lineCount(): number { + return this._lineCount + } + + get ansi(): string | Buffer { + return this._ansi + } + + set ansi(value: string | Buffer) { + if (this._ansi !== value) { + this._ansi = value + this._needsUpdate = true + this.requestRender() + } + } + + get cols(): number { + return this._cols + } + + set cols(value: number) { + if (this._cols !== value) { + this._cols = value + this._needsUpdate = true + this.requestRender() + } + } + + get rows(): number { + return this._rows + } + + set rows(value: number) { + if (this._rows !== value) { + this._rows = value + this._needsUpdate = true + this.requestRender() + } + } + + get limit(): number | undefined { + return this._limit + } + + set limit(value: number | undefined) { + if (this._limit !== value) { + this._limit = value + this._needsUpdate = true + this.requestRender() + } + } + + get trimEnd(): boolean | undefined { + return this._trimEnd + } + + set trimEnd(value: boolean | undefined) { + if (this._trimEnd !== value) { + this._trimEnd = value + this._needsUpdate = true + this.requestRender() + } + } + + protected renderSelf(buffer: OptimizedBuffer): void { + if (this._needsUpdate) { + const data = this._lib.vtermPtyToJson(this._ansi, { + cols: this._cols, + rows: this._rows, + limit: this._limit, + }) as VTermData + + if (this._trimEnd) trimEmptyLines(data) + + this.textBuffer.setStyledText(vtermDataToStyledText(data)) + this.updateTextInfo() + this._lineCount = this.textBufferView.logicalLineInfo.lineStarts.length + this._needsUpdate = false + } + super.renderSelf(buffer) + } + + getScrollPositionForLine(lineNumber: number): number { + const clampedLine = Math.max(0, Math.min(lineNumber, this._lineCount - 1)) + const lineStarts = this.textBufferView.logicalLineInfo.lineStarts + const lineYOffset = lineStarts?.[clampedLine] ?? clampedLine + return this.y + lineYOffset + } +} + +export interface TerminalOptions extends TextBufferOptions { + cols?: number + rows?: number + trimEnd?: boolean + readable?: ReadableStream + writable?: WritableStream +} + +let nextTerminalId = 1 + +export class TerminalRenderable extends TextBufferRenderable { + private _cols: number + private _rows: number + private _trimEnd?: boolean + private _contentDirty: boolean = true + private _lineCount: number = 0 + private _terminalId: number + private _lib: RenderLib + private _destroyed = false + private _readable?: ReadableStream + private _writable?: WritableStream + private _reader?: ReadableStreamDefaultReader + private _writer?: WritableStreamDefaultWriter + + constructor(ctx: RenderContext, options: TerminalOptions) { + super(ctx, { ...options, fg: DEFAULT_FG, wrapMode: "none" }) + + this._cols = options.cols ?? 120 + this._rows = options.rows ?? 40 + this._trimEnd = options.trimEnd + this._lib = resolveRenderLib() + this._terminalId = nextTerminalId++ + + const success = this._lib.vtermCreateTerminal(this._terminalId, this._cols, this._rows) + if (!success) { + throw new Error("Failed to create terminal") + } + + if (options.readable) { + this._readable = options.readable + this._reader = options.readable.getReader() + this.startReading() + } + + if (options.writable) { + this._writable = options.writable + this._writer = options.writable.getWriter() + } + } + + private async startReading(): Promise { + if (!this._reader) return + + try { + while (!this._destroyed) { + const { done, value } = await this._reader.read() + if (done || this._destroyed) break + if (value) { + this._lib.vtermFeedTerminal(this._terminalId, value) + this._contentDirty = true + this.requestRender() + } + } + } catch { + // Stream closed or errored + } + } + + get lineCount(): number { + return this._lineCount + } + + get cols(): number { + return this._cols + } + + set cols(value: number) { + if (this._cols !== value) { + this._cols = value + this._lib.vtermResizeTerminal(this._terminalId, value, this._rows) + this._contentDirty = true + this.requestRender() + } + } + + get rows(): number { + return this._rows + } + + set rows(value: number) { + if (this._rows !== value) { + this._rows = value + this._lib.vtermResizeTerminal(this._terminalId, this._cols, value) + this._contentDirty = true + this.requestRender() + } + } + + get trimEnd(): boolean | undefined { + return this._trimEnd + } + + set trimEnd(value: boolean | undefined) { + if (this._trimEnd !== value) { + this._trimEnd = value + this._contentDirty = true + this.requestRender() + } + } + + get readable(): ReadableStream | undefined { + return this._readable + } + + set readable(value: ReadableStream | undefined) { + if (value === this._readable) return + if (this._readable && value) { + throw new Error("TerminalRenderable: changing readable stream is not supported") + } + this._readable = value + if (value) { + this._reader = value.getReader() + this.startReading() + } + } + + get writable(): WritableStream | undefined { + return this._writable + } + + set writable(value: WritableStream | undefined) { + if (value === this._writable) return + if (this._writable && value) { + throw new Error("TerminalRenderable: changing writable stream is not supported") + } + this._writable = value + if (value) { + this._writer = value.getWriter() + } + } + + feed(data: string | Buffer): void { + this._lib.vtermFeedTerminal(this._terminalId, data) + this._contentDirty = true + this.requestRender() + } + + reset(): void { + this._lib.vtermResetTerminal(this._terminalId) + this._contentDirty = true + this.requestRender() + } + + getCursor(): [number, number] { + return this._lib.vtermGetTerminalCursor(this._terminalId) + } + + getText(): string { + return this._lib.vtermGetTerminalText(this._terminalId) + } + + isReady(): boolean { + return this._lib.vtermIsTerminalReady(this._terminalId) + } + + protected override onMouseEvent(event: MouseEvent): void { + super.onMouseEvent(event) + if (!this._writer) return + + const { x, y, type, button, modifiers, scroll } = event + + // Check bounds + if (x < this.x || x >= this.x + this.width || y < this.y || y >= this.y + this.height) return + + // Transform to 1-based terminal coordinates + const col = x - this.x + 1 + const row = y - this.y + 1 + + let encoded: string | null = null + + if (scroll) { + // Scroll: button 64=up, 65=down, 66=left, 67=right + const buttonMap = { up: 64, down: 65, left: 66, right: 67 } + const scrollBtn = buttonMap[scroll.direction] + encoded = this.encodeMouse("press", scrollBtn, col, row, modifiers) + } else { + // Mouse events + let encodeType: "press" | "release" | "move" | null = null + if (type === "down") encodeType = "press" + else if (type === "up") encodeType = "release" + else if (type === "move" || type === "drag") encodeType = "move" + + if (encodeType) { + encoded = this.encodeMouse(encodeType, button, col, row, modifiers) + } + } + + if (encoded) { + this._writer.write(encoded) + } + } + + private encodeMouse( + type: "press" | "release" | "move", + button: number, + col: number, + row: number, + modifiers?: { shift: boolean; alt: boolean; ctrl: boolean }, + ): string { + let btn = button + if (modifiers?.shift) btn |= 4 + if (modifiers?.alt) btn |= 8 + if (modifiers?.ctrl) btn |= 16 + if (type === "move") btn |= 32 + const suffix = type === "release" ? "m" : "M" + return `\x1b[<${btn};${col};${row}${suffix}` + } + + destroy(): void { + if (!this._destroyed) { + this._destroyed = true + this._reader?.cancel() + this._writer?.close() + this._lib.vtermDestroyTerminal(this._terminalId) + } + super.destroy() + } + + protected renderSelf(buffer: OptimizedBuffer): void { + if (this._contentDirty && !this._destroyed) { + const data = this._lib.vtermGetTerminalJson(this._terminalId, {}) as VTermData + + if (this._trimEnd) trimEmptyLines(data) + + this.textBuffer.setStyledText(vtermDataToStyledText(data)) + this.updateTextInfo() + this._lineCount = this.textBufferView.logicalLineInfo.lineStarts.length + this._contentDirty = false + } + super.renderSelf(buffer) + } +} diff --git a/packages/core/src/renderables/Text.ts b/packages/core/src/renderables/Text.ts index b426da81d..24a12263c 100644 --- a/packages/core/src/renderables/Text.ts +++ b/packages/core/src/renderables/Text.ts @@ -88,6 +88,7 @@ export class TextRenderable extends TextBufferRenderable { fg: this._defaultFg, bg: this._defaultBg, attributes: this._defaultAttributes, + link: undefined, }) this.textBuffer.setStyledText(new StyledText(chunks)) this.refreshLocalSelection() diff --git a/packages/core/src/renderables/TextNode.test.ts b/packages/core/src/renderables/TextNode.test.ts index 918ded0da..0ce661e47 100644 --- a/packages/core/src/renderables/TextNode.test.ts +++ b/packages/core/src/renderables/TextNode.test.ts @@ -794,6 +794,201 @@ describe("TextNodeRenderable", () => { }) }) + describe("Link Inheritance", () => { + it("should inherit link from parent to child", () => { + const parent = new TextNodeRenderable({ + link: { url: "https://opentui.com" }, + }) + + const child = new TextNodeRenderable({}) + child.add("Child text") + + parent.add("Parent text") + parent.add(child) + + const chunks = parent.gatherWithInheritedStyle() + + expect(chunks).toHaveLength(2) + expect(chunks[0].text).toBe("Parent text") + expect(chunks[0].link?.url).toBe("https://opentui.com") + + expect(chunks[1].text).toBe("Child text") + expect(chunks[1].link?.url).toBe("https://opentui.com") + }) + + it("should allow child to override parent link", () => { + const parent = new TextNodeRenderable({ + link: { url: "https://parent.com" }, + }) + + const child = new TextNodeRenderable({ + link: { url: "https://child.com" }, + }) + child.add("Child text") + + parent.add("Parent text") + parent.add(child) + + const chunks = parent.gatherWithInheritedStyle() + + expect(chunks).toHaveLength(2) + expect(chunks[0].link?.url).toBe("https://parent.com") + expect(chunks[1].link?.url).toBe("https://child.com") + }) + + it("should inherit link through multiple nesting levels", () => { + const grandparent = new TextNodeRenderable({ + link: { url: "https://example.com" }, + }) + + const parent = new TextNodeRenderable({}) + const child = new TextNodeRenderable({}) + + child.add("Grandchild") + parent.add(child) + grandparent.add(parent) + + const chunks = grandparent.gatherWithInheritedStyle() + + expect(chunks).toHaveLength(1) + expect(chunks[0].text).toBe("Grandchild") + expect(chunks[0].link?.url).toBe("https://example.com") + }) + + it("should merge link with other styles", () => { + const parent = new TextNodeRenderable({ + fg: RGBA.fromInts(255, 0, 0, 255), + attributes: 1, + link: { url: "https://opentui.com" }, + }) + + const child = new TextNodeRenderable({ + bg: RGBA.fromInts(0, 0, 255, 255), + attributes: 2, + }) + child.add("Styled linked text") + + parent.add(child) + + const chunks = parent.gatherWithInheritedStyle() + + expect(chunks).toHaveLength(1) + expect(chunks[0].text).toBe("Styled linked text") + expect(chunks[0].fg).toEqual(RGBA.fromInts(255, 0, 0, 255)) + expect(chunks[0].bg).toEqual(RGBA.fromInts(0, 0, 255, 255)) + expect(chunks[0].attributes).toBe(3) // 1 | 2 + expect(chunks[0].link?.url).toBe("https://opentui.com") + }) + + it("should handle undefined link in parent", () => { + const parent = new TextNodeRenderable({}) + + const child = new TextNodeRenderable({ + link: { url: "https://child.com" }, + }) + child.add("Child with link") + + parent.add("Parent without link") + parent.add(child) + + const chunks = parent.gatherWithInheritedStyle() + + expect(chunks).toHaveLength(2) + expect(chunks[0].link).toBeUndefined() + expect(chunks[1].link?.url).toBe("https://child.com") + }) + + it("should preserve link when merging styles", () => { + const node = new TextNodeRenderable({ + link: { url: "https://example.com" }, + attributes: 1, + }) + + const parentStyle = { + fg: RGBA.fromInts(255, 0, 0, 255), + bg: undefined, + attributes: 2, + } + + const merged = node.mergeStyles(parentStyle) + + expect(merged.link?.url).toBe("https://example.com") + expect(merged.fg).toEqual(RGBA.fromInts(255, 0, 0, 255)) + expect(merged.attributes).toBe(3) + }) + + it("should inherit link when node has no link", () => { + const node = new TextNodeRenderable({ + fg: RGBA.fromInts(0, 255, 0, 255), + }) + + const parentStyle = { + fg: undefined, + bg: undefined, + attributes: 0, + link: { url: "https://inherited.com" }, + } + + const merged = node.mergeStyles(parentStyle) + + expect(merged.link?.url).toBe("https://inherited.com") + expect(merged.fg).toEqual(RGBA.fromInts(0, 255, 0, 255)) + }) + + it("should handle complex link inheritance tree", () => { + // Grandparent with link + const grandparent = new TextNodeRenderable({ + link: { url: "https://grandparent.com" }, + fg: RGBA.fromInts(255, 0, 0, 255), + }) + + // Parent inherits link, adds bg + const parent = new TextNodeRenderable({ + bg: RGBA.fromInts(0, 0, 255, 255), + }) + + // Child1 inherits link, overrides fg + const child1 = new TextNodeRenderable({ + fg: RGBA.fromInts(0, 255, 0, 255), + }) + child1.add("Child1") + + // Child2 overrides link + const child2 = new TextNodeRenderable({ + link: { url: "https://child2.com" }, + }) + child2.add("Child2") + + // Child3 has no link set, should inherit from parent + const child3 = new TextNodeRenderable({ + attributes: 1, + }) + child3.add("Child3") + + parent.add(child1) + parent.add(child2) + parent.add(child3) + grandparent.add(parent) + + const chunks = grandparent.gatherWithInheritedStyle() + + expect(chunks).toHaveLength(3) + + // Child1: inherits link from grandparent + expect(chunks[0].text).toBe("Child1") + expect(chunks[0].link?.url).toBe("https://grandparent.com") + expect(chunks[0].fg).toEqual(RGBA.fromInts(0, 255, 0, 255)) + + // Child2: overrides link + expect(chunks[1].text).toBe("Child2") + expect(chunks[1].link?.url).toBe("https://child2.com") + + // Child3: inherits link from grandparent + expect(chunks[2].text).toBe("Child3") + expect(chunks[2].link?.url).toBe("https://grandparent.com") + }) + }) + describe("Edge Cases and Error Handling", () => { it("should handle empty strings", () => { const node = new TextNodeRenderable({}) diff --git a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts index 7ee3e4c38..a12436d75 100644 --- a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts +++ b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts @@ -5,6 +5,16 @@ import { RGBA } from "../../lib/RGBA" import { OptimizedBuffer } from "../../buffer" import { TextRenderable } from "../Text" +// TODO: Re-enable when Bun fixes Windows stdin escape sequence handling +// Windows: Bun stdin bug corrupts mouse event sequences, causing wrong selection coordinates +// See: https://github.com/oven-sh/bun/issues/22285 +const isWindows = process.platform === "win32" + +// TODO: Re-enable viewport scroll tests when Bun stdin bug is fixed (Windows) and timing is stabilized (Darwin) +// Windows: Bun stdin bug - https://github.com/oven-sh/bun/issues/22285 +// Darwin: timing flakiness in CI - mock mouse drag uses setTimeout which behaves inconsistently +const skipViewportScrollTests = process.platform === "win32" || process.platform === "darwin" + let currentRenderer: TestRenderer let renderOnce: () => Promise let currentMouse: MockMouse @@ -218,7 +228,7 @@ describe("Textarea - Selection Tests", () => { buffer.destroy() }) - it("should handle viewport-aware selection correctly", async () => { + it.skipIf(skipViewportScrollTests)("should handle viewport-aware selection correctly", async () => { const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { initialValue: Array.from({ length: 15 }, (_, i) => `Line ${i}`).join("\n"), width: 40, @@ -305,48 +315,51 @@ describe("Textarea - Selection Tests", () => { expect(sel!.start).toBeGreaterThanOrEqual(viewport.offsetX) }) - it("should render selection highlighting at correct screen position with viewport scroll", async () => { - const buffer = OptimizedBuffer.create(80, 24, "wcwidth") + it.skipIf(skipViewportScrollTests)( + "should render selection highlighting at correct screen position with viewport scroll", + async () => { + const buffer = OptimizedBuffer.create(80, 24, "wcwidth") - const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { - initialValue: Array.from({ length: 15 }, (_, i) => `Line${i}`).join("\n"), - width: 20, - height: 5, - selectable: true, - selectionBg: RGBA.fromValues(1, 0, 0, 1), - }) + const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { + initialValue: Array.from({ length: 15 }, (_, i) => `Line${i}`).join("\n"), + width: 20, + height: 5, + selectable: true, + selectionBg: RGBA.fromValues(1, 0, 0, 1), + }) - editor.gotoLine(8) - await renderOnce() + editor.gotoLine(8) + await renderOnce() - const viewport = editor.editorView.getViewport() - expect(viewport.offsetY).toBeGreaterThan(0) + const viewport = editor.editorView.getViewport() + expect(viewport.offsetY).toBeGreaterThan(0) - await currentMouse.drag(editor.x, editor.y, editor.x + 5, editor.y) - await renderOnce() + await currentMouse.drag(editor.x, editor.y, editor.x + 5, editor.y) + await renderOnce() - buffer.clear(RGBA.fromValues(0, 0, 0, 1)) - buffer.drawEditorView(editor.editorView, editor.x, editor.y) + buffer.clear(RGBA.fromValues(0, 0, 0, 1)) + buffer.drawEditorView(editor.editorView, editor.x, editor.y) - const selectedText = editor.getSelectedText() - expect(selectedText).toBe(`Line${viewport.offsetY}`.substring(0, 5)) + const selectedText = editor.getSelectedText() + expect(selectedText).toBe(`Line${viewport.offsetY}`.substring(0, 5)) - const { bg } = buffer.buffers - const bufferWidth = buffer.width + const { bg } = buffer.buffers + const bufferWidth = buffer.width - for (let cellX = editor.x; cellX < editor.x + 5; cellX++) { - const bufferIdx = editor.y * bufferWidth + cellX - const bgR = bg[bufferIdx * 4 + 0] - const bgG = bg[bufferIdx * 4 + 1] - const bgB = bg[bufferIdx * 4 + 2] + for (let cellX = editor.x; cellX < editor.x + 5; cellX++) { + const bufferIdx = editor.y * bufferWidth + cellX + const bgR = bg[bufferIdx * 4 + 0] + const bgG = bg[bufferIdx * 4 + 1] + const bgB = bg[bufferIdx * 4 + 2] - expect(Math.abs(bgR - 1.0)).toBeLessThan(0.01) - expect(Math.abs(bgG - 0.0)).toBeLessThan(0.01) - expect(Math.abs(bgB - 0.0)).toBeLessThan(0.01) - } + expect(Math.abs(bgR - 1.0)).toBeLessThan(0.01) + expect(Math.abs(bgG - 0.0)).toBeLessThan(0.01) + expect(Math.abs(bgB - 0.0)).toBeLessThan(0.01) + } - buffer.destroy() - }) + buffer.destroy() + }, + ) it("should render selection correctly with empty lines between content", async () => { const buffer = OptimizedBuffer.create(80, 24, "wcwidth") @@ -418,33 +431,36 @@ describe("Textarea - Selection Tests", () => { expect(sel!.end - sel!.start).toBe(5) }) - it("should handle mouse drag selection with scrolled viewport using correct offset", async () => { - const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { - initialValue: Array.from({ length: 30 }, (_, i) => `AAAA${i}`).join("\n"), - width: 40, - height: 5, - selectable: true, - }) - - editor.gotoLine(20) - await renderOnce() + it.skipIf(skipViewportScrollTests)( + "should handle mouse drag selection with scrolled viewport using correct offset", + async () => { + const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { + initialValue: Array.from({ length: 30 }, (_, i) => `AAAA${i}`).join("\n"), + width: 40, + height: 5, + selectable: true, + }) + + editor.gotoLine(20) + await renderOnce() - const viewport = editor.editorView.getViewport() - expect(viewport.offsetY).toBeGreaterThan(15) + const viewport = editor.editorView.getViewport() + expect(viewport.offsetY).toBeGreaterThan(15) - await currentMouse.drag(editor.x, editor.y, editor.x + 4, editor.y) - await renderOnce() + await currentMouse.drag(editor.x, editor.y, editor.x + 4, editor.y) + await renderOnce() - expect(editor.hasSelection()).toBe(true) - const selectedText = editor.getSelectedText() + expect(editor.hasSelection()).toBe(true) + const selectedText = editor.getSelectedText() - expect(selectedText).not.toContain("AAAA0") - expect(selectedText).not.toContain("AAAA1") + expect(selectedText).not.toContain("AAAA0") + expect(selectedText).not.toContain("AAAA1") - const firstVisibleLineIdx = viewport.offsetY - const expectedText = `AAAA${firstVisibleLineIdx}`.substring(0, 4) - expect(selectedText).toBe(expectedText) - }) + const firstVisibleLineIdx = viewport.offsetY + const expectedText = `AAAA${firstVisibleLineIdx}`.substring(0, 4) + expect(selectedText).toBe(expectedText) + }, + ) it("should handle multi-line mouse drag with scrolled viewport", async () => { const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { diff --git a/packages/core/src/renderables/index.ts b/packages/core/src/renderables/index.ts index 2abdd9827..bf671919e 100644 --- a/packages/core/src/renderables/index.ts +++ b/packages/core/src/renderables/index.ts @@ -13,6 +13,7 @@ export * from "./ScrollBox" export * from "./Select" export * from "./Slider" export * from "./TabSelect" +export * from "./Terminal" export * from "./Text" export * from "./TextBufferRenderable" export * from "./TextNode" diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 102c08923..28a644787 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -740,7 +740,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { } public get keyInput(): KeyHandler { - return this._keyHandler + return this._keyHandler as KeyHandler } public get _internalKeyInput(): InternalKeyHandler { diff --git a/packages/core/src/testing/mock-keys.test.ts b/packages/core/src/testing/mock-keys.test.ts index 23010713e..4446c7ebf 100644 --- a/packages/core/src/testing/mock-keys.test.ts +++ b/packages/core/src/testing/mock-keys.test.ts @@ -2,6 +2,9 @@ import { describe, test, expect } from "bun:test" import { createMockKeys, KeyCodes } from "./mock-keys" import { PassThrough } from "stream" +// Windows has coarser timer resolution (~15.6ms) so timing tests need more tolerance +const TIMING_TOLERANCE = process.platform === "win32" ? 40 : 20 + class MockRenderer { public stdin: PassThrough public emittedData: Buffer[] = [] @@ -184,7 +187,7 @@ describe("mock-keys", () => { expect(timestamps).toHaveLength(2) expect(timestamps[1] - timestamps[0]).toBeGreaterThanOrEqual(8) // Allow some tolerance - expect(timestamps[1] - timestamps[0]).toBeLessThan(20) + expect(timestamps[1] - timestamps[0]).toBeLessThan(TIMING_TOLERANCE) }) test("pressKey with shift modifier", () => { diff --git a/packages/core/src/testing/mock-mouse.test.ts b/packages/core/src/testing/mock-mouse.test.ts index 697d1e1f7..6a6342143 100644 --- a/packages/core/src/testing/mock-mouse.test.ts +++ b/packages/core/src/testing/mock-mouse.test.ts @@ -111,7 +111,7 @@ describe("mock-mouse", () => { // Check that drag events have the motion flag (32) for (let i = 1; i < mockRenderer.emittedData.length - 1; i++) { const event = mockRenderer.emittedData[i].toString() - expect(event).toMatch(/\x1b\[<32;\d+;\d+m/) // Should have motion flag (32) and release (m) + expect(event).toMatch(/\x1b\[<32;\d+;\d+M/) // Should have motion flag (32) and press (M) since button is held } const lastEvent = mockRenderer.emittedData[mockRenderer.emittedData.length - 1].toString() @@ -146,7 +146,7 @@ describe("mock-mouse", () => { await mockMouse.moveTo(15, 8) expect(mockRenderer.emittedData[0].toString()).toBe("\x1b[<0;6;6M") // down - expect(mockRenderer.emittedData[1].toString()).toBe("\x1b[<32;16;9m") // drag (32 = motion flag, no button 3) + expect(mockRenderer.emittedData[1].toString()).toBe("\x1b[<32;16;9M") // drag (32 = motion flag, M since button held) }) test("getCurrentPosition tracks position", async () => { diff --git a/packages/core/src/testing/mock-mouse.ts b/packages/core/src/testing/mock-mouse.ts index f81f57686..c5b4d0749 100644 --- a/packages/core/src/testing/mock-mouse.ts +++ b/packages/core/src/testing/mock-mouse.ts @@ -77,9 +77,10 @@ export function createMockMouse(renderer: CliRenderer) { const ansiY = y + 1 let pressRelease = "M" // Default to press - if (type === "up" || type === "move" || type === "drag") { + if (type === "up" || type === "move") { pressRelease = "m" } + // Note: "drag" events keep button pressed, so they use "M" not "m" return `\x1b[<${buttonCode};${ansiX};${ansiY}${pressRelease}` } diff --git a/packages/core/src/testing/test-recorder.test.ts b/packages/core/src/testing/test-recorder.test.ts index 483e4c21b..856e68218 100644 --- a/packages/core/src/testing/test-recorder.test.ts +++ b/packages/core/src/testing/test-recorder.test.ts @@ -211,14 +211,15 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Timestamp Test" }) renderer.root.add(text) - await Bun.sleep(1) + // Explicitly render twice with a delay between to ensure distinct timestamps + await renderOnce() await Bun.sleep(10) await renderOnce() const frames = recorder.recordedFrames - expect(frames.length).toBe(2) - expect(frames[1].timestamp).toBeGreaterThan(frames[0].timestamp) + expect(frames.length).toBeGreaterThanOrEqual(2) + expect(frames[frames.length - 1].timestamp).toBeGreaterThan(frames[0].timestamp) recorder.stop() }) diff --git a/packages/core/src/testing/test-renderer.ts b/packages/core/src/testing/test-renderer.ts index 6402e3758..e45ecfb47 100644 --- a/packages/core/src/testing/test-renderer.ts +++ b/packages/core/src/testing/test-renderer.ts @@ -93,7 +93,7 @@ async function setupTestRenderer(config: TestRendererOptions) { process.off("SIGWINCH", renderer["sigwinchHandler"]) - // Do not setup the terminal for testing as we will not actualy output anything to the terminal + // Do not setup the terminal for testing as we will not actually output anything to the terminal // await renderer.setupTerminal() return renderer diff --git a/packages/core/src/tests/renderer.idle.test.ts b/packages/core/src/tests/renderer.idle.test.ts index cd60cd8e8..364ea0af0 100644 --- a/packages/core/src/tests/renderer.idle.test.ts +++ b/packages/core/src/tests/renderer.idle.test.ts @@ -2,6 +2,9 @@ import { test, expect, beforeEach, afterEach } from "bun:test" import { createTestRenderer, type TestRenderer } from "../testing/test-renderer" import { RendererControlState } from "../renderer" +// CI environments can have variable timing, use generous timeout for "immediate" checks +const IMMEDIATE_TIMEOUT = 100 + let renderer: TestRenderer let renderOnce: () => Promise @@ -21,7 +24,7 @@ test("idle() resolves immediately when renderer is already idle", async () => { await renderer.idle() const elapsed = Date.now() - start - expect(elapsed).toBeLessThan(50) + expect(elapsed).toBeLessThan(IMMEDIATE_TIMEOUT) }) test("idle() waits for running renderer to stop", async () => { @@ -61,7 +64,7 @@ test("idle() resolves immediately after requestRender() completes", async () => await renderer.idle() const elapsed = Date.now() - start - expect(elapsed).toBeLessThan(50) + expect(elapsed).toBeLessThan(IMMEDIATE_TIMEOUT) }) test("multiple idle() calls all resolve when renderer becomes idle", async () => { @@ -117,7 +120,7 @@ test("idle() resolves immediately when called on paused renderer", async () => { await renderer.idle() const elapsed = Date.now() - start - expect(elapsed).toBeLessThan(50) + expect(elapsed).toBeLessThan(IMMEDIATE_TIMEOUT) }) test("idle() resolves when renderer is destroyed", async () => { @@ -137,7 +140,7 @@ test("idle() resolves immediately when called on destroyed renderer", async () = await renderer.idle() const elapsed = Date.now() - start - expect(elapsed).toBeLessThan(50) + expect(elapsed).toBeLessThan(IMMEDIATE_TIMEOUT) }) test("idle() waits through multiple requestRender() calls", async () => { diff --git a/packages/core/src/tests/wrap-resize-perf.test.ts b/packages/core/src/tests/wrap-resize-perf.test.ts new file mode 100644 index 000000000..499aec6ef --- /dev/null +++ b/packages/core/src/tests/wrap-resize-perf.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "bun:test" +import { TextBuffer } from "../text-buffer" +import { TextBufferView } from "../text-buffer-view" +import { stringToStyledText } from "../lib/styled-text" + +/** + * These tests verify algorithmic complexity rather than absolute performance. + * By comparing ratios of execution times for different input sizes, we can + * detect O(n²) regressions regardless of the machine's speed. + * + * For O(n) algorithms: doubling input size should roughly double the time (ratio ~2) + * For O(n²) algorithms: doubling input size should quadruple the time (ratio ~4) + * + * We use a threshold that allows for CI variance while still catching O(n²) behavior. + * The threshold is set to catch quadratic complexity (ratio ~4) while allowing + * linear complexity with noise (ratio ~2-3.5). + */ +describe("Word wrap algorithmic complexity", () => { + function measureMedian(fn: () => void, iterations = 11): number { + const times: number[] = [] + for (let i = 0; i < iterations; i++) { + const start = performance.now() + fn() + times.push(performance.now() - start) + } + times.sort((a, b) => a - b) + return times[Math.floor(times.length / 2)] + } + + const COMPLEXITY_THRESHOLD = 1.75 + + it("should have O(n) complexity for word wrap without word breaks", () => { + const smallSize = 20000 + const largeSize = 40000 + + const smallText = "x".repeat(smallSize) + const largeText = "x".repeat(largeSize) + + const smallBuffer = TextBuffer.create("wcwidth") + const largeBuffer = TextBuffer.create("wcwidth") + + smallBuffer.setStyledText(stringToStyledText(smallText)) + largeBuffer.setStyledText(stringToStyledText(largeText)) + + const smallView = TextBufferView.create(smallBuffer) + const largeView = TextBufferView.create(largeBuffer) + + smallView.setWrapMode("word") + largeView.setWrapMode("word") + smallView.setWrapWidth(80) + largeView.setWrapWidth(80) + + smallView.measureForDimensions(80, 100) + largeView.measureForDimensions(80, 100) + + const smallTime = measureMedian(() => { + smallView.measureForDimensions(80, 100) + }) + + const largeTime = measureMedian(() => { + largeView.measureForDimensions(80, 100) + }) + + smallView.destroy() + largeView.destroy() + smallBuffer.destroy() + largeBuffer.destroy() + + const ratio = largeTime / smallTime + const inputRatio = largeSize / smallSize + + expect(ratio).toBeLessThan(inputRatio * COMPLEXITY_THRESHOLD) + }) + + it("should have O(n) complexity for word wrap with word breaks", () => { + const smallSize = 20000 + const largeSize = 40000 + + const makeText = (size: number) => { + const words = Math.ceil(size / 11) + return Array(words).fill("xxxxxxxxxx").join(" ").slice(0, size) + } + + const smallText = makeText(smallSize) + const largeText = makeText(largeSize) + + const smallBuffer = TextBuffer.create("wcwidth") + const largeBuffer = TextBuffer.create("wcwidth") + + smallBuffer.setStyledText(stringToStyledText(smallText)) + largeBuffer.setStyledText(stringToStyledText(largeText)) + + const smallView = TextBufferView.create(smallBuffer) + const largeView = TextBufferView.create(largeBuffer) + + smallView.setWrapMode("word") + largeView.setWrapMode("word") + smallView.setWrapWidth(80) + largeView.setWrapWidth(80) + + // Warm up + smallView.measureForDimensions(80, 100) + largeView.measureForDimensions(80, 100) + + const smallTime = measureMedian(() => { + smallView.measureForDimensions(80, 100) + }) + + const largeTime = measureMedian(() => { + largeView.measureForDimensions(80, 100) + }) + + smallView.destroy() + largeView.destroy() + smallBuffer.destroy() + largeBuffer.destroy() + + const ratio = largeTime / smallTime + const inputRatio = largeSize / smallSize + + expect(ratio).toBeLessThan(inputRatio * COMPLEXITY_THRESHOLD) + }) + + it("should have O(n) complexity for char wrap mode", () => { + const smallSize = 20000 + const largeSize = 40000 + + const smallText = "x".repeat(smallSize) + const largeText = "x".repeat(largeSize) + + const smallBuffer = TextBuffer.create("wcwidth") + const largeBuffer = TextBuffer.create("wcwidth") + + smallBuffer.setStyledText(stringToStyledText(smallText)) + largeBuffer.setStyledText(stringToStyledText(largeText)) + + const smallView = TextBufferView.create(smallBuffer) + const largeView = TextBufferView.create(largeBuffer) + + smallView.setWrapMode("char") + largeView.setWrapMode("char") + smallView.setWrapWidth(80) + largeView.setWrapWidth(80) + + smallView.measureForDimensions(80, 100) + largeView.measureForDimensions(80, 100) + + const smallTime = measureMedian(() => { + smallView.measureForDimensions(80, 100) + }) + + const largeTime = measureMedian(() => { + largeView.measureForDimensions(80, 100) + }) + + smallView.destroy() + largeView.destroy() + smallBuffer.destroy() + largeBuffer.destroy() + + const ratio = largeTime / smallTime + const inputRatio = largeSize / smallSize + + expect(ratio).toBeLessThan(inputRatio * COMPLEXITY_THRESHOLD) + }) + + it("should scale linearly when wrap width changes", () => { + const text = "x".repeat(50000) + + const buffer = TextBuffer.create("wcwidth") + buffer.setStyledText(stringToStyledText(text)) + + const view = TextBufferView.create(buffer) + view.setWrapMode("word") + + const widths = [60, 70, 80, 90, 100] + const times: number[] = [] + + // Warmup + view.setWrapWidth(50) + view.measureForDimensions(50, 100) + + // Measure first (uncached) call for each width + for (const width of widths) { + view.setWrapWidth(width) + const start = performance.now() + view.measureForDimensions(width, 100) + times.push(performance.now() - start) + } + + view.destroy() + buffer.destroy() + + // All times should be roughly similar (within 3x of each other) + // since the text size is the same + const maxTime = Math.max(...times) + const minTime = Math.min(...times) + + expect(maxTime / minTime).toBeLessThan(5) + }) +}) diff --git a/packages/core/src/text-buffer-view.test.ts b/packages/core/src/text-buffer-view.test.ts index 9b42b28a5..0e4de9d2d 100644 --- a/packages/core/src/text-buffer-view.test.ts +++ b/packages/core/src/text-buffer-view.test.ts @@ -637,5 +637,69 @@ describe("TextBufferView", () => { expect(result!.lineCount).toBe(4) expect(result!.maxWidth).toBe(10) }) + + it("should cache measure results for same width", () => { + const styledText = stringToStyledText("ABCDEFGHIJKLMNOPQRST") + buffer.setStyledText(styledText) + + view.setWrapMode("char") + + // First call - cache miss + const result1 = view.measureForDimensions(10, 10) + expect(result1).not.toBeNull() + expect(result1!.lineCount).toBe(2) + + // Second call with same width - should return cached result + const result2 = view.measureForDimensions(10, 10) + expect(result2).not.toBeNull() + expect(result2!.lineCount).toBe(2) + expect(result2!.maxWidth).toBe(result1!.maxWidth) + }) + + it("should invalidate cache when content changes", () => { + const styledText1 = stringToStyledText("ABCDEFGHIJ") + buffer.setStyledText(styledText1) + + view.setWrapMode("char") + + // Measure with width 5 - should be 2 lines + const result1 = view.measureForDimensions(5, 10) + expect(result1!.lineCount).toBe(2) + + // Change content to be longer + const styledText2 = stringToStyledText("ABCDEFGHIJKLMNOPQRST") + buffer.setStyledText(styledText2) + + // Same width should now return different result + const result2 = view.measureForDimensions(5, 10) + expect(result2!.lineCount).toBe(4) + }) + + it("should invalidate cache when wrap mode changes", () => { + const styledText = stringToStyledText("Hello world test string here") + buffer.setStyledText(styledText) + + view.setWrapMode("word") + const resultWord = view.measureForDimensions(10, 10) + + view.setWrapMode("char") + const resultChar = view.measureForDimensions(10, 10) + + // Word and char wrap should produce different results + expect(resultWord!.lineCount).not.toBe(resultChar!.lineCount) + }) + + it("should handle width 0 for intrinsic measurement", () => { + const styledText = stringToStyledText("Hello World") + buffer.setStyledText(styledText) + + view.setWrapMode("word") + + // Width 0 means get intrinsic width (no wrapping) + const result = view.measureForDimensions(0, 10) + expect(result).not.toBeNull() + expect(result!.lineCount).toBe(1) + expect(result!.maxWidth).toBe(11) // "Hello World" = 11 chars + }) }) }) diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index cee0f3ea3..7577396e6 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -22,7 +22,12 @@ import { import { isBunfsPath } from "./lib/bunfs" import { attributesWithLink } from "./utils" -const module = await import(`@opentui/core-${process.platform}-${process.arch}/index.ts`) +// Detect musl vs glibc on Linux by checking for musl dynamic linker +const isMusl = + process.platform === "linux" && (existsSync("/lib/ld-musl-x86_64.so.1") || existsSync("/lib/ld-musl-aarch64.so.1")) +const platformName = isMusl ? "linux-musl" : process.platform + +const module = await import(`@opentui/core-${platformName}-${process.arch}/index.ts`) let targetLibPath = module.default if (isBunfsPath(targetLibPath)) { @@ -30,7 +35,7 @@ if (isBunfsPath(targetLibPath)) { } if (!existsSync(targetLibPath)) { - throw new Error(`opentui is not supported on the current platform: ${process.platform}-${process.arch}`) + throw new Error(`opentui is not supported on the current platform: ${platformName}-${process.arch}`) } registerEnvVar({ @@ -953,6 +958,61 @@ function getOpenTUILib(libPath?: string) { args: ["ptr", "u32", "u32", "u32", "ptr", "ptr", "u32"], returns: "void", }, + + // VTerm functions - use caller-provides-buffer pattern like rest of codebase + vtermPtyToJson: { + args: [ + "ptr", + "usize" as const, + "u16", + "u16", + "usize" as const, + "usize" as const, + "ptr", + "usize" as const, + ] as const, + returns: "usize" as const, + }, + vtermPtyToText: { + args: ["ptr", "usize" as const, "u16", "u16", "ptr", "usize" as const] as const, + returns: "usize" as const, + }, + vtermCreateTerminal: { + args: ["u32", "u32", "u32"] as const, + returns: "bool", + }, + vtermDestroyTerminal: { + args: ["u32"] as const, + returns: "void", + }, + vtermFeedTerminal: { + args: ["u32", "ptr", "usize" as const] as const, + returns: "bool", + }, + vtermResizeTerminal: { + args: ["u32", "u32", "u32"] as const, + returns: "bool", + }, + vtermResetTerminal: { + args: ["u32"] as const, + returns: "bool", + }, + vtermGetTerminalJson: { + args: ["u32", "u32", "u32", "ptr", "usize" as const] as const, + returns: "usize" as const, + }, + vtermGetTerminalText: { + args: ["u32", "ptr", "usize" as const] as const, + returns: "usize" as const, + }, + vtermGetTerminalCursor: { + args: ["u32", "ptr", "usize" as const] as const, + returns: "usize" as const, + }, + vtermIsTerminalReady: { + args: ["u32"] as const, + returns: "i32", + }, }) if (env.OTUI_DEBUG_FFI || env.OTUI_TRACE_FFI) { @@ -1589,6 +1649,22 @@ export interface RenderLib { onceNativeEvent: (name: string, handler: (data: ArrayBuffer) => void) => void offNativeEvent: (name: string, handler: (data: ArrayBuffer) => void) => void onAnyNativeEvent: (handler: (name: string, data: ArrayBuffer) => void) => void + + // VTerm functions + vtermPtyToJson: ( + input: Buffer | Uint8Array | string, + options?: { cols?: number; rows?: number; offset?: number; limit?: number }, + ) => any + vtermPtyToText: (input: Buffer | Uint8Array | string, options?: { cols?: number; rows?: number }) => string + vtermCreateTerminal: (id: number, cols: number, rows: number) => boolean + vtermDestroyTerminal: (id: number) => void + vtermFeedTerminal: (id: number, data: Buffer | Uint8Array | string) => boolean + vtermResizeTerminal: (id: number, cols: number, rows: number) => boolean + vtermResetTerminal: (id: number) => boolean + vtermGetTerminalJson: (id: number, options?: { offset?: number; limit?: number }) => any + vtermGetTerminalText: (id: number) => string + vtermGetTerminalCursor: (id: number) => [number, number] + vtermIsTerminalReady: (id: number) => boolean } class FFIRenderLib implements RenderLib { @@ -3297,6 +3373,221 @@ class FFIRenderLib implements RenderLib { public onAnyNativeEvent(handler: (name: string, data: ArrayBuffer) => void): void { this._anyEventHandlers.push(handler) } + + // VTerm methods - use caller-provides-buffer pattern like rest of codebase + + // Reusable buffer for vterm output (avoids 4MB allocation per call) + private vtermBuffer: Uint8Array | null = null + private readonly vtermBufferSize = 4 * 1024 * 1024 + + private getVtermBuffer(): Uint8Array { + if (!this.vtermBuffer) { + this.vtermBuffer = new Uint8Array(this.vtermBufferSize) + } + return this.vtermBuffer + } + + public vtermPtyToJson( + input: Buffer | Uint8Array | string, + options: { cols?: number; rows?: number; offset?: number; limit?: number } = {}, + ): any { + const { cols = 120, rows = 40, offset = 0, limit = 0 } = options + + const inputStr = typeof input === "string" ? input : input.toString("utf-8") + + if (inputStr.length === 0) { + return { + cols, + rows, + cursor: [0, 0], + offset, + totalLines: 0, + lines: [], + } + } + + const inputBuffer = Buffer.from(inputStr) + const outBuffer = this.getVtermBuffer() + + const actualLen = this.opentui.symbols.vtermPtyToJson( + ptr(inputBuffer), + inputBuffer.length, + cols, + rows, + offset, + limit, + ptr(outBuffer), + outBuffer.length, + ) + + const len = typeof actualLen === "bigint" ? Number(actualLen) : actualLen + if (len === 0) { + throw new Error("VTerm ptyToJson failed or output exceeded buffer size") + } + + const jsonStr = this.decoder.decode(outBuffer.subarray(0, len)) + + const raw = JSON.parse(jsonStr) as { + cols: number + rows: number + cursor: [number, number] + offset: number + totalLines: number + lines: Array> + } + + return { + cols: raw.cols, + rows: raw.rows, + cursor: raw.cursor, + offset: raw.offset, + totalLines: raw.totalLines, + lines: raw.lines.map((line) => ({ + spans: line.map(([text, fg, bg, flags, width]) => ({ + text, + fg, + bg, + flags, + width, + })), + })), + } + } + + public vtermPtyToText(input: Buffer | Uint8Array | string, options: { cols?: number; rows?: number } = {}): string { + const { cols = 500, rows = 256 } = options + + const inputStr = typeof input === "string" ? input : input.toString("utf-8") + + if (inputStr.length === 0) { + return "" + } + + const inputBuffer = Buffer.from(inputStr) + const outBuffer = this.getVtermBuffer() + + const actualLen = this.opentui.symbols.vtermPtyToText( + ptr(inputBuffer), + inputBuffer.length, + cols, + rows, + ptr(outBuffer), + outBuffer.length, + ) + + const len = typeof actualLen === "bigint" ? Number(actualLen) : actualLen + if (len === 0) { + return "" + } + + return this.decoder.decode(outBuffer.subarray(0, len)) + } + + public vtermCreateTerminal(id: number, cols: number, rows: number): boolean { + return this.opentui.symbols.vtermCreateTerminal(id, cols, rows) + } + + public vtermDestroyTerminal(id: number): void { + this.opentui.symbols.vtermDestroyTerminal(id) + } + + public vtermFeedTerminal(id: number, data: Buffer | Uint8Array | string): boolean { + let str: string + if (typeof data === "string") { + str = data + } else if (Buffer.isBuffer(data)) { + str = data.toString("utf-8") + } else { + str = new TextDecoder("utf-8").decode(data) + } + + const buffer = Buffer.from(str) + return this.opentui.symbols.vtermFeedTerminal(id, ptr(buffer), buffer.length) + } + + public vtermResizeTerminal(id: number, cols: number, rows: number): boolean { + return this.opentui.symbols.vtermResizeTerminal(id, cols, rows) + } + + public vtermResetTerminal(id: number): boolean { + return this.opentui.symbols.vtermResetTerminal(id) + } + + public vtermGetTerminalJson(id: number, options: { offset?: number; limit?: number } = {}): any { + const { offset = 0, limit = 0 } = options + + const outBuffer = this.getVtermBuffer() + + const actualLen = this.opentui.symbols.vtermGetTerminalJson(id, offset, limit, ptr(outBuffer), outBuffer.length) + + const len = typeof actualLen === "bigint" ? Number(actualLen) : actualLen + if (len === 0) { + throw new Error("Failed to get terminal JSON - terminal may not exist or output exceeded buffer") + } + + const jsonStr = this.decoder.decode(outBuffer.subarray(0, len)) + const raw = JSON.parse(jsonStr) as { + cols: number + rows: number + cursor: [number, number] + offset: number + totalLines: number + lines: Array> + } + + return { + cols: raw.cols, + rows: raw.rows, + cursor: raw.cursor, + offset: raw.offset, + totalLines: raw.totalLines, + lines: raw.lines.map((line) => ({ + spans: line.map(([text, fg, bg, flags, width]) => ({ + text, + fg, + bg, + flags, + width, + })), + })), + } + } + + public vtermGetTerminalText(id: number): string { + const outBuffer = this.getVtermBuffer() + + const actualLen = this.opentui.symbols.vtermGetTerminalText(id, ptr(outBuffer), outBuffer.length) + + const len = typeof actualLen === "bigint" ? Number(actualLen) : actualLen + if (len === 0) { + return "" + } + + return this.decoder.decode(outBuffer.subarray(0, len)) + } + + public vtermGetTerminalCursor(id: number): [number, number] { + // Cursor JSON is small: "[x,y]" - 32 bytes is plenty + const outBuffer = new Uint8Array(32) + + const actualLen = this.opentui.symbols.vtermGetTerminalCursor(id, ptr(outBuffer), outBuffer.length) + + const len = typeof actualLen === "bigint" ? Number(actualLen) : actualLen + if (len === 0) { + throw new Error("Failed to get terminal cursor - terminal may not exist") + } + + const jsonStr = this.decoder.decode(outBuffer.subarray(0, len)) + return JSON.parse(jsonStr) as [number, number] + } + + public vtermIsTerminalReady(id: number): boolean { + const result = this.opentui.symbols.vtermIsTerminalReady(id) + if (result === -1) { + throw new Error("Failed to check terminal ready state - terminal may not exist") + } + return result === 1 + } } let opentuiLibPath: string | undefined diff --git a/packages/core/src/zig/ansi.zig b/packages/core/src/zig/ansi.zig index 12a6edeec..bef215abd 100644 --- a/packages/core/src/zig/ansi.zig +++ b/packages/core/src/zig/ansi.zig @@ -21,15 +21,15 @@ pub const ANSI = struct { // Direct writing to any writer - the most efficient option pub fn moveToOutput(writer: anytype, x: u32, y: u32) AnsiError!void { - std.fmt.format(writer, "\x1b[{d};{d}H", .{ y, x }) catch return AnsiError.WriteFailed; + writer.print("\x1b[{d};{d}H", .{ y, x }) catch return AnsiError.WriteFailed; } pub fn fgColorOutput(writer: anytype, r: u8, g: u8, b: u8) AnsiError!void { - std.fmt.format(writer, "\x1b[38;2;{d};{d};{d}m", .{ r, g, b }) catch return AnsiError.WriteFailed; + writer.print("\x1b[38;2;{d};{d};{d}m", .{ r, g, b }) catch return AnsiError.WriteFailed; } pub fn bgColorOutput(writer: anytype, r: u8, g: u8, b: u8) AnsiError!void { - std.fmt.format(writer, "\x1b[48;2;{d};{d};{d}m", .{ r, g, b }) catch return AnsiError.WriteFailed; + writer.print("\x1b[48;2;{d};{d};{d}m", .{ r, g, b }) catch return AnsiError.WriteFailed; } // Text attribute constants @@ -51,11 +51,11 @@ pub const ANSI = struct { pub const cursorUnderlineBlink = "\x1b[3 q"; pub fn cursorColorOutputWriter(writer: anytype, r: u8, g: u8, b: u8) AnsiError!void { - std.fmt.format(writer, "\x1b]12;#{x:0>2}{x:0>2}{x:0>2}\x07", .{ r, g, b }) catch return AnsiError.WriteFailed; + writer.print("\x1b]12;#{x:0>2}{x:0>2}{x:0>2}\x07", .{ r, g, b }) catch return AnsiError.WriteFailed; } pub fn explicitWidthOutput(writer: anytype, width: u32, text: []const u8) AnsiError!void { - std.fmt.format(writer, "\x1b]66;w={d};{s}\x1b\\", .{ width, text }) catch return AnsiError.WriteFailed; + writer.print("\x1b]66;w={d};{s}\x1b\\", .{ width, text }) catch return AnsiError.WriteFailed; } pub const resetCursorColor = "\x1b]112\x07"; @@ -131,12 +131,15 @@ pub const ANSI = struct { pub const setTerminalTitle = "\x1b]0;{s}\x07"; pub fn setTerminalTitleOutput(writer: anytype, title: []const u8) AnsiError!void { - std.fmt.format(writer, setTerminalTitle, .{title}) catch return AnsiError.WriteFailed; + writer.print(setTerminalTitle, .{title}) catch return AnsiError.WriteFailed; } pub fn makeRoomForRendererOutput(writer: anytype, height: u32) AnsiError!void { if (height > 1) { - writer.writeByteNTimes('\n', height - 1) catch return AnsiError.WriteFailed; + var i: u32 = 0; + while (i < height - 1) : (i += 1) { + writer.writeByte('\n') catch return AnsiError.WriteFailed; + } } } }; diff --git a/packages/core/src/zig/bench-utils.zig b/packages/core/src/zig/bench-utils.zig index b81fdc5d1..288542da8 100644 --- a/packages/core/src/zig/bench-utils.zig +++ b/packages/core/src/zig/bench-utils.zig @@ -15,6 +15,109 @@ pub const BenchResult = struct { mem_stats: ?[]const MemStat, }; +/// Timing statistics collected during benchmark iterations +pub const BenchStats = struct { + min_ns: u64 = std.math.maxInt(u64), + max_ns: u64 = 0, + total_ns: u64 = 0, + count: usize = 0, + + pub fn record(self: *BenchStats, elapsed_ns: u64) void { + self.min_ns = @min(self.min_ns, elapsed_ns); + self.max_ns = @max(self.max_ns, elapsed_ns); + self.total_ns += elapsed_ns; + self.count += 1; + } + + pub fn avg(self: *const BenchStats) u64 { + if (self.count == 0) return 0; + return self.total_ns / self.count; + } +}; + +/// Helper for running benchmark iterations with timing +pub const BenchRunner = struct { + allocator: std.mem.Allocator, + results: std.ArrayListUnmanaged(BenchResult), + + pub fn init(allocator: std.mem.Allocator) BenchRunner { + return .{ + .allocator = allocator, + .results = .{}, + }; + } + + /// Add a benchmark result from collected stats + pub fn addResult( + self: *BenchRunner, + name: []const u8, + stats: BenchStats, + mem_stats: ?[]const MemStat, + ) !void { + try self.results.append(self.allocator, BenchResult{ + .name = name, + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, + .iterations = stats.count, + .mem_stats = mem_stats, + }); + } + + /// Convenience: run a simple benchmark with the given function + pub fn bench( + self: *BenchRunner, + name: []const u8, + iterations: usize, + comptime benchFn: anytype, + args: anytype, + ) !void { + var stats = BenchStats{}; + var iter: usize = 0; + while (iter < iterations) : (iter += 1) { + var timer = try std.time.Timer.start(); + @call(.auto, benchFn, args); + stats.record(timer.read()); + } + try self.addResult(name, stats, null); + } + + /// Get the results slice (caller owns memory via arena) + pub fn finish(self: *BenchRunner) ![]BenchResult { + return try self.results.toOwnedSlice(self.allocator); + } + + /// Append results from another runner or slice + pub fn appendSlice(self: *BenchRunner, other_results: []const BenchResult) !void { + try self.results.appendSlice(self.allocator, other_results); + } +}; + +/// Create a stdout writer with buffer for benchmark output +pub const StdoutWriter = struct { + buffer: [4096]u8 = undefined, + writer: std.fs.File.Writer = undefined, + + pub fn init() StdoutWriter { + var self = StdoutWriter{}; + self.writer = std.fs.File.stdout().writer(&self.buffer); + return self; + } + + pub fn interface(self: *StdoutWriter) *std.Io.Writer { + return &self.writer.interface; + } + + pub fn print(self: *StdoutWriter, comptime fmt: []const u8, args: anytype) !void { + try self.writer.interface.print(fmt, args); + } + + pub fn flush(self: *StdoutWriter) !void { + try self.writer.interface.flush(); + } +}; + pub fn formatDuration(ns: u64) struct { value: f64, unit: []const u8, color: []const u8 } { if (ns < 1_000) { // Bright green for nanoseconds @@ -66,7 +169,7 @@ pub fn printResults(writer: anytype, results: []const BenchResult) !void { const allocator = arena.allocator(); // Collect all unique memory stat names - var mem_stat_names = std.ArrayList([]const u8).init(allocator); + var mem_stat_names: std.ArrayListUnmanaged([]const u8) = .{}; for (results) |result| { if (result.mem_stats) |stats| { for (stats) |stat| { @@ -79,7 +182,7 @@ pub fn printResults(writer: anytype, results: []const BenchResult) !void { } } if (!found) { - try mem_stat_names.append(stat.name); + try mem_stat_names.append(allocator, stat.name); } } } @@ -92,9 +195,9 @@ pub fn printResults(writer: anytype, results: []const BenchResult) !void { var max_col_width: usize = 3; // minimum for "Max" // Create a map to store column widths for each memory stat - var mem_col_widths = std.ArrayList(usize).init(allocator); + var mem_col_widths: std.ArrayListUnmanaged(usize) = .{}; for (mem_stat_names.items) |name| { - try mem_col_widths.append(name.len); // minimum is the name length + try mem_col_widths.append(allocator, name.len); // minimum is the name length } // First pass: calculate maximum widths @@ -144,28 +247,28 @@ pub fn printResults(writer: anytype, results: []const BenchResult) !void { total_width += 3 + width; } try writer.writeAll("\x1b[2m"); - try writer.writeByteNTimes('-', total_width); + try writer.splatByteAll('-', total_width); try writer.writeAll("\x1b[0m\n"); // Column headers try writer.writeAll("\x1b[36m"); try writer.writeAll("Benchmark"); - try writer.writeByteNTimes(' ', max_name_len - 9); + try writer.splatByteAll(' ', max_name_len - 9); try writer.writeAll("\x1b[0m\x1b[2m | \x1b[0m"); try writer.writeAll("\x1b[36m"); try writer.writeAll("Min"); - try writer.writeByteNTimes(' ', min_col_width - 3); + try writer.splatByteAll(' ', min_col_width - 3); try writer.writeAll("\x1b[0m\x1b[2m | \x1b[0m"); try writer.writeAll("\x1b[36m"); try writer.writeAll("Avg"); - try writer.writeByteNTimes(' ', avg_col_width - 3); + try writer.splatByteAll(' ', avg_col_width - 3); try writer.writeAll("\x1b[0m\x1b[2m | \x1b[0m"); try writer.writeAll("\x1b[36m"); try writer.writeAll("Max"); - try writer.writeByteNTimes(' ', max_col_width - 3); + try writer.splatByteAll(' ', max_col_width - 3); try writer.writeAll("\x1b[0m"); // Dynamic memory stat headers @@ -174,7 +277,7 @@ pub fn printResults(writer: anytype, results: []const BenchResult) !void { try writer.writeAll("\x1b[36m"); try writer.writeAll(name); if (name.len < mem_col_widths.items[i]) { - try writer.writeByteNTimes(' ', mem_col_widths.items[i] - name.len); + try writer.splatByteAll(' ', mem_col_widths.items[i] - name.len); } try writer.writeAll("\x1b[0m"); } @@ -182,7 +285,7 @@ pub fn printResults(writer: anytype, results: []const BenchResult) !void { try writer.writeByte('\n'); try writer.writeAll("\x1b[2m"); - try writer.writeByteNTimes('-', total_width); + try writer.splatByteAll('-', total_width); try writer.writeAll("\x1b[0m\n"); // Print each result @@ -207,7 +310,7 @@ pub fn printResults(writer: anytype, results: []const BenchResult) !void { // Benchmark name try writer.writeAll(result.name); - try writer.writeByteNTimes(' ', max_name_len - result.name.len); + try writer.splatByteAll(' ', max_name_len - result.name.len); try writer.writeAll("\x1b[2m | \x1b[0m"); if (row_idx % 2 == 1) { try writer.writeAll("\x1b[48;5;234m"); @@ -215,7 +318,7 @@ pub fn printResults(writer: anytype, results: []const BenchResult) !void { // Min (right-aligned with color) if (min_str.len < min_col_width) { - try writer.writeByteNTimes(' ', min_col_width - min_str.len); + try writer.splatByteAll(' ', min_col_width - min_str.len); } try writer.writeAll(min.color); try writer.writeAll(min_str); @@ -227,7 +330,7 @@ pub fn printResults(writer: anytype, results: []const BenchResult) !void { // Avg (right-aligned with color) if (avg_str.len < avg_col_width) { - try writer.writeByteNTimes(' ', avg_col_width - avg_str.len); + try writer.splatByteAll(' ', avg_col_width - avg_str.len); } try writer.writeAll(avg.color); try writer.writeAll(avg_str); @@ -239,7 +342,7 @@ pub fn printResults(writer: anytype, results: []const BenchResult) !void { // Max (right-aligned with color) if (max_str.len < max_col_width) { - try writer.writeByteNTimes(' ', max_col_width - max_str.len); + try writer.splatByteAll(' ', max_col_width - max_str.len); } try writer.writeAll(max.color); try writer.writeAll(max_str); @@ -270,12 +373,12 @@ pub fn printResults(writer: anytype, results: []const BenchResult) !void { // Right-aligned if (mem_str.len < mem_col_widths.items[i]) { - try writer.writeByteNTimes(' ', mem_col_widths.items[i] - mem_str.len); + try writer.splatByteAll(' ', mem_col_widths.items[i] - mem_str.len); } try writer.writeAll(mem_str); } else { // Empty column - try writer.writeByteNTimes(' ', mem_col_widths.items[i]); + try writer.splatByteAll(' ', mem_col_widths.items[i]); } } @@ -286,6 +389,7 @@ pub fn printResults(writer: anytype, results: []const BenchResult) !void { } try writer.writeAll("\x1b[2m"); - try writer.writeByteNTimes('-', total_width); + try writer.splatByteAll('-', total_width); try writer.writeAll("\x1b[0m\n"); + try writer.flush(); } diff --git a/packages/core/src/zig/bench.zig b/packages/core/src/zig/bench.zig index 8fef6b670..89b2bf8a1 100644 --- a/packages/core/src/zig/bench.zig +++ b/packages/core/src/zig/bench.zig @@ -83,7 +83,6 @@ pub fn main() !void { _ = gp.initGlobalPool(allocator); defer gp.deinitGlobalPool(); - const benchmarks = [_]BenchModule{ .{ .name = text_buffer_view_bench.benchName, .run = text_buffer_view_bench.run }, .{ .name = edit_buffer_bench.benchName, .run = edit_buffer_bench.run }, @@ -112,7 +111,9 @@ pub fn main() !void { filter = args[i]; } } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { - const stdout = std.io.getStdOut().writer(); + var stdout_buffer: [4096]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); + const stdout = &stdout_writer.interface; try stdout.print("Usage: bench [options]\n\n", .{}); try stdout.print("Options:\n", .{}); try stdout.print(" --mem Show memory statistics\n", .{}); @@ -122,11 +123,14 @@ pub fn main() !void { for (benchmarks) |bench| { try stdout.print(" - {s}\n", .{bench.name}); } + try stdout.flush(); return; } } - const stdout = std.io.getStdOut().writer(); + var stdout_buffer: [4096]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); + const stdout = &stdout_writer.interface; if (filter) |f| { try stdout.print("Filtering benchmarks by: \"{s}\"\n", .{f}); @@ -137,13 +141,14 @@ pub fn main() !void { for (benchmarks) |bench| { if (matchesFilter(bench.name, filter)) { try stdout.print("\n=== {s} Benchmarks ===\n\n", .{bench.name}); + try stdout.flush(); - var arena = std.heap.ArenaAllocator.init(allocator); - defer arena.deinit(); - const arena_allocator = arena.allocator(); + // Use arena for results only - benchmark modules manage their own temp memory + var results_arena = std.heap.ArenaAllocator.init(allocator); + defer results_arena.deinit(); const start_time = std.time.nanoTimestamp(); - const results = try bench.run(arena_allocator, show_mem); + const results = try bench.run(results_arena.allocator(), show_mem); const end_time = std.time.nanoTimestamp(); const elapsed_ns = end_time - start_time; @@ -159,8 +164,10 @@ pub fn main() !void { if (!ran_any) { try stdout.print("\nNo benchmarks matched filter: \"{s}\"\n", .{filter.?}); try stdout.print("Use --help to see available benchmarks.\n", .{}); + try stdout.flush(); return; } try stdout.print("\n✓ Benchmarks complete\n", .{}); + try stdout.flush(); } diff --git a/packages/core/src/zig/bench/buffer-draw-text-buffer_bench.zig b/packages/core/src/zig/bench/buffer-draw-text-buffer_bench.zig index 877ecd1e2..5246c1fd0 100644 --- a/packages/core/src/zig/bench/buffer-draw-text-buffer_bench.zig +++ b/packages/core/src/zig/bench/buffer-draw-text-buffer_bench.zig @@ -10,13 +10,14 @@ const UnifiedTextBuffer = text_buffer.UnifiedTextBuffer; const UnifiedTextBufferView = text_buffer_view.UnifiedTextBufferView; const WrapMode = text_buffer.WrapMode; const BenchResult = bench_utils.BenchResult; +const BenchStats = bench_utils.BenchStats; const MemStat = bench_utils.MemStat; pub const benchName = "Buffer drawTextBuffer"; fn generateText(allocator: std.mem.Allocator, lines: u32, avg_line_len: u32) ![]u8 { - var buf = std.ArrayList(u8).init(allocator); - errdefer buf.deinit(); + var buf: std.ArrayListUnmanaged(u8) = .{}; + errdefer buf.deinit(allocator); const patterns = [_][]const u8{ "The quick brown fox jumps over the lazy dog. ", @@ -26,38 +27,34 @@ fn generateText(allocator: std.mem.Allocator, lines: u32, avg_line_len: u32) ![] "Mixed: ASCII 中文 emoji 🚀💻 text. ", }; - var i: u32 = 0; - while (i < lines) : (i += 1) { + for (0..lines) |i| { var line_len: u32 = 0; while (line_len < avg_line_len) { const pattern = patterns[i % patterns.len]; - try buf.appendSlice(pattern); + try buf.appendSlice(allocator, pattern); line_len += @intCast(pattern.len); } - try buf.append('\n'); + try buf.append(allocator, '\n'); } - return try buf.toOwnedSlice(); + return try buf.toOwnedSlice(allocator); } fn generateManySmallChunks(allocator: std.mem.Allocator, chunks: u32) ![]u8 { - var buf = std.ArrayList(u8).init(allocator); - errdefer buf.deinit(); + var buf: std.ArrayListUnmanaged(u8) = .{}; + errdefer buf.deinit(allocator); - var i: u32 = 0; - while (i < chunks) : (i += 1) { - try buf.appendSlice("ab "); - if (i % 20 == 19) try buf.append('\n'); + for (0..chunks) |i| { + try buf.appendSlice(allocator, "ab "); + if (i % 20 == 19) try buf.append(allocator, '\n'); } - return try buf.toOwnedSlice(); + return try buf.toOwnedSlice(allocator); } fn setupTextBuffer( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - text: []const u8, wrap_width: ?u32, ) !struct { *UnifiedTextBuffer, *UnifiedTextBufferView } { @@ -82,24 +79,19 @@ fn setupTextBuffer( fn benchRenderColdCache( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - iterations: usize, show_mem: bool, ) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); const text = try generateText(allocator, 500, 100); - defer allocator.free(text); { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_buf_mem: usize = 0; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |i| { const tb, const view = try setupTextBuffer(allocator, pool, text, 120); defer tb.deinit(); defer view.deinit(); @@ -111,63 +103,53 @@ fn benchRenderColdCache( var timer = try std.time.Timer.start(); try buf.drawTextBuffer(view, 0, 0); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (i == iterations - 1 and show_mem) { final_buf_mem = @sizeOf(OptimizedBuffer) + (buf.width * buf.height * (@sizeOf(u32) + @sizeOf(@TypeOf(buf.buffer.fg[0])) * 2 + @sizeOf(u8))); } } - const name = try std.fmt.allocPrint(allocator, "COLD: 120x40 render (500 lines, wrap=120, includes setup)", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "Buf", .bytes = final_buf_mem }; - break :blk stats; + const mem_stat_slice = try allocator.alloc(MemStat, 1); + mem_stat_slice[0] = .{ .name = "Buf", .bytes = final_buf_mem }; + break :blk mem_stat_slice; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "COLD: 120x40 render (500 lines, wrap=120, includes setup)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchRenderWarmCache( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - iterations: usize, show_mem: bool, ) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); const text = try generateText(allocator, 500, 100); - defer allocator.free(text); { const tb, const view = try setupTextBuffer(allocator, pool, text, 120); defer tb.deinit(); defer view.deinit(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_buf_mem: usize = 0; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |i| { const buf = try OptimizedBuffer.init(allocator, 120, 40, .{ .pool = pool }); defer buf.deinit(); @@ -175,30 +157,25 @@ fn benchRenderWarmCache( var timer = try std.time.Timer.start(); try buf.drawTextBuffer(view, 0, 0); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (i == iterations - 1 and show_mem) { final_buf_mem = @sizeOf(OptimizedBuffer) + (buf.width * buf.height * (@sizeOf(u32) + @sizeOf(@TypeOf(buf.buffer.fg[0])) * 2 + @sizeOf(u8))); } } - const name = try std.fmt.allocPrint(allocator, "WARM: 120x40 render (500 lines, pre-wrapped, pure render)", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "Buf", .bytes = final_buf_mem }; - break :blk stats; + const mem_stat_slice = try allocator.alloc(MemStat, 1); + mem_stat_slice[0] = .{ .name = "Buf", .bytes = final_buf_mem }; + break :blk mem_stat_slice; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "WARM: 120x40 render (500 lines, pre-wrapped, pure render)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); @@ -212,51 +189,40 @@ fn benchRenderWarmCache( const buf = try OptimizedBuffer.init(allocator, 120, 40, .{ .pool = pool }); defer buf.deinit(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |_| { try buf.clear(.{ 0.0, 0.0, 0.0, 1.0 }, null); var timer = try std.time.Timer.start(); try buf.drawTextBuffer(view, 0, 0); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "HOT: 120x40 render (500 lines, reused buffer, pure render)", .{}); - - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "HOT: 120x40 render (500 lines, reused buffer, pure render)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchRenderSmallResolution( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - iterations: usize, show_mem: bool, ) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); const text = try generateText(allocator, 100, 80); - defer allocator.free(text); { const tb, const view = try setupTextBuffer(allocator, pool, text, 80); @@ -266,41 +232,33 @@ fn benchRenderSmallResolution( const buf = try OptimizedBuffer.init(allocator, 80, 24, .{ .pool = pool }); defer buf.deinit(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_buf_mem: usize = 0; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |i| { try buf.clear(.{ 0.0, 0.0, 0.0, 1.0 }, null); var timer = try std.time.Timer.start(); try buf.drawTextBuffer(view, 0, 0); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (i == iterations - 1 and show_mem) { final_buf_mem = @sizeOf(OptimizedBuffer) + (buf.width * buf.height * (@sizeOf(u32) + @sizeOf(@TypeOf(buf.buffer.fg[0])) * 2 + @sizeOf(u8))); } } - const name = try std.fmt.allocPrint(allocator, "80x24 render (100 lines, no wrap)", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "Buf", .bytes = final_buf_mem }; - break :blk stats; + const mem_stat_slice = try allocator.alloc(MemStat, 1); + mem_stat_slice[0] = .{ .name = "Buf", .bytes = final_buf_mem }; + break :blk mem_stat_slice; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "80x24 render (100 lines, no wrap)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); @@ -314,51 +272,40 @@ fn benchRenderSmallResolution( const buf = try OptimizedBuffer.init(allocator, 80, 24, .{ .pool = pool }); defer buf.deinit(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |_| { try buf.clear(.{ 0.0, 0.0, 0.0, 1.0 }, null); var timer = try std.time.Timer.start(); try buf.drawTextBuffer(view, 0, 0); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "80x24 render (100 lines, wrap=40)", .{}); - - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "80x24 render (100 lines, wrap=40)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchRenderMediumResolution( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - iterations: usize, show_mem: bool, ) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); const text = try generateText(allocator, 1000, 120); - defer allocator.free(text); { const tb, const view = try setupTextBuffer(allocator, pool, text, 200); @@ -368,61 +315,51 @@ fn benchRenderMediumResolution( const buf = try OptimizedBuffer.init(allocator, 200, 60, .{ .pool = pool }); defer buf.deinit(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_buf_mem: usize = 0; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |i| { try buf.clear(.{ 0.0, 0.0, 0.0, 1.0 }, null); var timer = try std.time.Timer.start(); try buf.drawTextBuffer(view, 0, 0); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (i == iterations - 1 and show_mem) { final_buf_mem = @sizeOf(OptimizedBuffer) + (buf.width * buf.height * (@sizeOf(u32) + @sizeOf(@TypeOf(buf.buffer.fg[0])) * 2 + @sizeOf(u8))); } } - const name = try std.fmt.allocPrint(allocator, "200x60 render (1000 lines, wrap=200)", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "Buf", .bytes = final_buf_mem }; - break :blk stats; + const mem_stat_slice = try allocator.alloc(MemStat, 1); + mem_stat_slice[0] = .{ .name = "Buf", .bytes = final_buf_mem }; + break :blk mem_stat_slice; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "200x60 render (1000 lines, wrap=200)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchRenderMassiveResolution( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - iterations: usize, show_mem: bool, ) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); const text = try generateText(allocator, 10000, 200); - defer allocator.free(text); { const tb, const view = try setupTextBuffer(allocator, pool, text, 400); @@ -432,61 +369,51 @@ fn benchRenderMassiveResolution( const buf = try OptimizedBuffer.init(allocator, 400, 200, .{ .pool = pool }); defer buf.deinit(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_buf_mem: usize = 0; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |i| { try buf.clear(.{ 0.0, 0.0, 0.0, 1.0 }, null); var timer = try std.time.Timer.start(); try buf.drawTextBuffer(view, 0, 0); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (i == iterations - 1 and show_mem) { final_buf_mem = @sizeOf(OptimizedBuffer) + (buf.width * buf.height * (@sizeOf(u32) + @sizeOf(@TypeOf(buf.buffer.fg[0])) * 2 + @sizeOf(u8))); } } - const name = try std.fmt.allocPrint(allocator, "400x200 render (10k lines, wrap=400)", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "Buf", .bytes = final_buf_mem }; - break :blk stats; + const mem_stat_slice = try allocator.alloc(MemStat, 1); + mem_stat_slice[0] = .{ .name = "Buf", .bytes = final_buf_mem }; + break :blk mem_stat_slice; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "400x200 render (10k lines, wrap=400)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchRenderMassiveLines( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - iterations: usize, show_mem: bool, ) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); const text = try generateText(allocator, 50000, 60); - defer allocator.free(text); { const tb, const view = try setupTextBuffer(allocator, pool, text, null); @@ -496,68 +423,57 @@ fn benchRenderMassiveLines( const buf = try OptimizedBuffer.init(allocator, 120, 40, .{ .pool = pool }); defer buf.deinit(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_buf_mem: usize = 0; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |i| { try buf.clear(.{ 0.0, 0.0, 0.0, 1.0 }, null); var timer = try std.time.Timer.start(); try buf.drawTextBuffer(view, 0, 0); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (i == iterations - 1 and show_mem) { final_buf_mem = @sizeOf(OptimizedBuffer) + (buf.width * buf.height * (@sizeOf(u32) + @sizeOf(@TypeOf(buf.buffer.fg[0])) * 2 + @sizeOf(u8))); } } - const name = try std.fmt.allocPrint(allocator, "120x40 render (50k lines, viewport first 40)", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "Buf", .bytes = final_buf_mem }; - break :blk stats; + const mem_stat_slice = try allocator.alloc(MemStat, 1); + mem_stat_slice[0] = .{ .name = "Buf", .bytes = final_buf_mem }; + break :blk mem_stat_slice; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "120x40 render (50k lines, viewport first 40)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchRenderOneMassiveLine( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - iterations: usize, show_mem: bool, ) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); - var buf_builder = std.ArrayList(u8).init(allocator); - defer buf_builder.deinit(); + var buf_builder: std.ArrayListUnmanaged(u8) = .{}; + defer buf_builder.deinit(allocator); - var j: u32 = 0; - while (j < 100000) : (j += 1) { - try buf_builder.appendSlice("word "); + for (0..100000) |_| { + try buf_builder.appendSlice(allocator, "word "); } - const text = try buf_builder.toOwnedSlice(); - defer allocator.free(text); + const text = try buf_builder.toOwnedSlice(allocator); { const tb, const view = try setupTextBuffer(allocator, pool, text, 80); @@ -567,61 +483,51 @@ fn benchRenderOneMassiveLine( const buf = try OptimizedBuffer.init(allocator, 80, 30, .{ .pool = pool }); defer buf.deinit(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_buf_mem: usize = 0; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |i| { try buf.clear(.{ 0.0, 0.0, 0.0, 1.0 }, null); var timer = try std.time.Timer.start(); try buf.drawTextBuffer(view, 0, 0); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (i == iterations - 1 and show_mem) { final_buf_mem = @sizeOf(OptimizedBuffer) + (buf.width * buf.height * (@sizeOf(u32) + @sizeOf(@TypeOf(buf.buffer.fg[0])) * 2 + @sizeOf(u8))); } } - const name = try std.fmt.allocPrint(allocator, "80x30 render (1 massive line 500KB, wrap=80)", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "Buf", .bytes = final_buf_mem }; - break :blk stats; + const mem_stat_slice = try allocator.alloc(MemStat, 1); + mem_stat_slice[0] = .{ .name = "Buf", .bytes = final_buf_mem }; + break :blk mem_stat_slice; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "80x30 render (1 massive line 500KB, wrap=80)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchRenderManySmallChunks( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - iterations: usize, show_mem: bool, ) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); const text = try generateManySmallChunks(allocator, 10000); - defer allocator.free(text); { const tb, const view = try setupTextBuffer(allocator, pool, text, 80); @@ -631,62 +537,52 @@ fn benchRenderManySmallChunks( const buf = try OptimizedBuffer.init(allocator, 80, 30, .{ .pool = pool }); defer buf.deinit(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_buf_mem: usize = 0; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |i| { try buf.clear(.{ 0.0, 0.0, 0.0, 1.0 }, null); var timer = try std.time.Timer.start(); try buf.drawTextBuffer(view, 0, 0); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (i == iterations - 1 and show_mem) { final_buf_mem = @sizeOf(OptimizedBuffer) + (buf.width * buf.height * (@sizeOf(u32) + @sizeOf(@TypeOf(buf.buffer.fg[0])) * 2 + @sizeOf(u8))); } } - const name = try std.fmt.allocPrint(allocator, "80x30 render (10k tiny chunks)", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "Buf", .bytes = final_buf_mem }; - break :blk stats; + const mem_stat_slice = try allocator.alloc(MemStat, 1); + mem_stat_slice[0] = .{ .name = "Buf", .bytes = final_buf_mem }; + break :blk mem_stat_slice; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "80x30 render (10k tiny chunks)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchRenderWithViewport( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - iterations: usize, show_mem: bool, ) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); _ = show_mem; const text = try generateText(allocator, 10000, 100); - defer allocator.free(text); { const tb, const view = try setupTextBuffer(allocator, pool, text, null); @@ -698,31 +594,22 @@ fn benchRenderWithViewport( const buf = try OptimizedBuffer.init(allocator, 100, 30, .{ .pool = pool }); defer buf.deinit(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |_| { try buf.clear(.{ 0.0, 0.0, 0.0, 1.0 }, null); var timer = try std.time.Timer.start(); try buf.drawTextBuffer(view, 0, 0); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "100x30 render (10k lines, viewport at line 5000)", .{}); - - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "100x30 render (10k lines, viewport at line 5000)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -736,52 +623,41 @@ fn benchRenderWithViewport( const buf = try OptimizedBuffer.init(allocator, 100, 30, .{ .pool = pool }); defer buf.deinit(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |_| { try buf.clear(.{ 0.0, 0.0, 0.0, 1.0 }, null); var timer = try std.time.Timer.start(); try buf.drawTextBuffer(view, 0, 0); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "100x30 render (10k lines, no viewport)", .{}); - - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "100x30 render (10k lines, no viewport)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchRenderWithSelection( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - iterations: usize, show_mem: bool, ) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); _ = show_mem; const text = try generateText(allocator, 500, 100); - defer allocator.free(text); { const tb, const view = try setupTextBuffer(allocator, pool, text, 120); @@ -793,31 +669,22 @@ fn benchRenderWithSelection( const buf = try OptimizedBuffer.init(allocator, 120, 40, .{ .pool = pool }); defer buf.deinit(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |_| { try buf.clear(.{ 0.0, 0.0, 0.0, 1.0 }, null); var timer = try std.time.Timer.start(); try buf.drawTextBuffer(view, 0, 0); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "120x40 render (500 lines, with selection)", .{}); - - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "120x40 render (500 lines, with selection)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -831,100 +698,71 @@ fn benchRenderWithSelection( const buf = try OptimizedBuffer.init(allocator, 120, 40, .{ .pool = pool }); defer buf.deinit(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |_| { try buf.clear(.{ 0.0, 0.0, 0.0, 1.0 }, null); var timer = try std.time.Timer.start(); try buf.drawTextBuffer(view, 0, 0); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "120x40 render (500 lines, no selection)", .{}); - - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "120x40 render (500 lines, no selection)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } pub fn run( allocator: std.mem.Allocator, show_mem: bool, ) ![]BenchResult { - const stdout = std.io.getStdOut().writer(); - // Global pool and unicode data are initialized once in bench.zig const pool = gp.initGlobalPool(allocator); - - - if (show_mem) { - try stdout.print("Memory stats enabled\n", .{}); - } - try stdout.print("\n", .{}); - - var all_results = std.ArrayList(BenchResult).init(allocator); + var all_results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer all_results.deinit(allocator); const iterations: usize = 10; const cold_cache_results = try benchRenderColdCache(allocator, pool, iterations, show_mem); - defer allocator.free(cold_cache_results); - try all_results.appendSlice(cold_cache_results); + try all_results.appendSlice(allocator, cold_cache_results); const warm_cache_results = try benchRenderWarmCache(allocator, pool, iterations, show_mem); - defer allocator.free(warm_cache_results); - try all_results.appendSlice(warm_cache_results); - - try stdout.print("\n", .{}); + try all_results.appendSlice(allocator, warm_cache_results); const small_res_results = try benchRenderSmallResolution(allocator, pool, iterations, show_mem); - defer allocator.free(small_res_results); - try all_results.appendSlice(small_res_results); + try all_results.appendSlice(allocator, small_res_results); const medium_res_results = try benchRenderMediumResolution(allocator, pool, iterations, show_mem); - defer allocator.free(medium_res_results); - try all_results.appendSlice(medium_res_results); + try all_results.appendSlice(allocator, medium_res_results); const massive_res_results = try benchRenderMassiveResolution(allocator, pool, iterations, show_mem); - defer allocator.free(massive_res_results); - try all_results.appendSlice(massive_res_results); + try all_results.appendSlice(allocator, massive_res_results); const massive_lines_results = try benchRenderMassiveLines(allocator, pool, iterations, show_mem); - defer allocator.free(massive_lines_results); - try all_results.appendSlice(massive_lines_results); + try all_results.appendSlice(allocator, massive_lines_results); const one_massive_line_results = try benchRenderOneMassiveLine(allocator, pool, iterations, show_mem); - defer allocator.free(one_massive_line_results); - try all_results.appendSlice(one_massive_line_results); + try all_results.appendSlice(allocator, one_massive_line_results); const many_chunks_results = try benchRenderManySmallChunks(allocator, pool, iterations, show_mem); - defer allocator.free(many_chunks_results); - try all_results.appendSlice(many_chunks_results); + try all_results.appendSlice(allocator, many_chunks_results); const viewport_results = try benchRenderWithViewport(allocator, pool, iterations, show_mem); - defer allocator.free(viewport_results); - try all_results.appendSlice(viewport_results); + try all_results.appendSlice(allocator, viewport_results); const selection_results = try benchRenderWithSelection(allocator, pool, iterations, show_mem); - defer allocator.free(selection_results); - try all_results.appendSlice(selection_results); + try all_results.appendSlice(allocator, selection_results); - return try all_results.toOwnedSlice(); + return try all_results.toOwnedSlice(allocator); } diff --git a/packages/core/src/zig/bench/edit-buffer_bench.zig b/packages/core/src/zig/bench/edit-buffer_bench.zig index bc575c8d2..5398c1ecb 100644 --- a/packages/core/src/zig/bench/edit-buffer_bench.zig +++ b/packages/core/src/zig/bench/edit-buffer_bench.zig @@ -5,6 +5,7 @@ const gp = @import("../grapheme.zig"); const EditBuffer = edit_buffer.EditBuffer; const BenchResult = bench_utils.BenchResult; +const BenchStats = bench_utils.BenchStats; const MemStat = bench_utils.MemStat; pub const benchName = "EditBuffer Operations"; @@ -12,56 +13,46 @@ pub const benchName = "EditBuffer Operations"; fn benchInsertOperations( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - iterations: usize, show_mem: bool, ) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // Single-line insert at start { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_mem: usize = 0; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |iter| { var eb = try EditBuffer.init(allocator, pool, .unicode); defer eb.deinit(); const text = "Hello, world! "; var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 1000) : (i += 1) { + for (0..1000) |_| { try eb.insertText(text); try eb.setCursor(0, 0); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (iter == iterations - 1 and show_mem) { final_mem = eb.getTextBuffer().getArenaAllocatedBytes(); } } - const name = try std.fmt.allocPrint(allocator, "EditBuffer insert 1k times at start", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "TB", .bytes = final_mem }; - break :blk stats; + const s = try allocator.alloc(MemStat, 1); + s[0] = .{ .name = "TB", .bytes = final_mem }; + break :blk s; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "EditBuffer insert 1k times at start", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); @@ -69,112 +60,92 @@ fn benchInsertOperations( // Multi-line insert { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_mem: usize = 0; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |iter| { var eb = try EditBuffer.init(allocator, pool, .unicode); defer eb.deinit(); const text = "Line 1\nLine 2\nLine 3\n"; var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 500) : (i += 1) { + for (0..500) |_| { try eb.insertText(text); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (iter == iterations - 1 and show_mem) { final_mem = eb.getTextBuffer().getArenaAllocatedBytes(); } } - const name = try std.fmt.allocPrint(allocator, "EditBuffer insert 500 multi-line blocks", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "TB", .bytes = final_mem }; - break :blk stats; + const s = try allocator.alloc(MemStat, 1); + s[0] = .{ .name = "TB", .bytes = final_mem }; + break :blk s; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "EditBuffer insert 500 multi-line blocks", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchDeleteOperations( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - iterations: usize, show_mem: bool, ) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // Single-line delete with backspace { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_mem: usize = 0; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |iter| { var eb = try EditBuffer.init(allocator, pool, .unicode); defer eb.deinit(); // Build up text const text = "Hello, world! "; - var i: u32 = 0; - while (i < 1000) : (i += 1) { + for (0..1000) |_| { try eb.insertText(text); } var timer = try std.time.Timer.start(); - i = 0; - while (i < 500) : (i += 1) { + for (0..500) |_| { try eb.backspace(); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (iter == iterations - 1 and show_mem) { final_mem = eb.getTextBuffer().getArenaAllocatedBytes(); } } - const name = try std.fmt.allocPrint(allocator, "EditBuffer backspace 500 chars", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "TB", .bytes = final_mem }; - break :blk stats; + const s = try allocator.alloc(MemStat, 1); + s[0] = .{ .name = "TB", .bytes = final_mem }; + break :blk s; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "EditBuffer backspace 500 chars", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); @@ -182,85 +153,71 @@ fn benchDeleteOperations( // Multi-line delete range { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_mem: usize = 0; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |iter| { var eb = try EditBuffer.init(allocator, pool, .unicode); defer eb.deinit(); // Build up text with many lines const text = "Line 1\nLine 2\nLine 3\n"; - var i: u32 = 0; - while (i < 100) : (i += 1) { + for (0..100) |_| { try eb.insertText(text); } var timer = try std.time.Timer.start(); // Delete across 50 lines try eb.deleteRange(.{ .row = 10, .col = 0 }, .{ .row = 60, .col = 0 }); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (iter == iterations - 1 and show_mem) { final_mem = eb.getTextBuffer().getArenaAllocatedBytes(); } } - const name = try std.fmt.allocPrint(allocator, "EditBuffer delete 50-line range", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "TB", .bytes = final_mem }; - break :blk stats; + const s = try allocator.alloc(MemStat, 1); + s[0] = .{ .name = "TB", .bytes = final_mem }; + break :blk s; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "EditBuffer delete 50-line range", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchMixedOperations( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - iterations: usize, show_mem: bool, ) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // Simulated typing session { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_mem: usize = 0; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |iter| { var eb = try EditBuffer.init(allocator, pool, .unicode); defer eb.deinit(); var timer = try std.time.Timer.start(); // Type some text - var i: u32 = 0; - while (i < 100) : (i += 1) { + for (0..100) |_| { try eb.insertText("function test() {\n"); try eb.insertText(" return 42;\n"); try eb.insertText("}\n"); @@ -273,64 +230,54 @@ fn benchMixedOperations( // Delete a range try eb.deleteRange(.{ .row = 100, .col = 0 }, .{ .row = 120, .col = 0 }); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (iter == iterations - 1 and show_mem) { final_mem = eb.getTextBuffer().getArenaAllocatedBytes(); } } - const name = try std.fmt.allocPrint(allocator, "EditBuffer mixed operations (300 lines)", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "TB", .bytes = final_mem }; - break :blk stats; + const s = try allocator.alloc(MemStat, 1); + s[0] = .{ .name = "TB", .bytes = final_mem }; + break :blk s; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "EditBuffer mixed operations (300 lines)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchWordBoundaryOperations( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - iterations: usize, show_mem: bool, ) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // Next word boundary navigation { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_mem: usize = 0; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |iter| { var eb = try EditBuffer.init(allocator, pool, .unicode); defer eb.deinit(); // Build text with many words const text = "The quick brown fox jumps over the lazy dog. "; - var i: u32 = 0; - while (i < 100) : (i += 1) { + for (0..100) |_| { try eb.insertText(text); } @@ -338,35 +285,29 @@ fn benchWordBoundaryOperations( var timer = try std.time.Timer.start(); // Navigate through 1000 word boundaries - i = 0; - while (i < 1000) : (i += 1) { + for (0..1000) |_| { const cursor = eb.getNextWordBoundary(); try eb.setCursor(cursor.row, cursor.col); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (iter == iterations - 1 and show_mem) { final_mem = eb.getTextBuffer().getArenaAllocatedBytes(); } } - const name = try std.fmt.allocPrint(allocator, "EditBuffer getNextWordBoundary 1k times", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "TB", .bytes = final_mem }; - break :blk stats; + const s = try allocator.alloc(MemStat, 1); + s[0] = .{ .name = "TB", .bytes = final_mem }; + break :blk s; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "EditBuffer getNextWordBoundary 1k times", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); @@ -374,20 +315,16 @@ fn benchWordBoundaryOperations( // Previous word boundary navigation { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_mem: usize = 0; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |iter| { var eb = try EditBuffer.init(allocator, pool, .unicode); defer eb.deinit(); // Build text with many words const text = "The quick brown fox jumps over the lazy dog. "; - var i: u32 = 0; - while (i < 100) : (i += 1) { + for (0..100) |_| { try eb.insertText(text); } @@ -398,35 +335,29 @@ fn benchWordBoundaryOperations( var timer = try std.time.Timer.start(); // Navigate backward through 1000 word boundaries - i = 0; - while (i < 1000) : (i += 1) { + for (0..1000) |_| { const cursor = eb.getPrevWordBoundary(); try eb.setCursor(cursor.row, cursor.col); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (iter == iterations - 1 and show_mem) { final_mem = eb.getTextBuffer().getArenaAllocatedBytes(); } } - const name = try std.fmt.allocPrint(allocator, "EditBuffer getPrevWordBoundary 1k times", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "TB", .bytes = final_mem }; - break :blk stats; + const s = try allocator.alloc(MemStat, 1); + s[0] = .{ .name = "TB", .bytes = final_mem }; + break :blk s; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "EditBuffer getPrevWordBoundary 1k times", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); @@ -434,20 +365,16 @@ fn benchWordBoundaryOperations( // Word boundary with multi-line text { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_mem: usize = 0; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |iter| { var eb = try EditBuffer.init(allocator, pool, .unicode); defer eb.deinit(); // Build multi-line text with words const text = "Hello world test\nAnother line here\nThird line content\n"; - var i: u32 = 0; - while (i < 100) : (i += 1) { + for (0..100) |_| { try eb.insertText(text); } @@ -455,79 +382,61 @@ fn benchWordBoundaryOperations( var timer = try std.time.Timer.start(); // Navigate through 500 word boundaries across lines - i = 0; - while (i < 500) : (i += 1) { + for (0..500) |_| { const cursor = eb.getNextWordBoundary(); try eb.setCursor(cursor.row, cursor.col); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (iter == iterations - 1 and show_mem) { final_mem = eb.getTextBuffer().getArenaAllocatedBytes(); } } - const name = try std.fmt.allocPrint(allocator, "EditBuffer word boundary multi-line 500 times", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "TB", .bytes = final_mem }; - break :blk stats; + const s = try allocator.alloc(MemStat, 1); + s[0] = .{ .name = "TB", .bytes = final_mem }; + break :blk s; } else null; - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "EditBuffer word boundary multi-line 500 times", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } pub fn run( allocator: std.mem.Allocator, show_mem: bool, ) ![]BenchResult { - const stdout = std.io.getStdOut().writer(); - // Global pool and unicode data are initialized once in bench.zig const pool = gp.initGlobalPool(allocator); - - - - if (show_mem) { - try stdout.print("Memory stats enabled\n", .{}); - } - try stdout.print("\n", .{}); - var all_results = std.ArrayList(BenchResult).init(allocator); + var all_results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer all_results.deinit(allocator); const iterations: usize = 5; // Run all benchmark categories const insert_results = try benchInsertOperations(allocator, pool, iterations, show_mem); - defer allocator.free(insert_results); - try all_results.appendSlice(insert_results); + try all_results.appendSlice(allocator, insert_results); const delete_results = try benchDeleteOperations(allocator, pool, iterations, show_mem); - defer allocator.free(delete_results); - try all_results.appendSlice(delete_results); + try all_results.appendSlice(allocator, delete_results); const mixed_results = try benchMixedOperations(allocator, pool, iterations, show_mem); - defer allocator.free(mixed_results); - try all_results.appendSlice(mixed_results); + try all_results.appendSlice(allocator, mixed_results); const word_boundary_results = try benchWordBoundaryOperations(allocator, pool, iterations, show_mem); - defer allocator.free(word_boundary_results); - try all_results.appendSlice(word_boundary_results); + try all_results.appendSlice(allocator, word_boundary_results); - return try all_results.toOwnedSlice(); + return try all_results.toOwnedSlice(allocator); } diff --git a/packages/core/src/zig/bench/rope-markers_bench.zig b/packages/core/src/zig/bench/rope-markers_bench.zig index 087322b64..b53f796af 100644 --- a/packages/core/src/zig/bench/rope-markers_bench.zig +++ b/packages/core/src/zig/bench/rope-markers_bench.zig @@ -3,6 +3,7 @@ const bench_utils = @import("../bench-utils.zig"); const rope_mod = @import("../rope.zig"); const BenchResult = bench_utils.BenchResult; +const BenchStats = bench_utils.BenchStats; const MemStats = bench_utils.MemStats; pub const benchName = "Rope Marker Tracking"; @@ -50,14 +51,13 @@ const RopeType = rope_mod.Rope(Token); /// Create a rope with specific marker density /// marker_every: insert a marker every N text tokens fn createRope(allocator: std.mem.Allocator, text_count: u32, marker_every: u32) !RopeType { - var tokens = std.ArrayList(Token).init(allocator); - defer tokens.deinit(); + var tokens: std.ArrayListUnmanaged(Token) = .{}; + defer tokens.deinit(allocator); - var i: u32 = 0; - while (i < text_count) : (i += 1) { - try tokens.append(.{ .text = 10 }); // Each text segment has width 10 + for (0..text_count) |i| { + try tokens.append(allocator, .{ .text = 10 }); // Each text segment has width 10 if ((i + 1) % marker_every == 0) { - try tokens.append(.{ .marker = {} }); + try tokens.append(allocator, .{ .marker = {} }); } } @@ -65,36 +65,28 @@ fn createRope(allocator: std.mem.Allocator, text_count: u32, marker_every: u32) } fn benchRebuildMarkerIndex(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // Small rope, high marker density (every 10 tokens) { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var timer = try std.time.Timer.start(); const rope = try createRope(arena.allocator(), 1000, 10); _ = rope; // Markers are automatically indexed during rope creation - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Create rope with markers: 1k tokens, marker every 10 (~100 markers)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Create rope with markers: 1k tokens, marker every 10 (~100 markers)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -102,32 +94,23 @@ fn benchRebuildMarkerIndex(allocator: std.mem.Allocator, iterations: usize) ![]B // Small rope, low marker density (every 100 tokens) { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var timer = try std.time.Timer.start(); const rope = try createRope(arena.allocator(), 1000, 100); _ = rope; // Markers are automatically indexed during rope creation - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rebuild index: 1k tokens, marker every 100 (~10 markers)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rebuild index: 1k tokens, marker every 100 (~10 markers)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -135,32 +118,23 @@ fn benchRebuildMarkerIndex(allocator: std.mem.Allocator, iterations: usize) ![]B // Medium rope, high marker density { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var timer = try std.time.Timer.start(); const rope = try createRope(arena.allocator(), 10000, 10); _ = rope; // Markers are automatically indexed during rope creation - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rebuild index: 10k tokens, marker every 10 (~1k markers)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rebuild index: 10k tokens, marker every 10 (~1k markers)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -168,32 +142,23 @@ fn benchRebuildMarkerIndex(allocator: std.mem.Allocator, iterations: usize) ![]B // Medium rope, low marker density { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var timer = try std.time.Timer.start(); const rope = try createRope(arena.allocator(), 10000, 100); _ = rope; // Markers are automatically indexed during rope creation - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rebuild index: 10k tokens, marker every 100 (~100 markers)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rebuild index: 10k tokens, marker every 100 (~100 markers)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -201,32 +166,23 @@ fn benchRebuildMarkerIndex(allocator: std.mem.Allocator, iterations: usize) ![]B // Large rope, text-editor-like density (marker every 50 = ~50 chars/line) { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var timer = try std.time.Timer.start(); const rope = try createRope(arena.allocator(), 50000, 50); _ = rope; // Markers are automatically indexed during rope creation - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rebuild index: 50k tokens, marker every 50 (~1k markers, text-editor-like)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rebuild index: 50k tokens, marker every 50 (~1k markers, text-editor-like)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -234,76 +190,58 @@ fn benchRebuildMarkerIndex(allocator: std.mem.Allocator, iterations: usize) ![]B // Very large rope, sparse markers { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var timer = try std.time.Timer.start(); const rope = try createRope(arena.allocator(), 100000, 200); _ = rope; // Markers are automatically indexed during rope creation - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rebuild index: 100k tokens, marker every 200 (~500 markers)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rebuild index: 100k tokens, marker every 200 (~500 markers)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchMarkerLookup(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // O(1) lookup in small rope { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createRope(arena.allocator(), 1000, 10); // Markers are automatically indexed in the tree structure var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 100) : (i += 1) { - _ = rope.getMarker(.marker, i % rope.markerCount(.marker)); + for (0..100) |i| { + _ = rope.getMarker(.marker, @intCast(i % rope.markerCount(.marker))); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "O(1) lookup: 100 random marker accesses, ~100 markers", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "O(1) lookup: 100 random marker accesses, ~100 markers", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -311,37 +249,27 @@ fn benchMarkerLookup(allocator: std.mem.Allocator, iterations: usize) ![]BenchRe // O(1) lookup in medium rope { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createRope(arena.allocator(), 10000, 50); // Markers are automatically indexed in the tree structure var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 1000) : (i += 1) { - _ = rope.getMarker(.marker, i % rope.markerCount(.marker)); + for (0..1000) |i| { + _ = rope.getMarker(.marker, @intCast(i % rope.markerCount(.marker))); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "O(1) lookup: 1k random marker accesses, ~200 markers", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "O(1) lookup: 1k random marker accesses, ~200 markers", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -349,13 +277,9 @@ fn benchMarkerLookup(allocator: std.mem.Allocator, iterations: usize) ![]BenchRe // O(1) lookup in large rope (text-editor scenario) { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createRope(arena.allocator(), 50000, 50); @@ -366,25 +290,19 @@ fn benchMarkerLookup(allocator: std.mem.Allocator, iterations: usize) ![]BenchRe const random = prng.random(); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 10000) : (i += 1) { + for (0..10000) |_| { const line = random.intRangeAtMost(u32, 0, marker_count - 1); _ = rope.getMarker(.marker, line); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "O(1) lookup: 10k random line jumps, ~1k lines (text-editor)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "O(1) lookup: 10k random line jumps, ~1k lines (text-editor)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -392,13 +310,9 @@ fn benchMarkerLookup(allocator: std.mem.Allocator, iterations: usize) ![]BenchRe // Sequential marker access (best case) { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createRope(arena.allocator(), 10000, 50); @@ -406,107 +320,84 @@ fn benchMarkerLookup(allocator: std.mem.Allocator, iterations: usize) ![]BenchRe const marker_count = rope.markerCount(.marker); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < marker_count) : (i += 1) { - _ = rope.getMarker(.marker, i); + for (0..marker_count) |i| { + _ = rope.getMarker(.marker, @intCast(i)); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "O(1) lookup: Sequential access to all ~200 markers", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "O(1) lookup: Sequential access to all ~200 markers", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchMarkerCount(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // Count markers - should be O(1) hash lookup { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createRope(arena.allocator(), 10000, 50); // Markers are automatically indexed in the tree structure var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 100000) : (i += 1) { + for (0..100000) |_| { _ = rope.markerCount(.marker); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "markerCount: 100k calls (should be ~O(1))", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "markerCount: 100k calls (should be ~O(1))", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchDepthVsPerformance(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // Shallow tree (from_slice creates balanced tree) { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var timer = try std.time.Timer.start(); const rope = try createRope(arena.allocator(), 10000, 50); _ = rope; // Markers are automatically indexed during rope creation - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Create BALANCED tree with markers: 10k tokens, ~200 markers", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Create BALANCED tree with markers: 10k tokens, ~200 markers", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -514,19 +405,14 @@ fn benchDepthVsPerformance(allocator: std.mem.Allocator, iterations: usize) ![]B // Deep tree (built by sequential appends) { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); // Build unbalanced tree through sequential operations var rope = try RopeType.init(arena.allocator()); - var i: u32 = 0; - while (i < 10000) : (i += 1) { + for (0..10000) |i| { try rope.append(.{ .text = 10 }); if ((i + 1) % 50 == 0) { try rope.append(.{ .marker = {} }); @@ -535,40 +421,32 @@ fn benchDepthVsPerformance(allocator: std.mem.Allocator, iterations: usize) ![]B var timer = try std.time.Timer.start(); // Markers are automatically indexed in the tree structure - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rebuild on UNBALANCED tree: 10k tokens, ~200 markers", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rebuild on UNBALANCED tree: 10k tokens, ~200 markers", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchEditThenRebuild(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // Typical edit workflow: build, edit, rebuild { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createRope(arena.allocator(), 10000, 50); @@ -586,20 +464,15 @@ fn benchEditThenRebuild(allocator: std.mem.Allocator, iterations: usize) ![]Benc // Rebuild index after edit // Markers are automatically indexed in the tree structure - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Edit workflow: 3 inserts + rebuild (~200 markers)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Edit workflow: 3 inserts + rebuild (~200 markers)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -607,13 +480,9 @@ fn benchEditThenRebuild(allocator: std.mem.Allocator, iterations: usize) ![]Benc // Insert new line (adds marker) { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createRope(arena.allocator(), 10000, 50); @@ -623,20 +492,15 @@ fn benchEditThenRebuild(allocator: std.mem.Allocator, iterations: usize) ![]Benc // Insert new line (marker) at position 100 try rope.insert(100, .{ .marker = {} }); // Markers are automatically indexed in the tree structure - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Insert newline: insert marker + rebuild (~200 markers)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Insert newline: insert marker + rebuild (~200 markers)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -644,13 +508,9 @@ fn benchEditThenRebuild(allocator: std.mem.Allocator, iterations: usize) ![]Benc // Delete line (removes marker) { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createRope(arena.allocator(), 10000, 50); @@ -661,40 +521,32 @@ fn benchEditThenRebuild(allocator: std.mem.Allocator, iterations: usize) ![]Benc const marker_pos = rope.getMarker(.marker, 50).?.leaf_index; try rope.delete(marker_pos); // Markers are automatically indexed in the tree structure - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Delete line: remove marker + rebuild (~200 markers)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Delete line: remove marker + rebuild (~200 markers)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchMemoryUsage(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // Memory comparison: with vs without marker index { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const rope = try createRope(arena.allocator(), 50000, 50); @@ -702,57 +554,45 @@ fn benchMemoryUsage(allocator: std.mem.Allocator, iterations: usize) ![]BenchRes _ = rope; const elapsed: u64 = 0; // Placeholder for memory measurement - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(elapsed); } - const name = try std.fmt.allocPrint(allocator, "Memory: 50k tokens WITHOUT marker index", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Memory: 50k tokens WITHOUT marker index", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const rope = try createRope(arena.allocator(), 50000, 50); _ = rope; // Markers are automatically indexed in the tree structure const elapsed: u64 = 0; // Placeholder for memory measurement - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(elapsed); } - const name = try std.fmt.allocPrint(allocator, "Memory: 50k tokens WITH marker index (~1k markers)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Memory: 50k tokens WITH marker index (~1k markers)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } pub fn run( @@ -761,39 +601,34 @@ pub fn run( ) ![]BenchResult { _ = show_mem; - var all_results = std.ArrayList(BenchResult).init(allocator); + var all_results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer all_results.deinit(allocator); const iterations: usize = 10; // Rebuild index benchmarks const rebuild_results = try benchRebuildMarkerIndex(allocator, iterations); - defer allocator.free(rebuild_results); - try all_results.appendSlice(rebuild_results); + try all_results.appendSlice(allocator, rebuild_results); // Marker lookup benchmarks const lookup_results = try benchMarkerLookup(allocator, iterations); - defer allocator.free(lookup_results); - try all_results.appendSlice(lookup_results); + try all_results.appendSlice(allocator, lookup_results); // Marker count benchmarks const count_results = try benchMarkerCount(allocator, iterations); - defer allocator.free(count_results); - try all_results.appendSlice(count_results); + try all_results.appendSlice(allocator, count_results); // Tree depth impact const depth_results = try benchDepthVsPerformance(allocator, iterations); - defer allocator.free(depth_results); - try all_results.appendSlice(depth_results); + try all_results.appendSlice(allocator, depth_results); // Edit workflows const edit_results = try benchEditThenRebuild(allocator, iterations); - defer allocator.free(edit_results); - try all_results.appendSlice(edit_results); + try all_results.appendSlice(allocator, edit_results); // Memory usage comparison const memory_results = try benchMemoryUsage(allocator, iterations); - defer allocator.free(memory_results); - try all_results.appendSlice(memory_results); + try all_results.appendSlice(allocator, memory_results); - return try all_results.toOwnedSlice(); + return try all_results.toOwnedSlice(allocator); } diff --git a/packages/core/src/zig/bench/rope_bench.zig b/packages/core/src/zig/bench/rope_bench.zig index 4c2c1061b..3f3b10493 100644 --- a/packages/core/src/zig/bench/rope_bench.zig +++ b/packages/core/src/zig/bench/rope_bench.zig @@ -3,7 +3,7 @@ const bench_utils = @import("../bench-utils.zig"); const rope_mod = @import("../rope.zig"); const BenchResult = bench_utils.BenchResult; -const MemStats = bench_utils.MemStats; +const BenchStats = bench_utils.BenchStats; pub const benchName = "Rope Data Structure"; @@ -22,47 +22,31 @@ const TestItem = struct { const RopeType = rope_mod.Rope(TestItem); -const BenchData = struct { - min_ns: u64, - avg_ns: u64, - max_ns: u64, - total_ns: u64, -}; - fn benchInsertOperations(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // Sequential appends { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try RopeType.init(arena.allocator()); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 10000) : (i += 1) { - try rope.append(.{ .value = i }); + for (0..10000) |i| { + try rope.append(.{ .value = @intCast(i) }); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rope sequential append 10k items", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rope sequential append 10k items", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -70,35 +54,25 @@ fn benchInsertOperations(allocator: std.mem.Allocator, iterations: usize) ![]Ben // Sequential prepends { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try RopeType.init(arena.allocator()); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 10000) : (i += 1) { - try rope.prepend(.{ .value = i }); + for (0..10000) |i| { + try rope.prepend(.{ .value = @intCast(i) }); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rope sequential prepend 10k items", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rope sequential prepend 10k items", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -106,51 +80,42 @@ fn benchInsertOperations(allocator: std.mem.Allocator, iterations: usize) ![]Ben // Random inserts { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try RopeType.init(arena.allocator()); var prng = std.Random.DefaultPrng.init(42); const random = prng.random(); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 5000) : (i += 1) { + for (0..5000) |i| { const pos = if (rope.count() > 0) random.intRangeAtMost(u32, 0, rope.count()) else 0; - try rope.insert(pos, .{ .value = i }); + try rope.insert(pos, .{ .value = @intCast(i) }); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rope random insert 5k items", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rope random insert 5k items", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return results.toOwnedSlice(allocator); } fn benchDeleteOperations(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); var items: [10000]TestItem = undefined; for (&items, 0..) |*item, i| { @@ -159,35 +124,25 @@ fn benchDeleteOperations(allocator: std.mem.Allocator, iterations: usize) ![]Ben // Sequential deletes from end { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try RopeType.from_slice(arena.allocator(), &items); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 5000) : (i += 1) { + for (0..5000) |_| { try rope.delete(rope.count() - 1); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rope sequential delete 5k from end", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rope sequential delete 5k from end", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -195,35 +150,25 @@ fn benchDeleteOperations(allocator: std.mem.Allocator, iterations: usize) ![]Ben // Sequential deletes from beginning { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try RopeType.from_slice(arena.allocator(), &items); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 5000) : (i += 1) { + for (0..5000) |_| { try rope.delete(0); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rope sequential delete 5k from beginning", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rope sequential delete 5k from beginning", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -231,48 +176,39 @@ fn benchDeleteOperations(allocator: std.mem.Allocator, iterations: usize) ![]Ben // Random deletes { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try RopeType.from_slice(arena.allocator(), &items); var prng = std.Random.DefaultPrng.init(42); const random = prng.random(); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 5000) : (i += 1) { + for (0..5000) |_| { const pos = random.intRangeAtMost(u32, 0, rope.count() - 1); try rope.delete(pos); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rope random delete 5k items", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rope random delete 5k items", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return results.toOwnedSlice(allocator); } fn benchBulkOperations(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); var items: [10000]TestItem = undefined; for (&items, 0..) |*item, i| { @@ -281,13 +217,9 @@ fn benchBulkOperations(allocator: std.mem.Allocator, iterations: usize) ![]Bench // insert_slice { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try RopeType.init(arena.allocator()); @@ -296,24 +228,18 @@ fn benchBulkOperations(allocator: std.mem.Allocator, iterations: usize) ![]Bench item.* = .{ .value = @intCast(i) }; } var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 10) : (i += 1) { + for (0..10) |_| { try rope.insert_slice(rope.count(), &chunk); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rope insert_slice 10x1k items", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rope insert_slice 10x1k items", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -321,37 +247,27 @@ fn benchBulkOperations(allocator: std.mem.Allocator, iterations: usize) ![]Bench // delete_range { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try RopeType.from_slice(arena.allocator(), &items); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 10) : (i += 1) { + for (0..10) |_| { const start = if (rope.count() > 500) rope.count() - 500 else 0; const end = rope.count(); try rope.delete_range(start, end); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rope delete_range 10x500 items", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rope delete_range 10x500 items", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -359,37 +275,27 @@ fn benchBulkOperations(allocator: std.mem.Allocator, iterations: usize) ![]Bench // split/concat { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try RopeType.from_slice(arena.allocator(), &items); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 100) : (i += 1) { + for (0..100) |_| { const mid = rope.count() / 2; var right = try rope.split(mid); try rope.concat(&right); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rope split/concat 100 cycles at midpoint", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rope split/concat 100 cycles at midpoint", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -397,43 +303,35 @@ fn benchBulkOperations(allocator: std.mem.Allocator, iterations: usize) ![]Bench // concat two ropes { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope1 = try RopeType.from_slice(arena.allocator(), items[0..5000]); const rope2 = try RopeType.from_slice(arena.allocator(), items[5000..]); var timer = try std.time.Timer.start(); try rope1.concat(&rope2); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rope concat two 5k-item ropes", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rope concat two 5k-item ropes", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return results.toOwnedSlice(allocator); } fn benchAccessPatterns(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); var items: [10000]TestItem = undefined; for (&items, 0..) |*item, i| { @@ -442,35 +340,25 @@ fn benchAccessPatterns(allocator: std.mem.Allocator, iterations: usize) ![]Bench // Sequential get { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const rope = try RopeType.from_slice(arena.allocator(), &items); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 10000) : (i += 1) { - _ = rope.get(i); + for (0..10000) |i| { + _ = rope.get(@intCast(i)); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rope sequential get all 10k items", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rope sequential get all 10k items", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -478,38 +366,28 @@ fn benchAccessPatterns(allocator: std.mem.Allocator, iterations: usize) ![]Bench // Random get { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const rope = try RopeType.from_slice(arena.allocator(), &items); var prng = std.Random.DefaultPrng.init(42); const random = prng.random(); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 10000) : (i += 1) { + for (0..10000) |_| { const pos = random.intRangeAtMost(u32, 0, 9999); _ = rope.get(pos); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rope random get 10k accesses", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rope random get 10k accesses", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -517,13 +395,9 @@ fn benchAccessPatterns(allocator: std.mem.Allocator, iterations: usize) ![]Bench // Walk { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + var stats = BenchStats{}; + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const rope = try RopeType.from_slice(arena.allocator(), &items); @@ -539,26 +413,21 @@ fn benchAccessPatterns(allocator: std.mem.Allocator, iterations: usize) ![]Bench var ctx = Ctx{}; var timer = try std.time.Timer.start(); try rope.walk(&ctx, Ctx.walker); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "Rope walk all 10k items", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "Rope walk all 10k items", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return results.toOwnedSlice(allocator); } pub fn run( @@ -567,26 +436,23 @@ pub fn run( ) ![]BenchResult { _ = show_mem; // Rope benchmarks don't currently track memory - var all_results = std.ArrayList(BenchResult).init(allocator); + var all_results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer all_results.deinit(allocator); const iterations: usize = 10; // Run all benchmark categories const insert_results = try benchInsertOperations(allocator, iterations); - defer allocator.free(insert_results); - try all_results.appendSlice(insert_results); + try all_results.appendSlice(allocator, insert_results); const delete_results = try benchDeleteOperations(allocator, iterations); - defer allocator.free(delete_results); - try all_results.appendSlice(delete_results); + try all_results.appendSlice(allocator, delete_results); const bulk_results = try benchBulkOperations(allocator, iterations); - defer allocator.free(bulk_results); - try all_results.appendSlice(bulk_results); + try all_results.appendSlice(allocator, bulk_results); const access_results = try benchAccessPatterns(allocator, iterations); - defer allocator.free(access_results); - try all_results.appendSlice(access_results); + try all_results.appendSlice(allocator, access_results); - return try all_results.toOwnedSlice(); + return all_results.toOwnedSlice(allocator); } diff --git a/packages/core/src/zig/bench/styled-text_bench.zig b/packages/core/src/zig/bench/styled-text_bench.zig index 610a5664e..f580d08ab 100644 --- a/packages/core/src/zig/bench/styled-text_bench.zig +++ b/packages/core/src/zig/bench/styled-text_bench.zig @@ -5,6 +5,7 @@ const syntax_style_mod = @import("../syntax-style.zig"); const gp = @import("../grapheme.zig"); const BenchResult = bench_utils.BenchResult; +const BenchStats = bench_utils.BenchStats; const MemStats = bench_utils.MemStats; const TextBuffer = text_buffer_mod.UnifiedTextBuffer; const StyledChunk = text_buffer_mod.StyledChunk; // Use the unified type from text-buffer @@ -18,7 +19,8 @@ fn rgbaToPtr(rgba: *const [4]f32) [*]const f32 { } fn benchSetStyledTextOperations(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // Setup global resources var arena = std.heap.ArenaAllocator.init(allocator); @@ -26,20 +28,15 @@ fn benchSetStyledTextOperations(allocator: std.mem.Allocator, iterations: usize) const global_alloc = arena.allocator(); const pool = gp.initGlobalPool(global_alloc); - - // Single chunk - baseline { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; const text = "Hello, World! This is a test of styled text rendering."; const fg_color = [4]f32{ 1.0, 1.0, 1.0, 1.0 }; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |_| { const tb = try TextBuffer.init(allocator, pool, .wcwidth); defer tb.deinit(); @@ -57,20 +54,15 @@ fn benchSetStyledTextOperations(allocator: std.mem.Allocator, iterations: usize) var timer = try std.time.Timer.start(); try tb.setStyledText(&chunks); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "setStyledText - single chunk (55 chars)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "setStyledText - single chunk (55 chars)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -78,9 +70,7 @@ fn benchSetStyledTextOperations(allocator: std.mem.Allocator, iterations: usize) // Multiple small chunks { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; const red = [4]f32{ 1.0, 0.0, 0.0, 1.0 }; const green = [4]f32{ 0.0, 1.0, 0.0, 1.0 }; @@ -89,8 +79,7 @@ fn benchSetStyledTextOperations(allocator: std.mem.Allocator, iterations: usize) const cyan = [4]f32{ 0.0, 1.0, 1.0, 1.0 }; const magenta = [4]f32{ 1.0, 0.0, 1.0, 1.0 }; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |_| { const tb = try TextBuffer.init(allocator, pool, .wcwidth); defer tb.deinit(); @@ -116,20 +105,15 @@ fn benchSetStyledTextOperations(allocator: std.mem.Allocator, iterations: usize) var timer = try std.time.Timer.start(); try tb.setStyledText(&chunks); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "setStyledText - 6 small chunks (~6 chars each)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "setStyledText - 6 small chunks (~6 chars each)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -137,17 +121,14 @@ fn benchSetStyledTextOperations(allocator: std.mem.Allocator, iterations: usize) // Many chunks (simulating syntax highlighted code) { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; const keyword_color = [4]f32{ 0.8, 0.4, 1.0, 1.0 }; const identifier_color = [4]f32{ 0.7, 0.9, 1.0, 1.0 }; const operator_color = [4]f32{ 1.0, 1.0, 1.0, 1.0 }; const number_color = [4]f32{ 0.7, 1.0, 0.7, 1.0 }; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |_| { const tb = try TextBuffer.init(allocator, pool, .wcwidth); defer tb.deinit(); @@ -178,20 +159,15 @@ fn benchSetStyledTextOperations(allocator: std.mem.Allocator, iterations: usize) var timer = try std.time.Timer.start(); try tb.setStyledText(&chunks); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "setStyledText - 8 chunks (syntax highlighting)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "setStyledText - 8 chunks (syntax highlighting)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -199,14 +175,11 @@ fn benchSetStyledTextOperations(allocator: std.mem.Allocator, iterations: usize) // Large text with many chunks (simplified) { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; const text = "Lorem ipsum "; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |_| { const tb = try TextBuffer.init(allocator, pool, .wcwidth); defer tb.deinit(); @@ -231,20 +204,15 @@ fn benchSetStyledTextOperations(allocator: std.mem.Allocator, iterations: usize) var timer = try std.time.Timer.start(); try tb.setStyledText(&chunks); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "setStyledText - 10 chunks (~120 chars total)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "setStyledText - 10 chunks (~120 chars total)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -252,12 +220,9 @@ fn benchSetStyledTextOperations(allocator: std.mem.Allocator, iterations: usize) // Chunks with attributes (bold, italic, etc.) { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |_| { const tb = try TextBuffer.init(allocator, pool, .wcwidth); defer tb.deinit(); @@ -281,30 +246,26 @@ fn benchSetStyledTextOperations(allocator: std.mem.Allocator, iterations: usize) var timer = try std.time.Timer.start(); try tb.setStyledText(&chunks); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "setStyledText - 5 chunks with attributes", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "setStyledText - 5 chunks with attributes", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchHighlightOperations(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // Setup global resources var arena = std.heap.ArenaAllocator.init(allocator); @@ -312,17 +273,12 @@ fn benchHighlightOperations(allocator: std.mem.Allocator, iterations: usize) ![] const global_alloc = arena.allocator(); const pool = gp.initGlobalPool(global_alloc); - - // Baseline: 1000 sequential addHighlightByCharRange calls (unbatched) { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |_| { const tb = try TextBuffer.init(allocator, pool, .wcwidth); defer tb.deinit(); @@ -337,28 +293,22 @@ fn benchHighlightOperations(allocator: std.mem.Allocator, iterations: usize) ![] var timer = try std.time.Timer.start(); // Add 1000 highlights sequentially - var i: u32 = 0; - while (i < 1000) : (i += 1) { - const start_char = (i * 2) % 50; + for (0..1000) |i| { + const start_char: u32 = @intCast((i * 2) % 50); const end_char = start_char + 3; - const style_id = (i % 5) + 1; + const style_id: u32 = @intCast((i % 5) + 1); tb.addHighlightByCharRange(start_char, end_char, style_id, 1, 0) catch {}; } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "addHighlightByCharRange - 1000 calls (unbatched)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "addHighlightByCharRange - 1000 calls (unbatched)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -366,12 +316,9 @@ fn benchHighlightOperations(allocator: std.mem.Allocator, iterations: usize) ![] // Batched: 1000 sequential addHighlightByCharRange calls in a transaction { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |_| { const tb = try TextBuffer.init(allocator, pool, .wcwidth); defer tb.deinit(); @@ -390,28 +337,22 @@ fn benchHighlightOperations(allocator: std.mem.Allocator, iterations: usize) ![] defer tb.endHighlightsTransaction(); // Add 1000 highlights sequentially - var i: u32 = 0; - while (i < 1000) : (i += 1) { - const start_char = (i * 2) % 50; + for (0..1000) |i| { + const start_char: u32 = @intCast((i * 2) % 50); const end_char = start_char + 3; - const style_id = (i % 5) + 1; + const style_id: u32 = @intCast((i % 5) + 1); tb.addHighlightByCharRange(start_char, end_char, style_id, 1, 0) catch {}; } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "addHighlightByCharRange - 1000 calls (batched)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "addHighlightByCharRange - 1000 calls (batched)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -419,13 +360,11 @@ fn benchHighlightOperations(allocator: std.mem.Allocator, iterations: usize) ![] // setStyledText with 100 chunks (realistic syntax highlighting scenario) { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; // Build a realistic multi-line code snippet with 100 chunks - var chunk_list = std.ArrayList(StyledChunk).init(allocator); - defer chunk_list.deinit(); + var chunk_list: std.ArrayListUnmanaged(StyledChunk) = .{}; + defer chunk_list.deinit(allocator); const keyword_color = [4]f32{ 0.8, 0.4, 1.0, 1.0 }; const identifier_color = [4]f32{ 0.7, 0.9, 1.0, 1.0 }; @@ -434,22 +373,20 @@ fn benchHighlightOperations(allocator: std.mem.Allocator, iterations: usize) ![] const string_color = [4]f32{ 0.9, 0.8, 0.5, 1.0 }; // Repeat a pattern to create 100 chunks - var chunk_idx: usize = 0; - while (chunk_idx < 10) : (chunk_idx += 1) { - try chunk_list.append(.{ .text_ptr = "const".ptr, .text_len = 5, .fg_ptr = rgbaToPtr(&keyword_color), .bg_ptr = null, .attributes = 0 }); - try chunk_list.append(.{ .text_ptr = " ".ptr, .text_len = 1, .fg_ptr = null, .bg_ptr = null, .attributes = 0 }); - try chunk_list.append(.{ .text_ptr = "myVar".ptr, .text_len = 5, .fg_ptr = rgbaToPtr(&identifier_color), .bg_ptr = null, .attributes = 0 }); - try chunk_list.append(.{ .text_ptr = " ".ptr, .text_len = 1, .fg_ptr = null, .bg_ptr = null, .attributes = 0 }); - try chunk_list.append(.{ .text_ptr = "=".ptr, .text_len = 1, .fg_ptr = rgbaToPtr(&operator_color), .bg_ptr = null, .attributes = 0 }); - try chunk_list.append(.{ .text_ptr = " ".ptr, .text_len = 1, .fg_ptr = null, .bg_ptr = null, .attributes = 0 }); - try chunk_list.append(.{ .text_ptr = "42".ptr, .text_len = 2, .fg_ptr = rgbaToPtr(&number_color), .bg_ptr = null, .attributes = 0 }); - try chunk_list.append(.{ .text_ptr = ";".ptr, .text_len = 1, .fg_ptr = rgbaToPtr(&operator_color), .bg_ptr = null, .attributes = 0 }); - try chunk_list.append(.{ .text_ptr = "\n".ptr, .text_len = 1, .fg_ptr = null, .bg_ptr = null, .attributes = 0 }); - try chunk_list.append(.{ .text_ptr = "\"str\"".ptr, .text_len = 5, .fg_ptr = rgbaToPtr(&string_color), .bg_ptr = null, .attributes = 0 }); + for (0..10) |_| { + try chunk_list.append(allocator, .{ .text_ptr = "const".ptr, .text_len = 5, .fg_ptr = rgbaToPtr(&keyword_color), .bg_ptr = null, .attributes = 0 }); + try chunk_list.append(allocator, .{ .text_ptr = " ".ptr, .text_len = 1, .fg_ptr = null, .bg_ptr = null, .attributes = 0 }); + try chunk_list.append(allocator, .{ .text_ptr = "myVar".ptr, .text_len = 5, .fg_ptr = rgbaToPtr(&identifier_color), .bg_ptr = null, .attributes = 0 }); + try chunk_list.append(allocator, .{ .text_ptr = " ".ptr, .text_len = 1, .fg_ptr = null, .bg_ptr = null, .attributes = 0 }); + try chunk_list.append(allocator, .{ .text_ptr = "=".ptr, .text_len = 1, .fg_ptr = rgbaToPtr(&operator_color), .bg_ptr = null, .attributes = 0 }); + try chunk_list.append(allocator, .{ .text_ptr = " ".ptr, .text_len = 1, .fg_ptr = null, .bg_ptr = null, .attributes = 0 }); + try chunk_list.append(allocator, .{ .text_ptr = "42".ptr, .text_len = 2, .fg_ptr = rgbaToPtr(&number_color), .bg_ptr = null, .attributes = 0 }); + try chunk_list.append(allocator, .{ .text_ptr = ";".ptr, .text_len = 1, .fg_ptr = rgbaToPtr(&operator_color), .bg_ptr = null, .attributes = 0 }); + try chunk_list.append(allocator, .{ .text_ptr = "\n".ptr, .text_len = 1, .fg_ptr = null, .bg_ptr = null, .attributes = 0 }); + try chunk_list.append(allocator, .{ .text_ptr = "\"str\"".ptr, .text_len = 5, .fg_ptr = rgbaToPtr(&string_color), .bg_ptr = null, .attributes = 0 }); } - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + for (0..iterations) |_| { const tb = try TextBuffer.init(allocator, pool, .wcwidth); defer tb.deinit(); @@ -459,26 +396,21 @@ fn benchHighlightOperations(allocator: std.mem.Allocator, iterations: usize) ![] var timer = try std.time.Timer.start(); try tb.setStyledText(chunk_list.items); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "setStyledText - 100 chunks (realistic code)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "setStyledText - 100 chunks (realistic code)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } pub fn run( @@ -487,17 +419,16 @@ pub fn run( ) ![]BenchResult { _ = show_mem; - var all_results = std.ArrayList(BenchResult).init(allocator); + var all_results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer all_results.deinit(allocator); const iterations: usize = 100; const styled_text_results = try benchSetStyledTextOperations(allocator, iterations); - defer allocator.free(styled_text_results); - try all_results.appendSlice(styled_text_results); + try all_results.appendSlice(allocator, styled_text_results); const highlight_results = try benchHighlightOperations(allocator, iterations); - defer allocator.free(highlight_results); - try all_results.appendSlice(highlight_results); + try all_results.appendSlice(allocator, highlight_results); - return try all_results.toOwnedSlice(); + return try all_results.toOwnedSlice(allocator); } diff --git a/packages/core/src/zig/bench/text-buffer-coords_bench.zig b/packages/core/src/zig/bench/text-buffer-coords_bench.zig index 1156cfc9c..14c857a12 100644 --- a/packages/core/src/zig/bench/text-buffer-coords_bench.zig +++ b/packages/core/src/zig/bench/text-buffer-coords_bench.zig @@ -4,6 +4,7 @@ const seg_mod = @import("../text-buffer-segment.zig"); const iter_mod = @import("../text-buffer-iterators.zig"); const BenchResult = bench_utils.BenchResult; +const BenchStats = bench_utils.BenchStats; const Segment = seg_mod.Segment; const TextChunk = seg_mod.TextChunk; const UnifiedRope = seg_mod.UnifiedRope; @@ -12,13 +13,12 @@ pub const benchName = "TextBuffer Coordinate Conversion"; /// Create a text buffer with N lines for testing fn createTestBuffer(allocator: std.mem.Allocator, line_count: u32, chars_per_line: u32) !UnifiedRope { - var segments = std.ArrayList(Segment).init(allocator); - defer segments.deinit(); + var segments: std.ArrayListUnmanaged(Segment) = .{}; + defer segments.deinit(allocator); - var i: u32 = 0; - while (i < line_count) : (i += 1) { + for (0..line_count) |i| { // Add text segment - try segments.append(Segment{ + try segments.append(allocator, Segment{ .text = TextChunk{ .mem_id = 0, .byte_start = 0, @@ -29,7 +29,7 @@ fn createTestBuffer(allocator: std.mem.Allocator, line_count: u32, chars_per_lin }); // Add line break (except for last line) if (i < line_count - 1) { - try segments.append(Segment{ .brk = {} }); + try segments.append(allocator, Segment{ .brk = {} }); } } @@ -37,42 +37,34 @@ fn createTestBuffer(allocator: std.mem.Allocator, line_count: u32, chars_per_lin } fn benchCoordsToOffsetCurrent(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // Small buffer - 100 lines { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createTestBuffer(arena.allocator(), 100, 50); var timer = try std.time.Timer.start(); // Access lines throughout the buffer - var i: u32 = 0; - while (i < 100) : (i += 1) { - const line = i % 100; + for (0..100) |i| { + const line: u32 = @intCast(i % 100); _ = iter_mod.coordsToOffset(&rope, line, 25); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "[CURRENT] coordsToOffset: 100 calls, 100 lines", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "[CURRENT] coordsToOffset: 100 calls, 100 lines", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -80,37 +72,28 @@ fn benchCoordsToOffsetCurrent(allocator: std.mem.Allocator, iterations: usize) ! // Medium buffer - 1k lines { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createTestBuffer(arena.allocator(), 1000, 50); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 100) : (i += 1) { - const line = (i * 10) % 1000; + for (0..100) |i| { + const line: u32 = @intCast((i * 10) % 1000); _ = iter_mod.coordsToOffset(&rope, line, 25); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "[CURRENT] coordsToOffset: 100 calls, 1k lines", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "[CURRENT] coordsToOffset: 100 calls, 1k lines", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -118,37 +101,28 @@ fn benchCoordsToOffsetCurrent(allocator: std.mem.Allocator, iterations: usize) ! // Large buffer - 10k lines { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createTestBuffer(arena.allocator(), 10000, 50); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 100) : (i += 1) { - const line = (i * 100) % 10000; + for (0..100) |i| { + const line: u32 = @intCast((i * 100) % 10000); _ = iter_mod.coordsToOffset(&rope, line, 25); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "[CURRENT] coordsToOffset: 100 calls, 10k lines", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "[CURRENT] coordsToOffset: 100 calls, 10k lines", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -156,56 +130,45 @@ fn benchCoordsToOffsetCurrent(allocator: std.mem.Allocator, iterations: usize) ! // Worst case: access last line repeatedly { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createTestBuffer(arena.allocator(), 1000, 50); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 100) : (i += 1) { + for (0..100) |_| { _ = iter_mod.coordsToOffset(&rope, 999, 25); // Last line } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "[CURRENT] coordsToOffset: 100 calls to LAST line, 1k lines (worst case)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "[CURRENT] coordsToOffset: 100 calls to LAST line, 1k lines (worst case)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchOffsetToCoordsCurrent(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // Small buffer { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createTestBuffer(arena.allocator(), 100, 50); @@ -215,25 +178,19 @@ fn benchOffsetToCoordsCurrent(allocator: std.mem.Allocator, iterations: usize) ! const random = prng.random(); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 100) : (i += 1) { + for (0..100) |_| { const offset = random.intRangeAtMost(u32, 0, total_width); _ = iter_mod.offsetToCoords(&rope, offset); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "[CURRENT] offsetToCoords: 100 calls, 100 lines", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "[CURRENT] offsetToCoords: 100 calls, 100 lines", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -241,13 +198,10 @@ fn benchOffsetToCoordsCurrent(allocator: std.mem.Allocator, iterations: usize) ! // Medium buffer { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createTestBuffer(arena.allocator(), 1000, 50); @@ -257,25 +211,19 @@ fn benchOffsetToCoordsCurrent(allocator: std.mem.Allocator, iterations: usize) ! const random = prng.random(); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 100) : (i += 1) { + for (0..100) |_| { const offset = random.intRangeAtMost(u32, 0, total_width); _ = iter_mod.offsetToCoords(&rope, offset); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "[CURRENT] offsetToCoords: 100 calls, 1k lines", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "[CURRENT] offsetToCoords: 100 calls, 1k lines", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -283,13 +231,10 @@ fn benchOffsetToCoordsCurrent(allocator: std.mem.Allocator, iterations: usize) ! // Large buffer { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createTestBuffer(arena.allocator(), 10000, 50); @@ -299,74 +244,60 @@ fn benchOffsetToCoordsCurrent(allocator: std.mem.Allocator, iterations: usize) ! const random = prng.random(); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 100) : (i += 1) { + for (0..100) |_| { const offset = random.intRangeAtMost(u32, 0, total_width); _ = iter_mod.offsetToCoords(&rope, offset); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "[CURRENT] offsetToCoords: 100 calls, 10k lines", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "[CURRENT] offsetToCoords: 100 calls, 10k lines", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchGetLineCount(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // getLineCount is already optimized with metrics { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var arena = std.heap.ArenaAllocator.init(allocator); + for (0..iterations) |_| { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var rope = try createTestBuffer(arena.allocator(), 10000, 50); var timer = try std.time.Timer.start(); - var i: u32 = 0; - while (i < 100000) : (i += 1) { + for (0..100000) |_| { _ = iter_mod.getLineCount(&rope); } - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "getLineCount: 100k calls (already O(1) via metrics)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(allocator, BenchResult{ + .name = "getLineCount: 100k calls (already O(1) via metrics)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } pub fn run( @@ -375,22 +306,20 @@ pub fn run( ) ![]BenchResult { _ = show_mem; - var all_results = std.ArrayList(BenchResult).init(allocator); + var all_results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer all_results.deinit(allocator); const iterations: usize = 10; // Current implementation benchmarks const coords_results = try benchCoordsToOffsetCurrent(allocator, iterations); - defer allocator.free(coords_results); - try all_results.appendSlice(coords_results); + try all_results.appendSlice(allocator, coords_results); const offset_results = try benchOffsetToCoordsCurrent(allocator, iterations); - defer allocator.free(offset_results); - try all_results.appendSlice(offset_results); + try all_results.appendSlice(allocator, offset_results); const count_results = try benchGetLineCount(allocator, iterations); - defer allocator.free(count_results); - try all_results.appendSlice(count_results); + try all_results.appendSlice(allocator, count_results); - return try all_results.toOwnedSlice(); + return try all_results.toOwnedSlice(allocator); } diff --git a/packages/core/src/zig/bench/text-buffer-view_bench.zig b/packages/core/src/zig/bench/text-buffer-view_bench.zig index 3f5877255..68b37ac89 100644 --- a/packages/core/src/zig/bench/text-buffer-view_bench.zig +++ b/packages/core/src/zig/bench/text-buffer-view_bench.zig @@ -8,21 +8,14 @@ const UnifiedTextBuffer = text_buffer.UnifiedTextBuffer; const UnifiedTextBufferView = text_buffer_view.UnifiedTextBufferView; const WrapMode = text_buffer.WrapMode; const BenchResult = bench_utils.BenchResult; +const BenchStats = bench_utils.BenchStats; const MemStat = bench_utils.MemStat; pub const benchName = "TextBuffer Wrapping"; -const BenchData = struct { - min_ns: u64, - avg_ns: u64, - max_ns: u64, - total_ns: u64, - mem: ?[]const MemStat, -}; - pub fn generateLargeText(allocator: std.mem.Allocator, lines: u32, target_bytes: usize) ![]u8 { - var buffer = std.ArrayList(u8).init(allocator); - errdefer buffer.deinit(); + var buffer: std.ArrayListUnmanaged(u8) = .{}; + errdefer buffer.deinit(allocator); const patterns = [_][]const u8{ "The quick brown fox jumps over the lazy dog. ", @@ -42,22 +35,21 @@ pub fn generateLargeText(allocator: std.mem.Allocator, lines: u32, target_bytes: const pattern = patterns[line_idx % patterns.len]; const repeat_count = 2 + (line_idx % 5); - var repeat: usize = 0; - while (repeat < repeat_count) : (repeat += 1) { - try buffer.appendSlice(pattern); + for (0..repeat_count) |_| { + try buffer.appendSlice(allocator, pattern); current_bytes += pattern.len; } - try buffer.append('\n'); + try buffer.append(allocator, '\n'); current_bytes += 1; } - return try buffer.toOwnedSlice(); + return try buffer.toOwnedSlice(allocator); } pub fn generateLargeTextSingleLine(allocator: std.mem.Allocator, target_bytes: usize) ![]u8 { - var buffer = std.ArrayList(u8).init(allocator); - errdefer buffer.deinit(); + var buffer: std.ArrayListUnmanaged(u8) = .{}; + errdefer buffer.deinit(allocator); const patterns = [_][]const u8{ "The quick brown fox jumps over the lazy dog. ", @@ -75,44 +67,36 @@ pub fn generateLargeTextSingleLine(allocator: std.mem.Allocator, target_bytes: u while (current_bytes < target_bytes) { const pattern = patterns[pattern_idx % patterns.len]; - try buffer.appendSlice(pattern); + try buffer.appendSlice(allocator, pattern); current_bytes += pattern.len; pattern_idx += 1; } - return try buffer.toOwnedSlice(); + return try buffer.toOwnedSlice(allocator); } fn benchSetText( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - iterations: usize, show_mem: bool, ) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); // Small text { const text = "Hello, world!\nSecond line\nThird line"; - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_mem: usize = 0; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |i| { var tb = try UnifiedTextBuffer.init(allocator, pool, .unicode); defer tb.deinit(); var timer = try std.time.Timer.start(); try tb.setText(text); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (i == iterations - 1 and show_mem) { final_mem = tb.getArenaAllocatedBytes(); @@ -121,17 +105,17 @@ fn benchSetText( const name = try std.fmt.allocPrint(allocator, "TextBuffer setText small (3 lines, 40 bytes)", .{}); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "TB", .bytes = final_mem }; - break :blk stats; + const mem = try allocator.alloc(MemStat, 1); + mem[0] = .{ .name = "TB", .bytes = final_mem }; + break :blk mem; } else null; - try results.append(BenchResult{ + try results.append(allocator, BenchResult{ .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); @@ -142,23 +126,16 @@ fn benchSetText( const text = try generateLargeText(allocator, 5000, 1 * 1024 * 1024); defer allocator.free(text); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var final_mem: usize = 0; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |i| { var tb = try UnifiedTextBuffer.init(allocator, pool, .unicode); defer tb.deinit(); var timer = try std.time.Timer.start(); try tb.setText(text); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (i == iterations - 1 and show_mem) { final_mem = tb.getArenaAllocatedBytes(); @@ -166,13 +143,10 @@ fn benchSetText( } const text_mb = @as(f64, @floatFromInt(text.len)) / (1024.0 * 1024.0); - const line_count = blk: { - var count: usize = 1; - for (text) |byte| { - if (byte == '\n') count += 1; - } - break :blk count; - }; + var line_count: usize = 1; + for (text) |byte| { + if (byte == '\n') line_count += 1; + } const name = try std.fmt.allocPrint( allocator, @@ -180,44 +154,39 @@ fn benchSetText( .{ line_count, text_mb }, ); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "TB", .bytes = final_mem }; - break :blk stats; + const mem = try allocator.alloc(MemStat, 1); + mem[0] = .{ .name = "TB", .bytes = final_mem }; + break :blk mem; } else null; - try results.append(BenchResult{ + try results.append(allocator, BenchResult{ .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }); } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } fn benchWrap( allocator: std.mem.Allocator, pool: *gp.GraphemePool, - - text: []const u8, wrap_width: u32, wrap_mode: WrapMode, iterations: usize, show_mem: bool, -) !BenchData { - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; +) !BenchResult { + var stats = BenchStats{}; var final_tb_mem: usize = 0; var final_view_mem: usize = 0; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |i| { var tb = try UnifiedTextBuffer.init(allocator, pool, .unicode); defer tb.deinit(); @@ -231,12 +200,86 @@ fn benchWrap( var timer = try std.time.Timer.start(); view.setWrapWidth(wrap_width); const count = view.getVirtualLineCount(); - const elapsed = timer.read(); + stats.record(timer.read()); _ = count; - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + if (i == iterations - 1 and show_mem) { + final_tb_mem = tb.getArenaAllocatedBytes(); + final_view_mem = view.getArenaAllocatedBytes(); + } + } + + const mem_stats: ?[]const MemStat = if (show_mem) blk: { + const mem = try allocator.alloc(MemStat, 2); + mem[0] = .{ .name = "TB", .bytes = final_tb_mem }; + mem[1] = .{ .name = "View", .bytes = final_view_mem }; + break :blk mem; + } else null; + + return .{ + .name = "", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, + .iterations = iterations, + .mem_stats = mem_stats, + }; +} + +fn benchMeasureForDimensionsLayout( + allocator: std.mem.Allocator, + pool: *gp.GraphemePool, + text: []const u8, + streaming: bool, + measure_width: u32, + layout_passes: usize, + iterations: usize, + show_mem: bool, +) !BenchResult { + const steps: usize = 200; + + var stats = BenchStats{}; + var final_tb_mem: usize = 0; + var final_view_mem: usize = 0; + + const token = "token "; + const newline = "\n"; + const newline_stride: usize = 20; + + for (0..iterations) |i| { + var tb = try UnifiedTextBuffer.init(allocator, pool, .unicode); + defer tb.deinit(); + + try tb.setText(text); + + var view = try UnifiedTextBufferView.init(allocator, tb); + defer view.deinit(); + + view.setWrapMode(.word); + + var token_mem_id: u8 = 0; + var newline_mem_id: u8 = 0; + if (streaming) { + token_mem_id = try tb.registerMemBuffer(token, false); + newline_mem_id = try tb.registerMemBuffer(newline, false); + } + + var timer = try std.time.Timer.start(); + for (0..steps) |step| { + if (streaming) { + try tb.appendFromMemId(token_mem_id); + if ((step + 1) % newline_stride == 0) { + try tb.appendFromMemId(newline_mem_id); + } + } + + // Simulate Yoga's repeated measure calls within a single layout pass. + for (0..layout_passes) |_| { + _ = try view.measureForDimensions(measure_width, 24); + } + } + stats.record(timer.read()); if (i == iterations - 1 and show_mem) { final_tb_mem = tb.getArenaAllocatedBytes(); @@ -245,18 +288,20 @@ fn benchWrap( } const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 2); - stats[0] = .{ .name = "TB", .bytes = final_tb_mem }; - stats[1] = .{ .name = "View", .bytes = final_view_mem }; - break :blk stats; + const mem = try allocator.alloc(MemStat, 2); + mem[0] = .{ .name = "TB", .bytes = final_tb_mem }; + mem[1] = .{ .name = "View", .bytes = final_view_mem }; + break :blk mem; } else null; return .{ - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, - .mem = mem_stats, + .name = "", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, + .iterations = iterations, + .mem_stats = mem_stats, }; } @@ -264,46 +309,56 @@ pub fn run( allocator: std.mem.Allocator, show_mem: bool, ) ![]BenchResult { - const stdout = std.io.getStdOut().writer(); - // Global pool and unicode data are initialized once in bench.zig const pool = gp.initGlobalPool(allocator); - - - if (show_mem) { - try stdout.print("Memory stats enabled\n", .{}); - } - try stdout.print("\n", .{}); - - var all_results = std.ArrayList(BenchResult).init(allocator); + var all_results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer all_results.deinit(allocator); const iterations: usize = 10; // Run setText benchmarks const setText_results = try benchSetText(allocator, pool, iterations, show_mem); - defer allocator.free(setText_results); - try all_results.appendSlice(setText_results); + try all_results.appendSlice(allocator, setText_results); // Generate test data for wrapping benchmarks const text_multiline = try generateLargeText(allocator, 5000, 1 * 1024 * 1024); - defer allocator.free(text_multiline); - const text_singleline = try generateLargeTextSingleLine(allocator, 2 * 1024 * 1024); - defer allocator.free(text_singleline); - - const text_mb_multi = @as(f64, @floatFromInt(text_multiline.len)) / (1024.0 * 1024.0); - const text_mb_single = @as(f64, @floatFromInt(text_singleline.len)) / (1024.0 * 1024.0); - const line_count_multi = blk: { - var count: usize = 1; - for (text_multiline) |byte| { - if (byte == '\n') count += 1; - } - break :blk count; + + // Run measureForDimensions benchmarks + const layout_passes: usize = 3; + const wrap_width: u32 = 80; + const measure_scenarios = [_]struct { + label: []const u8, + streaming: bool, + width: u32, + }{ + .{ .label = "layout streaming wrap", .streaming = true, .width = wrap_width }, + .{ .label = "layout streaming intrinsic", .streaming = true, .width = 0 }, + .{ .label = "layout static wrap", .streaming = false, .width = wrap_width }, }; - try stdout.print("Generated {d:.2} MiB multiline text ({d} lines)\n", .{ text_mb_multi, line_count_multi }); - try stdout.print("Generated {d:.2} MiB single-line text\n", .{text_mb_single}); + for (measure_scenarios) |scenario| { + const bench_name = try std.fmt.allocPrint( + allocator, + "TextBufferView measureForDimensions ({s}, {d:.2} MiB)", + .{ scenario.label, @as(f64, @floatFromInt(text_multiline.len)) / (1024.0 * 1024.0) }, + ); + + var bench_result = try benchMeasureForDimensionsLayout( + allocator, + pool, + text_multiline, + scenario.streaming, + scenario.width, + layout_passes, + iterations, + show_mem, + ); + bench_result.name = bench_name; + + try all_results.append(allocator, bench_result); + } // Test wrapping scenarios const scenarios = [_]struct { @@ -335,9 +390,8 @@ pub fn run( scenario.width, line_type, }); - errdefer allocator.free(bench_name); - const bench_data = try benchWrap( + var bench_result = try benchWrap( allocator, pool, text, @@ -346,17 +400,10 @@ pub fn run( iterations, show_mem, ); + bench_result.name = bench_name; - try all_results.append(BenchResult{ - .name = bench_name, - .min_ns = bench_data.min_ns, - .avg_ns = bench_data.avg_ns, - .max_ns = bench_data.max_ns, - .total_ns = bench_data.total_ns, - .iterations = iterations, - .mem_stats = bench_data.mem, - }); + try all_results.append(allocator, bench_result); } - return try all_results.toOwnedSlice(); + return try all_results.toOwnedSlice(allocator); } diff --git a/packages/core/src/zig/bench/text-chunk-graphemes_bench.zig b/packages/core/src/zig/bench/text-chunk-graphemes_bench.zig index 1a40c495a..fea365bbf 100644 --- a/packages/core/src/zig/bench/text-chunk-graphemes_bench.zig +++ b/packages/core/src/zig/bench/text-chunk-graphemes_bench.zig @@ -8,6 +8,7 @@ const utf8 = @import("../utf8.zig"); const TextChunk = seg_mod.TextChunk; const MemRegistry = mem_registry_mod.MemRegistry; const BenchResult = bench_utils.BenchResult; +const BenchStats = bench_utils.BenchStats; const MemStat = bench_utils.MemStat; pub const benchName = "TextChunk getGraphemes"; @@ -15,8 +16,8 @@ pub const benchName = "TextChunk getGraphemes"; const TextType = enum { ascii, mixed, heavy_unicode }; fn generateTestText(allocator: std.mem.Allocator, size: usize, text_type: TextType) ![]u8 { - var buffer = std.ArrayList(u8).init(allocator); - errdefer buffer.deinit(); + var buffer: std.ArrayListUnmanaged(u8) = .{}; + errdefer buffer.deinit(allocator); switch (text_type) { .ascii => { @@ -31,7 +32,7 @@ fn generateTestText(allocator: std.mem.Allocator, size: usize, text_type: TextTy while (pos < size) { const pattern = patterns[pos % patterns.len]; const to_add = @min(pattern.len, size - pos); - try buffer.appendSlice(pattern[0..to_add]); + try buffer.appendSlice(allocator, pattern[0..to_add]); pos += to_add; } }, @@ -49,7 +50,7 @@ fn generateTestText(allocator: std.mem.Allocator, size: usize, text_type: TextTy while (pos < size) { const pattern = patterns[pos % patterns.len]; const to_add = @min(pattern.len, size - pos); - try buffer.appendSlice(pattern[0..to_add]); + try buffer.appendSlice(allocator, pattern[0..to_add]); pos += to_add; } }, @@ -67,13 +68,13 @@ fn generateTestText(allocator: std.mem.Allocator, size: usize, text_type: TextTy while (pos < size) { const pattern = patterns[pos % patterns.len]; const to_add = @min(pattern.len, size - pos); - try buffer.appendSlice(pattern[0..to_add]); + try buffer.appendSlice(allocator, pattern[0..to_add]); pos += to_add; } }, } - return try buffer.toOwnedSlice(); + return try buffer.toOwnedSlice(allocator); } fn benchGetGraphemes( @@ -110,14 +111,11 @@ fn benchGetGraphemes( .flags = if (is_ascii) TextChunk.Flags.ASCII_ONLY else 0, }; - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var stats = BenchStats{}; var grapheme_count: usize = 0; var final_mem: usize = 0; - var i: usize = 0; - while (i < iterations) : (i += 1) { + for (0..iterations) |i| { // Create a fresh arena for each iteration var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); @@ -133,11 +131,7 @@ fn benchGetGraphemes( 4, // tab width .unicode, ); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + stats.record(timer.read()); if (i == 0) { grapheme_count = graphemes.len; @@ -162,17 +156,17 @@ fn benchGetGraphemes( ); const mem_stats: ?[]const MemStat = if (show_mem) blk: { - const stats = try allocator.alloc(MemStat, 1); - stats[0] = .{ .name = "Graphemes", .bytes = final_mem }; - break :blk stats; + const mem_stat_slice = try allocator.alloc(MemStat, 1); + mem_stat_slice[0] = .{ .name = "Graphemes", .bytes = final_mem }; + break :blk mem_stat_slice; } else null; return BenchResult{ .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = mem_stats, }; @@ -182,17 +176,11 @@ pub fn run( allocator: std.mem.Allocator, show_mem: bool, ) ![]BenchResult { - const stdout = std.io.getStdOut().writer(); - // Global pool and unicode data are initialized once in bench.zig - const pool = gp.initGlobalPool(allocator); - - if (show_mem) { - try stdout.print("Memory stats enabled\n", .{}); - } - try stdout.print("\n", .{}); + _ = gp.initGlobalPool(allocator); - var results = std.ArrayList(BenchResult).init(allocator); + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(allocator); const iterations: usize = 100; @@ -200,15 +188,6 @@ pub fn run( const sizes = [_]usize{ 100, 1024, 4 * 1024, 16 * 1024, 64 * 1024 }; const text_types = [_]TextType{ .ascii, .mixed, .heavy_unicode }; - _ = pool; // unused - - try stdout.print("Testing chunk sizes: ", .{}); - for (sizes) |size| { - const kb = @as(f64, @floatFromInt(size)) / 1024.0; - try stdout.print("{d:.1}KB ", .{kb}); - } - try stdout.print("\n\n", .{}); - for (text_types) |text_type| { for (sizes) |size| { const result = try benchGetGraphemes( @@ -218,9 +197,9 @@ pub fn run( iterations, show_mem, ); - try results.append(result); + try results.append(allocator, result); } } - return try results.toOwnedSlice(); + return try results.toOwnedSlice(allocator); } diff --git a/packages/core/src/zig/bench/utf8_bench.zig b/packages/core/src/zig/bench/utf8_bench.zig index 704608a59..3e2baae67 100644 --- a/packages/core/src/zig/bench/utf8_bench.zig +++ b/packages/core/src/zig/bench/utf8_bench.zig @@ -3,120 +3,76 @@ const bench_utils = @import("../bench-utils.zig"); const utf8 = @import("../utf8.zig"); const BenchResult = bench_utils.BenchResult; +const BenchStats = bench_utils.BenchStats; pub const benchName = "UTF-8 Operations"; // Test data generators fn generateAsciiText(allocator: std.mem.Allocator, length: usize) ![]const u8 { - var text = try allocator.alloc(u8, length); - var i: usize = 0; - while (i < length) : (i += 1) { + const text = try allocator.alloc(u8, length); + for (text, 0..) |*c, i| { // Generate printable ASCII (32-126) - text[i] = @as(u8, @intCast(32 + (i % 95))); + c.* = @as(u8, @intCast(32 + (i % 95))); } return text; } fn generateMixedText(allocator: std.mem.Allocator, length: usize) ![]const u8 { - var text = std.ArrayList(u8).init(allocator); + var text: std.ArrayListUnmanaged(u8) = .{}; + errdefer text.deinit(allocator); var i: usize = 0; while (text.items.len < length) : (i += 1) { if (i % 4 == 0) { - // Unicode character (3 bytes) - try text.appendSlice("世"); + try text.appendSlice(allocator, "世"); } else if (i % 4 == 1) { - // Emoji (4 bytes) - try text.appendSlice("😀"); + try text.appendSlice(allocator, "😀"); } else { - // ASCII - try text.append(@as(u8, @intCast(32 + (i % 95)))); + try text.append(allocator, @as(u8, @intCast(32 + (i % 95)))); } } - return text.toOwnedSlice(); + return text.toOwnedSlice(allocator); } fn generateUnicodeHeavyText(allocator: std.mem.Allocator, length: usize) ![]const u8 { - var text = std.ArrayList(u8).init(allocator); + var text: std.ArrayListUnmanaged(u8) = .{}; + errdefer text.deinit(allocator); var i: usize = 0; while (text.items.len < length) : (i += 1) { if (i % 3 == 0) { - try text.appendSlice("世界"); + try text.appendSlice(allocator, "世界"); } else if (i % 3 == 1) { - try text.appendSlice("😀🎉"); + try text.appendSlice(allocator, "😀🎉"); } else { - try text.appendSlice("Ñoño"); + try text.appendSlice(allocator, "Ñoño"); } } - return text.toOwnedSlice(); -} - -fn generateTextWithLineBreaks(allocator: std.mem.Allocator, length: usize, break_kind: utf8.LineBreakKind) ![]const u8 { - var text = std.ArrayList(u8).init(allocator); - var i: usize = 0; - while (text.items.len < length) : (i += 1) { - // Add some content - var j: usize = 0; - while (j < 80 and text.items.len < length) : (j += 1) { - try text.append(@as(u8, @intCast(32 + (j % 95)))); - } - if (text.items.len >= length) break; - - // Add line break - switch (break_kind) { - .LF => try text.append('\n'), - .CR => try text.append('\r'), - .CRLF => try text.appendSlice("\r\n"), - } - } - return text.toOwnedSlice(); -} - -fn generateTextWithWrapBreaks(allocator: std.mem.Allocator, length: usize) ![]const u8 { - var text = std.ArrayList(u8).init(allocator); - const break_chars = " \t-/\\.,;:!?()[]{}"; - var i: usize = 0; - while (text.items.len < length) : (i += 1) { - if (i % 10 == 0 and i > 0) { - try text.append(break_chars[i % break_chars.len]); - } else { - try text.append(@as(u8, @intCast(97 + (i % 26)))); // a-z - } - } - return text.toOwnedSlice(); + return text.toOwnedSlice(allocator); } // Benchmark isAsciiOnly -fn benchIsAsciiOnly(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); +fn benchIsAsciiOnly(results_alloc: std.mem.Allocator, iterations: usize) ![]BenchResult { + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(results_alloc); // Small ASCII text (1KB) { - const text = try generateAsciiText(allocator, 1024); - defer allocator.free(text); + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateAsciiText(temp.allocator(), 1024); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - const result = utf8.isAsciiOnly(text); - const elapsed = timer.read(); - _ = result; - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + _ = utf8.isAsciiOnly(text); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "isAsciiOnly: ASCII text (1KB)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "isAsciiOnly: ASCII text (1KB)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -124,219 +80,214 @@ fn benchIsAsciiOnly(allocator: std.mem.Allocator, iterations: usize) ![]BenchRes // Large ASCII text (100KB) { - const text = try generateAsciiText(allocator, 100 * 1024); - defer allocator.free(text); - - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateAsciiText(temp.allocator(), 100 * 1024); - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - const result = utf8.isAsciiOnly(text); - const elapsed = timer.read(); - _ = result; - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + _ = utf8.isAsciiOnly(text); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "isAsciiOnly: ASCII text (100KB)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "isAsciiOnly: ASCII text (100KB)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - // Mixed text (1KB) + // Very large ASCII text (1MB) { - const text = try generateMixedText(allocator, 1024); - defer allocator.free(text); + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateAsciiText(temp.allocator(), 1024 * 1024); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - const result = utf8.isAsciiOnly(text); - const elapsed = timer.read(); - _ = result; + _ = utf8.isAsciiOnly(text); + stats.record(timer.read()); + } + + try results.append(results_alloc, BenchResult{ + .name = "isAsciiOnly: ASCII text (1MB)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, + .iterations = iterations, + .mem_stats = null, + }); + } - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + // Mixed text (10KB) + { + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateMixedText(temp.allocator(), 10 * 1024); + + var stats = BenchStats{}; + for (0..iterations) |_| { + var timer = try std.time.Timer.start(); + _ = utf8.isAsciiOnly(text); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "isAsciiOnly: Mixed text (1KB) - early exit", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "isAsciiOnly: Mixed text (10KB)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return results.toOwnedSlice(results_alloc); } // Benchmark findLineBreaks -fn benchFindLineBreaks(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); +fn benchFindLineBreaks(results_alloc: std.mem.Allocator, iterations: usize) ![]BenchResult { + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(results_alloc); - // LF line breaks + // Text with LF breaks { - const text = try generateTextWithLineBreaks(allocator, 10 * 1024, .LF); - defer allocator.free(text); + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const alloc = temp.allocator(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var text: std.ArrayListUnmanaged(u8) = .{}; + for (0..100) |_| { + try text.appendSlice(alloc, "This is a line of text that ends with a newline character.\n"); + } + const test_text = text.items; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var line_breaks = utf8.LineBreakResult.init(allocator); - defer line_breaks.deinit(); + var line_result = utf8.LineBreakResult.init(alloc); + defer line_result.deinit(); + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - try utf8.findLineBreaks(text, &line_breaks); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + try utf8.findLineBreaks(test_text, &line_result); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "findLineBreaks: LF (10KB, ~125 breaks)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "findLineBreaks: 100 LF lines", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - // CRLF line breaks + // Text with CRLF breaks { - const text = try generateTextWithLineBreaks(allocator, 10 * 1024, .CRLF); - defer allocator.free(text); + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const alloc = temp.allocator(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var text: std.ArrayListUnmanaged(u8) = .{}; + for (0..100) |_| { + try text.appendSlice(alloc, "This is a line of text that ends with CRLF.\r\n"); + } + const test_text = text.items; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var line_breaks = utf8.LineBreakResult.init(allocator); - defer line_breaks.deinit(); + var line_result = utf8.LineBreakResult.init(alloc); + defer line_result.deinit(); + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - try utf8.findLineBreaks(text, &line_breaks); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + try utf8.findLineBreaks(test_text, &line_result); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "findLineBreaks: CRLF (10KB, ~120 breaks)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "findLineBreaks: 100 CRLF lines", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - // No line breaks (fast path) + // Large text with many lines { - const text = try generateAsciiText(allocator, 10 * 1024); - defer allocator.free(text); + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const alloc = temp.allocator(); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var text: std.ArrayListUnmanaged(u8) = .{}; + for (0..1000) |_| { + try text.appendSlice(alloc, "Short line\n"); + } + const test_text = text.items; - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var line_breaks = utf8.LineBreakResult.init(allocator); - defer line_breaks.deinit(); + var line_result = utf8.LineBreakResult.init(alloc); + defer line_result.deinit(); + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - try utf8.findLineBreaks(text, &line_breaks); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + try utf8.findLineBreaks(test_text, &line_result); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "findLineBreaks: No breaks (10KB) - fast path", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "findLineBreaks: 1000 short lines", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return results.toOwnedSlice(results_alloc); } // Benchmark findWrapBreaks -fn benchFindWrapBreaks(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); +fn benchFindWrapBreaks(results_alloc: std.mem.Allocator, iterations: usize) ![]BenchResult { + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(results_alloc); - // ASCII with wrap breaks + // ASCII text { - const text = try generateTextWithWrapBreaks(allocator, 10 * 1024); - defer allocator.free(text); - - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const alloc = temp.allocator(); + const text = try generateAsciiText(alloc, 10 * 1024); - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var wrap_breaks = utf8.WrapBreakResult.init(allocator); - defer wrap_breaks.deinit(); + var wrap_result = utf8.WrapBreakResult.init(alloc); + defer wrap_result.deinit(); + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - try utf8.findWrapBreaks(text, &wrap_breaks, .unicode); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + try utf8.findWrapBreaks(text, &wrap_result, .unicode); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "findWrapBreaks: ASCII (10KB)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "findWrapBreaks: ASCII (10KB)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -344,111 +295,83 @@ fn benchFindWrapBreaks(allocator: std.mem.Allocator, iterations: usize) ![]Bench // Mixed text { - const text = try generateMixedText(allocator, 10 * 1024); - defer allocator.free(text); + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const alloc = temp.allocator(); + const text = try generateMixedText(alloc, 10 * 1024); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var wrap_breaks = utf8.WrapBreakResult.init(allocator); - defer wrap_breaks.deinit(); + var wrap_result = utf8.WrapBreakResult.init(alloc); + defer wrap_result.deinit(); + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - try utf8.findWrapBreaks(text, &wrap_breaks, .unicode); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + try utf8.findWrapBreaks(text, &wrap_result, .unicode); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "findWrapBreaks: Mixed ASCII/Unicode (10KB)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "findWrapBreaks: Mixed (10KB)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - // Unicode heavy - { - const text = try generateUnicodeHeavyText(allocator, 10 * 1024); - defer allocator.free(text); + return results.toOwnedSlice(results_alloc); +} - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; +// Benchmark findWrapPosByWidth +fn benchFindWrapPosByWidth(results_alloc: std.mem.Allocator, iterations: usize) ![]BenchResult { + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(results_alloc); - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { - var wrap_breaks = utf8.WrapBreakResult.init(allocator); - defer wrap_breaks.deinit(); + // ASCII text, narrow width + { + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateAsciiText(temp.allocator(), 1024); + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - try utf8.findWrapBreaks(text, &wrap_breaks, .unicode); - const elapsed = timer.read(); - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + _ = utf8.findWrapPosByWidth(text, 40, 4, true, .unicode); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "findWrapBreaks: Unicode heavy (10KB)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "findWrapPosByWidth: ASCII 1KB, width=40", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); -} - -// Benchmark findWrapPosByWidth -fn benchFindWrapPosByWidth(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); - - // ASCII text - various column limits + // ASCII text, wide width { - const text = try generateAsciiText(allocator, 1024); - defer allocator.free(text); - - const max_columns = 80; + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateAsciiText(temp.allocator(), 1024); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - const result = utf8.findWrapPosByWidth(text, max_columns, 4, true, .unicode); - const elapsed = timer.read(); - _ = result; - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + _ = utf8.findWrapPosByWidth(text, 120, 4, true, .unicode); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "findWrapPosByWidth: ASCII (1KB, max_cols=80)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "findWrapPosByWidth: ASCII 1KB, width=120", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -456,146 +379,103 @@ fn benchFindWrapPosByWidth(allocator: std.mem.Allocator, iterations: usize) ![]B // Mixed text { - const text = try generateMixedText(allocator, 1024); - defer allocator.free(text); - - const max_columns = 80; + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateMixedText(temp.allocator(), 1024); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - const result = utf8.findWrapPosByWidth(text, max_columns, 4, false, .unicode); - const elapsed = timer.read(); - _ = result; - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + _ = utf8.findWrapPosByWidth(text, 80, 4, false, .unicode); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "findWrapPosByWidth: Mixed (1KB, max_cols=80)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "findWrapPosByWidth: Mixed 1KB, width=80", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - // Unicode heavy with CJK (double-width chars) + // Unicode heavy text { - const text = try generateUnicodeHeavyText(allocator, 1024); - defer allocator.free(text); + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateUnicodeHeavyText(temp.allocator(), 1024); - const max_columns = 80; - - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - const result = utf8.findWrapPosByWidth(text, max_columns, 4, false, .unicode); - const elapsed = timer.read(); - _ = result; - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + _ = utf8.findWrapPosByWidth(text, 80, 4, false, .unicode); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "findWrapPosByWidth: CJK/Emoji (1KB, max_cols=80)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "findWrapPosByWidth: Unicode 1KB, width=80", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return results.toOwnedSlice(results_alloc); } // Benchmark findPosByWidth -fn benchFindPosByWidth(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); +fn benchFindPosByWidth(results_alloc: std.mem.Allocator, iterations: usize) ![]BenchResult { + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(results_alloc); - // ASCII - include_start_before = true + // ASCII text, find middle { - const text = try generateAsciiText(allocator, 1024); - defer allocator.free(text); - - const max_columns = 80; - - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateAsciiText(temp.allocator(), 1024); - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - const result = utf8.findPosByWidth(text, max_columns, 4, true, true, .unicode); - const elapsed = timer.read(); - _ = result; - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + _ = utf8.findPosByWidth(text, 500, 4, true, true, .unicode); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "findPosByWidth: ASCII (1KB, include_before=true)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "findPosByWidth: ASCII 1KB, target=500", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - // ASCII - include_start_before = false + // Large ASCII text, find near end { - const text = try generateAsciiText(allocator, 1024); - defer allocator.free(text); - - const max_columns = 80; - - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateAsciiText(temp.allocator(), 100 * 1024); - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - const result = utf8.findPosByWidth(text, max_columns, 4, true, false, .unicode); - const elapsed = timer.read(); - _ = result; - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + _ = utf8.findPosByWidth(text, 90000, 4, true, true, .unicode); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "findPosByWidth: ASCII (1KB, include_before=false)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "findPosByWidth: ASCII 100KB, target=90000", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -603,74 +483,55 @@ fn benchFindPosByWidth(allocator: std.mem.Allocator, iterations: usize) ![]Bench // Mixed text { - const text = try generateMixedText(allocator, 1024); - defer allocator.free(text); - - const max_columns = 80; + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateMixedText(temp.allocator(), 10 * 1024); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - const result = utf8.findPosByWidth(text, max_columns, 4, false, true, .unicode); - const elapsed = timer.read(); - _ = result; - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + _ = utf8.findPosByWidth(text, 5000, 4, false, true, .unicode); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "findPosByWidth: Mixed (1KB, max_cols=80)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "findPosByWidth: Mixed 10KB, target=5000", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return results.toOwnedSlice(results_alloc); } // Benchmark calculateTextWidth -fn benchCalculateTextWidth(allocator: std.mem.Allocator, iterations: usize) ![]BenchResult { - var results = std.ArrayList(BenchResult).init(allocator); +fn benchCalculateTextWidth(results_alloc: std.mem.Allocator, iterations: usize) ![]BenchResult { + var results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer results.deinit(results_alloc); // Small ASCII text (1KB) { - const text = try generateAsciiText(allocator, 1024); - defer allocator.free(text); - - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateAsciiText(temp.allocator(), 1024); - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - const result = utf8.calculateTextWidth(text, 4, true, .unicode); - const elapsed = timer.read(); - _ = result; - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + _ = utf8.calculateTextWidth(text, 4, true, .unicode); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "calculateTextWidth: ASCII (1KB)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "calculateTextWidth: ASCII (1KB)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -678,32 +539,23 @@ fn benchCalculateTextWidth(allocator: std.mem.Allocator, iterations: usize) ![]B // Large ASCII text (100KB) { - const text = try generateAsciiText(allocator, 100 * 1024); - defer allocator.free(text); - - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateAsciiText(temp.allocator(), 100 * 1024); - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - const result = utf8.calculateTextWidth(text, 4, true, .unicode); - const elapsed = timer.read(); - _ = result; - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + _ = utf8.calculateTextWidth(text, 4, true, .unicode); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "calculateTextWidth: ASCII (100KB)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "calculateTextWidth: ASCII (100KB)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -711,32 +563,23 @@ fn benchCalculateTextWidth(allocator: std.mem.Allocator, iterations: usize) ![]B // Very large ASCII text (1MB) { - const text = try generateAsciiText(allocator, 1024 * 1024); - defer allocator.free(text); - - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateAsciiText(temp.allocator(), 1024 * 1024); - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - const result = utf8.calculateTextWidth(text, 4, true, .unicode); - const elapsed = timer.read(); - _ = result; - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + _ = utf8.calculateTextWidth(text, 4, true, .unicode); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "calculateTextWidth: ASCII (1MB)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "calculateTextWidth: ASCII (1MB)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -744,40 +587,32 @@ fn benchCalculateTextWidth(allocator: std.mem.Allocator, iterations: usize) ![]B // ASCII with tabs { - var text = std.ArrayList(u8).init(allocator); - defer text.deinit(); - var i: usize = 0; - while (text.items.len < 10 * 1024) : (i += 1) { + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const alloc = temp.allocator(); + + var text: std.ArrayListUnmanaged(u8) = .{}; + for (0..10 * 1024) |i| { if (i % 20 == 0) { - try text.append('\t'); + try text.append(alloc, '\t'); } else { - try text.append(@as(u8, @intCast(32 + (i % 95)))); + try text.append(alloc, @as(u8, @intCast(32 + (i % 95)))); } } - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - const result = utf8.calculateTextWidth(text.items, 4, true, .unicode); - const elapsed = timer.read(); - _ = result; - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + _ = utf8.calculateTextWidth(text.items, 4, true, .unicode); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "calculateTextWidth: ASCII with tabs (10KB)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "calculateTextWidth: ASCII with tabs (10KB)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); @@ -785,71 +620,53 @@ fn benchCalculateTextWidth(allocator: std.mem.Allocator, iterations: usize) ![]B // Mixed text { - const text = try generateMixedText(allocator, 10 * 1024); - defer allocator.free(text); - - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateMixedText(temp.allocator(), 10 * 1024); - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - const result = utf8.calculateTextWidth(text, 4, false, .unicode); - const elapsed = timer.read(); - _ = result; - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + _ = utf8.calculateTextWidth(text, 4, false, .unicode); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "calculateTextWidth: Mixed ASCII/Unicode (10KB)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "calculateTextWidth: Mixed (10KB)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - // Unicode heavy + // Unicode heavy text { - const text = try generateUnicodeHeavyText(allocator, 10 * 1024); - defer allocator.free(text); + var temp = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer temp.deinit(); + const text = try generateUnicodeHeavyText(temp.allocator(), 10 * 1024); - var min_ns: u64 = std.math.maxInt(u64); - var max_ns: u64 = 0; - var total_ns: u64 = 0; - - var iter: usize = 0; - while (iter < iterations) : (iter += 1) { + var stats = BenchStats{}; + for (0..iterations) |_| { var timer = try std.time.Timer.start(); - const result = utf8.calculateTextWidth(text, 4, false, .unicode); - const elapsed = timer.read(); - _ = result; - - min_ns = @min(min_ns, elapsed); - max_ns = @max(max_ns, elapsed); - total_ns += elapsed; + _ = utf8.calculateTextWidth(text, 4, false, .unicode); + stats.record(timer.read()); } - const name = try std.fmt.allocPrint(allocator, "calculateTextWidth: Unicode heavy (10KB)", .{}); - try results.append(BenchResult{ - .name = name, - .min_ns = min_ns, - .avg_ns = total_ns / iterations, - .max_ns = max_ns, - .total_ns = total_ns, + try results.append(results_alloc, BenchResult{ + .name = "calculateTextWidth: Unicode heavy (10KB)", + .min_ns = stats.min_ns, + .avg_ns = stats.avg(), + .max_ns = stats.max_ns, + .total_ns = stats.total_ns, .iterations = iterations, .mem_stats = null, }); } - return try results.toOwnedSlice(); + return results.toOwnedSlice(results_alloc); } pub fn run( @@ -858,39 +675,34 @@ pub fn run( ) ![]BenchResult { _ = show_mem; - var all_results = std.ArrayList(BenchResult).init(allocator); + var all_results: std.ArrayListUnmanaged(BenchResult) = .{}; + errdefer all_results.deinit(allocator); const iterations: usize = 1000; // isAsciiOnly benchmarks const ascii_only_results = try benchIsAsciiOnly(allocator, iterations); - defer allocator.free(ascii_only_results); - try all_results.appendSlice(ascii_only_results); + try all_results.appendSlice(allocator, ascii_only_results); // findLineBreaks benchmarks const line_breaks_results = try benchFindLineBreaks(allocator, iterations); - defer allocator.free(line_breaks_results); - try all_results.appendSlice(line_breaks_results); + try all_results.appendSlice(allocator, line_breaks_results); // findWrapBreaks benchmarks const wrap_breaks_results = try benchFindWrapBreaks(allocator, iterations); - defer allocator.free(wrap_breaks_results); - try all_results.appendSlice(wrap_breaks_results); + try all_results.appendSlice(allocator, wrap_breaks_results); // findWrapPosByWidth benchmarks const wrap_pos_results = try benchFindWrapPosByWidth(allocator, iterations); - defer allocator.free(wrap_pos_results); - try all_results.appendSlice(wrap_pos_results); + try all_results.appendSlice(allocator, wrap_pos_results); // findPosByWidth benchmarks const pos_width_results = try benchFindPosByWidth(allocator, iterations); - defer allocator.free(pos_width_results); - try all_results.appendSlice(pos_width_results); + try all_results.appendSlice(allocator, pos_width_results); // calculateTextWidth benchmarks const text_width_results = try benchCalculateTextWidth(allocator, iterations); - defer allocator.free(text_width_results); - try all_results.appendSlice(text_width_results); + try all_results.appendSlice(allocator, text_width_results); - return try all_results.toOwnedSlice(); + return all_results.toOwnedSlice(allocator); } diff --git a/packages/core/src/zig/buffer.zig b/packages/core/src/zig/buffer.zig index b67d9bda5..3265c333e 100644 --- a/packages/core/src/zig/buffer.zig +++ b/packages/core/src/zig/buffer.zig @@ -138,8 +138,8 @@ pub const OptimizedBuffer = struct { link_tracker: link.LinkTracker, width_method: utf8.WidthMethod, id: []const u8, - scissor_stack: std.ArrayList(ClipRect), - opacity_stack: std.ArrayList(f32), + scissor_stack: std.ArrayListUnmanaged(ClipRect), + opacity_stack: std.ArrayListUnmanaged(f32), const InitOptions = struct { respectAlpha: bool = false, @@ -163,11 +163,11 @@ pub const OptimizedBuffer = struct { const owned_id = allocator.dupe(u8, options.id) catch return BufferError.OutOfMemory; errdefer allocator.free(owned_id); - var scissor_stack = std.ArrayList(ClipRect).init(allocator); - errdefer scissor_stack.deinit(); + var scissor_stack: std.ArrayListUnmanaged(ClipRect) = .{}; + errdefer scissor_stack.deinit(allocator); - var opacity_stack = std.ArrayList(f32).init(allocator); - errdefer opacity_stack.deinit(); + var opacity_stack: std.ArrayListUnmanaged(f32) = .{}; + errdefer opacity_stack.deinit(allocator); const lp = options.link_pool orelse link.initGlobalLinkPool(allocator); @@ -217,8 +217,8 @@ pub const OptimizedBuffer = struct { } pub fn deinit(self: *OptimizedBuffer) void { - self.opacity_stack.deinit(); - self.scissor_stack.deinit(); + self.opacity_stack.deinit(self.allocator); + self.scissor_stack.deinit(self.allocator); self.link_tracker.deinit(); self.grapheme_tracker.deinit(); self.allocator.free(self.buffer.char); @@ -301,7 +301,7 @@ pub const OptimizedBuffer = struct { } } - try self.scissor_stack.append(rect); + try self.scissor_stack.append(self.allocator, rect); } pub fn popScissorRect(self: *OptimizedBuffer) void { @@ -324,7 +324,7 @@ pub const OptimizedBuffer = struct { pub fn pushOpacity(self: *OptimizedBuffer, opacity: f32) !void { const current = self.getCurrentOpacity(); const effective = current * std.math.clamp(opacity, 0.0, 1.0); - try self.opacity_stack.append(effective); + try self.opacity_stack.append(self.allocator, effective); } /// Pop an opacity value from the stack @@ -841,11 +841,11 @@ pub const OptimizedBuffer = struct { const is_ascii_only = utf8.isAsciiOnly(text); - var grapheme_list = std.ArrayList(utf8.GraphemeInfo).init(self.allocator); - defer grapheme_list.deinit(); + var grapheme_list: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer grapheme_list.deinit(self.allocator); const tab_width: u8 = 2; - try utf8.findGraphemeInfo(text, tab_width, is_ascii_only, self.width_method, &grapheme_list); + try utf8.findGraphemeInfo(text, tab_width, is_ascii_only, self.width_method, self.allocator, &grapheme_list); const specials = grapheme_list.items; var advance_cells: u32 = 0; diff --git a/packages/core/src/zig/build.zig b/packages/core/src/zig/build.zig index 68ad4b839..5bd4ee709 100644 --- a/packages/core/src/zig/build.zig +++ b/packages/core/src/zig/build.zig @@ -8,11 +8,29 @@ const SupportedZigVersion = struct { }; const SUPPORTED_ZIG_VERSIONS = [_]SupportedZigVersion{ - .{ .major = 0, .minor = 14, .patch = 0 }, - .{ .major = 0, .minor = 14, .patch = 1 }, - // .{ .major = 0, .minor = 15, .patch = 0 }, + .{ .major = 0, .minor = 15, .patch = 2 }, }; +const SupportedTarget = struct { + zig_target: []const u8, + output_name: []const u8, + description: []const u8, +}; + +const SUPPORTED_TARGETS = [_]SupportedTarget{ + .{ .zig_target = "x86_64-linux-gnu", .output_name = "x86_64-linux", .description = "Linux x86_64 (glibc)" }, + .{ .zig_target = "aarch64-linux-gnu", .output_name = "aarch64-linux", .description = "Linux aarch64 (glibc)" }, + .{ .zig_target = "x86_64-linux-musl", .output_name = "x86_64-linux-musl", .description = "Linux x86_64 (musl/Alpine)" }, + .{ .zig_target = "aarch64-linux-musl", .output_name = "aarch64-linux-musl", .description = "Linux aarch64 (musl/Alpine)" }, + .{ .zig_target = "x86_64-macos", .output_name = "x86_64-macos", .description = "macOS x86_64 (Intel)" }, + .{ .zig_target = "aarch64-macos", .output_name = "aarch64-macos", .description = "macOS aarch64 (Apple Silicon)" }, + .{ .zig_target = "x86_64-windows-gnu", .output_name = "x86_64-windows", .description = "Windows x86_64" }, + .{ .zig_target = "aarch64-windows-gnu", .output_name = "aarch64-windows", .description = "Windows aarch64" }, +}; + +const LIB_NAME = "opentui"; +const ROOT_SOURCE_FILE = "lib.zig"; + /// Apply dependencies to a module fn applyDependencies(b: *std.Build, module: *std.Build.Module, optimize: std.builtin.OptimizeMode, target: std.Build.ResolvedTarget) void { // Add uucode for grapheme break detection @@ -25,25 +43,12 @@ fn applyDependencies(b: *std.Build, module: *std.Build.Module, optimize: std.bui })) |uucode_dep| { module.addImport("uucode", uucode_dep.module("uucode")); } -} - -const SupportedTarget = struct { - cpu_arch: std.Target.Cpu.Arch, - os_tag: std.Target.Os.Tag, - description: []const u8, -}; -const SUPPORTED_TARGETS = [_]SupportedTarget{ - .{ .cpu_arch = .x86_64, .os_tag = .linux, .description = "Linux x86_64" }, - .{ .cpu_arch = .x86_64, .os_tag = .macos, .description = "macOS x86_64 (Intel)" }, - .{ .cpu_arch = .aarch64, .os_tag = .macos, .description = "macOS aarch64 (Apple Silicon)" }, - .{ .cpu_arch = .x86_64, .os_tag = .windows, .description = "Windows x86_64" }, - .{ .cpu_arch = .aarch64, .os_tag = .windows, .description = "Windows aarch64" }, - .{ .cpu_arch = .aarch64, .os_tag = .linux, .description = "Linux aarch64" }, -}; - -const LIB_NAME = "opentui"; -const ROOT_SOURCE_FILE = "lib.zig"; + // Add ghostty for terminal emulation + if (b.lazyDependency("ghostty", .{ .target = target, .optimize = optimize })) |ghostty_dep| { + module.addImport("ghostty-vt", ghostty_dep.module("ghostty-vt")); + } +} fn checkZigVersion() void { const current_version = builtin.zig_version; @@ -81,145 +86,150 @@ fn checkZigVersion() void { pub fn build(b: *std.Build) void { checkZigVersion(); - const optimize = b.option(std.builtin.OptimizeMode, "optimize", "Optimization level (Debug, ReleaseFast, ReleaseSafe, ReleaseSmall)") orelse .Debug; - const target_option = b.option([]const u8, "target", "Build for specific target (e.g., 'x86_64-linux'). If not specified, builds for all supported targets."); + const optimize = b.standardOptimizeOption(.{}); + const target_option = b.option([]const u8, "target", "Build for specific target (e.g., 'x86_64-linux-gnu')."); + const build_all = b.option(bool, "all", "Build for all supported targets") orelse false; if (target_option) |target_str| { + // Build single target buildSingleTarget(b, target_str, optimize) catch |err| { std.debug.print("Error building target '{s}': {}\n", .{ target_str, err }); std.process.exit(1); }; - } else { + } else if (build_all) { + // Build all supported targets buildAllTargets(b, optimize); + } else { + // Build for native target only (default) + buildNativeTarget(b, optimize); } - // Add test step - const test_step = b.step("test", "Run all tests"); - const test_target_query = std.Target.Query{ - .cpu_arch = builtin.cpu.arch, - .os_tag = builtin.os.tag, - }; - const test_target = b.resolveTargetQuery(test_target_query); - - // Run tests using the test index file - const test_exe = b.addTest(.{ + // Test step (native only) + const test_step = b.step("test", "Run unit tests"); + const native_target = b.resolveTargetQuery(.{}); + const test_mod = b.createModule(.{ .root_source_file = b.path("test.zig"), - .target = test_target, - .filter = b.option([]const u8, "test-filter", "Skip tests that do not match filter"), + .target = native_target, + .optimize = .Debug, }); - - applyDependencies(b, test_exe.root_module, .Debug, test_target); - - const run_test = b.addRunArtifact(test_exe); + applyDependencies(b, test_mod, .Debug, native_target); + const run_test = b.addRunArtifact(b.addTest(.{ + .root_module = test_mod, + .filters = if (b.option([]const u8, "test-filter", "Skip tests that do not match filter")) |f| &.{f} else &.{}, + })); test_step.dependOn(&run_test.step); - // Add bench step + // Bench step (native only) const bench_step = b.step("bench", "Run benchmarks"); - const bench_target_query = std.Target.Query{ - .cpu_arch = builtin.cpu.arch, - .os_tag = builtin.os.tag, - }; - const bench_target = b.resolveTargetQuery(bench_target_query); - - const bench_exe = b.addExecutable(.{ - .name = "opentui-bench", + const bench_mod = b.createModule(.{ .root_source_file = b.path("bench.zig"), - .target = bench_target, + .target = native_target, .optimize = optimize, }); - - applyDependencies(b, bench_exe.root_module, optimize, bench_target); - + applyDependencies(b, bench_mod, optimize, native_target); + const bench_exe = b.addExecutable(.{ + .name = "opentui-bench", + .root_module = bench_mod, + }); const run_bench = b.addRunArtifact(bench_exe); if (b.args) |args| { run_bench.addArgs(args); } bench_step.dependOn(&run_bench.step); - // Add debug step for standalone debugging + // Debug step (native only) const debug_step = b.step("debug", "Run debug executable"); - const debug_exe = b.addExecutable(.{ - .name = "opentui-debug", + const debug_mod = b.createModule(.{ .root_source_file = b.path("debug-view.zig"), - .target = test_target, + .target = native_target, .optimize = .Debug, }); - - applyDependencies(b, debug_exe.root_module, .Debug, test_target); - + applyDependencies(b, debug_mod, .Debug, native_target); + const debug_exe = b.addExecutable(.{ + .name = "opentui-debug", + .root_module = debug_mod, + }); const run_debug = b.addRunArtifact(debug_exe); debug_step.dependOn(&run_debug.step); } fn buildAllTargets(b: *std.Build, optimize: std.builtin.OptimizeMode) void { for (SUPPORTED_TARGETS) |supported_target| { - const target_query = std.Target.Query{ - .cpu_arch = supported_target.cpu_arch, - .os_tag = supported_target.os_tag, - }; - - buildTargetFromQuery(b, target_query, supported_target.description, optimize) catch |err| { + buildTarget(b, supported_target.zig_target, supported_target.output_name, supported_target.description, optimize) catch |err| { std.debug.print("Failed to build target {s}: {}\n", .{ supported_target.description, err }); continue; }; } } +fn buildNativeTarget(b: *std.Build, optimize: std.builtin.OptimizeMode) void { + // Find the matching supported target for the native platform + const native_arch = @tagName(builtin.cpu.arch); + const native_os = @tagName(builtin.os.tag); + + for (SUPPORTED_TARGETS) |supported_target| { + // Check if this target matches the native platform + if (std.mem.indexOf(u8, supported_target.zig_target, native_arch) != null and + std.mem.indexOf(u8, supported_target.zig_target, native_os) != null) + { + buildTarget(b, supported_target.zig_target, supported_target.output_name, supported_target.description, optimize) catch |err| { + std.debug.print("Failed to build native target {s}: {}\n", .{ supported_target.description, err }); + }; + return; + } + } + + std.debug.print("No matching supported target for native platform ({s}-{s})\n", .{ native_arch, native_os }); +} + fn buildSingleTarget(b: *std.Build, target_str: []const u8, optimize: std.builtin.OptimizeMode) !void { - const target_query = try std.Target.Query.parse(.{ .arch_os_abi = target_str }); + // Check if it matches a known target, use its output_name + for (SUPPORTED_TARGETS) |supported_target| { + if (std.mem.eql(u8, target_str, supported_target.zig_target)) { + try buildTarget(b, supported_target.zig_target, supported_target.output_name, supported_target.description, optimize); + return; + } + } + // Custom target - use target string as output name const description = try std.fmt.allocPrint(b.allocator, "Custom target: {s}", .{target_str}); - try buildTargetFromQuery(b, target_query, description, optimize); + try buildTarget(b, target_str, target_str, description, optimize); } -fn buildTargetFromQuery( +fn buildTarget( b: *std.Build, - target_query: std.Target.Query, + zig_target: []const u8, + output_name: []const u8, description: []const u8, optimize: std.builtin.OptimizeMode, ) !void { + const target_query = try std.Target.Query.parse(.{ .arch_os_abi = zig_target }); const target = b.resolveTargetQuery(target_query); - var target_output: *std.Build.Step.Compile = undefined; - const module = b.addModule(LIB_NAME, .{ + const module = b.createModule(.{ .root_source_file = b.path(ROOT_SOURCE_FILE), .target = target, .optimize = optimize, - .link_libc = false, }); applyDependencies(b, module, optimize, target); - target_output = b.addLibrary(.{ + const lib = b.addLibrary(.{ .name = LIB_NAME, .root_module = module, .linkage = .dynamic, }); - const target_name = try createTargetName(b.allocator, target.result); - defer b.allocator.free(target_name); - - const install_dir = b.addInstallArtifact(target_output, .{ + const install_dir = b.addInstallArtifact(lib, .{ .dest_dir = .{ .override = .{ - .custom = try std.fmt.allocPrint(b.allocator, "../lib/{s}", .{target_name}), + .custom = try std.fmt.allocPrint(b.allocator, "../lib/{s}", .{output_name}), }, }, }); - const build_step_name = try std.fmt.allocPrint(b.allocator, "build-{s}", .{target_name}); + const build_step_name = try std.fmt.allocPrint(b.allocator, "build-{s}", .{output_name}); const build_step = b.step(build_step_name, try std.fmt.allocPrint(b.allocator, "Build for {s}", .{description})); build_step.dependOn(&install_dir.step); b.getInstallStep().dependOn(&install_dir.step); } - -fn createTargetName(allocator: std.mem.Allocator, target: std.Target) ![]u8 { - return std.fmt.allocPrint( - allocator, - "{s}-{s}", - .{ - @tagName(target.cpu.arch), - @tagName(target.os.tag), - }, - ); -} diff --git a/packages/core/src/zig/build.zig.zon b/packages/core/src/zig/build.zig.zon index 0f8b4bc91..e62e75ea8 100644 --- a/packages/core/src/zig/build.zig.zon +++ b/packages/core/src/zig/build.zig.zon @@ -2,11 +2,16 @@ .name = .opentui, .version = "0.1.11", .fingerprint = 0x5445027d063f5083, - .minimum_zig_version = "0.14.1", + .minimum_zig_version = "0.15.2", .dependencies = .{ .uucode = .{ - .url = "https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.1.0-zig-0.14.tar.gz", - .hash = "uucode-0.1.0-ZZjBPpAFQABNCvd9cVPBg4I7233Ays-NWfWphPNqGbyE", + .url = "https://github.com/jacobsandlund/uucode/archive/84ceda8561a17ba4a9b96ac5c583f779660bbd4e.tar.gz", + .hash = "uucode-0.1.0-ZZjBPtA_TQCWp5PIKmfm5tu1WOkKWFmBGFEMxircPfkA", + }, + // TODO: switch back to ghostty-org/ghostty when merged: https://github.com/ghostty-org/ghostty/pull/10198 + .ghostty = .{ + .url = "git+https://github.com/remorses/ghostty.git#a87633a5cc619a21672e4e9cf449c7f1c29ef966", + .hash = "ghostty-1.3.0-dev-5UdBC1sKRAQDnZ_wL5BuiJYvO-ZaJ3vxwh4IPV1gCfCE", }, }, .paths = .{ diff --git a/packages/core/src/zig/edit-buffer.zig b/packages/core/src/zig/edit-buffer.zig index 276ad0699..e016cfb35 100644 --- a/packages/core/src/zig/edit-buffer.zig +++ b/packages/core/src/zig/edit-buffer.zig @@ -270,7 +270,7 @@ pub const EditBuffer = struct { const base_start = chunk_ref.start; var result = try self.tb.textToSegments(self.allocator, bytes, base_mem_id, base_start, false); - defer result.segments.deinit(); + defer result.segments.deinit(result.allocator); const inserted_width = result.total_width; diff --git a/packages/core/src/zig/event-bus.zig b/packages/core/src/zig/event-bus.zig index 873c8dcc4..f1effbd34 100644 --- a/packages/core/src/zig/event-bus.zig +++ b/packages/core/src/zig/event-bus.zig @@ -1,8 +1,8 @@ const std = @import("std"); -var global_event_callback: ?*const fn (namePtr: [*]const u8, nameLen: usize, dataPtr: [*]const u8, dataLen: usize) callconv(.C) void = null; +var global_event_callback: ?*const fn (namePtr: [*]const u8, nameLen: usize, dataPtr: [*]const u8, dataLen: usize) callconv(.c) void = null; -pub fn setEventCallback(callback: ?*const fn (namePtr: [*]const u8, nameLen: usize, dataPtr: [*]const u8, dataLen: usize) callconv(.C) void) void { +pub fn setEventCallback(callback: ?*const fn (namePtr: [*]const u8, nameLen: usize, dataPtr: [*]const u8, dataLen: usize) callconv(.c) void) void { global_event_callback = callback; } diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index 217c7b394..292a83153 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -1,6 +1,34 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +// Suppress ghostty-vt logs. Zig's std.log calls `@import("root").std_options.logFn`, +// so defining this in the root file (lib.zig) overrides logging for all modules. +pub const std_options: std.Options = .{ + .logFn = struct { + pub fn logFn( + comptime level: std.log.Level, + comptime scope: @Type(.enum_literal), + comptime format: []const u8, + args: anytype, + ) void { + // Suppress ghostty-vt related scopes + const scope_name = @tagName(scope); + const suppressed = std.mem.eql(u8, scope_name, "osc") or + std.mem.eql(u8, scope_name, "terminal") or + std.mem.eql(u8, scope_name, "stream") or + std.mem.eql(u8, scope_name, "page") or + std.mem.eql(u8, scope_name, "sgr") or + std.mem.eql(u8, scope_name, "kitty") or + std.mem.eql(u8, scope_name, "csi") or + std.mem.eql(u8, scope_name, "modes"); + if (suppressed) return; + + // Use default logging for other scopes (opentui's own logs) + std.log.defaultLog(level, scope, format, args); + } + }.logFn, +}; + const ansi = @import("ansi.zig"); const buffer = @import("buffer.zig"); const renderer = @import("renderer.zig"); @@ -16,21 +44,25 @@ const utf8 = @import("utf8.zig"); const logger = @import("logger.zig"); const event_bus = @import("event-bus.zig"); const utils = @import("utils.zig"); +const ghostty = @import("ghostty-vt"); +const vterm = @import("vterm.zig"); pub const OptimizedBuffer = buffer.OptimizedBuffer; pub const CliRenderer = renderer.CliRenderer; pub const Terminal = terminal.Terminal; pub const RGBA = buffer.RGBA; -export fn setLogCallback(callback: ?*const fn (level: u8, msgPtr: [*]const u8, msgLen: usize) callconv(.C) void) void { +export fn setLogCallback(callback: ?*const fn (level: u8, msgPtr: [*]const u8, msgLen: usize) callconv(.c) void) void { logger.setLogCallback(callback); } -export fn setEventCallback(callback: ?*const fn (namePtr: [*]const u8, nameLen: usize, dataPtr: [*]const u8, dataLen: usize) callconv(.C) void) void { +export fn setEventCallback(callback: ?*const fn (namePtr: [*]const u8, nameLen: usize, dataPtr: [*]const u8, dataLen: usize) callconv(.c) void) void { event_bus.setEventCallback(callback); } -var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); +var gpa = std.heap.GeneralPurposeAllocator(.{}){}; +const globalAllocator = gpa.allocator(); +var arena = std.heap.ArenaAllocator.init(globalAllocator); const globalArena = arena.allocator(); export fn getArenaAllocatedBytes() usize { @@ -45,7 +77,7 @@ export fn createRenderer(width: u32, height: u32, testing: bool) ?*renderer.CliR const pool = gp.initGlobalPool(globalArena); _ = link.initGlobalLinkPool(globalArena); - return renderer.CliRenderer.create(std.heap.page_allocator, width, height, pool, testing) catch |err| { + return renderer.CliRenderer.create(globalAllocator, width, height, pool, testing) catch |err| { logger.err("Failed to create renderer: {}", .{err}); return null; }; @@ -83,6 +115,25 @@ export fn getCurrentBuffer(rendererPtr: *renderer.CliRenderer) *buffer.Optimized return rendererPtr.getCurrentBuffer(); } +const OutputSlice = extern struct { + ptr: [*]const u8, + len: usize, +}; + +export fn getLastOutputForTest(rendererPtr: *renderer.CliRenderer, outSlice: *OutputSlice) void { + const output = rendererPtr.getLastOutputForTest(); + outSlice.ptr = output.ptr; + outSlice.len = output.len; +} + +export fn setHyperlinksCapability(rendererPtr: *renderer.CliRenderer, enabled: bool) void { + rendererPtr.terminal.caps.hyperlinks = enabled; +} + +export fn clearGlobalLinkPool() void { + link.deinitGlobalLinkPool(); +} + export fn getBufferWidth(bufferPtr: *buffer.OptimizedBuffer) u32 { return bufferPtr.width; } @@ -106,7 +157,7 @@ export fn createOptimizedBuffer(width: u32, height: u32, respectAlpha: bool, wid const wMethod: utf8.WidthMethod = if (widthMethod == 0) .wcwidth else .unicode; const id = idPtr[0..idLen]; - return buffer.OptimizedBuffer.init(std.heap.page_allocator, width, height, .{ + return buffer.OptimizedBuffer.init(globalAllocator, width, height, .{ .respectAlpha = respectAlpha, .pool = pool, .width_method = wMethod, @@ -254,9 +305,9 @@ export fn clearTerminal(rendererPtr: *renderer.CliRenderer) void { export fn setTerminalTitle(rendererPtr: *renderer.CliRenderer, titlePtr: [*]const u8, titleLen: usize) void { const title = titlePtr[0..titleLen]; - var bufferedWriter = &rendererPtr.stdoutWriter; - const writer = bufferedWriter.writer(); - rendererPtr.terminal.setTerminalTitle(writer.any(), title); + var stdoutWriter = std.fs.File.stdout().writer(&rendererPtr.stdoutBuffer); + const writer = &stdoutWriter.interface; + rendererPtr.terminal.setTerminalTitle(writer, title); } // Buffer functions @@ -504,7 +555,7 @@ export fn createTextBuffer(widthMethod: u8) ?*text_buffer.UnifiedTextBuffer { const pool = gp.initGlobalPool(globalArena); const wMethod: utf8.WidthMethod = if (widthMethod == 0) .wcwidth else .unicode; - const tb = text_buffer.UnifiedTextBuffer.init(std.heap.page_allocator, pool, wMethod) catch { + const tb = text_buffer.UnifiedTextBuffer.init(globalAllocator, pool, wMethod) catch { return null; }; @@ -614,7 +665,7 @@ export fn textBufferGetPlainText(tb: *text_buffer.UnifiedTextBuffer, outPtr: [*] // TextBufferView functions (Array-based for backward compatibility) export fn createTextBufferView(tb: *text_buffer.UnifiedTextBuffer) ?*text_buffer_view.UnifiedTextBufferView { - const view = text_buffer_view.UnifiedTextBufferView.init(std.heap.page_allocator, tb) catch { + const view = text_buffer_view.UnifiedTextBufferView.init(globalAllocator, tb) catch { return null; }; return view; @@ -762,7 +813,7 @@ export fn createEditBuffer(widthMethod: u8) ?*edit_buffer_mod.EditBuffer { const wMethod: utf8.WidthMethod = if (widthMethod == 0) .wcwidth else .unicode; return edit_buffer_mod.EditBuffer.init( - std.heap.page_allocator, + globalAllocator, pool, wMethod, ) catch null; @@ -1322,8 +1373,7 @@ export fn textBufferGetLineHighlightsPtr( return null; } - const alloc = std.heap.page_allocator; - var slice = alloc.alloc(ExternalHighlight, highs.len) catch return null; + var slice = globalAllocator.alloc(ExternalHighlight, highs.len) catch return null; for (highs, 0..) |hl, i| { slice[i] = .{ @@ -1340,8 +1390,7 @@ export fn textBufferGetLineHighlightsPtr( } export fn textBufferFreeLineHighlights(ptr: [*]const ExternalHighlight, count: usize) void { - const alloc = std.heap.page_allocator; - alloc.free(@constCast(ptr)[0..count]); + globalAllocator.free(@constCast(ptr)[0..count]); } export fn textBufferGetHighlightCount(tb: *text_buffer.UnifiedTextBuffer) u32 { @@ -1360,7 +1409,7 @@ export fn textBufferGetTextRangeByCoords(tb: *text_buffer.UnifiedTextBuffer, sta // SyntaxStyle functions export fn createSyntaxStyle() ?*syntax_style.SyntaxStyle { - return syntax_style.SyntaxStyle.init(std.heap.page_allocator) catch |err| { + return syntax_style.SyntaxStyle.init(globalAllocator) catch |err| { logger.err("Failed to create SyntaxStyle: {}", .{err}); return null; }; @@ -1408,16 +1457,16 @@ export fn encodeUnicode( const is_ascii_only = utf8.isAsciiOnly(text); // Find grapheme info - var grapheme_list = std.ArrayList(utf8.GraphemeInfo).init(std.heap.page_allocator); - defer grapheme_list.deinit(); + var grapheme_list: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer grapheme_list.deinit(globalAllocator); const tab_width: u8 = 2; - utf8.findGraphemeInfo(text, tab_width, is_ascii_only, wMethod, &grapheme_list) catch return false; + utf8.findGraphemeInfo(text, tab_width, is_ascii_only, wMethod, globalAllocator, &grapheme_list) catch return false; const specials = grapheme_list.items; // Allocate output array const estimated_count = if (is_ascii_only) text.len else text.len * 2; - var result = std.heap.page_allocator.alloc(EncodedChar, estimated_count) catch return false; + var result = globalAllocator.alloc(EncodedChar, estimated_count) catch return false; var result_idx: usize = 0; var success = false; var pending_gid: ?u32 = null; // Track grapheme allocated but not yet stored in result @@ -1440,7 +1489,7 @@ export fn encodeUnicode( pool.decref(gid) catch {}; } } - std.heap.page_allocator.free(result); + globalAllocator.free(result); } } @@ -1494,7 +1543,7 @@ export fn encodeUnicode( // Ensure we have space if (result_idx >= result.len) { const new_len = result.len * 2; - result = std.heap.page_allocator.realloc(result, new_len) catch return false; + result = globalAllocator.realloc(result, new_len) catch return false; } result[result_idx] = EncodedChar{ @@ -1507,7 +1556,7 @@ export fn encodeUnicode( } // Trim to actual size - result = std.heap.page_allocator.realloc(result, result_idx) catch result; + result = globalAllocator.realloc(result, result_idx) catch result; outPtr.* = result.ptr; outLenPtr.* = result_idx; @@ -1530,7 +1579,7 @@ export fn freeUnicode(charsPtr: [*]const EncodedChar, charsLen: usize) void { } // Free the array itself - std.heap.page_allocator.free(chars); + globalAllocator.free(chars); } export fn bufferDrawChar( @@ -1546,3 +1595,72 @@ export fn bufferDrawChar( const rgbaBg = utils.f32PtrToRGBA(bg); bufferPtr.drawChar(char, x, y, rgbaFg, rgbaBg, attributes) catch {}; } + +// ============================================================================= +// VTerm FFI Export Functions +// ============================================================================= + +// NOTE: vterm.zig has its own arena allocator, separate from globalArena. +// This is critical because globalArena is shared with text buffers, editor views, etc. +// VTerm functions use caller-provides-buffer pattern (outPtr, maxLen) like rest of codebase. +// No memory management needed - JS owns the buffer. + +export fn vtermPtyToJson( + input_ptr: [*]const u8, + input_len: usize, + cols: u16, + rows: u16, + offset: usize, + limit: usize, + out_ptr: [*]u8, + max_len: usize, +) usize { + return vterm.ptyToJson(input_ptr, input_len, cols, rows, offset, limit, out_ptr, max_len); +} + +export fn vtermPtyToText( + input_ptr: [*]const u8, + input_len: usize, + cols: u16, + rows: u16, + out_ptr: [*]u8, + max_len: usize, +) usize { + return vterm.ptyToText(input_ptr, input_len, cols, rows, out_ptr, max_len); +} + +export fn vtermCreateTerminal(id: u32, cols: u32, rows: u32) bool { + return vterm.createTerminal(id, cols, rows); +} + +export fn vtermDestroyTerminal(id: u32) void { + vterm.destroyTerminal(id); +} + +export fn vtermFeedTerminal(id: u32, data_ptr: [*]const u8, data_len: usize) bool { + return vterm.feedTerminal(id, data_ptr, data_len); +} + +export fn vtermResizeTerminal(id: u32, cols: u32, rows: u32) bool { + return vterm.resizeTerminal(id, cols, rows); +} + +export fn vtermResetTerminal(id: u32) bool { + return vterm.resetTerminal(id); +} + +export fn vtermGetTerminalJson(id: u32, offset: u32, limit: u32, out_ptr: [*]u8, max_len: usize) usize { + return vterm.getTerminalJson(id, offset, limit, out_ptr, max_len); +} + +export fn vtermGetTerminalText(id: u32, out_ptr: [*]u8, max_len: usize) usize { + return vterm.getTerminalText(id, out_ptr, max_len); +} + +export fn vtermGetTerminalCursor(id: u32, out_ptr: [*]u8, max_len: usize) usize { + return vterm.getTerminalCursor(id, out_ptr, max_len); +} + +export fn vtermIsTerminalReady(id: u32) i32 { + return vterm.isTerminalReady(id); +} diff --git a/packages/core/src/zig/logger.zig b/packages/core/src/zig/logger.zig index faaf19795..b9b72c07e 100644 --- a/packages/core/src/zig/logger.zig +++ b/packages/core/src/zig/logger.zig @@ -7,9 +7,9 @@ pub const LogLevel = enum(u8) { debug = 3, }; -var global_log_callback: ?*const fn (level: u8, msgPtr: [*]const u8, msgLen: usize) callconv(.C) void = null; +var global_log_callback: ?*const fn (level: u8, msgPtr: [*]const u8, msgLen: usize) callconv(.c) void = null; -pub fn setLogCallback(callback: ?*const fn (level: u8, msgPtr: [*]const u8, msgLen: usize) callconv(.C) void) void { +pub fn setLogCallback(callback: ?*const fn (level: u8, msgPtr: [*]const u8, msgLen: usize) callconv(.c) void) void { global_log_callback = callback; } diff --git a/packages/core/src/zig/renderer.zig b/packages/core/src/zig/renderer.zig index 436835f19..c9adcae0c 100644 --- a/packages/core/src/zig/renderer.zig +++ b/packages/core/src/zig/renderer.zig @@ -69,18 +69,18 @@ pub const CliRenderer = struct { frameCallbackTime: ?f64, }, statSamples: struct { - lastFrameTime: std.ArrayList(f64), - renderTime: std.ArrayList(f64), - overallFrameTime: std.ArrayList(f64), - bufferResetTime: std.ArrayList(f64), - stdoutWriteTime: std.ArrayList(f64), - cellsUpdated: std.ArrayList(u32), - frameCallbackTime: std.ArrayList(f64), + lastFrameTime: std.ArrayListUnmanaged(f64), + renderTime: std.ArrayListUnmanaged(f64), + overallFrameTime: std.ArrayListUnmanaged(f64), + bufferResetTime: std.ArrayListUnmanaged(f64), + stdoutWriteTime: std.ArrayListUnmanaged(f64), + cellsUpdated: std.ArrayListUnmanaged(u32), + frameCallbackTime: std.ArrayListUnmanaged(f64), }, lastRenderTime: i64, allocator: Allocator, renderThread: ?std.Thread = null, - stdoutWriter: std.io.BufferedWriter(4096, std.fs.File.Writer), + stdoutBuffer: [4096]u8, debugOverlay: struct { enabled: bool, corner: DebugOverlayCorner, @@ -130,7 +130,9 @@ pub const CliRenderer = struct { return data.len; } - pub fn writer() std.io.Writer(void, error{BufferFull}, write) { + // TODO: std.io.GenericWriter is deprecated, however the "correct" option seems to be much more involved + // So I have simply used GenericWriter here, and then the proper migration can be done later + pub fn writer() std.io.GenericWriter(void, error{BufferFull}, write) { return .{ .context = {} }; } }; @@ -141,35 +143,22 @@ pub const CliRenderer = struct { const currentBuffer = try OptimizedBuffer.init(allocator, width, height, .{ .pool = pool, .width_method = .unicode, .id = "current buffer" }); const nextBuffer = try OptimizedBuffer.init(allocator, width, height, .{ .pool = pool, .width_method = .unicode, .id = "next buffer" }); - const stdoutWriter = if (testing) blk: { - // In testing mode, use /dev/null to discard output - const devnull = std.fs.openFileAbsolute("/dev/null", .{ .mode = .write_only }) catch { - // Fallback to stdout if /dev/null can't be opened - logger.warn("Failed to open /dev/null, falling back to stdout\n", .{}); - break :blk std.io.BufferedWriter(4096, std.fs.File.Writer){ .unbuffered_writer = std.io.getStdOut().writer() }; - }; - break :blk std.io.BufferedWriter(4096, std.fs.File.Writer){ .unbuffered_writer = devnull.writer() }; - } else blk: { - const stdout = std.io.getStdOut(); - break :blk std.io.BufferedWriter(4096, std.fs.File.Writer){ .unbuffered_writer = stdout.writer() }; - }; - // stat sample arrays - var lastFrameTime = std.ArrayList(f64).init(allocator); - var renderTime = std.ArrayList(f64).init(allocator); - var overallFrameTime = std.ArrayList(f64).init(allocator); - var bufferResetTime = std.ArrayList(f64).init(allocator); - var stdoutWriteTime = std.ArrayList(f64).init(allocator); - var cellsUpdated = std.ArrayList(u32).init(allocator); - var frameCallbackTimes = std.ArrayList(f64).init(allocator); - - try lastFrameTime.ensureTotalCapacity(STAT_SAMPLE_CAPACITY); - try renderTime.ensureTotalCapacity(STAT_SAMPLE_CAPACITY); - try overallFrameTime.ensureTotalCapacity(STAT_SAMPLE_CAPACITY); - try bufferResetTime.ensureTotalCapacity(STAT_SAMPLE_CAPACITY); - try stdoutWriteTime.ensureTotalCapacity(STAT_SAMPLE_CAPACITY); - try cellsUpdated.ensureTotalCapacity(STAT_SAMPLE_CAPACITY); - try frameCallbackTimes.ensureTotalCapacity(STAT_SAMPLE_CAPACITY); + var lastFrameTime: std.ArrayListUnmanaged(f64) = .{}; + var renderTime: std.ArrayListUnmanaged(f64) = .{}; + var overallFrameTime: std.ArrayListUnmanaged(f64) = .{}; + var bufferResetTime: std.ArrayListUnmanaged(f64) = .{}; + var stdoutWriteTime: std.ArrayListUnmanaged(f64) = .{}; + var cellsUpdated: std.ArrayListUnmanaged(u32) = .{}; + var frameCallbackTimes: std.ArrayListUnmanaged(f64) = .{}; + + try lastFrameTime.ensureTotalCapacity(allocator, STAT_SAMPLE_CAPACITY); + try renderTime.ensureTotalCapacity(allocator, STAT_SAMPLE_CAPACITY); + try overallFrameTime.ensureTotalCapacity(allocator, STAT_SAMPLE_CAPACITY); + try bufferResetTime.ensureTotalCapacity(allocator, STAT_SAMPLE_CAPACITY); + try stdoutWriteTime.ensureTotalCapacity(allocator, STAT_SAMPLE_CAPACITY); + try cellsUpdated.ensureTotalCapacity(allocator, STAT_SAMPLE_CAPACITY); + try frameCallbackTimes.ensureTotalCapacity(allocator, STAT_SAMPLE_CAPACITY); const hitGridSize = width * height; const currentHitGrid = try allocator.alloc(u32, hitGridSize); @@ -217,7 +206,7 @@ pub const CliRenderer = struct { }, .lastRenderTime = std.time.microTimestamp(), .allocator = allocator, - .stdoutWriter = stdoutWriter, + .stdoutBuffer = undefined, .currentHitGrid = currentHitGrid, .nextHitGrid = nextHitGrid, .hitGridWidth = width, @@ -251,13 +240,13 @@ pub const CliRenderer = struct { self.nextRenderBuffer.deinit(); // Free stat sample arrays - self.statSamples.lastFrameTime.deinit(); - self.statSamples.renderTime.deinit(); - self.statSamples.overallFrameTime.deinit(); - self.statSamples.bufferResetTime.deinit(); - self.statSamples.stdoutWriteTime.deinit(); - self.statSamples.cellsUpdated.deinit(); - self.statSamples.frameCallbackTime.deinit(); + self.statSamples.lastFrameTime.deinit(self.allocator); + self.statSamples.renderTime.deinit(self.allocator); + self.statSamples.overallFrameTime.deinit(self.allocator); + self.statSamples.bufferResetTime.deinit(self.allocator); + self.statSamples.stdoutWriteTime.deinit(self.allocator); + self.statSamples.cellsUpdated.deinit(self.allocator); + self.statSamples.frameCallbackTime.deinit(self.allocator); self.allocator.free(self.currentHitGrid); self.allocator.free(self.nextHitGrid); @@ -269,8 +258,8 @@ pub const CliRenderer = struct { self.useAlternateScreen = useAlternateScreen; self.terminalSetup = true; - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; self.terminal.queryTerminalSend(writer) catch { logger.warn("Failed to query terminal capabilities", .{}); @@ -280,8 +269,8 @@ pub const CliRenderer = struct { } fn setupTerminalWithoutDetection(self: *CliRenderer, useAlternateScreen: bool) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; writer.writeAll(ansi.ANSI.saveCursorState) catch {}; @@ -295,7 +284,7 @@ pub const CliRenderer = struct { const useKitty = self.terminal.opts.kitty_keyboard_flags > 0; self.terminal.enableDetectedFeatures(writer, useKitty) catch {}; - bufferedWriter.flush() catch {}; + writer.flush() catch {}; } pub fn suspendRenderer(self: *CliRenderer) void { @@ -311,16 +300,17 @@ pub const CliRenderer = struct { pub fn performShutdownSequence(self: *CliRenderer) void { if (!self.terminalSetup) return; - const direct = self.stdoutWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const direct = &stdoutWriter.interface; self.terminal.resetState(direct) catch { logger.warn("Failed to reset terminal state", .{}); }; if (self.useAlternateScreen) { - self.stdoutWriter.flush() catch {}; + direct.flush() catch {}; } else if (self.renderOffset == 0) { direct.writeAll("\x1b[H\x1b[J") catch {}; - self.stdoutWriter.flush() catch {}; + direct.flush() catch {}; } else if (self.renderOffset > 0) { // Currently still handled in typescript // const consoleEndLine = self.height - self.renderOffset; @@ -335,22 +325,22 @@ pub const CliRenderer = struct { direct.writeAll(ansi.ANSI.defaultCursorStyle) catch {}; // Workaround for Ghostty not showing the cursor after shutdown for some reason direct.writeAll(ansi.ANSI.showCursor) catch {}; - self.stdoutWriter.flush() catch {}; - std.time.sleep(10 * std.time.ns_per_ms); + direct.flush() catch {}; + std.Thread.sleep(10 * std.time.ns_per_ms); direct.writeAll(ansi.ANSI.showCursor) catch {}; - self.stdoutWriter.flush() catch {}; - std.time.sleep(10 * std.time.ns_per_ms); + direct.flush() catch {}; + std.Thread.sleep(10 * std.time.ns_per_ms); } - fn addStatSample(comptime T: type, samples: *std.ArrayList(T), value: T) void { - samples.append(value) catch return; + fn addStatSample(self: *CliRenderer, comptime T: type, samples: *std.ArrayListUnmanaged(T), value: T) void { + samples.append(self.allocator, value) catch return; if (samples.items.len > MAX_STAT_SAMPLES) { _ = samples.orderedRemove(0); } } - fn getStatAverage(comptime T: type, samples: *const std.ArrayList(T)) T { + fn getStatAverage(comptime T: type, samples: *const std.ArrayListUnmanaged(T)) T { if (samples.items.len == 0) { return 0; } @@ -406,8 +396,8 @@ pub const CliRenderer = struct { self.renderStats.fps = fps; self.renderStats.frameCallbackTime = frameCallbackTime; - addStatSample(f64, &self.statSamples.overallFrameTime, time); - addStatSample(f64, &self.statSamples.frameCallbackTime, frameCallbackTime); + self.addStatSample(f64, &self.statSamples.overallFrameTime, time); + self.addStatSample(f64, &self.statSamples.frameCallbackTime, frameCallbackTime); } pub fn updateMemoryStats(self: *CliRenderer, heapUsed: u32, heapTotal: u32, arrayBuffers: u32) void { @@ -474,10 +464,12 @@ pub const CliRenderer = struct { const outputLen = self.currentOutputLen; const writeStart = std.time.microTimestamp(); - if (outputLen > 0) { - var bufferedWriter = &self.stdoutWriter; - bufferedWriter.writer().writeAll(outputData[0..outputLen]) catch {}; - bufferedWriter.flush() catch {}; + // Skip stdout writes in testing mode to avoid blocking + if (outputLen > 0 and !self.testing) { + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const w = &stdoutWriter.interface; + w.writeAll(outputData[0..outputLen]) catch {}; + w.flush() catch {}; } // Signal that rendering is complete @@ -521,26 +513,30 @@ pub const CliRenderer = struct { self.renderMutex.unlock(); } else { const writeStart = std.time.microTimestamp(); - var bufferedWriter = &self.stdoutWriter; - bufferedWriter.writer().writeAll(outputBuffer[0..outputBufferLen]) catch {}; - bufferedWriter.flush() catch {}; + // Skip stdout writes in testing mode to avoid blocking + if (!self.testing) { + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const w = &stdoutWriter.interface; + w.writeAll(outputBuffer[0..outputBufferLen]) catch {}; + w.flush() catch {}; + } self.renderStats.stdoutWriteTime = @as(f64, @floatFromInt(std.time.microTimestamp() - writeStart)); } self.renderStats.lastFrameTime = deltaTime * 1000.0; self.renderStats.frameCount += 1; - addStatSample(f64, &self.statSamples.lastFrameTime, deltaTime * 1000.0); + self.addStatSample(f64, &self.statSamples.lastFrameTime, deltaTime * 1000.0); if (self.renderStats.renderTime) |rt| { - addStatSample(f64, &self.statSamples.renderTime, rt); + self.addStatSample(f64, &self.statSamples.renderTime, rt); } if (self.renderStats.bufferResetTime) |brt| { - addStatSample(f64, &self.statSamples.bufferResetTime, brt); + self.addStatSample(f64, &self.statSamples.bufferResetTime, brt); } if (self.renderStats.stdoutWriteTime) |swt| { - addStatSample(f64, &self.statSamples.stdoutWriteTime, swt); + self.addStatSample(f64, &self.statSamples.stdoutWriteTime, swt); } - addStatSample(u32, &self.statSamples.cellsUpdated, self.renderStats.cellsUpdated); + self.addStatSample(u32, &self.statSamples.cellsUpdated, self.renderStats.cellsUpdated); } pub fn getNextBuffer(self: *CliRenderer) *OptimizedBuffer { @@ -621,7 +617,7 @@ pub const CliRenderer = struct { if (currentLinkId != 0) { const lp = link.initGlobalLinkPool(self.allocator); if (lp.get(currentLinkId)) |url_bytes| { - std.fmt.format(writer, "\x1b]8;;{s}\x1b\\", .{url_bytes}) catch {}; + writer.print("\x1b]8;;{s}\x1b\\", .{url_bytes}) catch {}; } else |_| { // Link not found, treat as no link currentLinkId = 0; @@ -790,9 +786,10 @@ pub const CliRenderer = struct { } pub fn clearTerminal(self: *CliRenderer) void { - var bufferedWriter = &self.stdoutWriter; - bufferedWriter.writer().writeAll(ansi.ANSI.clearAndHome) catch {}; - bufferedWriter.flush() catch {}; + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const w = &stdoutWriter.interface; + w.writeAll(ansi.ANSI.clearAndHome) catch {}; + w.flush() catch {}; } pub fn addToHitGrid(self: *CliRenderer, x: i32, y: i32, width: u32, height: u32, id: u32) void { @@ -834,7 +831,9 @@ pub const CliRenderer = struct { const file = std.fs.cwd().createFile(filename, .{}) catch return; defer file.close(); - const writer = file.writer(); + var fileBuffer: [4096]u8 = undefined; + var fileWriter = file.writer(&fileBuffer); + const writer = &fileWriter.interface; for (0..self.hitGridHeight) |y| { for (0..self.hitGridWidth) |x| { @@ -846,6 +845,7 @@ pub const CliRenderer = struct { } writer.writeByte('\n') catch return; } + writer.flush() catch {}; } fn dumpSingleBuffer(self: *CliRenderer, buffer: *OptimizedBuffer, buffer_name: []const u8, timestamp: i64) void { @@ -860,7 +860,9 @@ pub const CliRenderer = struct { const file = std.fs.cwd().createFile(filename, .{}) catch return; defer file.close(); - const writer = file.writer(); + var fileBuffer: [4096]u8 = undefined; + var fileWriter = file.writer(&fileBuffer); + const writer = &fileWriter.interface; writer.print("{s} Buffer ({d}x{d}):\n", .{ buffer_name, self.width, self.height }) catch return; writer.writeAll("Characters:\n") catch return; @@ -886,6 +888,7 @@ pub const CliRenderer = struct { } writer.writeByte('\n') catch return; } + writer.flush() catch {}; } pub fn getLastOutputForTest(_: *CliRenderer) []const u8 { @@ -909,7 +912,9 @@ pub const CliRenderer = struct { const file = std.fs.cwd().createFile(filename, .{}) catch return; defer file.close(); - const writer = file.writer(); + var fileBuffer: [4096]u8 = undefined; + var fileWriter = file.writer(&fileBuffer); + const writer = &fileWriter.interface; writer.print("Stdout Buffer Output (timestamp: {d}):\n", .{timestamp}) catch return; writer.writeAll("Last Rendered ANSI Output:\n") catch return; @@ -927,6 +932,7 @@ pub const CliRenderer = struct { writer.writeAll("\n================\n") catch return; writer.print("Buffer size: {d} bytes\n", .{lastLen}) catch return; writer.print("Active buffer: {s}\n", .{if (activeBuffer == .A) "A" else "B"}) catch return; + writer.flush() catch {}; } pub fn dumpBuffers(self: *CliRenderer, timestamp: i64) void { @@ -937,46 +943,46 @@ pub const CliRenderer = struct { pub fn enableMouse(self: *CliRenderer, enableMovement: bool) void { _ = enableMovement; // TODO: Use this to control motion tracking levels - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; self.terminal.setMouseMode(writer, true) catch {}; - bufferedWriter.flush() catch {}; + writer.flush() catch {}; } pub fn queryPixelResolution(self: *CliRenderer) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; writer.writeAll(ansi.ANSI.queryPixelSize) catch {}; - bufferedWriter.flush() catch {}; + writer.flush() catch {}; } pub fn disableMouse(self: *CliRenderer) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; self.terminal.setMouseMode(writer, false) catch {}; - bufferedWriter.flush() catch {}; + writer.flush() catch {}; } pub fn enableKittyKeyboard(self: *CliRenderer, flags: u8) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; self.terminal.setKittyKeyboard(writer, true, flags) catch {}; - bufferedWriter.flush() catch {}; + writer.flush() catch {}; } pub fn disableKittyKeyboard(self: *CliRenderer) void { - var bufferedWriter = &self.stdoutWriter; - const writer = bufferedWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; self.terminal.setKittyKeyboard(writer, false, 0) catch {}; - bufferedWriter.flush() catch {}; + writer.flush() catch {}; } pub fn getTerminalCapabilities(self: *CliRenderer) Terminal.Capabilities { @@ -985,7 +991,8 @@ pub const CliRenderer = struct { pub fn processCapabilityResponse(self: *CliRenderer, response: []const u8) void { self.terminal.processCapabilityResponse(response); - const writer = self.stdoutWriter.writer(); + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const writer = &stdoutWriter.interface; const useKitty = self.terminal.opts.kitty_keyboard_flags > 0; self.terminal.enableDetectedFeatures(writer, useKitty) catch {}; } diff --git a/packages/core/src/zig/rope.zig b/packages/core/src/zig/rope.zig index 5b2ed891b..8e3945087 100644 --- a/packages/core/src/zig/rope.zig +++ b/packages/core/src/zig/rope.zig @@ -38,13 +38,13 @@ pub fn Rope(comptime T: type) type { }; pub const MarkerCache = if (marker_enabled) struct { // Flat arrays of positions for each marker type - positions: std.AutoHashMap(std.meta.Tag(T), std.ArrayList(MarkerPosition)), + positions: std.AutoHashMap(std.meta.Tag(T), std.ArrayListUnmanaged(MarkerPosition)), version: u64, // Rope version when cache was built allocator: Allocator, pub fn init(allocator: Allocator) MarkerCache { return .{ - .positions = std.AutoHashMap(std.meta.Tag(T), std.ArrayList(MarkerPosition)).init(allocator), + .positions = std.AutoHashMap(std.meta.Tag(T), std.ArrayListUnmanaged(MarkerPosition)).init(allocator), .version = std.math.maxInt(u64), // Sentinel: cache is invalid until first rebuild .allocator = allocator, }; @@ -53,7 +53,7 @@ pub fn Rope(comptime T: type) type { pub fn deinit(self: *MarkerCache) void { var iter = self.positions.valueIterator(); while (iter.next()) |list| { - list.deinit(); + list.deinit(self.allocator); } self.positions.deinit(); } @@ -291,13 +291,13 @@ pub fn Rope(comptime T: type) type { }; } - fn collect(self: *const Node, list: *std.ArrayList(*const Node)) !void { + fn collect(self: *const Node, list: *std.ArrayListUnmanaged(*const Node), allocator: Allocator) !void { switch (self.*) { .branch => |*b| { - try b.left.collect(list); - try b.right.collect(list); + try b.left.collect(list, allocator); + try b.right.collect(list, allocator); }, - .leaf => try list.append(self), + .leaf => try list.append(allocator, self), } } @@ -318,11 +318,11 @@ pub fn Rope(comptime T: type) type { pub fn rebalance(self: *const Node, allocator: Allocator, tmp_allocator: Allocator) !*const Node { if (self.is_balanced()) return self; - var leaves = std.ArrayList(*const Node).init(tmp_allocator); - defer leaves.deinit(); + var leaves: std.ArrayListUnmanaged(*const Node) = .{}; + defer leaves.deinit(tmp_allocator); - try leaves.ensureTotalCapacity(self.count()); - try self.collect(&leaves); + try leaves.ensureTotalCapacity(tmp_allocator, self.count()); + try self.collect(&leaves, tmp_allocator); return try merge_leaves(leaves.items, allocator); } @@ -524,12 +524,13 @@ pub fn Rope(comptime T: type) type { return try initWithConfig(allocator, config); } - var leaves = try std.ArrayList(*const Node).initCapacity(allocator, items.len); - defer leaves.deinit(); + var leaves: std.ArrayListUnmanaged(*const Node) = .{}; + defer leaves.deinit(allocator); + try leaves.ensureTotalCapacity(allocator, items.len); for (items) |item| { const leaf = try Node.new_leaf(allocator, item); - try leaves.append(leaf); + try leaves.append(allocator, leaf); } const root = try Node.merge_leaves(leaves.items, allocator); @@ -669,7 +670,8 @@ pub fn Rope(comptime T: type) type { if (start >= end) return &[_]T{}; const SliceContext = struct { - items: std.ArrayList(T), + items: std.ArrayListUnmanaged(T), + allocator: Allocator, start: u32, end: u32, current_index: u32 = 0, @@ -678,7 +680,7 @@ pub fn Rope(comptime T: type) type { _ = idx; const context = @as(*@This(), @ptrCast(@alignCast(ctx))); if (context.current_index >= context.start and context.current_index < context.end) { - context.items.append(data.*) catch |e| return .{ .err = e }; + context.items.append(context.allocator, data.*) catch |e| return .{ .err = e }; } context.current_index += 1; if (context.current_index >= context.end) { @@ -689,14 +691,15 @@ pub fn Rope(comptime T: type) type { }; var context = SliceContext{ - .items = std.ArrayList(T).init(allocator), + .items = .{}, + .allocator = allocator, .start = start, .end = end, }; - errdefer context.items.deinit(); + errdefer context.items.deinit(allocator); try self.walk(&context, SliceContext.walker); - return context.items.toOwnedSlice(); + return context.items.toOwnedSlice(allocator); } pub fn delete_range(self: *Self, start: u32, end: u32) !void { @@ -727,47 +730,49 @@ pub fn Rope(comptime T: type) type { pub fn to_array(self: *const Self, allocator: Allocator) ![]T { const ToArrayContext = struct { - items: std.ArrayList(T), + items: std.ArrayListUnmanaged(T), + allocator: Allocator, fn walker(ctx: *anyopaque, data: *const T, idx: u32) Node.WalkerResult { _ = idx; const context = @as(*@This(), @ptrCast(@alignCast(ctx))); - context.items.append(data.*) catch |e| return .{ .err = e }; + context.items.append(context.allocator, data.*) catch |e| return .{ .err = e }; return .{}; } }; var context = ToArrayContext{ - .items = std.ArrayList(T).init(allocator), + .items = .{}, + .allocator = allocator, }; - errdefer context.items.deinit(); + errdefer context.items.deinit(allocator); try self.walk(&context, ToArrayContext.walker); - return context.items.toOwnedSlice(); + return context.items.toOwnedSlice(allocator); } pub fn toText(self: *const Self, allocator: Allocator) ![]u8 { - var buffer = std.ArrayList(u8).init(allocator); - errdefer buffer.deinit(); + var buffer: std.ArrayListUnmanaged(u8) = .{}; + errdefer buffer.deinit(allocator); - try buffer.appendSlice("[root"); - try nodeToText(self.root, &buffer); - try buffer.append(']'); + try buffer.appendSlice(allocator, "[root"); + try nodeToText(self.root, &buffer, allocator); + try buffer.append(allocator, ']'); - return buffer.toOwnedSlice(); + return buffer.toOwnedSlice(allocator); } - fn nodeToText(node: *const Node, buffer: *std.ArrayList(u8)) !void { + fn nodeToText(node: *const Node, buffer: *std.ArrayListUnmanaged(u8), allocator: Allocator) !void { switch (node.*) { .branch => |*b| { - try buffer.appendSlice("[branch"); - try nodeToText(b.left, buffer); - try nodeToText(b.right, buffer); - try buffer.append(']'); + try buffer.appendSlice(allocator, "[branch"); + try nodeToText(b.left, buffer, allocator); + try nodeToText(b.right, buffer, allocator); + try buffer.append(allocator, ']'); }, .leaf => |*l| { if (l.is_sentinel) { - try buffer.appendSlice("[empty]"); + try buffer.appendSlice(allocator, "[empty]"); return; } @@ -775,31 +780,31 @@ pub fn Rope(comptime T: type) type { const tag = std.meta.activeTag(l.data); const tag_name = @tagName(tag); - try buffer.append('['); - try buffer.appendSlice(tag_name); + try buffer.append(allocator, '['); + try buffer.appendSlice(allocator, tag_name); if (@hasDecl(T, "Metrics")) { const metrics = l.metrics(); - try buffer.append(':'); - try std.fmt.format(buffer.writer(), "w{d}", .{metrics.weight()}); + try buffer.append(allocator, ':'); + try buffer.writer(allocator).print("w{d}", .{metrics.weight()}); if (@hasDecl(T.Metrics, "total_width")) { - try std.fmt.format(buffer.writer(), ",tw{d}", .{metrics.custom.total_width}); + try buffer.writer(allocator).print(",tw{d}", .{metrics.custom.total_width}); } if (@hasDecl(T.Metrics, "total_bytes")) { - try std.fmt.format(buffer.writer(), ",b{d}", .{metrics.custom.total_bytes}); + try buffer.writer(allocator).print(",b{d}", .{metrics.custom.total_bytes}); } } - try buffer.append(']'); + try buffer.append(allocator, ']'); } else { - try buffer.appendSlice("[leaf"); + try buffer.appendSlice(allocator, "[leaf"); if (@hasDecl(T, "Metrics")) { const metrics = l.metrics(); - try buffer.append(':'); - try std.fmt.format(buffer.writer(), "w{d}", .{metrics.weight()}); + try buffer.append(allocator, ':'); + try buffer.writer(allocator).print("w{d}", .{metrics.weight()}); } - try buffer.append(']'); + try buffer.append(allocator, ']'); } }, } @@ -925,12 +930,13 @@ pub fn Rope(comptime T: type) type { // Handle insertion if (action.insert_between.len > 0) { - var leaves = try std.ArrayList(*const Node).initCapacity(self.allocator, action.insert_between.len); - defer leaves.deinit(); + var leaves: std.ArrayListUnmanaged(*const Node) = .{}; + defer leaves.deinit(self.allocator); + try leaves.ensureTotalCapacity(self.allocator, action.insert_between.len); for (action.insert_between) |item| { const leaf = try Node.new_leaf(self.allocator, item); - try leaves.append(leaf); + try leaves.append(self.allocator, leaf); } const insert_root = try Node.merge_leaves(leaves.items, self.allocator); @@ -1115,12 +1121,13 @@ pub fn Rope(comptime T: type) type { return; } - var leaves = try std.ArrayList(*const Node).initCapacity(self.allocator, items.len); - defer leaves.deinit(); + var leaves: std.ArrayListUnmanaged(*const Node) = .{}; + defer leaves.deinit(self.allocator); + try leaves.ensureTotalCapacity(self.allocator, items.len); for (items) |item| { const leaf = try Node.new_leaf(self.allocator, item); - try leaves.append(leaf); + try leaves.append(self.allocator, leaf); } self.root = try Node.merge_leaves(leaves.items, self.allocator); @@ -1164,10 +1171,10 @@ pub fn Rope(comptime T: type) type { return .{ .keep_walking = false, .err = e }; }; if (!gop.found_existing) { - gop.value_ptr.* = std.ArrayList(MarkerPosition).init(context.cache.allocator); + gop.value_ptr.* = .{}; } - gop.value_ptr.append(.{ + gop.value_ptr.append(context.cache.allocator, .{ .leaf_index = context.current_leaf, .global_weight = context.current_weight, }) catch |e| { diff --git a/packages/core/src/zig/syntax-style.zig b/packages/core/src/zig/syntax-style.zig index 1d8434a19..198954f26 100644 --- a/packages/core/src/zig/syntax-style.zig +++ b/packages/core/src/zig/syntax-style.zig @@ -109,7 +109,7 @@ pub const SyntaxStyle = struct { for (ids, 0..) |id, i| { if (i > 0) writer.writeByte(':') catch return SyntaxStyleError.OutOfMemory; - std.fmt.formatInt(id, 10, .lower, .{}, writer) catch return SyntaxStyleError.OutOfMemory; + writer.print("{d}", .{id}) catch return SyntaxStyleError.OutOfMemory; } const cache_key = cache_key_stream.getWritten(); diff --git a/packages/core/src/zig/test.zig b/packages/core/src/zig/test.zig index fbae51eec..a6adb0e92 100644 --- a/packages/core/src/zig/test.zig +++ b/packages/core/src/zig/test.zig @@ -27,6 +27,7 @@ const renderer_tests = @import("tests/renderer_test.zig"); const terminal_tests = @import("tests/terminal_test.zig"); const mem_registry_tests = @import("tests/mem-registry_test.zig"); const memory_leak_regression_tests = @import("tests/memory_leak_regression_test.zig"); +const wrap_cache_perf_tests = @import("tests/wrap-cache-perf_test.zig"); // const example_tests = @import("example_test.zig"); // Re-export test declarations from individual test files @@ -58,5 +59,6 @@ comptime { _ = terminal_tests; _ = mem_registry_tests; _ = memory_leak_regression_tests; + _ = wrap_cache_perf_tests; // _ = example_tests; } diff --git a/packages/core/src/zig/tests/buffer_test.zig b/packages/core/src/zig/tests/buffer_test.zig index 86b11c22c..a9a0bf7f4 100644 --- a/packages/core/src/zig/tests/buffer_test.zig +++ b/packages/core/src/zig/tests/buffer_test.zig @@ -251,14 +251,14 @@ test "OptimizedBuffer - large text buffer with wrapping repeated render" { var view = try TextBufferView.init(std.testing.allocator, tb); defer view.deinit(); - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); var line: u32 = 0; while (line < 20) : (line += 1) { - try text_builder.appendSlice("Line "); - try std.fmt.format(text_builder.writer(), "{d}", .{line}); - try text_builder.appendSlice(": 🌟 测试 🎨 Test 🚀\n"); + try text_builder.appendSlice(std.testing.allocator, "Line "); + try text_builder.writer(std.testing.allocator).print("{d}", .{line}); + try text_builder.appendSlice(std.testing.allocator, ": 🌟 测试 🎨 Test 🚀\n"); } try tb.setText(text_builder.items); @@ -416,12 +416,12 @@ test "OptimizedBuffer - stress test with many graphemes" { var view = try TextBufferView.init(std.testing.allocator, tb); defer view.deinit(); - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); var line: u32 = 0; while (line < 10) : (line += 1) { - try text_builder.appendSlice("🌟🎨🚀🍕🍔🍟🌈🎭🎪🎨🎬🎤🎧🎼🎹🎺🎸🎻\n"); + try text_builder.appendSlice(std.testing.allocator, "🌟🎨🚀🍕🍔🍟🌈🎭🎪🎨🎬🎤🎧🎼🎹🎺🎸🎻\n"); } try tb.setText(text_builder.items); @@ -515,8 +515,8 @@ test "OptimizedBuffer - many unique graphemes with small pool" { var failure_count: u32 = 0; while (render_count < 1000) : (render_count += 1) { - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); const base_codepoint: u21 = 0x2600 + @as(u21, @intCast(render_count % 500)); const char_bytes = [_]u8{ @@ -524,9 +524,9 @@ test "OptimizedBuffer - many unique graphemes with small pool" { @intCast(0x80 | ((base_codepoint >> 6) & 0x3F)), @intCast(0x80 | (base_codepoint & 0x3F)), }; - try text_builder.appendSlice(&char_bytes); - try text_builder.appendSlice(" "); - try text_builder.appendSlice(&char_bytes); + try text_builder.appendSlice(std.testing.allocator, &char_bytes); + try text_builder.appendSlice(std.testing.allocator, " "); + try text_builder.appendSlice(std.testing.allocator, &char_bytes); tb.setText(text_builder.items) catch { failure_count += 1; diff --git a/packages/core/src/zig/tests/grapheme_test.zig b/packages/core/src/zig/tests/grapheme_test.zig index 9320ade20..d9c0757ec 100644 --- a/packages/core/src/zig/tests/grapheme_test.zig +++ b/packages/core/src/zig/tests/grapheme_test.zig @@ -285,16 +285,16 @@ test "GraphemePool - many allocations" { for (0..count) |i| { var buffer: [8]u8 = undefined; - const len = std.fmt.formatIntBuf(&buffer, i, 10, .lower, .{}); - ids[i] = try pool.alloc(buffer[0..len]); + const slice = std.fmt.bufPrint(&buffer, "{d}", .{i}) catch unreachable; + ids[i] = try pool.alloc(slice); try pool.incref(ids[i]); } for (ids, 0..count) |id, i| { const retrieved = try pool.get(id); var buffer: [8]u8 = undefined; - const len = std.fmt.formatIntBuf(&buffer, i, 10, .lower, .{}); - try std.testing.expectEqualSlices(u8, buffer[0..len], retrieved); + const slice = std.fmt.bufPrint(&buffer, "{d}", .{i}) catch unreachable; + try std.testing.expectEqualSlices(u8, slice, retrieved); } for (ids) |id| { @@ -306,8 +306,8 @@ test "GraphemePool - allocations with varying sizes" { var pool = GraphemePool.init(std.testing.allocator); defer pool.deinit(); - var ids = std.ArrayList(u32).init(std.testing.allocator); - defer ids.deinit(); + var ids: std.ArrayListUnmanaged(u32) = .{}; + defer ids.deinit(std.testing.allocator); for (0..50) |i| { const size = (i % 5) * 16 + 5; // Vary sizes: 5, 21, 37, 53, 69... @@ -315,7 +315,7 @@ test "GraphemePool - allocations with varying sizes" { @memset(buffer[0..size], @intCast(i % 256)); const id = try pool.alloc(buffer[0..size]); try pool.incref(id); - try ids.append(id); + try ids.append(std.testing.allocator, id); } for (ids.items, 0..50) |id, i| { @@ -338,12 +338,12 @@ test "GraphemePool - reuse many slots" { for (0..100) |i| { var buffer: [8]u8 = undefined; - const len = std.fmt.formatIntBuf(&buffer, i, 10, .lower, .{}); - const id = try pool.alloc(buffer[0..len]); + const slice = std.fmt.bufPrint(&buffer, "{d}", .{i}) catch unreachable; + const id = try pool.alloc(slice); try pool.incref(id); const retrieved = try pool.get(id); - try std.testing.expectEqualSlices(u8, buffer[0..len], retrieved); + try std.testing.expectEqualSlices(u8, slice, retrieved); try pool.decref(id); } @@ -427,8 +427,8 @@ test "GraphemePool - IDs remain unique across many allocations" { for (0..count) |i| { var buffer: [8]u8 = undefined; - const len = std.fmt.formatIntBuf(&buffer, i, 10, .lower, .{}); - ids[i] = try pool.alloc(buffer[0..len]); + const slice = std.fmt.bufPrint(&buffer, "{d}", .{i}) catch unreachable; + ids[i] = try pool.alloc(slice); try pool.incref(ids[i]); } @@ -827,8 +827,8 @@ test "GraphemeTracker - stress test many graphemes" { // Add many graphemes for (0..count) |i| { var buffer: [8]u8 = undefined; - const len = std.fmt.formatIntBuf(&buffer, i, 10, .lower, .{}); - ids[i] = try pool.alloc(buffer[0..len]); + const slice = std.fmt.bufPrint(&buffer, "{d}", .{i}) catch unreachable; + ids[i] = try pool.alloc(slice); tracker.add(ids[i]); } diff --git a/packages/core/src/zig/tests/memory_leak_regression_test.zig b/packages/core/src/zig/tests/memory_leak_regression_test.zig index 01b23a9b1..d610909e8 100644 --- a/packages/core/src/zig/tests/memory_leak_regression_test.zig +++ b/packages/core/src/zig/tests/memory_leak_regression_test.zig @@ -23,15 +23,15 @@ test "GraphemePool - defer cleanup on failure path" { var pool = GraphemePool.init(std.testing.allocator); defer pool.deinit(); - var allocated_ids = std.ArrayList(u32).init(std.testing.allocator); - defer allocated_ids.deinit(); + var allocated_ids: std.ArrayListUnmanaged(u32) = .{}; + defer allocated_ids.deinit(std.testing.allocator); for (0..5) |i| { var buffer: [8]u8 = undefined; - const len = std.fmt.formatIntBuf(&buffer, i, 10, .lower, .{}); - const gid = try pool.alloc(buffer[0..len]); + const slice = std.fmt.bufPrint(&buffer, "{d}", .{i}) catch unreachable; + const gid = try pool.alloc(slice); try pool.incref(gid); - try allocated_ids.append(gid); + try allocated_ids.append(std.testing.allocator, gid); } // Simulate failure cleanup @@ -53,8 +53,8 @@ test "GraphemePool - pending grapheme cleanup on failure" { var pool = GraphemePool.init(std.testing.allocator); defer pool.deinit(); - var result_graphemes = std.ArrayList(u32).init(std.testing.allocator); - defer result_graphemes.deinit(); + var result_graphemes: std.ArrayListUnmanaged(u32) = .{}; + defer result_graphemes.deinit(std.testing.allocator); var pending_gid: ?u32 = null; const success = false; // intentionally never true to test cleanup path @@ -73,7 +73,7 @@ test "GraphemePool - pending grapheme cleanup on failure" { const gid1 = try pool.alloc("grapheme1"); pending_gid = gid1; try pool.incref(gid1); - try result_graphemes.append(gid1); + try result_graphemes.append(std.testing.allocator, gid1); pending_gid = null; const gid2 = try pool.alloc("grapheme2"); diff --git a/packages/core/src/zig/tests/text-buffer-drawing_test.zig b/packages/core/src/zig/tests/text-buffer-drawing_test.zig index 1453086e7..85be4276b 100644 --- a/packages/core/src/zig/tests/text-buffer-drawing_test.zig +++ b/packages/core/src/zig/tests/text-buffer-drawing_test.zig @@ -455,9 +455,9 @@ test "drawTextBuffer - very long unwrapped line clipping" { var view = try TextBufferView.init(std.testing.allocator, tb); defer view.deinit(); - var long_text = std.ArrayList(u8).init(std.testing.allocator); - defer long_text.deinit(); - try long_text.appendNTimes('A', 200); + var long_text: std.ArrayListUnmanaged(u8) = .{}; + defer long_text.deinit(std.testing.allocator); + try long_text.appendNTimes(std.testing.allocator, 'A', 200); try tb.setText(long_text.items); view.setWrapMode(.word); @@ -1358,9 +1358,9 @@ test "drawTextBuffer - horizontal viewport width limits rendering (efficiency te var view = try TextBufferView.init(std.testing.allocator, tb); defer view.deinit(); - var long_line = std.ArrayList(u8).init(std.testing.allocator); - defer long_line.deinit(); - try long_line.appendNTimes('A', 1000); + var long_line: std.ArrayListUnmanaged(u8) = .{}; + defer long_line.deinit(std.testing.allocator); + try long_line.appendNTimes(std.testing.allocator, 'A', 1000); try tb.setText(long_line.items); diff --git a/packages/core/src/zig/tests/text-buffer-iterators_test.zig b/packages/core/src/zig/tests/text-buffer-iterators_test.zig index c6c741226..34153827c 100644 --- a/packages/core/src/zig/tests/text-buffer-iterators_test.zig +++ b/packages/core/src/zig/tests/text-buffer-iterators_test.zig @@ -55,16 +55,17 @@ test "walkLines - single text segment" { }); const Context = struct { - lines: std.ArrayList(LineInfo), + lines: std.ArrayListUnmanaged(LineInfo), + allocator: std.mem.Allocator, fn callback(ctx_ptr: *anyopaque, line_info: LineInfo) void { const ctx = @as(*@This(), @ptrCast(@alignCast(ctx_ptr))); - ctx.lines.append(line_info) catch {}; + ctx.lines.append(ctx.allocator, line_info) catch {}; } }; - var ctx = Context{ .lines = std.ArrayList(LineInfo).init(allocator) }; - defer ctx.lines.deinit(); + var ctx = Context{ .lines = .{}, .allocator = allocator }; + defer ctx.lines.deinit(allocator); iter_mod.walkLines(&rope, &ctx, Context.callback, true); @@ -101,16 +102,17 @@ test "walkLines - text + break + text" { }); const Context = struct { - lines: std.ArrayList(LineInfo), + lines: std.ArrayListUnmanaged(LineInfo), + allocator: std.mem.Allocator, fn callback(ctx_ptr: *anyopaque, line_info: LineInfo) void { const ctx = @as(*@This(), @ptrCast(@alignCast(ctx_ptr))); - ctx.lines.append(line_info) catch {}; + ctx.lines.append(ctx.allocator, line_info) catch {}; } }; - var ctx = Context{ .lines = std.ArrayList(LineInfo).init(allocator) }; - defer ctx.lines.deinit(); + var ctx = Context{ .lines = .{}, .allocator = allocator }; + defer ctx.lines.deinit(allocator); iter_mod.walkLines(&rope, &ctx, Context.callback, true); @@ -154,16 +156,17 @@ test "walkLines - exclude newlines in offset" { }); const Context = struct { - lines: std.ArrayList(LineInfo), + lines: std.ArrayListUnmanaged(LineInfo), + allocator: std.mem.Allocator, fn callback(ctx_ptr: *anyopaque, line_info: LineInfo) void { const ctx = @as(*@This(), @ptrCast(@alignCast(ctx_ptr))); - ctx.lines.append(line_info) catch {}; + ctx.lines.append(ctx.allocator, line_info) catch {}; } }; - var ctx = Context{ .lines = std.ArrayList(LineInfo).init(allocator) }; - defer ctx.lines.deinit(); + var ctx = Context{ .lines = .{}, .allocator = allocator }; + defer ctx.lines.deinit(allocator); iter_mod.walkLines(&rope, &ctx, Context.callback, false); diff --git a/packages/core/src/zig/tests/text-buffer-view_test.zig b/packages/core/src/zig/tests/text-buffer-view_test.zig index a8b155c42..6c233bcb5 100644 --- a/packages/core/src/zig/tests/text-buffer-view_test.zig +++ b/packages/core/src/zig/tests/text-buffer-view_test.zig @@ -1,5 +1,6 @@ const std = @import("std"); const text_buffer = @import("../text-buffer.zig"); +const iter_mod = @import("../text-buffer-iterators.zig"); const text_buffer_view = @import("../text-buffer-view.zig"); const gp = @import("../grapheme.zig"); @@ -699,13 +700,13 @@ test "TextBufferView word wrapping - fragmented rope with word boundary" { const chunk2 = tb.createChunk(mem_id, 14, 15); // "f" const chunk3 = tb.createChunk(mem_id, 15, 20); // "riend" - var segments = std.ArrayList(Segment).init(std.testing.allocator); - defer segments.deinit(); + var segments: std.ArrayListUnmanaged(Segment) = .{}; + defer segments.deinit(std.testing.allocator); - try segments.append(Segment{ .linestart = {} }); - try segments.append(Segment{ .text = chunk1 }); - try segments.append(Segment{ .text = chunk2 }); - try segments.append(Segment{ .text = chunk3 }); + try segments.append(std.testing.allocator, Segment{ .linestart = {} }); + try segments.append(std.testing.allocator, Segment{ .text = chunk1 }); + try segments.append(std.testing.allocator, Segment{ .text = chunk2 }); + try segments.append(std.testing.allocator, Segment{ .text = chunk3 }); try tb.rope.setSegments(segments.items); @@ -1436,11 +1437,11 @@ test "TextBufferView line info - lines with different widths" { var view = try TextBufferView.init(std.testing.allocator, tb); defer view.deinit(); - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); - try text_builder.appendSlice("Short\n"); - try text_builder.appendNTimes('A', 50); - try text_builder.appendSlice("\nMedium"); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); + try text_builder.appendSlice(std.testing.allocator, "Short\n"); + try text_builder.appendNTimes(std.testing.allocator, 'A', 50); + try text_builder.appendSlice(std.testing.allocator, "\nMedium"); const text = text_builder.items; try tb.setText(text); @@ -1484,14 +1485,14 @@ test "TextBufferView line info - thousands of lines" { var view = try TextBufferView.init(std.testing.allocator, tb); defer view.deinit(); - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); var i: u32 = 0; while (i < 999) : (i += 1) { - try std.fmt.format(text_builder.writer(), "Line {}\n", .{i}); + try text_builder.writer(std.testing.allocator).print("Line {}\n", .{i}); } - try std.fmt.format(text_builder.writer(), "Line {}", .{i}); + try text_builder.writer(std.testing.allocator).print("Line {}", .{i}); try tb.setText(text_builder.items); @@ -1915,6 +1916,107 @@ test "TextBufferView measureForDimensions - does not modify cache" { try std.testing.expectEqual(@as(u32, 1), actual_count); } +test "TextBufferView measureForDimensions - cache invalidates after updateVirtualLines" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth); + defer tb.deinit(); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + try tb.setText("AAAAA"); + view.setWrapMode(.char); + view.setWrapWidth(5); + + const result1 = try view.measureForDimensions(5, 10); + try std.testing.expectEqual(@as(u32, 1), result1.line_count); + try std.testing.expectEqual(@as(u32, 5), result1.max_width); + + try tb.setText("AAAAAAAAAA"); + + // This clears the dirty flag, which would cause a false cache hit + // if we keyed on dirty instead of epoch. + _ = view.getVirtualLineCount(); + + const result2 = try view.measureForDimensions(5, 10); + try std.testing.expectEqual(@as(u32, 2), result2.line_count); + try std.testing.expectEqual(@as(u32, 5), result2.max_width); +} + +test "TextBufferView measureForDimensions - width 0 uses intrinsic line widths" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth); + defer tb.deinit(); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + try tb.setText("abc\ndefghij"); + view.setWrapMode(.char); + + const result = try view.measureForDimensions(0, 24); + try std.testing.expectEqual(tb.getLineCount(), result.line_count); + try std.testing.expectEqual(iter_mod.getMaxLineWidth(&tb.rope), result.max_width); +} + +test "TextBufferView measureForDimensions - no wrap matches multi-segment line widths" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth); + defer tb.deinit(); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + try tb.setText("AAAA"); + try tb.append("BBBB"); + view.setWrapMode(.none); + + const line_info = view.getCachedLineInfo(); + var expected_max: u32 = 0; + for (line_info.widths) |w| { + expected_max = @max(expected_max, w); + } + + const result = try view.measureForDimensions(80, 24); + try std.testing.expectEqual(expected_max, result.max_width); + try std.testing.expectEqual(@as(u32, @intCast(line_info.widths.len)), result.line_count); +} + +test "TextBufferView measureForDimensions - cache invalidates on switchToBuffer" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth); + defer tb.deinit(); + + var other_tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth); + defer other_tb.deinit(); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + try tb.setText("AAAAAA"); + view.setWrapMode(.char); + + const result1 = try view.measureForDimensions(10, 10); + try std.testing.expectEqual(@as(u32, 6), result1.max_width); + + try other_tb.setText("BBBBBBBBBB"); + try std.testing.expectEqual(tb.getContentEpoch(), other_tb.getContentEpoch()); + + view.switchToBuffer(other_tb); + + const result2 = try view.measureForDimensions(10, 10); + try std.testing.expectEqual(@as(u32, 10), result2.max_width); + try std.testing.expectEqual(@as(u32, 1), result2.line_count); +} + test "TextBufferView measureForDimensions - char wrap" { const pool = gp.initGlobalPool(std.testing.allocator); defer gp.deinitGlobalPool(); @@ -2488,14 +2590,14 @@ test "TextBufferView line info - line starts monotonically increasing" { var view = try TextBufferView.init(std.testing.allocator, tb); defer view.deinit(); - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); var i: u32 = 0; while (i < 99) : (i += 1) { - try std.fmt.format(text_builder.writer(), "Line {}\n", .{i}); + try text_builder.writer(std.testing.allocator).print("Line {}\n", .{i}); } - try std.fmt.format(text_builder.writer(), "Line {}", .{i}); + try text_builder.writer(std.testing.allocator).print("Line {}", .{i}); try tb.setText(text_builder.items); @@ -2897,16 +2999,16 @@ test "TextBufferView word wrapping - chunk at exact wrap boundary" { const seg_mod = @import("../text-buffer-segment.zig"); const Segment = seg_mod.Segment; - var segments = std.ArrayList(Segment).init(std.testing.allocator); - defer segments.deinit(); + var segments: std.ArrayListUnmanaged(Segment) = .{}; + defer segments.deinit(std.testing.allocator); - try segments.append(Segment{ .linestart = {} }); + try segments.append(std.testing.allocator, Segment{ .linestart = {} }); const chunk1 = tb.createChunk(mem_id, 0, 17); - try segments.append(Segment{ .text = chunk1 }); + try segments.append(std.testing.allocator, Segment{ .text = chunk1 }); const chunk2 = tb.createChunk(mem_id, 17, 21); - try segments.append(Segment{ .text = chunk2 }); + try segments.append(std.testing.allocator, Segment{ .text = chunk2 }); try tb.rope.setSegments(segments.items); view.virtual_lines_dirty = true; diff --git a/packages/core/src/zig/tests/text-buffer_test.zig b/packages/core/src/zig/tests/text-buffer_test.zig index e5ab8b763..b774c10b1 100644 --- a/packages/core/src/zig/tests/text-buffer_test.zig +++ b/packages/core/src/zig/tests/text-buffer_test.zig @@ -238,11 +238,11 @@ test "TextBuffer line info - lines with different widths" { defer tb.deinit(); // Create text with different line lengths - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); - try text_builder.appendSlice("Short\n"); - try text_builder.appendNTimes('A', 50); - try text_builder.appendSlice("\nMedium"); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); + try text_builder.appendSlice(std.testing.allocator, "Short\n"); + try text_builder.appendNTimes(std.testing.allocator, 'A', 50); + try text_builder.appendSlice(std.testing.allocator, "\nMedium"); const text = text_builder.items; try tb.setText(text); @@ -335,11 +335,11 @@ test "TextBuffer line info - buffer resize operations" { defer tb.deinit(); // Add text that will cause multiple resizes - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); - try text_builder.appendNTimes('A', 100); - try text_builder.appendSlice("\n"); - try text_builder.appendNTimes('B', 100); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); + try text_builder.appendNTimes(std.testing.allocator, 'A', 100); + try text_builder.appendSlice(std.testing.allocator, "\n"); + try text_builder.appendNTimes(std.testing.allocator, 'B', 100); const longText = text_builder.items; try tb.setText(longText); @@ -355,15 +355,15 @@ test "TextBuffer line info - thousands of lines" { defer tb.deinit(); // Create text with 1000 lines - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); var i: u32 = 0; while (i < 999) : (i += 1) { - try std.fmt.format(text_builder.writer(), "Line {}\n", .{i}); + try text_builder.writer(std.testing.allocator).print("Line {}\n", .{i}); } // Last line without newline - try std.fmt.format(text_builder.writer(), "Line {}", .{i}); + try text_builder.writer(std.testing.allocator).print("Line {}", .{i}); try tb.setText(text_builder.items); @@ -543,16 +543,17 @@ test "TextBuffer line iteration - walkLines callback" { try tb.setText(text); const Context = struct { - lines: std.ArrayList(iter_mod.LineInfo), + lines: std.ArrayListUnmanaged(iter_mod.LineInfo), + allocator: std.mem.Allocator, fn callback(ctx_ptr: *anyopaque, line_info: iter_mod.LineInfo) void { const ctx = @as(*@This(), @ptrCast(@alignCast(ctx_ptr))); - ctx.lines.append(line_info) catch {}; + ctx.lines.append(ctx.allocator, line_info) catch {}; } }; - var ctx = Context{ .lines = std.ArrayList(iter_mod.LineInfo).init(std.testing.allocator) }; - defer ctx.lines.deinit(); + var ctx = Context{ .lines = .{}, .allocator = std.testing.allocator }; + defer ctx.lines.deinit(std.testing.allocator); iter_mod.walkLines(&tb.rope, &ctx, Context.callback, true); @@ -1380,14 +1381,14 @@ test "TextBuffer setText - very long line with SIMD processing" { defer tb.deinit(); // Create a text longer than 16 bytes (SIMD vector size) to test SIMD path - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); - try text_builder.appendNTimes('A', 100); - try text_builder.appendSlice("\r\n"); - try text_builder.appendNTimes('B', 100); - try text_builder.appendSlice("\n"); - try text_builder.appendNTimes('C', 100); + try text_builder.appendNTimes(std.testing.allocator, 'A', 100); + try text_builder.appendSlice(std.testing.allocator, "\r\n"); + try text_builder.appendNTimes(std.testing.allocator, 'B', 100); + try text_builder.appendSlice(std.testing.allocator, "\n"); + try text_builder.appendNTimes(std.testing.allocator, 'C', 100); try tb.setText(text_builder.items); @@ -1438,17 +1439,17 @@ test "TextBuffer setText - SIMD boundary conditions" { defer tb.deinit(); // Create text with newlines at SIMD vector boundaries (16 bytes) - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); // 15 chars + \n = exactly 16 bytes - try text_builder.appendNTimes('X', 15); - try text_builder.appendSlice("\n"); + try text_builder.appendNTimes(std.testing.allocator, 'X', 15); + try text_builder.appendSlice(std.testing.allocator, "\n"); // 15 more chars + \n - try text_builder.appendNTimes('Y', 15); - try text_builder.appendSlice("\n"); + try text_builder.appendNTimes(std.testing.allocator, 'Y', 15); + try text_builder.appendSlice(std.testing.allocator, "\n"); // Final line - try text_builder.appendNTimes('Z', 10); + try text_builder.appendNTimes(std.testing.allocator, 'Z', 10); try tb.setText(text_builder.items); @@ -1467,13 +1468,13 @@ test "TextBuffer setText - CRLF at SIMD boundary" { defer tb.deinit(); // Create text where \r is at end of SIMD vector and \n is at start of next - var text_builder = std.ArrayList(u8).init(std.testing.allocator); - defer text_builder.deinit(); + var text_builder: std.ArrayListUnmanaged(u8) = .{}; + defer text_builder.deinit(std.testing.allocator); // 15 chars + \r = 16 bytes, then \n at position 16 - try text_builder.appendNTimes('A', 15); - try text_builder.appendSlice("\r\n"); - try text_builder.appendSlice("Next line"); + try text_builder.appendNTimes(std.testing.allocator, 'A', 15); + try text_builder.appendSlice(std.testing.allocator, "\r\n"); + try text_builder.appendSlice(std.testing.allocator, "Next line"); try tb.setText(text_builder.items); @@ -1874,13 +1875,13 @@ test "TextBuffer append - streaming/chunked append vs ground truth" { try tb.append(" end"); // Build expected ground truth - var expected = std.ArrayList(u8).init(std.testing.allocator); - defer expected.deinit(); - try expected.appendSlice("First"); - try expected.appendSlice("\nLine2"); - try expected.appendSlice("\n"); - try expected.appendSlice("Line3"); - try expected.appendSlice(" end"); + var expected: std.ArrayListUnmanaged(u8) = .{}; + defer expected.deinit(std.testing.allocator); + try expected.appendSlice(std.testing.allocator, "First"); + try expected.appendSlice(std.testing.allocator, "\nLine2"); + try expected.appendSlice(std.testing.allocator, "\n"); + try expected.appendSlice(std.testing.allocator, "Line3"); + try expected.appendSlice(std.testing.allocator, " end"); var out_buffer: [100]u8 = undefined; const written = tb.getPlainTextIntoBuffer(&out_buffer); diff --git a/packages/core/src/zig/tests/utf8_no_zwj_test.zig b/packages/core/src/zig/tests/utf8_no_zwj_test.zig index d7d2b3c8d..85d3552ee 100644 --- a/packages/core/src/zig/tests/utf8_no_zwj_test.zig +++ b/packages/core/src/zig/tests/utf8_no_zwj_test.zig @@ -91,15 +91,15 @@ test "no_zwj: mixed text with ZWJ emoji" { } test "no_zwj: findGraphemeInfo splits ZWJ sequences" { - var result_unicode = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result_unicode.deinit(); - var result_no_zwj = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result_no_zwj.deinit(); + var result_unicode: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result_unicode.deinit(testing.allocator); + var result_no_zwj: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result_no_zwj.deinit(testing.allocator); const text = "Hi👩‍🚀Bye"; - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result_unicode); - try utf8.findGraphemeInfo(text, 4, false, .no_zwj, &result_no_zwj); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result_unicode); + try utf8.findGraphemeInfo(text, 4, false, .no_zwj, testing.allocator, &result_no_zwj); // unicode: 1 grapheme (the whole ZWJ sequence) try testing.expectEqual(@as(usize, 1), result_unicode.items.len); diff --git a/packages/core/src/zig/tests/utf8_test.zig b/packages/core/src/zig/tests/utf8_test.zig index dc0e82aa7..1bddeee53 100644 --- a/packages/core/src/zig/tests/utf8_test.zig +++ b/packages/core/src/zig/tests/utf8_test.zig @@ -972,6 +972,29 @@ test "wrap breaks: large buffer" { try testing.expect(result.breaks.items.len > 0); } +test "wrap breaks: buffer exceeding 64KB" { + const size = 100_000; + const buf = try testing.allocator.alloc(u8, size); + defer testing.allocator.free(buf); + + @memset(buf, 'a'); + + // Place a space at 70000, with u16, this will truncate to 4464 (70000 % 65536) + const break_pos: usize = 70_000; + buf[break_pos] = ' '; + + var result = utf8.WrapBreakResult.init(testing.allocator); + defer result.deinit(); + try utf8.findWrapBreaks(buf, &result, .unicode); + + // Should find exactly one wrap break + try testing.expectEqual(@as(usize, 1), result.breaks.items.len); + + // The byte_offset must be the actual position, not truncated + try testing.expectEqual(@as(u32, break_pos), result.breaks.items[0].byte_offset); + try testing.expectEqual(@as(u32, break_pos), result.breaks.items[0].char_offset); +} + // ============================================================================ // EDGE CASES AND INTEGRATION TESTS // ============================================================================ @@ -2077,26 +2100,26 @@ test "calculateTextWidth: U+269B atom symbol should be width 2" { // ============================================================================ test "findGraphemeInfo: empty string" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("", 4, true, .unicode, &result); + try utf8.findGraphemeInfo("", 4, true, .unicode, testing.allocator, &result); try testing.expectEqual(@as(usize, 0), result.items.len); } test "findGraphemeInfo: ASCII-only returns empty" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("hello world", 4, true, .unicode, &result); + try utf8.findGraphemeInfo("hello world", 4, true, .unicode, testing.allocator, &result); try testing.expectEqual(@as(usize, 0), result.items.len); } test "findGraphemeInfo: ASCII with tab" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("hello\tworld", 4, false, .unicode, &result); + try utf8.findGraphemeInfo("hello\tworld", 4, false, .unicode, testing.allocator, &result); // Should have one entry for the tab try testing.expectEqual(@as(usize, 1), result.items.len); @@ -2107,10 +2130,10 @@ test "findGraphemeInfo: ASCII with tab" { } test "findGraphemeInfo: multiple tabs" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("a\tb\tc", 4, false, .unicode, &result); + try utf8.findGraphemeInfo("a\tb\tc", 4, false, .unicode, testing.allocator, &result); // Should have two entries for the tabs try testing.expectEqual(@as(usize, 2), result.items.len); @@ -2129,11 +2152,11 @@ test "findGraphemeInfo: multiple tabs" { } test "findGraphemeInfo: CJK characters" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "hello世界"; - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have two entries for the CJK characters try testing.expectEqual(@as(usize, 2), result.items.len); @@ -2152,11 +2175,11 @@ test "findGraphemeInfo: CJK characters" { } test "findGraphemeInfo: emoji with skin tone" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "Hi👋🏿Bye"; // Hi + wave + dark skin tone + Bye - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have one entry for the emoji cluster try testing.expectEqual(@as(usize, 1), result.items.len); @@ -2168,11 +2191,11 @@ test "findGraphemeInfo: emoji with skin tone" { } test "findGraphemeInfo: emoji with ZWJ" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "a👩‍🚀b"; // a + woman astronaut + b - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have one entry for the emoji cluster try testing.expectEqual(@as(usize, 1), result.items.len); @@ -2183,11 +2206,11 @@ test "findGraphemeInfo: emoji with ZWJ" { } test "findGraphemeInfo: combining mark" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "cafe\u{0301}"; // café with combining acute - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have one entry for e + combining mark try testing.expectEqual(@as(usize, 1), result.items.len); @@ -2199,11 +2222,11 @@ test "findGraphemeInfo: combining mark" { } test "findGraphemeInfo: flag emoji" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "US🇺🇸"; // US + flag - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have one entry for the flag (two regional indicators) try testing.expectEqual(@as(usize, 1), result.items.len); @@ -2215,11 +2238,11 @@ test "findGraphemeInfo: flag emoji" { } test "findGraphemeInfo: mixed content" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "Hi\t世界!"; // Hi + tab + CJK + ! - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have three entries: tab, 世, 界 try testing.expectEqual(@as(usize, 3), result.items.len); @@ -2244,21 +2267,21 @@ test "findGraphemeInfo: mixed content" { } test "findGraphemeInfo: only ASCII letters no cache" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("abcdefghij", 4, false, .unicode, &result); + try utf8.findGraphemeInfo("abcdefghij", 4, false, .unicode, testing.allocator, &result); // No special characters, should be empty try testing.expectEqual(@as(usize, 0), result.items.len); } test "findGraphemeInfo: emoji with VS16" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "I ❤️ U"; // I + space + heart + VS16 + space + U - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have one entry for the emoji cluster try testing.expectEqual(@as(usize, 1), result.items.len); @@ -2269,22 +2292,22 @@ test "findGraphemeInfo: emoji with VS16" { } test "findGraphemeInfo: realistic text" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "function test() {\n\tconst 世界 = 10;\n}"; - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have entries for: tab, 世, 界 try testing.expectEqual(@as(usize, 3), result.items.len); } test "findGraphemeInfo: hiragana" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "こんにちは"; - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); // Should have 5 entries (each hiragana is 3 bytes, width 2) try testing.expectEqual(@as(usize, 5), result.items.len); @@ -2296,8 +2319,8 @@ test "findGraphemeInfo: hiragana" { } test "findGraphemeInfo: at SIMD boundary" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); // Create text with multibyte char near SIMD boundary (16 bytes) var buf: [32]u8 = undefined; @@ -2305,7 +2328,7 @@ test "findGraphemeInfo: at SIMD boundary" { const cjk = "世"; @memcpy(buf[14..17], cjk); // Place CJK char at boundary - try utf8.findGraphemeInfo(&buf, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(&buf, 4, false, .unicode, testing.allocator, &result); // Should find the CJK character var found = false; @@ -2919,14 +2942,14 @@ test "calculateTextWidth: surrogate pair edge cases" { test "calculateTextWidth: long grapheme cluster chain" { // Create a base + many combining marks - var text = std.ArrayList(u8).init(testing.allocator); - defer text.deinit(); + var text: std.ArrayListUnmanaged(u8) = .{}; + defer text.deinit(testing.allocator); - try text.appendSlice("e"); + try text.appendSlice(testing.allocator, "e"); // Add 10 combining marks var i: usize = 0; while (i < 10) : (i += 1) { - try text.appendSlice("\u{0301}"); // Combining acute accent + try text.appendSlice(testing.allocator, "\u{0301}"); // Combining acute accent } const width = utf8.calculateTextWidth(text.items, 4, false, .unicode); @@ -3509,32 +3532,28 @@ test "calculateTextWidth: complex text with emojis and multiple scripts" { test "calculateTextWidth: validate against unicode-width-map.zon" { const zon_content = @embedFile("unicode-width-map.zon"); - const zon_with_null = try testing.allocator.dupeZ(u8, zon_content); - defer testing.allocator.free(zon_with_null); + + // Use arena allocator to avoid memory leaks from ZON parser string allocations + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const zon_with_null = try allocator.dupeZ(u8, zon_content); const WidthEntry = struct { codepoint: []const u8, width: i32, }; - var status: std.zon.parse.Status = .{}; - defer status.deinit(testing.allocator); - const width_entries = std.zon.parse.fromSlice( []const WidthEntry, - testing.allocator, + allocator, zon_with_null, - &status, + null, .{}, ) catch |err| { return err; }; - defer { - for (width_entries) |entry| { - testing.allocator.free(entry.codepoint); - } - testing.allocator.free(width_entries); - } var successes: usize = 0; var failures: usize = 0; @@ -3592,10 +3611,10 @@ test "findGraphemeInfo: comprehensive multilingual text" { const expected_width = utf8.calculateTextWidth(text, 4, false, .unicode); - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result); try testing.expect(result.items.len > 0); var prev_end_byte: usize = 0; diff --git a/packages/core/src/zig/tests/utf8_wcwidth_test.zig b/packages/core/src/zig/tests/utf8_wcwidth_test.zig index a0131d5a3..aec6338f6 100644 --- a/packages/core/src/zig/tests/utf8_wcwidth_test.zig +++ b/packages/core/src/zig/tests/utf8_wcwidth_test.zig @@ -3,26 +3,26 @@ const testing = std.testing; const utf8 = @import("../utf8.zig"); test "findGraphemeInfo wcwidth: empty string" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("", 4, true, .wcwidth, &result); + try utf8.findGraphemeInfo("", 4, true, .wcwidth, testing.allocator, &result); try testing.expectEqual(@as(usize, 0), result.items.len); } test "findGraphemeInfo wcwidth: ASCII-only returns empty" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("hello world", 4, true, .wcwidth, &result); + try utf8.findGraphemeInfo("hello world", 4, true, .wcwidth, testing.allocator, &result); try testing.expectEqual(@as(usize, 0), result.items.len); } test "findGraphemeInfo wcwidth: ASCII with tab" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); - try utf8.findGraphemeInfo("hello\tworld", 4, false, .wcwidth, &result); + try utf8.findGraphemeInfo("hello\tworld", 4, false, .wcwidth, testing.allocator, &result); // Should have one entry for the tab try testing.expectEqual(@as(usize, 1), result.items.len); @@ -33,11 +33,11 @@ test "findGraphemeInfo wcwidth: ASCII with tab" { } test "findGraphemeInfo wcwidth: CJK characters" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "hello世界"; - try utf8.findGraphemeInfo(text, 4, false, .wcwidth, &result); + try utf8.findGraphemeInfo(text, 4, false, .wcwidth, testing.allocator, &result); // Should have two entries for the CJK characters (each codepoint separately) try testing.expectEqual(@as(usize, 2), result.items.len); @@ -56,11 +56,11 @@ test "findGraphemeInfo wcwidth: CJK characters" { } test "findGraphemeInfo wcwidth: emoji with skin tone - each codepoint separate" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "👋🏿"; // Wave (4 bytes) + skin tone modifier (4 bytes) - try utf8.findGraphemeInfo(text, 4, false, .wcwidth, &result); + try utf8.findGraphemeInfo(text, 4, false, .wcwidth, testing.allocator, &result); // In wcwidth mode, these are TWO separate codepoints try testing.expectEqual(@as(usize, 2), result.items.len); @@ -77,11 +77,11 @@ test "findGraphemeInfo wcwidth: emoji with skin tone - each codepoint separate" } test "findGraphemeInfo wcwidth: emoji with ZWJ - each codepoint separate" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "👩‍🚀"; // Woman + ZWJ + Rocket - try utf8.findGraphemeInfo(text, 4, false, .wcwidth, &result); + try utf8.findGraphemeInfo(text, 4, false, .wcwidth, testing.allocator, &result); // In wcwidth mode, we see woman (width 2) and rocket (width 2) // ZWJ has width 0 so it's not in the list @@ -89,11 +89,11 @@ test "findGraphemeInfo wcwidth: emoji with ZWJ - each codepoint separate" { } test "findGraphemeInfo wcwidth: combining mark - base and mark separate" { - var result = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result.deinit(); + var result: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result.deinit(testing.allocator); const text = "e\u{0301}test"; // e + combining acute accent - try utf8.findGraphemeInfo(text, 4, false, .wcwidth, &result); + try utf8.findGraphemeInfo(text, 4, false, .wcwidth, testing.allocator, &result); // In wcwidth mode, combining mark is a separate codepoint with width 0 // So we don't see it in the results (only non-zero width codepoints) @@ -102,15 +102,15 @@ test "findGraphemeInfo wcwidth: combining mark - base and mark separate" { } test "findGraphemeInfo wcwidth vs unicode: emoji with skin tone" { - var result_wcwidth = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result_wcwidth.deinit(); - var result_unicode = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result_unicode.deinit(); + var result_wcwidth: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result_wcwidth.deinit(testing.allocator); + var result_unicode: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result_unicode.deinit(testing.allocator); const text = "Hi👋🏿Bye"; - try utf8.findGraphemeInfo(text, 4, false, .wcwidth, &result_wcwidth); - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result_unicode); + try utf8.findGraphemeInfo(text, 4, false, .wcwidth, testing.allocator, &result_wcwidth); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result_unicode); // wcwidth: 2 codepoints (wave + skin tone) try testing.expectEqual(@as(usize, 2), result_wcwidth.items.len); @@ -122,15 +122,15 @@ test "findGraphemeInfo wcwidth vs unicode: emoji with skin tone" { } test "findGraphemeInfo wcwidth vs unicode: flag emoji" { - var result_wcwidth = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result_wcwidth.deinit(); - var result_unicode = std.ArrayList(utf8.GraphemeInfo).init(testing.allocator); - defer result_unicode.deinit(); + var result_wcwidth: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result_wcwidth.deinit(testing.allocator); + var result_unicode: std.ArrayListUnmanaged(utf8.GraphemeInfo) = .{}; + defer result_unicode.deinit(testing.allocator); const text = "🇺🇸"; // US flag (two regional indicators) - try utf8.findGraphemeInfo(text, 4, false, .wcwidth, &result_wcwidth); - try utf8.findGraphemeInfo(text, 4, false, .unicode, &result_unicode); + try utf8.findGraphemeInfo(text, 4, false, .wcwidth, testing.allocator, &result_wcwidth); + try utf8.findGraphemeInfo(text, 4, false, .unicode, testing.allocator, &result_unicode); // wcwidth: 2 codepoints (two regional indicators, each width 1) try testing.expectEqual(@as(usize, 2), result_wcwidth.items.len); diff --git a/packages/core/src/zig/tests/wrap-cache-perf_test.zig b/packages/core/src/zig/tests/wrap-cache-perf_test.zig new file mode 100644 index 000000000..7316289da --- /dev/null +++ b/packages/core/src/zig/tests/wrap-cache-perf_test.zig @@ -0,0 +1,97 @@ +const std = @import("std"); +const text_buffer = @import("../text-buffer.zig"); +const text_buffer_view = @import("../text-buffer-view.zig"); +const gp = @import("../grapheme.zig"); + +const TextBuffer = text_buffer.UnifiedTextBuffer; +const TextBufferView = text_buffer_view.UnifiedTextBufferView; + +test "word wrap complexity - width changes are O(n)" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + const size: usize = 100_000; + + const text = try std.testing.allocator.alloc(u8, size); + defer std.testing.allocator.free(text); + @memset(text, 'x'); + + var tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth); + defer tb.deinit(); + try tb.setText(text); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + view.setWrapMode(.word); + + const widths = [_]u32{ 60, 70, 80, 90, 100 }; + var times: [widths.len]u64 = undefined; + + // Warmup + view.setWrapWidth(50); + _ = view.getVirtualLineCount(); + + // Measure first (uncached) call for each width + for (widths, 0..) |width, i| { + view.setWrapWidth(width); + var timer = std.time.Timer.start() catch unreachable; + _ = view.getVirtualLineCount(); + times[i] = timer.read(); + } + + var min_time: u64 = std.math.maxInt(u64); + var max_time: u64 = 0; + for (times) |t| { + min_time = @min(min_time, t); + max_time = @max(max_time, t); + } + + const ratio = @as(f64, @floatFromInt(max_time)) / @as(f64, @floatFromInt(min_time)); + + // All times should be roughly similar since text size is constant + try std.testing.expect(ratio < 3.0); +} + +test "word wrap - virtual line count correctness" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, .wcwidth); + defer tb.deinit(); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + const pattern = "var abc=123;function foo(){return bar+baz;}if(x>0){y=z*2;}else{y=0;}"; + const size = 10_000; + var text = try std.testing.allocator.alloc(u8, size); + defer std.testing.allocator.free(text); + + var i: usize = 0; + while (i < size) { + const remaining = size - i; + const copy_len = @min(pattern.len, remaining); + @memcpy(text[i .. i + copy_len], pattern[0..copy_len]); + i += copy_len; + } + + try tb.setText(text); + view.setWrapMode(.word); + + view.setWrapWidth(80); + const count_80 = view.getVirtualLineCount(); + + view.setWrapWidth(100); + const count_100 = view.getVirtualLineCount(); + + view.setWrapWidth(60); + const count_60 = view.getVirtualLineCount(); + + view.setWrapWidth(80); + const count_80_again = view.getVirtualLineCount(); + + try std.testing.expect(count_80 > 100); + try std.testing.expectEqual(count_80, count_80_again); + try std.testing.expect(count_100 < count_80); + try std.testing.expect(count_60 > count_80); +} diff --git a/packages/core/src/zig/text-buffer-segment.zig b/packages/core/src/zig/text-buffer-segment.zig index d59137744..23ac61500 100644 --- a/packages/core/src/zig/text-buffer-segment.zig +++ b/packages/core/src/zig/text-buffer-segment.zig @@ -94,13 +94,13 @@ pub const TextChunk = struct { const chunk_bytes = self.getBytes(mem_registry); - var grapheme_list = std.ArrayList(GraphemeInfo).init(allocator); - errdefer grapheme_list.deinit(); + var grapheme_list: std.ArrayListUnmanaged(GraphemeInfo) = .{}; + errdefer grapheme_list.deinit(allocator); - try utf8.findGraphemeInfo(chunk_bytes, tabwidth, self.isAsciiOnly(), width_method, &grapheme_list); + try utf8.findGraphemeInfo(chunk_bytes, tabwidth, self.isAsciiOnly(), width_method, allocator, &grapheme_list); // TODO: Calling this with an arena allocator will just double the memory usage? - const graphemes = try grapheme_list.toOwnedSlice(); + const graphemes = try grapheme_list.toOwnedSlice(allocator); mut_self.graphemes = graphemes; return graphemes; @@ -121,12 +121,13 @@ pub const TextChunk = struct { const chunk_bytes = self.getBytes(mem_registry); var wrap_result = utf8.WrapBreakResult.init(allocator); - defer wrap_result.deinit(); + errdefer wrap_result.deinit(); try utf8.findWrapBreaks(chunk_bytes, &wrap_result, width_method); // TODO: Do not cache for chunks < 64 bytes, as it does not profit from the cache - const wrap_offsets = try allocator.dupe(utf8.WrapBreak, wrap_result.breaks.items); + // Use toOwnedSlice to transfer ownership without copying + const wrap_offsets = try wrap_result.breaks.toOwnedSlice(allocator); mut_self.wrap_offsets = wrap_offsets; return wrap_offsets; diff --git a/packages/core/src/zig/text-buffer-view.zig b/packages/core/src/zig/text-buffer-view.zig index 3c29f363c..6f20f5bf8 100644 --- a/packages/core/src/zig/text-buffer-view.zig +++ b/packages/core/src/zig/text-buffer-view.zig @@ -124,9 +124,22 @@ pub const UnifiedTextBufferView = struct { cached_line_vline_counts: std.ArrayListUnmanaged(u32), global_allocator: Allocator, virtual_lines_arena: *std.heap.ArenaAllocator, + + /// Persistent arena for measureForDimensions. Each call resets it with + /// retain_capacity to avoid mmap/munmap churn during streaming. + measure_arena: std.heap.ArenaAllocator, tab_indicator: ?u32, tab_indicator_color: ?RGBA, + // Measurement cache for Yoga layout. Keyed by (buffer, epoch, width, wrap_mode). + // Using epoch instead of dirty flag prevents stale returns when unrelated + // code paths clear dirty (e.g., updateVirtualLines). + cached_measure_width: ?u32, + cached_measure_wrap_mode: WrapMode, + cached_measure_result: ?MeasureResult, + cached_measure_epoch: u64, + cached_measure_buffer: ?*UnifiedTextBuffer, + pub fn init(global_allocator: Allocator, text_buffer: *UnifiedTextBuffer) TextBufferViewError!*Self { const self = global_allocator.create(Self) catch return TextBufferViewError.OutOfMemory; errdefer global_allocator.destroy(self); @@ -156,8 +169,14 @@ pub const UnifiedTextBufferView = struct { .cached_line_vline_counts = .{}, .global_allocator = global_allocator, .virtual_lines_arena = virtual_lines_internal_arena, + .measure_arena = std.heap.ArenaAllocator.init(global_allocator), .tab_indicator = null, .tab_indicator_color = null, + .cached_measure_width = null, + .cached_measure_wrap_mode = .none, + .cached_measure_result = null, + .cached_measure_epoch = 0, + .cached_measure_buffer = null, }; return self; @@ -170,6 +189,7 @@ pub const UnifiedTextBufferView = struct { self.original_text_buffer.unregisterView(self.view_id); self.virtual_lines_arena.deinit(); self.global_allocator.destroy(self.virtual_lines_arena); + self.measure_arena.deinit(); self.global_allocator.destroy(self); } @@ -690,14 +710,47 @@ pub const UnifiedTextBufferView = struct { /// Measure dimensions for given width/height WITHOUT modifying virtual lines cache /// This is useful for Yoga measure functions that need to know dimensions without committing changes - /// Special case: width=0 means "measure intrinsic/max-content width" (no wrapping) - pub fn measureForDimensions(self: *const Self, width: u32, height: u32) TextBufferViewError!MeasureResult { + /// Special case: width=0 or wrap_mode=.none means "measure intrinsic/max-content width" (no wrapping) + pub fn measureForDimensions(self: *Self, width: u32, height: u32) TextBufferViewError!MeasureResult { _ = height; // Height is for future use, currently only width affects layout - // Create temporary arena for measurement - var measure_arena = std.heap.ArenaAllocator.init(self.global_allocator); - defer measure_arena.deinit(); - const measure_allocator = measure_arena.allocator(); + const epoch = self.text_buffer.getContentEpoch(); + if (self.cached_measure_result) |result| { + if (self.cached_measure_epoch == epoch and self.cached_measure_buffer == self.text_buffer) { + if (self.cached_measure_width) |cached_width| { + if (cached_width == width and self.cached_measure_wrap_mode == self.wrap_mode) { + return result; + } + } + } + } + + // No-wrap path avoids allocations by using marker-based line widths. + if (width == 0 or self.wrap_mode == .none) { + const line_count = self.text_buffer.getLineCount(); + var max_width: u32 = 0; + var row: u32 = 0; + while (row < line_count) : (row += 1) { + max_width = @max(max_width, iter_mod.lineWidthAt(&self.text_buffer.rope, row)); + } + + const result = MeasureResult{ + .line_count = line_count, + .max_width = max_width, + }; + + self.cached_measure_width = width; + self.cached_measure_wrap_mode = self.wrap_mode; + self.cached_measure_result = result; + self.cached_measure_epoch = epoch; + self.cached_measure_buffer = self.text_buffer; + + return result; + } + + // Reuse arena capacity to avoid allocation overhead during streaming. + _ = self.measure_arena.reset(.retain_capacity); + const measure_allocator = self.measure_arena.allocator(); // Create temporary output structures var temp_virtual_lines = std.ArrayListUnmanaged(VirtualLine){}; @@ -719,7 +772,6 @@ pub const UnifiedTextBufferView = struct { }; // Use width for wrap calculation - // Special case: width=0 means get intrinsic width (no wrapping), so pass null const wrap_width_for_measure = if (self.wrap_mode != .none and width > 0) width else null; // Call generic calculation with temporary structures @@ -737,10 +789,18 @@ pub const UnifiedTextBufferView = struct { max_width = @max(max_width, w); } - return MeasureResult{ + const result = MeasureResult{ .line_count = @intCast(temp_virtual_lines.items.len), .max_width = max_width, }; + + self.cached_measure_width = width; + self.cached_measure_wrap_mode = self.wrap_mode; + self.cached_measure_result = result; + self.cached_measure_epoch = epoch; + self.cached_measure_buffer = self.text_buffer; + + return result; } /// Generic virtual line calculation that writes to provided output structures @@ -864,8 +924,10 @@ pub const UnifiedTextBufferView = struct { if (wctx.wrap_mode == .word) { const chunk_bytes = chunk.getBytes(&wctx.text_buffer.mem_registry); const wrap_offsets = chunk.getWrapOffsets(&wctx.text_buffer.mem_registry, wctx.text_buffer.allocator, wctx.text_buffer.width_method) catch &[_]utf8.WrapBreak{}; + const is_ascii_only = (chunk.flags & TextChunk.Flags.ASCII_ONLY) != 0; var char_offset: u32 = 0; + var byte_offset: u32 = 0; var wrap_idx: usize = 0; while (char_offset < chunk.width) { const remaining_in_chunk = chunk.width - char_offset; @@ -904,16 +966,16 @@ pub const UnifiedTextBufferView = struct { to_add = boundary_w; has_wrap_after = true; } else if (wctx.line_position == 0) { - const is_ascii_only = (chunk.flags & TextChunk.Flags.ASCII_ONLY) != 0; - var byte_offset: u32 = 0; - if (char_offset > 0) { - const pos_result = utf8.findPosByWidth(chunk_bytes, char_offset, wctx.text_buffer.tab_width, is_ascii_only, false, wctx.text_buffer.width_method); - byte_offset = pos_result.byte_offset; - } + // Use tracked byte_offset instead of recalculating from scratch (avoids O(n²)) const remaining_bytes = chunk_bytes[byte_offset..]; const wrap_result = utf8.findWrapPosByWidth(remaining_bytes, remaining_on_line, wctx.text_buffer.tab_width, is_ascii_only, wctx.text_buffer.width_method); to_add = wrap_result.columns_used; - if (to_add == 0) to_add = 1; + byte_offset += wrap_result.byte_offset; + if (to_add == 0) { + to_add = 1; + const single_result = utf8.findWrapPosByWidth(remaining_bytes, 1, wctx.text_buffer.tab_width, is_ascii_only, wctx.text_buffer.width_method); + byte_offset += single_result.byte_offset; + } } else if (wctx.last_wrap_chunk_count > 0) { var accumulated_width: u32 = 0; for (wctx.current_vline.chunks.items[0..wctx.last_wrap_chunk_count]) |vchunk| { @@ -970,16 +1032,15 @@ pub const UnifiedTextBufferView = struct { continue; } else { commitVirtualLine(wctx); - const is_ascii_only = (chunk.flags & TextChunk.Flags.ASCII_ONLY) != 0; - var byte_offset: u32 = 0; - if (char_offset > 0) { - const pos_result = utf8.findPosByWidth(chunk_bytes, char_offset, wctx.text_buffer.tab_width, is_ascii_only, false, wctx.text_buffer.width_method); - byte_offset = pos_result.byte_offset; - } const remaining_bytes = chunk_bytes[byte_offset..]; const wrap_result = utf8.findWrapPosByWidth(remaining_bytes, wctx.wrap_w, wctx.text_buffer.tab_width, is_ascii_only, wctx.text_buffer.width_method); to_add = wrap_result.columns_used; - if (to_add == 0) to_add = 1; + byte_offset += wrap_result.byte_offset; + if (to_add == 0) { + to_add = 1; + const single_result = utf8.findWrapPosByWidth(remaining_bytes, 1, wctx.text_buffer.tab_width, is_ascii_only, wctx.text_buffer.width_method); + byte_offset += single_result.byte_offset; + } } if (to_add > 0) { diff --git a/packages/core/src/zig/text-buffer.zig b/packages/core/src/zig/text-buffer.zig index 98b409dc4..81f778222 100644 --- a/packages/core/src/zig/text-buffer.zig +++ b/packages/core/src/zig/text-buffer.zig @@ -62,6 +62,10 @@ pub const UnifiedTextBuffer = struct { next_view_id: u32, free_view_ids: std.ArrayListUnmanaged(u32), + /// Monotonic counter that increments on every content change. Views use this + /// to detect stale caches even after clearViewDirty() runs. + content_epoch: u64, + // Per-line highlight cache (invalidated on edits) // Maps line_idx to highlights for that line line_highlights: std.ArrayListUnmanaged(std.ArrayListUnmanaged(Highlight)), @@ -118,6 +122,7 @@ pub const UnifiedTextBuffer = struct { .view_dirty_flags = view_dirty_flags, .next_view_id = 0, .free_view_ids = free_view_ids, + .content_epoch = 0, .line_highlights = .{}, .line_spans = .{}, .highlight_batch_depth = 0, @@ -198,7 +203,16 @@ pub const UnifiedTextBuffer = struct { } } + /// Returns the current content epoch. Use this to detect buffer changes + /// independent of the dirty flag (other code paths may clear dirty). + pub fn getContentEpoch(self: *const Self) u64 { + return self.content_epoch; + } + fn markAllViewsDirty(self: *Self) void { + // Increment epoch first so views see the new value when checking caches. + // Use wrapping add for safety, though u64 won't overflow in practice. + self.content_epoch +%= 1; for (self.view_dirty_flags.items) |*flag| { flag.* = true; } @@ -347,7 +361,7 @@ pub const UnifiedTextBuffer = struct { // The rope's boundary rewrite will handle normalization at join points var result = try self.textToSegments(self.global_allocator, text, mem_id, 0, false); - defer result.segments.deinit(); + defer result.segments.deinit(result.allocator); const insert_pos = self.rope.count(); try self.rope.insert_slice(insert_pos, result.segments.items); @@ -363,7 +377,7 @@ pub const UnifiedTextBuffer = struct { } var result = try self.textToSegments(self.global_allocator, text, mem_id, 0, true); - defer result.segments.deinit(); + defer result.segments.deinit(result.allocator); try self.rope.setSegments(result.segments.items); @@ -406,16 +420,16 @@ pub const UnifiedTextBuffer = struct { mem_id: u8, byte_offset: u32, prepend_linestart: bool, - ) TextBufferError!struct { segments: std.ArrayList(Segment), total_width: u32 } { + ) TextBufferError!struct { segments: std.ArrayListUnmanaged(Segment), total_width: u32, allocator: Allocator } { var break_result = utf8.LineBreakResult.init(allocator); defer break_result.deinit(); try utf8.findLineBreaks(text, &break_result); - var segments = std.ArrayList(Segment).init(allocator); - errdefer segments.deinit(); + var segments: std.ArrayListUnmanaged(Segment) = .{}; + errdefer segments.deinit(allocator); if (prepend_linestart) { - try segments.append(Segment{ .linestart = {} }); + try segments.append(allocator, Segment{ .linestart = {} }); } var local_start: u32 = 0; @@ -430,23 +444,23 @@ pub const UnifiedTextBuffer = struct { if (local_end > local_start) { const chunk = self.createChunk(mem_id, byte_offset + local_start, byte_offset + local_end); - try segments.append(Segment{ .text = chunk }); + try segments.append(allocator, Segment{ .text = chunk }); total_width += chunk.width; } - try segments.append(Segment{ .brk = {} }); - try segments.append(Segment{ .linestart = {} }); + try segments.append(allocator, Segment{ .brk = {} }); + try segments.append(allocator, Segment{ .linestart = {} }); local_start = break_pos + 1; } if (local_start < text.len) { const chunk = self.createChunk(mem_id, byte_offset + local_start, byte_offset + @as(u32, @intCast(text.len))); - try segments.append(Segment{ .text = chunk }); + try segments.append(allocator, Segment{ .text = chunk }); total_width += chunk.width; } - return .{ .segments = segments, .total_width = total_width }; + return .{ .segments = segments, .total_width = total_width, .allocator = allocator }; } pub fn getLineCount(self: *const Self) u32 { @@ -645,12 +659,12 @@ pub const UnifiedTextBuffer = struct { hl_idx: usize, }; - var events = std.ArrayList(Event).init(self.global_allocator); - defer events.deinit(); + var events: std.ArrayListUnmanaged(Event) = .{}; + defer events.deinit(self.global_allocator); for (highlights, 0..) |hl, idx| { - try events.append(.{ .col = hl.col_start, .is_start = true, .hl_idx = idx }); - try events.append(.{ .col = hl.col_end, .is_start = false, .hl_idx = idx }); + try events.append(self.global_allocator, .{ .col = hl.col_start, .is_start = true, .hl_idx = idx }); + try events.append(self.global_allocator, .{ .col = hl.col_end, .is_start = false, .hl_idx = idx }); } // Sort by column, ends before starts at same position @@ -972,10 +986,15 @@ pub const UnifiedTextBuffer = struct { return self.tab_width; } - /// Set tab width (will be rounded up to nearest multiple of 2) + /// Set tab width, rounding up to nearest multiple of 2 (minimum 2). + /// Marks all views dirty if the width actually changes, since tab width + /// affects measured line widths and virtual line calculations. pub fn setTabWidth(self: *Self, width: u8) void { const clamped_width = @max(2, width); - self.tab_width = if (clamped_width % 2 == 0) clamped_width else clamped_width + 1; + const new_width = if (clamped_width % 2 == 0) clamped_width else clamped_width + 1; + if (self.tab_width == new_width) return; + self.tab_width = new_width; + self.markAllViewsDirty(); } /// Debug log the rope structure using rope.toText diff --git a/packages/core/src/zig/utf8.zig b/packages/core/src/zig/utf8.zig index 605d1f842..56cc42d69 100644 --- a/packages/core/src/zig/utf8.zig +++ b/packages/core/src/zig/utf8.zig @@ -60,16 +60,18 @@ pub const LineBreak = struct { }; pub const LineBreakResult = struct { - breaks: std.ArrayList(LineBreak), + breaks: std.ArrayListUnmanaged(LineBreak), + allocator: std.mem.Allocator, pub fn init(allocator: std.mem.Allocator) LineBreakResult { return .{ - .breaks = std.ArrayList(LineBreak).init(allocator), + .breaks = .{}, + .allocator = allocator, }; } pub fn deinit(self: *LineBreakResult) void { - self.breaks.deinit(); + self.breaks.deinit(self.allocator); } pub fn reset(self: *LineBreakResult) void { @@ -78,16 +80,18 @@ pub const LineBreakResult = struct { }; pub const TabStopResult = struct { - positions: std.ArrayList(usize), + positions: std.ArrayListUnmanaged(usize), + allocator: std.mem.Allocator, pub fn init(allocator: std.mem.Allocator) TabStopResult { return .{ - .positions = std.ArrayList(usize).init(allocator), + .positions = .{}, + .allocator = allocator, }; } pub fn deinit(self: *TabStopResult) void { - self.positions.deinit(); + self.positions.deinit(self.allocator); } pub fn reset(self: *TabStopResult) void { @@ -96,21 +100,23 @@ pub const TabStopResult = struct { }; pub const WrapBreak = struct { - byte_offset: u16, - char_offset: u16, + byte_offset: u32, + char_offset: u32, }; pub const WrapBreakResult = struct { - breaks: std.ArrayList(WrapBreak), + breaks: std.ArrayListUnmanaged(WrapBreak), + allocator: std.mem.Allocator, pub fn init(allocator: std.mem.Allocator) WrapBreakResult { return .{ - .breaks = std.ArrayList(WrapBreak).init(allocator), + .breaks = .{}, + .allocator = allocator, }; } pub fn deinit(self: *WrapBreakResult) void { - self.breaks.deinit(); + self.breaks.deinit(self.allocator); } pub fn reset(self: *WrapBreakResult) void { @@ -183,7 +189,7 @@ pub fn findWrapBreaks(text: []const u8, result: *WrapBreakResult, width_method: const vector_len = 16; var pos: usize = 0; - var char_offset: u16 = 0; + var char_offset: u32 = 0; var prev_cp: ?u21 = null; // Track previous codepoint for grapheme detection var break_state: uucode.grapheme.BreakState = .default; @@ -233,9 +239,9 @@ pub fn findWrapBreaks(text: []const u8, result: *WrapBreakResult, width_method: // Use bit manipulation to extract positions while (bitmask != 0) { const bit_pos = @ctz(bitmask); - try result.breaks.append(.{ + try result.breaks.append(result.allocator, .{ .byte_offset = @intCast(pos + bit_pos), - .char_offset = char_offset + @as(u16, @intCast(bit_pos)), + .char_offset = char_offset + @as(u32, @intCast(bit_pos)), }); bitmask &= bitmask - 1; } @@ -261,7 +267,7 @@ pub fn findWrapBreaks(text: []const u8, result: *WrapBreakResult, width_method: } else true; if (isAsciiWrapBreak(b0)) { - try result.breaks.append(.{ + try result.breaks.append(result.allocator, .{ .byte_offset = @intCast(pos + i), .char_offset = char_offset, }); @@ -283,7 +289,7 @@ pub fn findWrapBreaks(text: []const u8, result: *WrapBreakResult, width_method: } else true; if (isUnicodeWrapBreak(dec.cp)) { - try result.breaks.append(.{ + try result.breaks.append(result.allocator, .{ .byte_offset = @intCast(pos + i), .char_offset = char_offset, }); @@ -310,7 +316,7 @@ pub fn findWrapBreaks(text: []const u8, result: *WrapBreakResult, width_method: } else true; if (isAsciiWrapBreak(b0)) { - try result.breaks.append(.{ + try result.breaks.append(result.allocator, .{ .byte_offset = @intCast(i), .char_offset = char_offset, }); @@ -330,7 +336,7 @@ pub fn findWrapBreaks(text: []const u8, result: *WrapBreakResult, width_method: } else true; if (isUnicodeWrapBreak(dec.cp)) { - try result.breaks.append(.{ + try result.breaks.append(result.allocator, .{ .byte_offset = @intCast(i), .char_offset = char_offset, }); @@ -361,7 +367,7 @@ pub fn findTabStops(text: []const u8, result: *TabStopResult) !void { var i: usize = 0; while (i < vector_len) : (i += 1) { if (text[pos + i] == '\t') { - try result.positions.append(pos + i); + try result.positions.append(result.allocator, pos + i); } } } @@ -370,7 +376,7 @@ pub fn findTabStops(text: []const u8, result: *TabStopResult) !void { while (pos < text.len) : (pos += 1) { if (text[pos] == '\t') { - try result.positions.append(pos); + try result.positions.append(result.allocator, pos); } } } @@ -408,14 +414,14 @@ pub fn findLineBreaks(text: []const u8, result: *LineBreakResult) !void { } // Check if this is part of CRLF const kind: LineBreakKind = if (absolute_index > 0 and text[absolute_index - 1] == '\r') .CRLF else .LF; - try result.breaks.append(.{ .pos = absolute_index, .kind = kind }); + try result.breaks.append(result.allocator, .{ .pos = absolute_index, .kind = kind }); } else if (b == '\r') { // Check for CRLF if (absolute_index + 1 < text.len and text[absolute_index + 1] == '\n') { - try result.breaks.append(.{ .pos = absolute_index + 1, .kind = .CRLF }); + try result.breaks.append(result.allocator, .{ .pos = absolute_index + 1, .kind = .CRLF }); i += 1; // Skip the \n in next iteration } else { - try result.breaks.append(.{ .pos = absolute_index, .kind = .CR }); + try result.breaks.append(result.allocator, .{ .pos = absolute_index, .kind = .CR }); } } } @@ -440,13 +446,13 @@ pub fn findLineBreaks(text: []const u8, result: *LineBreakResult) !void { } } const kind: LineBreakKind = if (pos > 0 and text[pos - 1] == '\r') .CRLF else .LF; - try result.breaks.append(.{ .pos = pos, .kind = kind }); + try result.breaks.append(result.allocator, .{ .pos = pos, .kind = kind }); } else if (b == '\r') { if (pos + 1 < text.len and text[pos + 1] == '\n') { - try result.breaks.append(.{ .pos = pos + 1, .kind = .CRLF }); + try result.breaks.append(result.allocator, .{ .pos = pos + 1, .kind = .CRLF }); pos += 1; } else { - try result.breaks.append(.{ .pos = pos, .kind = .CR }); + try result.breaks.append(result.allocator, .{ .pos = pos, .kind = .CR }); } } prev_was_cr = false; @@ -1711,11 +1717,12 @@ pub fn findGraphemeInfo( tab_width: u8, isASCIIOnly: bool, width_method: WidthMethod, - result: *std.ArrayList(GraphemeInfo), + allocator: std.mem.Allocator, + result: *std.ArrayListUnmanaged(GraphemeInfo), ) !void { switch (width_method) { - .unicode, .no_zwj => try findGraphemeInfoUnicode(text, tab_width, isASCIIOnly, width_method, result), - .wcwidth => try findGraphemeInfoWCWidth(text, tab_width, isASCIIOnly, result), + .unicode, .no_zwj => try findGraphemeInfoUnicode(text, tab_width, isASCIIOnly, width_method, allocator, result), + .wcwidth => try findGraphemeInfoWCWidth(text, tab_width, isASCIIOnly, allocator, result), } } @@ -1726,7 +1733,8 @@ fn findGraphemeInfoUnicode( tab_width: u8, isASCIIOnly: bool, width_method: WidthMethod, - result: *std.ArrayList(GraphemeInfo), + allocator: std.mem.Allocator, + result: *std.ArrayListUnmanaged(GraphemeInfo), ) !void { if (isASCIIOnly) { return; @@ -1768,7 +1776,7 @@ fn findGraphemeInfoUnicode( if (prev_cp != null and (cluster_is_multibyte or cluster_is_tab)) { if (cluster_width_state.width > 0 or width_method != .wcwidth) { const cluster_byte_len = (pos + i) - cluster_start; - try result.append(GraphemeInfo{ + try result.append(allocator, GraphemeInfo{ .byte_offset = @intCast(cluster_start), .byte_len = @intCast(cluster_byte_len), .width = @intCast(cluster_width_state.width), @@ -1819,7 +1827,7 @@ fn findGraphemeInfoUnicode( if (prev_cp != null and (cluster_is_multibyte or cluster_is_tab)) { if (cluster_width_state.width > 0 or width_method != .wcwidth) { const cluster_byte_len = (pos + i) - cluster_start; - try result.append(GraphemeInfo{ + try result.append(allocator, GraphemeInfo{ .byte_offset = @intCast(cluster_start), .byte_len = @intCast(cluster_byte_len), .width = @intCast(cluster_width_state.width), @@ -1870,7 +1878,7 @@ fn findGraphemeInfoUnicode( if (prev_cp != null and (cluster_is_multibyte or cluster_is_tab)) { if (cluster_width_state.width > 0 or width_method != .wcwidth) { const cluster_byte_len = pos - cluster_start; - try result.append(GraphemeInfo{ + try result.append(allocator, GraphemeInfo{ .byte_offset = @intCast(cluster_start), .byte_len = @intCast(cluster_byte_len), .width = @intCast(cluster_width_state.width), @@ -1908,7 +1916,7 @@ fn findGraphemeInfoUnicode( if (prev_cp != null and (cluster_is_multibyte or cluster_is_tab)) { if (cluster_width_state.width > 0 or width_method != .wcwidth) { const cluster_byte_len = text.len - cluster_start; - try result.append(GraphemeInfo{ + try result.append(allocator, GraphemeInfo{ .byte_offset = @intCast(cluster_start), .byte_len = @intCast(cluster_byte_len), .width = @intCast(cluster_width_state.width), @@ -1924,7 +1932,8 @@ fn findGraphemeInfoWCWidth( text: []const u8, tab_width: u8, isASCIIOnly: bool, - result: *std.ArrayList(GraphemeInfo), + allocator: std.mem.Allocator, + result: *std.ArrayListUnmanaged(GraphemeInfo), ) !void { if (isASCIIOnly) { return; @@ -1955,7 +1964,7 @@ fn findGraphemeInfoWCWidth( const is_multibyte = (cp_len != 1); if ((is_multibyte or is_tab) and cp_width > 0) { - try result.append(GraphemeInfo{ + try result.append(allocator, GraphemeInfo{ .byte_offset = @intCast(pos), .byte_len = @intCast(cp_len), .width = @intCast(cp_width), diff --git a/packages/core/src/zig/vterm.zig b/packages/core/src/zig/vterm.zig new file mode 100644 index 000000000..caced5eea --- /dev/null +++ b/packages/core/src/zig/vterm.zig @@ -0,0 +1,619 @@ +const std = @import("std"); +const ghostty_vt = @import("ghostty-vt"); +const color = ghostty_vt.color; +const pagepkg = ghostty_vt.page; +const formatter = ghostty_vt.formatter; +const Screen = ghostty_vt.Screen; + +// Reusable arena for stateless functions (ptyToJson, ptyToText). +// Reset after each call to reuse allocated pages - avoids mmap/munmap per call. +var stateless_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + +pub const StyleFlags = packed struct(u8) { + bold: bool = false, + italic: bool = false, + underline: bool = false, + strikethrough: bool = false, + inverse: bool = false, + faint: bool = false, + _padding: u2 = 0, + + pub fn toInt(self: StyleFlags) u8 { + return @bitCast(self); + } + + pub fn eql(self: StyleFlags, other: StyleFlags) bool { + return self.toInt() == other.toInt(); + } +}; + +pub const CellStyle = struct { + fg: ?color.RGB, + bg: ?color.RGB, + flags: StyleFlags, + + pub fn eql(self: CellStyle, other: CellStyle) bool { + const fg_eq = if (self.fg) |a| (if (other.fg) |b| a.r == b.r and a.g == b.g and a.b == b.b else false) else other.fg == null; + const bg_eq = if (self.bg) |a| (if (other.bg) |b| a.r == b.r and a.g == b.g and a.b == b.b else false) else other.bg == null; + return fg_eq and bg_eq and self.flags.eql(other.flags); + } +}; + +fn getStyleFromCell( + cell: *const pagepkg.Cell, + pin: ghostty_vt.Pin, + palette: *const color.Palette, + terminal_bg: ?color.RGB, +) CellStyle { + var flags: StyleFlags = .{}; + var fg: ?color.RGB = null; + var bg: ?color.RGB = null; + + const style = pin.style(cell); + + flags.bold = style.flags.bold; + flags.italic = style.flags.italic; + flags.faint = style.flags.faint; + flags.inverse = style.flags.inverse; + flags.strikethrough = style.flags.strikethrough; + flags.underline = style.flags.underline != .none; + + fg = switch (style.fg_color) { + .none => null, + .palette => |idx| palette[idx], + .rgb => |rgb| rgb, + }; + + bg = style.bg(cell, palette) orelse switch (cell.content_tag) { + .bg_color_palette => palette[cell.content.color_palette], + .bg_color_rgb => .{ .r = cell.content.color_rgb.r, .g = cell.content.color_rgb.g, .b = cell.content.color_rgb.b }, + else => null, + }; + + if (bg) |cell_bg| { + if (terminal_bg) |term_bg| { + if (cell_bg.r == term_bg.r and cell_bg.g == term_bg.g and cell_bg.b == term_bg.b) { + bg = null; + } + } + } + + return .{ .fg = fg, .bg = bg, .flags = flags }; +} + +fn writeJsonString(writer: anytype, s: []const u8) !void { + try writer.writeByte('"'); + for (s) |c| { + switch (c) { + '"' => try writer.writeAll("\\\""), + '\\' => try writer.writeAll("\\\\"), + '\n' => try writer.writeAll("\\n"), + '\r' => try writer.writeAll("\\r"), + '\t' => try writer.writeAll("\\t"), + else => { + if (c < 0x20) { + try writer.print("\\u{x:0>4}", .{c}); + } else { + try writer.writeByte(c); + } + }, + } + } + try writer.writeByte('"'); +} + +fn writeColor(writer: anytype, rgb: ?color.RGB) !void { + if (rgb) |c| { + try writer.print("\"#{x:0>2}{x:0>2}{x:0>2}\"", .{ c.r, c.g, c.b }); + } else { + try writer.writeAll("null"); + } +} + +fn countLines(screen: *Screen) usize { + var total: usize = 0; + var iter = screen.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + while (iter.next()) |_| { + total += 1; + } + return total; +} + +fn hasEnoughLines(screen: *Screen, threshold: usize) bool { + var count: usize = 0; + var iter = screen.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + while (iter.next()) |_| { + count += 1; + if (count >= threshold) return true; + } + return false; +} + +pub fn writeJsonOutput( + writer: anytype, + t: *ghostty_vt.Terminal, + offset: usize, + limit: ?usize, + show_cursor: bool, +) !void { + const screen = t.screens.active; + const palette = &t.colors.palette.current; + const terminal_bg = t.colors.background.get(); + + const total_lines = countLines(screen); + + // Calculate cursor row in absolute screen coordinates (for inverting cursor cell) + const cursor_abs_row: ?usize = if (show_cursor) blk: { + const rows: usize = screen.pages.rows; + const viewport_start = if (total_lines >= rows) total_lines - rows else 0; + break :blk viewport_start + screen.cursor.y; + } else null; + const cursor_col: usize = screen.cursor.x; + + try writer.writeAll("{"); + try writer.print("\"cols\":{},\"rows\":{},", .{ screen.pages.cols, screen.pages.rows }); + try writer.print("\"cursor\":[{},{}],", .{ screen.cursor.x, screen.cursor.y }); + try writer.print("\"offset\":{},\"totalLines\":{},", .{ offset, total_lines }); + try writer.writeAll("\"lines\":["); + + var text_buf: [4096]u8 = undefined; + var row_iter = screen.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + var row_idx: usize = 0; + var output_idx: usize = 0; + + while (row_iter.next()) |pin| { + if (row_idx < offset) { + row_idx += 1; + continue; + } + + if (limit) |lim| { + if (output_idx >= lim) break; + } + + if (output_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + + const cells = pin.cells(.all); + var span_start: usize = 0; + var span_len: usize = 0; + var current_style: ?CellStyle = null; + var text_len: usize = 0; + var span_idx: usize = 0; + + // Check if cursor is on this row + const is_cursor_row = if (cursor_abs_row) |crow| row_idx == crow else false; + + for (cells, 0..) |*cell, col_idx| { + if (cell.wide == .spacer_tail) continue; + + const cp = cell.codepoint(); + const is_null = cp == 0; + + // Check if this cell is at cursor position + const is_cursor_cell = is_cursor_row and col_idx == cursor_col; + + // Handle cursor on empty cell - emit a single-char inverted span + if (is_null and is_cursor_cell) { + // First flush any pending span + if (text_len > 0) { + if (span_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + try writeJsonString(writer, text_buf[0..text_len]); + try writer.writeByte(','); + try writeColor(writer, current_style.?.fg); + try writer.writeByte(','); + try writeColor(writer, current_style.?.bg); + try writer.print(",{},{}", .{ current_style.?.flags.toInt(), span_len }); + try writer.writeByte(']'); + span_idx += 1; + text_len = 0; + span_len = 0; + current_style = null; + } + // Emit cursor span with space and inverse flag + if (span_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + try writeJsonString(writer, " "); + try writer.writeAll(",null,null,"); + const cursor_flags = StyleFlags{ .inverse = true }; + try writer.print("{},1", .{cursor_flags.toInt()}); + try writer.writeByte(']'); + span_idx += 1; + continue; + } + + if (is_null) { + if (text_len > 0) { + if (span_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + try writeJsonString(writer, text_buf[0..text_len]); + try writer.writeByte(','); + try writeColor(writer, current_style.?.fg); + try writer.writeByte(','); + try writeColor(writer, current_style.?.bg); + try writer.print(",{},{}", .{ current_style.?.flags.toInt(), span_len }); + try writer.writeByte(']'); + span_idx += 1; + text_len = 0; + span_len = 0; + } + current_style = null; + continue; + } + + var style = getStyleFromCell(cell, pin, palette, terminal_bg); + + // Toggle inverse for cursor cell + if (is_cursor_cell) { + style.flags.inverse = !style.flags.inverse; + } + + const style_changed = if (current_style) |cs| !cs.eql(style) else true; + + if (style_changed and text_len > 0) { + if (span_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + try writeJsonString(writer, text_buf[0..text_len]); + try writer.writeByte(','); + try writeColor(writer, current_style.?.fg); + try writer.writeByte(','); + try writeColor(writer, current_style.?.bg); + try writer.print(",{},{}", .{ current_style.?.flags.toInt(), span_len }); + try writer.writeByte(']'); + span_idx += 1; + text_len = 0; + span_len = 0; + } + + if (style_changed) { + span_start = col_idx; + current_style = style; + } + + const cp21: u21 = @intCast(cp); + const len = std.unicode.utf8CodepointSequenceLength(cp21) catch 1; + if (text_len + len <= text_buf.len) { + _ = std.unicode.utf8Encode(cp21, text_buf[text_len..]) catch 0; + text_len += len; + } + + span_len += if (cell.wide == .wide) 2 else 1; + } + + if (text_len > 0) { + if (span_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + try writeJsonString(writer, text_buf[0..text_len]); + try writer.writeByte(','); + try writeColor(writer, current_style.?.fg); + try writer.writeByte(','); + try writeColor(writer, current_style.?.bg); + try writer.print(",{},{}", .{ current_style.?.flags.toInt(), span_len }); + try writer.writeByte(']'); + } + + try writer.writeByte(']'); + row_idx += 1; + output_idx += 1; + } + + try writer.writeAll("]}"); +} + +const ReadonlyStream = @typeInfo(@TypeOf(ghostty_vt.Terminal.vtStream)).@"fn".return_type.?; + +pub const PersistentTerminal = struct { + terminal: ghostty_vt.Terminal, + arena: std.heap.ArenaAllocator, + stream: ?ReadonlyStream, + + /// Create an uninitialized PersistentTerminal. Must call initTerminal() after + /// the struct is in its final memory location (heap-allocated). + pub fn create(backing_alloc: std.mem.Allocator) PersistentTerminal { + return .{ + .terminal = undefined, + .arena = std.heap.ArenaAllocator.init(backing_alloc), + .stream = null, + }; + } + + /// Initialize the terminal. Must be called after the struct is heap-allocated + /// so the arena's address is stable when stored in the terminal. + pub fn initTerminal(self: *PersistentTerminal, cols: u16, rows: u16) !void { + self.terminal = try ghostty_vt.Terminal.init(self.arena.allocator(), .{ + .cols = cols, + .rows = rows, + .max_scrollback = std.math.maxInt(usize), + }); + self.terminal.modes.set(.linefeed, true); + } + + pub fn initStream(self: *PersistentTerminal) void { + self.stream = self.terminal.vtStream(); + } + + pub fn deinit(self: *PersistentTerminal) void { + // Arena deinit frees everything: terminal internals, stream, and output strings + self.arena.deinit(); + } + + pub fn allocator(self: *PersistentTerminal) std.mem.Allocator { + return self.arena.allocator(); + } + + pub fn feed(self: *PersistentTerminal, data: []const u8) !void { + try self.stream.?.nextSlice(data); + } + + pub fn isReady(self: *const PersistentTerminal) bool { + if (self.stream) |s| { + return s.parser.state == .ground; + } + return true; + } + + pub fn resize(self: *PersistentTerminal, cols: u16, rows: u16) !void { + try self.terminal.resize(self.arena.allocator(), cols, rows); + } + + pub fn reset(self: *PersistentTerminal) void { + self.terminal.fullReset(); + if (self.stream) |*s| { + s.deinit(); + } + self.stream = self.terminal.vtStream(); + } +}; + +var terminals_mutex: std.Thread.Mutex = .{}; +var terminals: ?std.AutoHashMap(u32, *PersistentTerminal) = null; + +fn getTerminalsMap() *std.AutoHashMap(u32, *PersistentTerminal) { + if (terminals == null) { + terminals = std.AutoHashMap(u32, *PersistentTerminal).init(std.heap.page_allocator); + } + return &terminals.?; +} + +/// Stateless: parse PTY input and write JSON to caller-provided buffer. +/// Returns bytes written, or 0 on error. +pub fn ptyToJson( + input_ptr: [*]const u8, + input_len: usize, + cols: u16, + rows: u16, + offset: usize, + limit: usize, + out_ptr: [*]u8, + max_len: usize, +) usize { + // Reset arena after use - keeps allocated pages for next call + defer _ = stateless_arena.reset(.retain_capacity); + const alloc = stateless_arena.allocator(); + + const input = input_ptr[0..input_len]; + const lim: ?usize = if (limit == 0) null else limit; + const out_buffer = out_ptr[0..max_len]; + + var t: ghostty_vt.Terminal = ghostty_vt.Terminal.init(alloc, .{ + .cols = cols, + .rows = rows, + .max_scrollback = std.math.maxInt(usize), + }) catch return 0; + + t.modes.set(.linefeed, true); + + var stream = t.vtStream(); + defer stream.deinit(); + + if (lim) |line_limit| { + const chunk_size: usize = 4096; + const threshold = line_limit + offset + 20; + var pos: usize = 0; + + while (pos < input.len) { + const end = @min(pos + chunk_size, input.len); + stream.nextSlice(input[pos..end]) catch return 0; + pos = end; + + if (stream.parser.state == .ground) { + if (hasEnoughLines(t.screens.active, threshold)) { + break; + } + } + } + } else { + stream.nextSlice(input) catch return 0; + } + + // Write directly to the caller-provided buffer + var fbs = std.io.fixedBufferStream(out_buffer); + writeJsonOutput(fbs.writer(), &t, offset, lim, false) catch return 0; + + return fbs.pos; +} + +/// Stateless: parse PTY input and write plain text to caller-provided buffer. +/// Returns bytes written, or 0 on error. +pub fn ptyToText( + input_ptr: [*]const u8, + input_len: usize, + cols: u16, + rows: u16, + out_ptr: [*]u8, + max_len: usize, +) usize { + // Reset arena after use - keeps allocated pages for next call + defer _ = stateless_arena.reset(.retain_capacity); + const alloc = stateless_arena.allocator(); + + const input = input_ptr[0..input_len]; + const out_buffer = out_ptr[0..max_len]; + + var t: ghostty_vt.Terminal = ghostty_vt.Terminal.init(alloc, .{ + .cols = cols, + .rows = rows, + .max_scrollback = std.math.maxInt(usize), + }) catch return 0; + + t.modes.set(.linefeed, true); + + var stream = t.vtStream(); + defer stream.deinit(); + + stream.nextSlice(input) catch return 0; + + // TerminalFormatter requires std.Io.Writer.Allocating, so write to temp buffer first + var builder: std.Io.Writer.Allocating = .init(alloc); + var fmt: formatter.TerminalFormatter = formatter.TerminalFormatter.init(&t, .plain); + fmt.format(&builder.writer) catch return 0; + + const temp_output = builder.writer.buffered(); + const copy_len = @min(temp_output.len, max_len); + @memcpy(out_buffer[0..copy_len], temp_output[0..copy_len]); + + return copy_len; +} + +pub fn createTerminal(id: u32, cols: u32, rows: u32) bool { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + + if (map.get(id)) |existing| { + existing.deinit(); + std.heap.page_allocator.destroy(existing); + _ = map.remove(id); + } + + // Two-phase init: first allocate struct to heap, then init terminal in-place. + // This ensures the arena's address is stable when stored in the terminal. + const term_ptr = std.heap.page_allocator.create(PersistentTerminal) catch return false; + term_ptr.* = PersistentTerminal.create(std.heap.page_allocator); + + term_ptr.initTerminal(@intCast(cols), @intCast(rows)) catch { + term_ptr.arena.deinit(); + std.heap.page_allocator.destroy(term_ptr); + return false; + }; + + term_ptr.initStream(); + + map.put(id, term_ptr) catch { + term_ptr.deinit(); + std.heap.page_allocator.destroy(term_ptr); + return false; + }; + + return true; +} + +pub fn destroyTerminal(id: u32) void { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + if (map.get(id)) |term| { + term.deinit(); + std.heap.page_allocator.destroy(term); + _ = map.remove(id); + } +} + +pub fn feedTerminal(id: u32, data_ptr: [*]const u8, data_len: usize) bool { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return false; + term.feed(data_ptr[0..data_len]) catch return false; + return true; +} + +pub fn resizeTerminal(id: u32, cols: u32, rows: u32) bool { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return false; + term.resize(@intCast(cols), @intCast(rows)) catch return false; + return true; +} + +pub fn resetTerminal(id: u32) bool { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return false; + term.reset(); + return true; +} + +/// Write terminal JSON to caller-provided buffer. Returns bytes written. +pub fn getTerminalJson(id: u32, offset: u32, limit: u32, out_ptr: [*]u8, max_len: usize) usize { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return 0; + + const lim: ?usize = if (limit == 0) null else @intCast(limit); + const out_buffer = out_ptr[0..max_len]; + + var fbs = std.io.fixedBufferStream(out_buffer); + writeJsonOutput(fbs.writer(), &term.terminal, @intCast(offset), lim, true) catch return 0; + + return fbs.pos; +} + +/// Write terminal plain text to caller-provided buffer. Returns bytes written. +pub fn getTerminalText(id: u32, out_ptr: [*]u8, max_len: usize) usize { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return 0; + + const out_buffer = out_ptr[0..max_len]; + + // TerminalFormatter requires std.Io.Writer.Allocating, so write to temp buffer first + var builder: std.Io.Writer.Allocating = .init(term.allocator()); + var fmt: formatter.TerminalFormatter = formatter.TerminalFormatter.init(&term.terminal, .plain); + fmt.format(&builder.writer) catch return 0; + + const temp_output = builder.writer.buffered(); + const copy_len = @min(temp_output.len, max_len); + @memcpy(out_buffer[0..copy_len], temp_output[0..copy_len]); + + return copy_len; +} + +/// Write terminal cursor position JSON to caller-provided buffer. Returns bytes written. +pub fn getTerminalCursor(id: u32, out_ptr: [*]u8, max_len: usize) usize { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return 0; + + const screen = term.terminal.screens.active; + const out_buffer = out_ptr[0..max_len]; + + var fbs = std.io.fixedBufferStream(out_buffer); + fbs.writer().print("[{},{}]", .{ screen.cursor.x, screen.cursor.y }) catch return 0; + + return fbs.pos; +} + +pub fn isTerminalReady(id: u32) i32 { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return -1; + + return if (term.isReady()) 1 else 0; +} diff --git a/packages/go/README.md b/packages/go/README.md index 5252150d6..71cbb53b2 100644 --- a/packages/go/README.md +++ b/packages/go/README.md @@ -232,7 +232,7 @@ cd examples/console && go run . To build with a custom OpenTUI library: ```bash -# Build Zig library (requires Zig 0.14+) +# Build Zig library (requires Zig 0.15.2+) cd ../../core/src/zig zig build -Doptimize=ReleaseFast diff --git a/packages/react/examples/flush-sync.tsx b/packages/react/examples/flush-sync.tsx new file mode 100644 index 000000000..ababac787 --- /dev/null +++ b/packages/react/examples/flush-sync.tsx @@ -0,0 +1,61 @@ +import { createCliRenderer } from "@opentui/core" +import { createRoot, flushSync, useKeyboard } from "@opentui/react" +import { useRef, useState } from "react" + +/** + * flushSync forces React to flush updates synchronously, preventing batching. + * Press 'a' to see batched updates (1 render for 3 setState calls). + * Press 's' to see flushSync updates (3 separate renders). + */ +export const App = () => { + const [a, setA] = useState(0) + const [b, setB] = useState(0) + const [c, setC] = useState(0) + const renderCount = useRef(0) + const [log, setLog] = useState([]) + + renderCount.current++ + + useKeyboard((key) => { + if (key.name === "q") process.exit(0) + + if (key.name === "a") { + const before = renderCount.current + // Without flushSync: React batches all 3 into 1 render + setA((x) => x + 1) + setB((x) => x + 1) + setC((x) => x + 1) + const after = renderCount.current + setLog((l) => [...l.slice(-4), `batched: renders ${before} -> ${after} (no change yet)`]) + } + + if (key.name === "s") { + const before = renderCount.current + // With flushSync: each update triggers a separate render + flushSync(() => setA((x) => x + 1)) + flushSync(() => setB((x) => x + 1)) + flushSync(() => setC((x) => x + 1)) + const after = renderCount.current + setLog((l) => [...l.slice(-4), `flushSync: renders ${before} -> ${after} (+3 renders)`]) + } + }) + + return ( + + + + + + {log.map((l, i) => ( + + ))} + + + ) +} + +const renderer = await createCliRenderer() +createRoot(renderer).render() diff --git a/packages/react/package.json b/packages/react/package.json index 6267dd0a1..0c43f8d90 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@opentui/react", - "version": "0.1.67", + "version": "0.1.69", "description": "React renderer for building terminal user interfaces using OpenTUI core", "license": "MIT", "repository": { diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 94f568c12..69905c328 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -7,7 +7,9 @@ import { LineNumberRenderable, ScrollBoxRenderable, SelectRenderable, + StatelessTerminalRenderable, TabSelectRenderable, + TerminalRenderable, TextareaRenderable, TextRenderable, } from "@opentui/core" @@ -30,6 +32,8 @@ export const baseComponents = { select: SelectRenderable, textarea: TextareaRenderable, scrollbox: ScrollBoxRenderable, + terminal: TerminalRenderable, + "stateless-terminal": StatelessTerminalRenderable, "ascii-font": ASCIIFontRenderable, "tab-select": TabSelectRenderable, "line-number": LineNumberRenderable, diff --git a/packages/react/src/reconciler/renderer.ts b/packages/react/src/reconciler/renderer.ts index 79acee57c..145333415 100644 --- a/packages/react/src/reconciler/renderer.ts +++ b/packages/react/src/reconciler/renderer.ts @@ -5,7 +5,11 @@ import { AppContext } from "../components/app" import { ErrorBoundary } from "../components/error-boundary" import { _render, reconciler } from "./reconciler" -const { flushSync, createPortal } = reconciler +// flushSync was renamed to flushSyncFromReconciler in react-reconciler 0.32.0 +// the types for react-reconciler are not up to date with the library +const _r = reconciler as typeof reconciler & { flushSyncFromReconciler?: typeof reconciler.flushSync } +const flushSync = _r.flushSyncFromReconciler ?? _r.flushSync +const { createPortal } = reconciler export type Root = { render: (node: ReactNode) => void diff --git a/packages/react/src/types/components.ts b/packages/react/src/types/components.ts index fe63d1142..8e40ad156 100644 --- a/packages/react/src/types/components.ts +++ b/packages/react/src/types/components.ts @@ -19,9 +19,13 @@ import type { SelectOption, SelectRenderable, SelectRenderableOptions, + StatelessTerminalOptions, + StatelessTerminalRenderable, TabSelectOption, TabSelectRenderable, TabSelectRenderableOptions, + TerminalOptions, + TerminalRenderable, TextareaOptions, TextareaRenderable, TextNodeOptions, @@ -144,6 +148,10 @@ export type CodeProps = ComponentProps export type DiffProps = ComponentProps +export type TerminalProps = ComponentProps + +export type StatelessTerminalProps = ComponentProps + export type SelectProps = ComponentProps & { focused?: boolean onChange?: (index: number, option: SelectOption | null) => void diff --git a/packages/solid/examples/components/ExampleSelector.tsx b/packages/solid/examples/components/ExampleSelector.tsx index bbaa002ab..7dac7e3e8 100644 --- a/packages/solid/examples/components/ExampleSelector.tsx +++ b/packages/solid/examples/components/ExampleSelector.tsx @@ -13,11 +13,17 @@ import MouseScene from "./mouse-demo.tsx" import { ScrollDemo, ScrollDemoIndex } from "./scroll-demo.tsx" import { CustomScrollAccelDemo } from "./custom-scroll-accel-demo.tsx" import TabSelectDemo from "./tab-select-demo.tsx" +import TerminalGridDemo from "./terminal-grid-demo.tsx" import TextSelectionDemo from "./text-selection-demo.tsx" import TextStyleScene from "./text-style-demo.tsx" import { TextareaDemo } from "./textarea-demo.tsx" const EXAMPLES = [ + { + name: "Terminal Grid Demo", + description: "2x2 grid of interactive terminals with focus switching", + scene: "terminal-grid-demo", + }, { name: "Diff Viewer Demo", description: "Unified and split diff view with syntax highlighting", @@ -150,6 +156,9 @@ const ExampleSelector = () => { return ( + + + diff --git a/packages/solid/examples/components/terminal-grid-demo.tsx b/packages/solid/examples/components/terminal-grid-demo.tsx new file mode 100644 index 000000000..bdf4d4d4f --- /dev/null +++ b/packages/solid/examples/components/terminal-grid-demo.tsx @@ -0,0 +1,171 @@ +import { type KeyEvent } from "@opentui/core" +import { spawn, type IPty } from "bun-pty" +import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { createSignal, onCleanup, onMount, For, createEffect } from "solid-js" + +interface TerminalStream { + readable: ReadableStream + writable: WritableStream + pty: IPty +} + +const GRID_COLS = 2 +const GRID_ROWS = 2 +const TOTAL_TERMINALS = GRID_COLS * GRID_ROWS + +async function spawnPty(cols: number, rows: number) { + try { + return spawn("opencode", [], { + name: "xterm-256color", + cols, + rows, + cwd: process.cwd(), + env: { ...process.env, TERM: "xterm-256color" }, + }) + } catch (e) { + console.error("Failed to spawn PTY:", e) + return null + } +} + +export default function TerminalGridDemo() { + const renderer = useRenderer() + const dims = useTerminalDimensions() + + const [focusedIndex, setFocusedIndex] = createSignal(0) + const [status, setStatus] = createSignal("Initializing...") + const [streams, setStreams] = createSignal<(TerminalStream | null)[]>([]) + + const terminalCols = () => Math.floor((dims().width - 4) / GRID_COLS) - 2 + const terminalRows = () => Math.floor((dims().height - 5) / GRID_ROWS) - 2 + + onMount(async () => { + renderer.useMouse = true + + const newStreams: (TerminalStream | null)[] = [] + + for (let i = 0; i < TOTAL_TERMINALS; i++) { + const pty = await spawnPty(terminalCols(), terminalRows()) + + if (pty) { + const readable = new ReadableStream({ + start(controller) { + pty.onData((data) => controller.enqueue(data)) + + pty.onExit(() => controller.close()) + }, + }) + + const writable = new WritableStream({ + write(chunk) { + pty.write(chunk) + }, + }) + + newStreams.push({ readable, writable, pty }) + } else { + newStreams.push(null) + } + } + + setStreams(newStreams) + setStatus(`${TOTAL_TERMINALS} terminals ready - Tab to switch focus, Ctrl+Q to quit`) + }) + + onCleanup(() => { + for (const stream of streams()) { + stream?.pty.kill() + } + }) + + createEffect(() => { + const cols = terminalCols() + const rows = terminalRows() + for (const stream of streams()) { + stream?.pty.resize(cols, rows) + } + }) + + useKeyboard((key: KeyEvent) => { + if (key.name === "tab") { + if (key.shift) { + setFocusedIndex((prev) => (prev - 1 + TOTAL_TERMINALS) % TOTAL_TERMINALS) + } else { + setFocusedIndex((prev) => (prev + 1) % TOTAL_TERMINALS) + } + return + } + + if (key.ctrl && key.name === "q") { + for (const stream of streams()) { + stream?.pty.kill() + } + renderer.stop() + process.exit(0) + } + + const stream = streams()[focusedIndex()] + if (stream && key.raw) { + stream.pty.write(key.raw) + } + }) + + const handleClick = (index: number) => () => setFocusedIndex(index) + + return ( + + + + + + + + + + + i)}> + {(row) => ( + + i)}> + {(col) => { + const index = row * GRID_COLS + col + const stream = () => streams()[index] + const isFocused = () => focusedIndex() === index + + return ( + + {stream() && ( + + )} + + ) + }} + + + )} + + + + ) +} diff --git a/packages/solid/examples/index.tsx b/packages/solid/examples/index.tsx index cdebd894e..1e90868b3 100644 --- a/packages/solid/examples/index.tsx +++ b/packages/solid/examples/index.tsx @@ -1,6 +1,6 @@ import { render } from "@opentui/solid" import { ConsolePosition } from "@opentui/core" -import ExampleSelector from "./components/ExampleSelector" +import ExampleSelector from "./components/terminal-grid-demo" // Uncomment to debug solidjs reconciler // process.env.DEBUG = "true" diff --git a/packages/solid/index.ts b/packages/solid/index.ts index 94bc098ee..011d926f2 100644 --- a/packages/solid/index.ts +++ b/packages/solid/index.ts @@ -1,24 +1,39 @@ -import { createCliRenderer, engine, type CliRendererConfig } from "@opentui/core" +import { CliRenderer, createCliRenderer, engine, type CliRendererConfig } from "@opentui/core" import { createTestRenderer, type TestRendererOptions } from "@opentui/core/testing" import type { JSX } from "./jsx-runtime" import { RendererContext } from "./src/elements" import { _render as renderInternal, createComponent } from "./src/reconciler" -export const render = async (node: () => JSX.Element, renderConfig: CliRendererConfig = {}) => { +export const render = async (node: () => JSX.Element, rendererOrConfig: CliRenderer | CliRendererConfig = {}) => { let isDisposed = false - const renderer = await createCliRenderer({ - ...renderConfig, - onDestroy: () => { + let dispose: () => void + + const renderer = + rendererOrConfig instanceof CliRenderer + ? rendererOrConfig + : await createCliRenderer({ + ...rendererOrConfig, + onDestroy: () => { + if (!isDisposed) { + isDisposed = true + dispose() + } + rendererOrConfig.onDestroy?.() + }, + }) + + if (rendererOrConfig instanceof CliRenderer) { + renderer.on("destroy", () => { if (!isDisposed) { isDisposed = true dispose() } - renderConfig.onDestroy?.() - }, - }) + }) + } + engine.attach(renderer) - const dispose = renderInternal( + dispose = renderInternal( () => createComponent(RendererContext.Provider, { get value() { diff --git a/packages/solid/package.json b/packages/solid/package.json index 4bb4dec42..76249ed7a 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@opentui/solid", - "version": "0.1.67", + "version": "0.1.69", "description": "SolidJS renderer for OpenTUI", "repository": { "type": "git", diff --git a/packages/solid/src/elements/index.ts b/packages/solid/src/elements/index.ts index bf46a04d5..ed27b1599 100644 --- a/packages/solid/src/elements/index.ts +++ b/packages/solid/src/elements/index.ts @@ -7,7 +7,9 @@ import { LineNumberRenderable, ScrollBoxRenderable, SelectRenderable, + StatelessTerminalRenderable, TabSelectRenderable, + TerminalRenderable, TextareaRenderable, TextAttributes, TextNodeRenderable, @@ -102,6 +104,8 @@ export const baseComponents = { code: CodeRenderable, diff: DiffRenderable, line_number: LineNumberRenderable, + terminal: TerminalRenderable, + stateless_terminal: StatelessTerminalRenderable, span: SpanRenderable, strong: BoldSpanRenderable, diff --git a/packages/solid/tests/__snapshots__/textarea.test.tsx.snap b/packages/solid/tests/__snapshots__/textarea.test.tsx.snap index faf26535d..d3f4b376b 100644 --- a/packages/solid/tests/__snapshots__/textarea.test.tsx.snap +++ b/packages/solid/tests/__snapshots__/textarea.test.tsx.snap @@ -519,5 +519,175 @@ start +" +`; + +exports[`Textarea Layout Tests Measure Cache Edge Cases should correctly measure text after content change 1`] = ` +"┌──────────────────────────────────────┐ +│Short text │ +└──────────────────────────────────────┘ + + + + + + + + + + + + +" +`; + +exports[`Textarea Layout Tests Measure Cache Edge Cases should correctly measure text after content change 2`] = ` +"┌──────────────────────────────────────┐ +│This is a much longer text that will │ +│definitely wrap to multiple lines │ +│when rendered │ +└──────────────────────────────────────┘ + + + + + + + + + + +" +`; + +exports[`Textarea Layout Tests Measure Cache Edge Cases should handle rapid content updates correctly 1`] = ` +"┌────────────────────────────┐ +│Update 4: some text here │ +└────────────────────────────┘ + + + + + + + +" +`; + +exports[`Textarea Layout Tests Measure Cache Edge Cases should handle width changes with cached measures 1`] = ` +"┌────────────────────────────┐ +│Content that will wrap │ +│differently at different │ +│widths │ +└────────────────────────────┘ + + + + + + + + + + +" +`; + +exports[`Textarea Layout Tests Measure Cache Edge Cases should handle width changes with cached measures 2`] = ` +"┌────────────────────────────────────────────────┐ +│Content that will wrap differently at different │ +│widths │ +└────────────────────────────────────────────────┘ + + + + + + + + + + + +" +`; + +exports[`Textarea Layout Tests Measure Cache Edge Cases should handle width changes with cached measures 3`] = ` +"┌──────────────────┐ +│Content that will │ +│wrap differently │ +│at different │ +│widths │ +└──────────────────┘ + + + + + + + + + +" +`; + +exports[`Textarea Layout Tests Measure Cache Edge Cases should handle empty to non-empty content transition 1`] = ` +"┌──────────────────────────────────────┐ +│ │ +└──────────────────────────────────────┘ + + + + + + + +" +`; + +exports[`Textarea Layout Tests Measure Cache Edge Cases should handle empty to non-empty content transition 2`] = ` +"┌──────────────────────────────────────┐ +│Now with content │ +└──────────────────────────────────────┘ + + + + + + + +" +`; + +exports[`Textarea Layout Tests Measure Cache Edge Cases should handle empty to non-empty content transition 3`] = ` +"┌──────────────────────────────────────┐ +│ │ +└──────────────────────────────────────┘ + + + + + + + +" +`; + +exports[`Textarea Layout Tests Measure Cache Edge Cases should correctly measure multiline content with unicode 1`] = ` +"┌────────────────────────────┐ +│Hello 世界 │ +│こんにちは │ +│🌟 Emoji 🚀 │ +└────────────────────────────┘ + + + + + + + + + + " `; diff --git a/packages/solid/tests/link.test.tsx b/packages/solid/tests/link.test.tsx index 6fce0c9ba..c9267c5fe 100644 --- a/packages/solid/tests/link.test.tsx +++ b/packages/solid/tests/link.test.tsx @@ -1,8 +1,14 @@ import { describe, expect, it, beforeEach, afterEach } from "bun:test" import { testRender } from "../index" +import type { TextRenderable } from "@opentui/core" let testSetup: Awaited> +// Helper to get text renderable from renderer +function getTextRenderable(renderer: any): TextRenderable { + return renderer.root.getChildren()[0] as TextRenderable +} + describe("Link Rendering Tests", () => { beforeEach(async () => { if (testSetup) { @@ -10,12 +16,6 @@ describe("Link Rendering Tests", () => { } }) - afterEach(() => { - if (testSetup) { - testSetup.renderer.destroy() - } - }) - it("should render link with href correctly", async () => { testSetup = await testRender( () => ( @@ -78,4 +78,300 @@ describe("Link Rendering Tests", () => { expect(frame).toContain("GitHub") expect(frame).toContain("our website") }) + + it("should inherit link from parent to nested styled span", async () => { + testSetup = await testRender( + () => ( + + + styled text default style + + + ), + { + width: 60, + height: 5, + }, + ) + + await testSetup.renderOnce() + const frame = testSetup.captureCharFrame() + + // Both parts should be rendered + expect(frame).toContain("styled text default style") + }) + + it("should inherit link from parent to multiple nested elements", async () => { + testSetup = await testRender( + () => ( + + Visit{" "} + + our awesome website + {" "} + today + + ), + { + width: 60, + height: 5, + }, + ) + + await testSetup.renderOnce() + const frame = testSetup.captureCharFrame() + + expect(frame).toContain("Visit our awesome website today") + }) + + it("should inherit link to deeply nested spans", async () => { + testSetup = await testRender( + () => ( + + + + Level 1 Level 2 + + + + ), + { + width: 60, + height: 5, + }, + ) + + await testSetup.renderOnce() + const frame = testSetup.captureCharFrame() + + expect(frame).toContain("Level 1 Level 2") + }) + + it("should handle mixed linked and non-linked text", async () => { + testSetup = await testRender( + () => ( + + Plain text linked text more plain other link + + ), + { + width: 80, + height: 5, + }, + ) + + await testSetup.renderOnce() + const frame = testSetup.captureCharFrame() + + expect(frame).toContain("Plain text linked text more plain other link") + }) + + it("should preserve styles when inheriting link", async () => { + testSetup = await testRender( + () => ( + + + Bold Italic Underline Normal + + + ), + { + width: 80, + height: 5, + }, + ) + + await testSetup.renderOnce() + const frame = testSetup.captureCharFrame() + + expect(frame).toContain("Bold Italic Underline Normal") + }) + + it("should not override child link with parent link", async () => { + testSetup = await testRender( + () => ( + + + Parent link child link parent again + + + ), + { + width: 80, + height: 5, + }, + ) + + await testSetup.renderOnce() + const frame = testSetup.captureCharFrame() + + expect(frame).toContain("Parent link child link parent again") + }) + + it("should handle empty link content", async () => { + testSetup = await testRender( + () => ( + + Before After + + ), + { + width: 80, + height: 5, + }, + ) + + await testSetup.renderOnce() + const frame = testSetup.captureCharFrame() + + expect(frame).toContain("Before After") + }) + + describe("Link Chunk Verification", () => { + it("should create chunks with link for all nested content", async () => { + testSetup = await testRender( + () => ( + + + styled plain + + + ), + { + width: 80, + height: 5, + }, + ) + + await testSetup.renderOnce() + const textRenderable = getTextRenderable(testSetup.renderer) + const chunks = textRenderable.textNode.gatherWithInheritedStyle() + + // All chunks should have the link + for (const chunk of chunks) { + if (chunk.text.trim()) { + // Skip empty chunks + expect(chunk.link).toBeDefined() + expect(chunk.link?.url).toBe("https://opentui.com") + } + } + }) + + it("should inherit link through multiple nesting levels", async () => { + testSetup = await testRender( + () => ( + + + + + deeply nested + + + + + ), + { + width: 80, + height: 5, + }, + ) + + await testSetup.renderOnce() + const textRenderable = getTextRenderable(testSetup.renderer) + const chunks = textRenderable.textNode.gatherWithInheritedStyle() + + // Find the chunk with text + const textChunk = chunks.find((c) => c.text.includes("deeply nested")) + expect(textChunk).toBeDefined() + expect(textChunk?.link).toBeDefined() + expect(textChunk?.link?.url).toBe("https://example.com") + }) + + it("should respect child link over parent link", async () => { + testSetup = await testRender( + () => ( + + + parent child parent + + + ), + { + width: 80, + height: 5, + }, + ) + + await testSetup.renderOnce() + const textRenderable = getTextRenderable(testSetup.renderer) + const chunks = textRenderable.textNode.gatherWithInheritedStyle() + + // Find chunks + const parentChunks = chunks.filter((c) => c.text.includes("parent")) + const childChunk = chunks.find((c) => c.text.includes("child")) + + // Parent chunks should have parent link + for (const chunk of parentChunks) { + expect(chunk.link?.url).toBe("https://parent.com") + } + + // Child chunk should have child link + expect(childChunk?.link?.url).toBe("https://child.com") + }) + + it("should handle mixed styled content with inherited link", async () => { + testSetup = await testRender( + () => ( + + + Bold Italic Plain + + + ), + { + width: 80, + height: 5, + }, + ) + + await testSetup.renderOnce() + const textRenderable = getTextRenderable(testSetup.renderer) + const chunks = textRenderable.textNode.gatherWithInheritedStyle() + + // All text chunks should have the same link + const textChunks = chunks.filter((c) => c.text.trim().length > 0) + expect(textChunks.length).toBeGreaterThan(0) + + for (const chunk of textChunks) { + expect(chunk.link?.url).toBe("https://opentui.com") + } + }) + + it("should only apply link to content within link element", async () => { + testSetup = await testRender( + () => ( + + before linked after + + ), + { + width: 80, + height: 5, + }, + ) + + await testSetup.renderOnce() + const textRenderable = getTextRenderable(testSetup.renderer) + const chunks = textRenderable.textNode.gatherWithInheritedStyle() + + const beforeChunk = chunks.find((c) => c.text.includes("before")) + const linkedChunk = chunks.find((c) => c.text.includes("linked")) + const afterChunk = chunks.find((c) => c.text.includes("after")) + + // Only the linked chunk should have the link + expect(beforeChunk?.link).toBeUndefined() + expect(linkedChunk?.link?.url).toBe("https://example.com") + expect(afterChunk?.link).toBeUndefined() + }) + }) }) diff --git a/packages/solid/tests/textarea.test.tsx b/packages/solid/tests/textarea.test.tsx index 05e82f3af..09140ba31 100644 --- a/packages/solid/tests/textarea.test.tsx +++ b/packages/solid/tests/textarea.test.tsx @@ -847,4 +847,138 @@ describe("Textarea Layout Tests", () => { expect(frame).toMatchSnapshot() }) }) + + describe("Measure Cache Edge Cases", () => { + it("should correctly measure text after content change", async () => { + const [value, setValue] = createSignal("Short text") + + testSetup = await testRender( + () => ( + + + {value()} + + + ), + { width: 50, height: 15 }, + ) + + await testSetup.renderOnce() + const initialFrame = testSetup.captureCharFrame() + + // Change to longer content that should cause more wrapping + setValue("This is a much longer text that will definitely wrap to multiple lines when rendered") + await testSetup.renderOnce() + const updatedFrame = testSetup.captureCharFrame() + + expect(initialFrame).toMatchSnapshot() + expect(updatedFrame).toMatchSnapshot() + expect(updatedFrame).not.toBe(initialFrame) + }) + + it("should handle rapid content updates correctly", async () => { + const [value, setValue] = createSignal("Initial") + + testSetup = await testRender( + () => ( + + + {value()} + + + ), + { width: 40, height: 10 }, + ) + + // Rapid updates to simulate typing + for (let i = 0; i < 5; i++) { + setValue(`Update ${i}: some text here`) + await testSetup.renderOnce() + } + + const finalFrame = testSetup.captureCharFrame() + expect(finalFrame).toMatchSnapshot() + }) + + it("should handle width changes with cached measures", async () => { + const [width, setWidth] = createSignal(30) + + testSetup = await testRender( + () => ( + + + Content that will wrap differently at different widths + + + ), + { width: 60, height: 15 }, + ) + + await testSetup.renderOnce() + const frame30 = testSetup.captureCharFrame() + + setWidth(50) + await testSetup.renderOnce() + const frame50 = testSetup.captureCharFrame() + + setWidth(20) + await testSetup.renderOnce() + const frame20 = testSetup.captureCharFrame() + + expect(frame30).toMatchSnapshot() + expect(frame50).toMatchSnapshot() + expect(frame20).toMatchSnapshot() + }) + + it("should handle empty to non-empty content transition", async () => { + const [value, setValue] = createSignal("") + + testSetup = await testRender( + () => ( + + + {value() || " "} + + + ), + { width: 50, height: 10 }, + ) + + await testSetup.renderOnce() + const emptyFrame = testSetup.captureCharFrame() + + setValue("Now with content") + await testSetup.renderOnce() + const contentFrame = testSetup.captureCharFrame() + + setValue("") + await testSetup.renderOnce() + const emptyAgainFrame = testSetup.captureCharFrame() + + expect(emptyFrame).toMatchSnapshot() + expect(contentFrame).toMatchSnapshot() + expect(emptyAgainFrame).toMatchSnapshot() + }) + + it("should correctly measure multiline content with unicode", async () => { + testSetup = await testRender( + () => ( + + + Hello 世界 +
+ こんにちは +
+ 🌟 Emoji 🚀 +
+
+ ), + { width: 40, height: 15 }, + ) + + await testSetup.renderOnce() + const frame = testSetup.captureCharFrame() + expect(frame).toMatchSnapshot() + }) + }) }) diff --git a/packages/vue/src/elements.ts b/packages/vue/src/elements.ts index 2bdec94a3..505f9f574 100644 --- a/packages/vue/src/elements.ts +++ b/packages/vue/src/elements.ts @@ -3,7 +3,9 @@ import { BoxRenderable, InputRenderable, SelectRenderable, + StatelessTerminalRenderable, TabSelectRenderable, + TerminalRenderable, TextRenderable, ScrollBoxRenderable, } from "@opentui/core" @@ -14,6 +16,8 @@ export const elements = { inputRenderable: InputRenderable, selectRenderable: SelectRenderable, tabSelectRenderable: TabSelectRenderable, + terminalRenderable: TerminalRenderable, + statelessTerminalRenderable: StatelessTerminalRenderable, textRenderable: TextRenderable, scrollBoxRenderable: ScrollBoxRenderable, }