From 1d8f378ecea6b3372e695cda91358e007cfbffe3 Mon Sep 17 00:00:00 2001 From: Rezo Date: Wed, 11 Feb 2026 14:00:19 +0400 Subject: [PATCH 1/5] bench: add benchmark suite --- package-lock.json | 190 ++++- packages/bench/package.json | 10 +- packages/bench/ratatui-bench/.gitignore | 1 + packages/bench/ratatui-bench/Cargo.lock | 650 ++++++++++++++++++ packages/bench/ratatui-bench/Cargo.toml | 13 + packages/bench/ratatui-bench/src/main.rs | 441 ++++++++++++ packages/bench/src/backends.ts | 15 +- packages/bench/src/blessed-backend.ts | 119 ++++ packages/bench/src/blessed.d.ts | 16 + packages/bench/src/ratatui-runner.ts | 111 +++ packages/bench/src/report.ts | 55 +- packages/bench/src/run.ts | 19 +- packages/bench/src/scenarios/construction.ts | 84 ++- .../bench/src/scenarios/content-update.ts | 450 ++++++++++++ packages/bench/src/scenarios/index.ts | 4 + packages/bench/src/scenarios/memory.ts | 206 +++++- packages/bench/src/scenarios/rerender.ts | 139 +++- packages/bench/src/scenarios/startup.ts | 372 ++++++++++ packages/bench/src/scenarios/treeBuilders.ts | 87 +++ packages/bench/src/terminal-kit.d.ts | 13 + packages/bench/src/termkit-backend.ts | 102 +++ packages/bench/src/types.ts | 10 +- 22 files changed, 3061 insertions(+), 46 deletions(-) create mode 100644 packages/bench/ratatui-bench/.gitignore create mode 100644 packages/bench/ratatui-bench/Cargo.lock create mode 100644 packages/bench/ratatui-bench/Cargo.toml create mode 100644 packages/bench/ratatui-bench/src/main.rs create mode 100644 packages/bench/src/blessed-backend.ts create mode 100644 packages/bench/src/blessed.d.ts create mode 100644 packages/bench/src/ratatui-runner.ts create mode 100644 packages/bench/src/scenarios/content-update.ts create mode 100644 packages/bench/src/scenarios/startup.ts create mode 100644 packages/bench/src/terminal-kit.d.ts create mode 100644 packages/bench/src/termkit-backend.ts diff --git a/package-lock.json b/package-lock.json index 65b8121d..533e2b95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ } }, "examples/release-demo": { + "extraneous": true, "dependencies": { "@rezi-ui/core": "0.1.0-alpha.0", "@rezi-ui/node": "0.1.0-alpha.0" @@ -212,6 +213,20 @@ "node": ">=14.21.3" } }, + "node_modules/@cronvel/get-pixels": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@cronvel/get-pixels/-/get-pixels-3.4.1.tgz", + "integrity": "sha512-gB5C5nDIacLUdsMuW8YsM9SzK3vaFANe4J11CVXpovpy7bZUGrcJKmc6m/0gWG789pKr6XSZY2aEetjFvSRw5g==", + "license": "MIT", + "dependencies": { + "jpeg-js": "^0.4.4", + "ndarray": "^1.0.19", + "ndarray-pack": "^1.1.1", + "node-bitmap": "0.0.1", + "omggif": "^1.0.10", + "pngjs": "^6.0.0" + } + }, "node_modules/@gerrit0/mini-shiki": { "version": "3.22.0", "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.22.0.tgz", @@ -428,6 +443,18 @@ "dev": true, "license": "MIT" }, + "node_modules/blessed": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz", + "integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==", + "license": "MIT", + "bin": { + "blessed": "bin/tput.js" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -450,6 +477,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chroma-js": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.6.0.tgz", + "integrity": "sha512-BLHvCB9s8Z1EV4ethr6xnkl/P2YRFOGqfgvuMG/MyCbZPrTA+NeiByY6XvgF0zP4/2deU2CXnWyMa3zu1LqQ3A==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -524,6 +557,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/cwise-compiler": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz", + "integrity": "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==", + "license": "MIT", + "dependencies": { + "uniq": "^1.0.0" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -568,6 +610,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/iota-array": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz", + "integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==", + "license": "MIT" + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, "node_modules/is-ci": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", @@ -610,12 +664,27 @@ "tslib": "^2.0.3" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/lazyness": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/lazyness/-/lazyness-1.2.0.tgz", + "integrity": "sha512-KenL6EFbwxBwRxG93t0gcUyi0Nw0Ub31FJKN1laA4UscdkL1K1AxUd0gYZdcLU3v+x+wcFi4uQKS5hL+fk500g==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -701,6 +770,49 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/ndarray": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.0.19.tgz", + "integrity": "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==", + "license": "MIT", + "dependencies": { + "iota-array": "^1.0.0", + "is-buffer": "^1.0.2" + } + }, + "node_modules/ndarray-pack": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ndarray-pack/-/ndarray-pack-1.2.1.tgz", + "integrity": "sha512-51cECUJMT0rUZNQa09EoKsnFeDL4x2dHRT0VR5U2H5ZgEcm95ZDWcMA5JShroXjHOejmAD/fg8+H+OvUnVXz2g==", + "license": "MIT", + "dependencies": { + "cwise-compiler": "^1.1.2", + "ndarray": "^1.0.13" + } + }, + "node_modules/nextgen-events": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/nextgen-events/-/nextgen-events-1.5.3.tgz", + "integrity": "sha512-P6qw6kenNXP+J9XlKJNi/MNHUQ+Lx5K8FEcSfX7/w8KJdZan5+BB5MKzuNgL2RTjHG1Svg8SehfseVEp8zAqwA==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-bitmap": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/node-bitmap/-/node-bitmap-0.0.1.tgz", + "integrity": "sha512-Jx5lPaaLdIaOsj2mVLWMWulXF6GQVdyLvNSxmiYCvZ8Ma2hfKX0POoR2kgKOqz+oFsRreq0yYZjQ2wjE9VNzCA==", + "engines": { + "node": ">=v0.6.5" + } + }, + "node_modules/omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==", + "license": "MIT" + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -725,6 +837,15 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -767,10 +888,6 @@ "react": "^18.3.1" } }, - "node_modules/release-demo": { - "resolved": "examples/release-demo", - "link": true - }, "node_modules/restore-cursor": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", @@ -796,6 +913,24 @@ "loose-envify": "^1.1.0" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/seventh": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/seventh/-/seventh-0.9.4.tgz", + "integrity": "sha512-O85mosi4sOfxG+slvqy0j7zLuFD4ylUgEMt7Pvt9Q/wnwNwG/6MNnHKzV9JkAoPoPM26t/DLFn17p7o7u5kIBA==", + "license": "MIT", + "dependencies": { + "setimmediate": "^1.0.5" + }, + "engines": { + "node": ">=16.13.0" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -814,6 +949,15 @@ "node": ">=10" } }, + "node_modules/string-kit": { + "version": "0.19.3", + "resolved": "https://registry.npmjs.org/string-kit/-/string-kit-0.19.3.tgz", + "integrity": "sha512-94p913R6+Ea6656A39bqJeHgt64HM9RSq36oc0F8N8Mz76g5+cLaqEwkouqguEDrJ4rhVQpS+tKz214FdtMoqA==", + "license": "MIT", + "engines": { + "node": ">=14.15.0" + } + }, "node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -829,6 +973,34 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/terminal-kit": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/terminal-kit/-/terminal-kit-3.1.2.tgz", + "integrity": "sha512-ro2FyU4A+NwA74DLTYTnoCFYuFpgV1aM07IS6MPrJeajoI2hwF44EdUqjoTmKEl6srYDWtbVkc/b1C16iUnxFQ==", + "license": "MIT", + "dependencies": { + "@cronvel/get-pixels": "^3.4.1", + "chroma-js": "^2.4.2", + "lazyness": "^1.2.0", + "ndarray": "^1.0.19", + "nextgen-events": "^1.5.3", + "seventh": "^0.9.2", + "string-kit": "^0.19.0", + "tree-kit": "^0.8.7" + }, + "engines": { + "node": ">=16.13.0" + } + }, + "node_modules/tree-kit": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/tree-kit/-/tree-kit-0.8.8.tgz", + "integrity": "sha512-L7zwpXp0/Nha6mljVcVOnhhxuCkFRWmt26wza3TKnyMBewid4F2vyiVdcSsw41ZoG1Wj+3lM48Er9lhttbxfLA==", + "license": "MIT", + "engines": { + "node": ">=16.13.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -883,6 +1055,12 @@ "dev": true, "license": "MIT" }, + "node_modules/uniq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", + "integrity": "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==", + "license": "MIT" + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", @@ -933,8 +1111,10 @@ "dependencies": { "@rezi-ui/core": "0.1.0-alpha.0", "@rezi-ui/ink-compat": "0.1.0-alpha.0", + "blessed": "^0.1.81", "ink": "^4.0.0", - "react": "^18.0.0" + "react": "^18.0.0", + "terminal-kit": "^3.1.2" }, "devDependencies": { "@types/react": "^18.0.0", diff --git a/packages/bench/package.json b/packages/bench/package.json index 5e694826..01883087 100644 --- a/packages/bench/package.json +++ b/packages/bench/package.json @@ -1,11 +1,13 @@ { "name": "@rezi-ui/bench", "version": "0.1.0-alpha.0", - "description": "Comprehensive benchmark suite: Rezi vs Ink vs ink-compat", + "description": "Comprehensive benchmark suite: Rezi vs Ink vs ink-compat vs terminal-kit", "private": true, "type": "module", "license": "Apache-2.0", - "engines": { "node": ">=18" }, + "engines": { + "node": ">=18" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -16,8 +18,10 @@ "dependencies": { "@rezi-ui/core": "0.1.0-alpha.0", "@rezi-ui/ink-compat": "0.1.0-alpha.0", + "blessed": "^0.1.81", "ink": "^4.0.0", - "react": "^18.0.0" + "react": "^18.0.0", + "terminal-kit": "^3.1.2" }, "devDependencies": { "@types/react": "^18.0.0", diff --git a/packages/bench/ratatui-bench/.gitignore b/packages/bench/ratatui-bench/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/packages/bench/ratatui-bench/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/packages/bench/ratatui-bench/Cargo.lock b/packages/bench/ratatui-bench/Cargo.lock new file mode 100644 index 00000000..68299836 --- /dev/null +++ b/packages/bench/ratatui-bench/Cargo.lock @@ -0,0 +1,650 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.181" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "ratatui-bench" +version = "0.1.0" +dependencies = [ + "ratatui", + "serde", + "serde_json", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/packages/bench/ratatui-bench/Cargo.toml b/packages/bench/ratatui-bench/Cargo.toml new file mode 100644 index 00000000..476bf894 --- /dev/null +++ b/packages/bench/ratatui-bench/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ratatui-bench" +version = "0.1.0" +edition = "2021" + +[dependencies] +ratatui = "0.29" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = 3 +lto = true diff --git a/packages/bench/ratatui-bench/src/main.rs b/packages/bench/ratatui-bench/src/main.rs new file mode 100644 index 00000000..0b383ecb --- /dev/null +++ b/packages/bench/ratatui-bench/src/main.rs @@ -0,0 +1,441 @@ +//! Ratatui benchmark harness. +//! +//! Runs the same scenarios as the Node.js bench suite and outputs +//! JSON metrics that the TS runner can consume. +//! +//! Usage: +//! ratatui-bench --scenario --iterations --warmup [--items ] +//! +//! Scenarios: construction, rerender, content-update, memory-profile, startup + +use ratatui::{ + backend::TestBackend, + layout::{Constraint, Direction, Layout}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{List, ListItem, Paragraph}, + Terminal, +}; +use serde::Serialize; +use std::time::Instant; + +#[derive(Serialize)] +struct TimingStats { + mean: f64, + median: f64, + p95: f64, + p99: f64, + min: f64, + max: f64, + stddev: f64, + cv: f64, +} + +#[derive(Serialize)] +struct BenchOutput { + timing: TimingStats, + iterations: usize, + total_wall_ms: f64, + ops_per_sec: f64, + peak_rss_kb: u64, +} + +fn compute_stats(samples: &mut Vec) -> TimingStats { + if samples.is_empty() { + return TimingStats { + mean: 0.0, median: 0.0, p95: 0.0, p99: 0.0, + min: 0.0, max: 0.0, stddev: 0.0, cv: 0.0, + }; + } + samples.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let n = samples.len(); + let sum: f64 = samples.iter().sum(); + let mean = sum / n as f64; + + let median = if n % 2 == 0 { + (samples[n / 2 - 1] + samples[n / 2]) / 2.0 + } else { + samples[n / 2] + }; + + let p95 = samples[(n as f64 * 0.95).ceil() as usize - 1]; + let p99 = samples[((n as f64 * 0.99).ceil() as usize - 1).min(n - 1)]; + let min = samples[0]; + let max = samples[n - 1]; + + let variance: f64 = samples.iter().map(|v| (v - mean).powi(2)).sum::() / n as f64; + let stddev = variance.sqrt(); + let cv = if mean > 0.0 { stddev / mean } else { 0.0 }; + + TimingStats { mean, median, p95, p99, min, max, stddev, cv } +} + +fn get_peak_rss_kb() -> u64 { + // Read from /proc/self/status on Linux + if let Ok(status) = std::fs::read_to_string("/proc/self/status") { + for line in status.lines() { + if line.starts_with("VmHWM:") { + if let Some(val) = line.split_whitespace().nth(1) { + return val.parse().unwrap_or(0); + } + } + } + } + 0 +} + +/// Build the same tree structure as the Node.js benchmarks: +/// Title (bold), summary row, then N item rows with dim index + text + italic details +fn render_list(terminal: &mut Terminal, n: usize, seed: usize) { + terminal.draw(|frame| { + let area = frame.area(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // title + Constraint::Length(1), // summary + Constraint::Min(0), // items + ]) + .split(area); + + // Title + let title = Paragraph::new(Line::from(Span::styled( + format!("Benchmark: {} items (#{})", n, seed), + Style::default().add_modifier(Modifier::BOLD), + ))); + frame.render_widget(title, chunks[0]); + + // Summary + let summary = Paragraph::new(Line::from(vec![ + Span::raw(format!("Total: {}", n)), + Span::raw(" "), + Span::raw("Page 1"), + ])); + frame.render_widget(summary, chunks[1]); + + // Item list + let items: Vec = (0..n) + .map(|i| { + ListItem::new(Line::from(vec![ + Span::styled(format!("{}.", i), Style::default().add_modifier(Modifier::DIM)), + Span::raw(" "), + Span::raw(format!("Item {}", i)), + Span::raw(" "), + Span::styled("details", Style::default().add_modifier(Modifier::ITALIC)), + ])) + }) + .collect(); + let list = List::new(items); + frame.render_widget(list, chunks[2]); + }).unwrap(); +} + +/// Counter app (same as rerender scenario) +fn render_counter(terminal: &mut Terminal, count: usize) { + terminal.draw(|frame| { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + ]) + .split(area); + + frame.render_widget( + Paragraph::new(Span::styled("Counter Benchmark", Style::default().add_modifier(Modifier::BOLD))), + chunks[0], + ); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(format!("Count: {}", count)), + Span::raw(" [+1] [-1]"), + ])), + chunks[1], + ); + frame.render_widget( + Paragraph::new(Span::styled( + format!("Last updated: iteration {}", count), + Style::default().add_modifier(Modifier::DIM), + )), + chunks[3], + ); + }).unwrap(); +} + +/// 500-row list with movable selection (content-update scenario) +fn render_content_update(terminal: &mut Terminal, selected: usize) { + let list_size = 500; + terminal.draw(|frame| { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(0), + ]) + .split(area); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Files", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!(" {} items", list_size)), + Span::styled(format!(" Selected: {}", selected), Style::default().add_modifier(Modifier::DIM)), + ])), + chunks[0], + ); + + let items: Vec = (0..list_size) + .map(|i| { + let is_sel = i == selected; + let marker = if is_sel { "> " } else { " " }; + let idx_style = if is_sel { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default().add_modifier(Modifier::DIM) + }; + let name_style = if is_sel { + Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED) + } else { + Style::default() + }; + + ListItem::new(Line::from(vec![ + Span::styled(marker, idx_style), + Span::styled(format!("{:>3}.", i), idx_style), + Span::raw(" "), + Span::styled(format!("entry-{}.log", i), name_style), + Span::raw(" "), + Span::styled(format!("{} B", i * 1024 + 512), Style::default().add_modifier(Modifier::DIM)), + ])) + }) + .collect(); + let list = List::new(items); + frame.render_widget(list, chunks[1]); + }).unwrap(); +} + +/// Memory-profile tree: progress bar + 20 lines +fn render_memory_tree(terminal: &mut Terminal, iter: usize) { + terminal.draw(|frame| { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + ]) + .split(area); + + frame.render_widget( + Paragraph::new(Span::styled( + format!("Iteration {}", iter), + Style::default().add_modifier(Modifier::BOLD), + )), + chunks[0], + ); + + let pct = iter % 100; + let filled = pct / 5; + let bar = format!("[{}{}] {}%", "#".repeat(filled), ".".repeat(20 - filled), pct); + frame.render_widget(Paragraph::new(bar), chunks[1]); + + let items: Vec = (0..20) + .map(|j| { + let style = if j % 2 == 0 { + Style::default().add_modifier(Modifier::DIM) + } else { + Style::default() + }; + ListItem::new(Span::styled( + format!(" Line {}: value={}", j, iter * 20 + j), + style, + )) + }) + .collect(); + let list = List::new(items); + frame.render_widget(list, chunks[2]); + }).unwrap(); +} + +fn run_scenario(scenario: &str, warmup: usize, iterations: usize, items: usize) -> BenchOutput { + match scenario { + "construction" => run_construction(warmup, iterations, items), + "rerender" => run_rerender(warmup, iterations), + "content-update" => run_content_update(warmup, iterations), + "memory-profile" => run_memory_profile(warmup, iterations), + "startup" => run_startup(warmup, iterations), + _ => panic!("Unknown scenario: {}", scenario), + } +} + +fn run_construction(warmup: usize, iterations: usize, n: usize) -> BenchOutput { + let mut terminal = Terminal::new(TestBackend::new(120, 540.max(n as u16 + 5))).unwrap(); + + for i in 0..warmup { + render_list(&mut terminal, n, i); + } + + let mut samples = Vec::with_capacity(iterations); + let t0 = Instant::now(); + + for i in 0..iterations { + let ts = Instant::now(); + render_list(&mut terminal, n, warmup + i); + samples.push(ts.elapsed().as_secs_f64() * 1000.0); + } + + let total_wall_ms = t0.elapsed().as_secs_f64() * 1000.0; + let ops_per_sec = iterations as f64 / (total_wall_ms / 1000.0); + + BenchOutput { + timing: compute_stats(&mut samples), + iterations, + total_wall_ms, + ops_per_sec, + peak_rss_kb: get_peak_rss_kb(), + } +} + +fn run_rerender(warmup: usize, iterations: usize) -> BenchOutput { + let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap(); + + for i in 0..warmup { + render_counter(&mut terminal, i); + } + + let mut samples = Vec::with_capacity(iterations); + let t0 = Instant::now(); + + for i in 0..iterations { + let ts = Instant::now(); + render_counter(&mut terminal, warmup + i); + samples.push(ts.elapsed().as_secs_f64() * 1000.0); + } + + let total_wall_ms = t0.elapsed().as_secs_f64() * 1000.0; + + BenchOutput { + timing: compute_stats(&mut samples), + iterations, + total_wall_ms, + ops_per_sec: iterations as f64 / (total_wall_ms / 1000.0), + peak_rss_kb: get_peak_rss_kb(), + } +} + +fn run_content_update(warmup: usize, iterations: usize) -> BenchOutput { + let mut terminal = Terminal::new(TestBackend::new(120, 540)).unwrap(); + let list_size = 500; + let mut selected: usize = 0; + + for _ in 0..warmup { + selected = (selected + 1) % list_size; + render_content_update(&mut terminal, selected); + } + + let mut samples = Vec::with_capacity(iterations); + let t0 = Instant::now(); + + for _ in 0..iterations { + selected = (selected + 1) % list_size; + let ts = Instant::now(); + render_content_update(&mut terminal, selected); + samples.push(ts.elapsed().as_secs_f64() * 1000.0); + } + + let total_wall_ms = t0.elapsed().as_secs_f64() * 1000.0; + + BenchOutput { + timing: compute_stats(&mut samples), + iterations, + total_wall_ms, + ops_per_sec: iterations as f64 / (total_wall_ms / 1000.0), + peak_rss_kb: get_peak_rss_kb(), + } +} + +fn run_memory_profile(warmup: usize, iterations: usize) -> BenchOutput { + let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap(); + + for i in 0..warmup { + render_memory_tree(&mut terminal, i); + } + + let mut samples = Vec::with_capacity(iterations); + let t0 = Instant::now(); + + for i in 0..iterations { + let ts = Instant::now(); + render_memory_tree(&mut terminal, warmup + i); + samples.push(ts.elapsed().as_secs_f64() * 1000.0); + } + + let total_wall_ms = t0.elapsed().as_secs_f64() * 1000.0; + + BenchOutput { + timing: compute_stats(&mut samples), + iterations, + total_wall_ms, + ops_per_sec: iterations as f64 / (total_wall_ms / 1000.0), + peak_rss_kb: get_peak_rss_kb(), + } +} + +fn run_startup(warmup: usize, iterations: usize) -> BenchOutput { + let n = 50; + + for i in 0..warmup { + let mut terminal = Terminal::new(TestBackend::new(120, 60)).unwrap(); + render_list(&mut terminal, n, i); + drop(terminal); + } + + let mut samples = Vec::with_capacity(iterations); + let t0 = Instant::now(); + + for i in 0..iterations { + let ts = Instant::now(); + let mut terminal = Terminal::new(TestBackend::new(120, 60)).unwrap(); + render_list(&mut terminal, n, warmup + i); + samples.push(ts.elapsed().as_secs_f64() * 1000.0); + drop(terminal); + } + + let total_wall_ms = t0.elapsed().as_secs_f64() * 1000.0; + + BenchOutput { + timing: compute_stats(&mut samples), + iterations, + total_wall_ms, + ops_per_sec: iterations as f64 / (total_wall_ms / 1000.0), + peak_rss_kb: get_peak_rss_kb(), + } +} + +fn main() { + let args: Vec = std::env::args().collect(); + let mut scenario = "construction"; + let mut warmup = 50; + let mut iterations = 500; + let mut items = 1000; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--scenario" => { i += 1; scenario = args.get(i).map(|s| s.as_str()).unwrap_or(scenario); } + "--warmup" => { i += 1; warmup = args.get(i).and_then(|s| s.parse().ok()).unwrap_or(warmup); } + "--iterations" => { i += 1; iterations = args.get(i).and_then(|s| s.parse().ok()).unwrap_or(iterations); } + "--items" => { i += 1; items = args.get(i).and_then(|s| s.parse().ok()).unwrap_or(items); } + _ => {} + } + i += 1; + } + + let output = run_scenario(scenario, warmup, iterations, items); + println!("{}", serde_json::to_string(&output).unwrap()); +} diff --git a/packages/bench/src/backends.ts b/packages/bench/src/backends.ts index 8c8747a5..87f3a798 100644 --- a/packages/bench/src/backends.ts +++ b/packages/bench/src/backends.ts @@ -79,6 +79,15 @@ export class BenchBackend implements RuntimeBackend { private stopped = false; private needsResize = true; + /** Configurable viewport dimensions. */ + readonly cols: number; + readonly rows: number; + + constructor(cols = 120, rows = 40) { + this.cols = cols; + this.rows = rows; + } + async start(): Promise { this.stopped = false; this.needsResize = true; @@ -100,8 +109,8 @@ export class BenchBackend implements RuntimeBackend { async getCaps() { return { ...DEFAULT_TERMINAL_CAPS, - cols: 120, - rows: 40, + cols: this.cols, + rows: this.rows, }; } @@ -133,7 +142,7 @@ export class BenchBackend implements RuntimeBackend { if (this.needsResize) { this.needsResize = false; return new Promise((resolve) => { - setTimeout(() => resolve(resizeZrevBatch(120, 40)), 0); + setTimeout(() => resolve(resizeZrevBatch(this.cols, this.rows)), 0); }); } // Subsequent polls yield empty batches after a short delay so the frame diff --git a/packages/bench/src/blessed-backend.ts b/packages/bench/src/blessed-backend.ts new file mode 100644 index 00000000..a42224c6 --- /dev/null +++ b/packages/bench/src/blessed-backend.ts @@ -0,0 +1,119 @@ +/** + * blessed benchmark backend. + * + * Creates a blessed Screen that outputs to an in-memory Writable, + * similar to the MeasuringStream approach used for Ink benchmarks. + */ + +import { createRequire } from "node:module"; +import { Readable, Writable } from "node:stream"; + +const require = createRequire(import.meta.url); + +// biome-ignore lint/suspicious/noExplicitAny: blessed has no type definitions +type BlessedModule = any; + +export interface BlessedContext { + screen: { + render: () => void; + destroy: () => void; + append: (el: unknown) => void; + remove: (el: unknown) => void; + children: unknown[]; + }; + blessed: BlessedModule; + /** Flush program buffer to the output stream. */ + flush(): void; + /** Total bytes written to the output stream since last resetBytes(). */ + totalBytes: number; + /** Reset byte counter. */ + resetBytes(): void; + /** Remove all children except the screen itself, then re-render. */ + clearChildren(): void; + /** Clean up blessed resources. */ + dispose(): void; +} + +// Cache the module +let _blessed: BlessedModule | null = null; +function getBlessed(): BlessedModule { + if (!_blessed) { + _blessed = require("blessed"); + } + return _blessed; +} + +/** + * Create a blessed Screen that outputs to an in-memory Writable. + */ +export function createBlessedContext(cols: number, rows: number): BlessedContext { + const blessed = getBlessed(); + + let totalBytes = 0; + + const measureStream = new Writable({ + write( + chunk: Buffer | string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ) { + totalBytes += + typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength; + callback(); + }, + highWaterMark: 1024 * 1024, + }); + + // blessed needs a stream that looks like a TTY + Object.defineProperty(measureStream, "columns", { value: cols, writable: true }); + Object.defineProperty(measureStream, "rows", { value: rows, writable: true }); + Object.defineProperty(measureStream, "isTTY", { value: true }); + + const nullInput = new Readable({ read() {} }); + Object.defineProperty(nullInput, "isTTY", { value: false }); + // blessed calls setRawMode on stdin + (nullInput as unknown as Record)["setRawMode"] = () => nullInput; + + const screen = blessed.screen({ + output: measureStream, + input: nullInput, + smartCSR: true, + autoPadding: false, + mouse: false, + cursor: { artificial: true, shape: "block", blink: false }, + fullUnicode: false, + warnings: false, + // Prevent blessed from taking over signal handlers + resizeTimeout: 0, + }); + + return { + screen, + blessed, + flush() { + if (screen.program && screen.program.flush) { + screen.program.flush(); + } + }, + get totalBytes() { + return totalBytes; + }, + resetBytes() { + totalBytes = 0; + }, + clearChildren() { + while (screen.children.length > 0) { + screen.remove(screen.children[0]); + } + }, + dispose() { + try { + screen.destroy(); + } catch { + // blessed can throw on cleanup — ignore + } + measureStream.destroy(); + nullInput.destroy(); + }, + }; +} diff --git a/packages/bench/src/blessed.d.ts b/packages/bench/src/blessed.d.ts new file mode 100644 index 00000000..304b31d0 --- /dev/null +++ b/packages/bench/src/blessed.d.ts @@ -0,0 +1,16 @@ +declare module "blessed" { + const blessed: { + screen: (opts: Record) => { + render: () => void; + destroy: () => void; + append: (el: unknown) => void; + remove: (el: unknown) => void; + children: unknown[]; + program: { flush: () => void }; + }; + box: (opts: Record) => unknown; + text: (opts: Record) => unknown; + line: (opts: Record) => unknown; + }; + export = blessed; +} diff --git a/packages/bench/src/ratatui-runner.ts b/packages/bench/src/ratatui-runner.ts new file mode 100644 index 00000000..b6232051 --- /dev/null +++ b/packages/bench/src/ratatui-runner.ts @@ -0,0 +1,111 @@ +/** + * Ratatui benchmark runner. + * + * Manages the Rust ratatui-bench binary: builds it if needed, then + * shells out to run individual scenarios and parses the JSON output + * back into BenchMetrics. + */ + +import { execSync, execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { computeStats, takeMemory } from "./measure.js"; +import type { BenchMetrics, ScenarioConfig } from "./types.js"; + +const BENCH_DIR = resolve(import.meta.dirname ?? ".", "..", "ratatui-bench"); +const BINARY = resolve(BENCH_DIR, "target", "release", "ratatui-bench"); + +/** Check if we can build/run the ratatui benchmark. */ +export function checkRatatui(): boolean { + try { + execSync("cargo --version", { stdio: "ignore" }); + return existsSync(resolve(BENCH_DIR, "Cargo.toml")); + } catch { + return false; + } +} + +let _built = false; + +/** Build the ratatui-bench binary in release mode. */ +export function ensureBuilt(): void { + if (_built && existsSync(BINARY)) return; + console.log("\n [ratatui] Building release binary..."); + execSync("cargo build --release", { + cwd: BENCH_DIR, + stdio: ["ignore", "ignore", "inherit"], + timeout: 120_000, + }); + _built = true; +} + +interface RatatuiOutput { + timing: { + mean: number; + median: number; + p95: number; + p99: number; + min: number; + max: number; + stddev: number; + cv: number; + }; + iterations: number; + total_wall_ms: number; + ops_per_sec: number; + peak_rss_kb: number; +} + +/** + * Run a ratatui scenario and return BenchMetrics. + */ +export function runRatatui( + scenario: string, + config: ScenarioConfig, + params: Record = {}, +): BenchMetrics { + ensureBuilt(); + + const args = [ + "--scenario", + scenario, + "--warmup", + String(config.warmup), + "--iterations", + String(config.iterations), + ]; + + if (params["items"] !== undefined) { + args.push("--items", String(params["items"])); + } + + const stdout = execFileSync(BINARY, args, { + timeout: 300_000, + encoding: "utf-8", + stdio: ["ignore", "pipe", "inherit"], + }); + + const output: RatatuiOutput = JSON.parse(stdout.trim()); + + // We can't measure Ratatui's memory from inside Node, so use its self-report + const memSnapshot = { + rssKb: output.peak_rss_kb, + heapUsedKb: 0, // N/A for Rust + heapTotalKb: 0, + externalKb: 0, + arrayBuffersKb: 0, + }; + + return { + timing: output.timing, + memBefore: memSnapshot, + memAfter: memSnapshot, + memPeak: memSnapshot, + cpu: { userMs: 0, systemMs: 0 }, // would need /proc/self/stat parsing + iterations: output.iterations, + totalWallMs: output.total_wall_ms, + opsPerSec: output.ops_per_sec, + framesProduced: output.iterations, + bytesProduced: 0, + }; +} diff --git a/packages/bench/src/report.ts b/packages/bench/src/report.ts index 2c98c2c8..4e739abc 100644 --- a/packages/bench/src/report.ts +++ b/packages/bench/src/report.ts @@ -22,6 +22,9 @@ const FRAMEWORK_LABELS: Record = { "rezi-native": "Rezi (native)", "ink-compat": "Ink-on-Rezi", ink: "Ink", + "terminal-kit": "terminal-kit", + blessed: "blessed", + ratatui: "Ratatui (Rust)", }; export function printTerminalTable(results: readonly BenchResult[]): void { @@ -54,8 +57,14 @@ export function printTerminalTable(results: readonly BenchResult[]): void { console.log(` ${header}`); console.log(` ${"─".repeat(98)}`); - // Sort: rezi-native first, then ink-compat, then ink - const order: Framework[] = ["rezi-native", "ink-compat", "ink"]; + const order: Framework[] = [ + "rezi-native", + "ink-compat", + "ink", + "terminal-kit", + "blessed", + "ratatui", + ]; const sorted = [...items].sort( (a, b) => order.indexOf(a.framework) - order.indexOf(b.framework), ); @@ -114,7 +123,7 @@ export function toMarkdown(results: readonly BenchResult[]): string { ); lines.push("|---|---:|---:|---:|---:|---:|---:|---:|---:|"); - const order: Framework[] = ["rezi-native", "ink-compat", "ink"]; + const order: Framework[] = ["rezi-native", "ink-compat", "ink", "terminal-kit", "blessed", "ratatui"]; const sorted = [...items].sort( (a, b) => order.indexOf(a.framework) - order.indexOf(b.framework), ); @@ -128,27 +137,33 @@ export function toMarkdown(results: readonly BenchResult[]): string { lines.push(""); } - // Summary: speedup comparison - lines.push("## Speedup Summary\n"); - lines.push("| Scenario | Ink-on-Rezi vs Ink | Rezi native vs Ink |"); - lines.push("|---|---:|---:|"); + // Summary: relative performance (ratio = other / rezi; >1 means rezi is faster) + lines.push("## Relative Performance (vs Rezi native)\n"); + lines.push("> \"Xx slower\" = Rezi native is X times faster. \"Xx faster\" = other framework is faster.\n"); + + // Determine which frameworks have results + const allFws: Framework[] = ["ink", "ink-compat", "terminal-kit", "blessed", "ratatui"]; + const presentFws = allFws.filter((fw) => + [...groups.values()].some((items) => items.some((r) => r.framework === fw)), + ); + + const headerCols = presentFws.map((fw) => FRAMEWORK_LABELS[fw]); + lines.push(`| Scenario | ${headerCols.join(" | ")} |`); + lines.push(`|---|${presentFws.map(() => "---:").join("|")}|`); for (const [group, items] of groups) { - const inkResult = items.find((r) => r.framework === "ink"); - const compatResult = items.find((r) => r.framework === "ink-compat"); const nativeResult = items.find((r) => r.framework === "rezi-native"); + const nativeMean = nativeResult?.metrics.timing.mean ?? 0; + + const fmtRatio = (fw: Framework) => { + const other = items.find((r) => r.framework === fw); + if (!other || nativeMean <= 0) return "N/A"; + const ratio = other.metrics.timing.mean / nativeMean; + return ratio >= 1 ? `${ratio.toFixed(1)}x slower` : `${(1 / ratio).toFixed(1)}x faster`; + }; - const inkMean = inkResult?.metrics.timing.mean ?? 0; - const compatSpeedup = - compatResult && inkMean > 0 - ? `${(inkMean / compatResult.metrics.timing.mean).toFixed(1)}x` - : "N/A"; - const nativeSpeedup = - nativeResult && inkMean > 0 - ? `${(inkMean / nativeResult.metrics.timing.mean).toFixed(1)}x` - : "N/A"; - - lines.push(`| ${group} | ${compatSpeedup} | ${nativeSpeedup} |`); + const cols = presentFws.map(fmtRatio); + lines.push(`| ${group} | ${cols.join(" | ")} |`); } lines.push("\n## Memory Comparison\n"); diff --git a/packages/bench/src/run.ts b/packages/bench/src/run.ts index 09caea6a..7118457e 100644 --- a/packages/bench/src/run.ts +++ b/packages/bench/src/run.ts @@ -93,6 +93,16 @@ async function checkFramework(fw: Framework): Promise { case "ink": await import("ink"); return true; + case "terminal-kit": + await import("terminal-kit"); + return true; + case "blessed": + await import("blessed"); + return true; + case "ratatui": { + const { checkRatatui } = await import("./ratatui-runner.js"); + return checkRatatui(); + } } } catch { return false; @@ -113,7 +123,14 @@ async function main(): Promise { // Check which frameworks are available const availableFrameworks = new Map(); - for (const fw of ["rezi-native", "ink-compat", "ink"] as Framework[]) { + for (const fw of [ + "rezi-native", + "ink-compat", + "ink", + "terminal-kit", + "blessed", + "ratatui", + ] as Framework[]) { if (opts.framework && opts.framework !== fw) { availableFrameworks.set(fw, false); continue; diff --git a/packages/bench/src/scenarios/construction.ts b/packages/bench/src/scenarios/construction.ts index fe603f07..e068c4fc 100644 --- a/packages/bench/src/scenarios/construction.ts +++ b/packages/bench/src/scenarios/construction.ts @@ -11,13 +11,16 @@ */ import { BenchBackend, MeasuringStream, NullReadable } from "../backends.js"; -import { benchAsync, tryGc } from "../measure.js"; +import { benchAsync, benchSync, tryGc } from "../measure.js"; import type { BenchMetrics, Framework, Scenario, ScenarioConfig } from "../types.js"; -import { buildReactTree, buildReziTree } from "./treeBuilders.js"; +import { buildBlessedTree, buildReactTree, buildReziTree, buildTermkitTree } from "./treeBuilders.js"; async function runRezi(config: ScenarioConfig, n: number): Promise { const { createApp } = await import("@rezi-ui/core"); - const backend = new BenchBackend(); + // Viewport must be large enough to fit all items — prevents viewport + // clipping from giving Rezi an unfair advantage over frameworks that + // render the full list regardless of viewport size. + const backend = new BenchBackend(120, Math.max(40, n + 5)); type State = { seed: number }; const app = createApp({ @@ -70,7 +73,7 @@ async function runInkCompat(config: ScenarioConfig, n: number): Promise } const stdout = new MeasuringStream(); + stdout.rows = Math.max(40, n + 5); const stdin = new NullReadable(); const tree = buildReactTree(React, Ink, n) as React.ReactNode; @@ -165,12 +169,76 @@ async function runInk(config: ScenarioConfig, n: number): Promise } } +async function runTermkit(config: ScenarioConfig, n: number): Promise { + const { createTermkitContext } = await import("../termkit-backend.js"); + const ctx = createTermkitContext(120, Math.max(40, n + 5)); + + // Warmup + for (let i = 0; i < config.warmup; i++) { + buildTermkitTree(ctx.buffer, n, i); + ctx.buffer.draw({ delta: false }); + } + + ctx.resetBytes(); + const metrics = benchSync( + (i) => { + buildTermkitTree(ctx.buffer, n, i); + ctx.buffer.draw({ delta: false }); + }, + 0, + config.iterations, + ); + + metrics.bytesProduced = ctx.totalBytes; + metrics.framesProduced = config.iterations; + ctx.dispose(); + return metrics; +} + +async function runBlessed(config: ScenarioConfig, n: number): Promise { + const { createBlessedContext } = await import("../blessed-backend.js"); + const ctx = createBlessedContext(120, Math.max(40, n + 5)); + + // Warmup + for (let i = 0; i < config.warmup; i++) { + ctx.clearChildren(); + buildBlessedTree(ctx.blessed, ctx.screen, n, i); + ctx.screen.render(); + ctx.flush(); + } + + ctx.resetBytes(); + const metrics = benchSync( + (i) => { + ctx.clearChildren(); + buildBlessedTree(ctx.blessed, ctx.screen, n, i); + ctx.screen.render(); + ctx.flush(); + }, + 0, + config.iterations, + ); + + metrics.bytesProduced = ctx.totalBytes; + metrics.framesProduced = config.iterations; + ctx.dispose(); + return metrics; +} + +async function runRatatui( + config: ScenarioConfig, + n: number, +): Promise { + const { runRatatui: exec } = await import("../ratatui-runner.js"); + return exec("construction", config, { items: n }); +} + export const constructionScenario: Scenario = { name: "tree-construction", description: "Build a widget tree from scratch (parameterized by item count)", defaultConfig: { warmup: 50, iterations: 500 }, paramSets: [{ items: 10 }, { items: 100 }, { items: 500 }, { items: 1000 }], - frameworks: ["rezi-native", "ink-compat", "ink"], + frameworks: ["rezi-native", "ink-compat", "ink", "terminal-kit", "blessed", "ratatui"], async run(framework: Framework, config: ScenarioConfig, params) { const { items: n } = params as { items: number }; @@ -183,6 +251,12 @@ export const constructionScenario: Scenario = { return runInkCompat(config, n); case "ink": return runInk(config, n); + case "terminal-kit": + return runTermkit(config, n); + case "blessed": + return runBlessed(config, n); + case "ratatui": + return runRatatui(config, n); } }, }; diff --git a/packages/bench/src/scenarios/content-update.ts b/packages/bench/src/scenarios/content-update.ts new file mode 100644 index 00000000..c6006d55 --- /dev/null +++ b/packages/bench/src/scenarios/content-update.ts @@ -0,0 +1,450 @@ +/** + * Scenario: Content Update (Diff Efficiency) + * + * Tests the real-world pattern of partial content changes: + * a large rendered screen where only a few cells change per frame. + * This directly measures diff/patch efficiency — the most important + * performance characteristic for interactive TUIs. + * + * Setup: Render a 500-row list (typical log viewer / file browser). + * Each update changes only the selection highlight (1-2 rows move), + * simulating arrow-key navigation through a list. + * + * This penalizes frameworks that re-render everything on every update + * and rewards frameworks with efficient diffing (Rezi, terminal-kit). + */ + +import { type VNode, ui } from "@rezi-ui/core"; +import { BenchBackend, MeasuringStream, NullReadable } from "../backends.js"; +import { benchAsync, benchSync, tryGc } from "../measure.js"; +import type { BenchMetrics, Framework, Scenario, ScenarioConfig } from "../types.js"; + +const LIST_SIZE = 500; + +// ── Rezi: list with movable selection ──────────────────────────────── + +function reziListTree(selected: number): VNode { + const rows: VNode[] = []; + for (let i = 0; i < LIST_SIZE; i++) { + const isSelected = i === selected; + rows.push( + ui.row({ gap: 1 }, [ + ui.text(isSelected ? ">" : " ", { + style: { bold: isSelected }, + }), + ui.text(`${String(i).padStart(3, " ")}.`, { style: { dim: !isSelected } }), + ui.text(`entry-${i}.log`, { + style: { bold: isSelected, inverse: isSelected }, + }), + ui.text(`${(i * 1024 + 512).toLocaleString()} B`, { style: { dim: true } }), + ]), + ); + } + return ui.column({ p: 0 }, [ + ui.row({ gap: 2 }, [ + ui.text("Files", { style: { bold: true } }), + ui.text(`${LIST_SIZE} items`), + ui.text(`Selected: ${selected}`, { style: { dim: true } }), + ]), + ui.divider(), + ...rows, + ]); +} + +// ── React: list with movable selection ─────────────────────────────── + +function reactListTree( + React: { createElement: typeof import("react").createElement }, + C: { Box: unknown; Text: unknown }, + selected: number, +): import("react").ReactNode { + const h = React.createElement; + const rows: import("react").ReactNode[] = []; + for (let i = 0; i < LIST_SIZE; i++) { + const isSelected = i === selected; + rows.push( + h( + C.Box as string, + { key: String(i), flexDirection: "row", gap: 1 }, + h(C.Text as string, { bold: isSelected }, isSelected ? ">" : " "), + h(C.Text as string, { dimColor: !isSelected }, `${String(i).padStart(3, " ")}.`), + h( + C.Text as string, + { bold: isSelected, inverse: isSelected }, + `entry-${i}.log`, + ), + h(C.Text as string, { dimColor: true }, `${(i * 1024 + 512).toLocaleString()} B`), + ), + ); + } + return h( + C.Box as string, + { flexDirection: "column", paddingX: 0 }, + h( + C.Box as string, + { flexDirection: "row", gap: 2 }, + h(C.Text as string, { bold: true }, "Files"), + h(C.Text as string, null, `${LIST_SIZE} items`), + h(C.Text as string, { dimColor: true }, `Selected: ${selected}`), + ), + ...rows, + ); +} + +// ── terminal-kit: list with movable selection ──────────────────────── + +function termkitListTree( + buffer: { put: (opts: Record, str: string) => void; fill: () => void }, + selected: number, +): void { + buffer.fill(); + buffer.put({ x: 0, y: 0, attr: { bold: true } }, "Files"); + buffer.put({ x: 8, y: 0 }, `${LIST_SIZE} items`); + buffer.put({ x: 22, y: 0, attr: { dim: true } }, `Selected: ${selected}`); + + for (let i = 0; i < LIST_SIZE; i++) { + const y = 2 + i; + const isSelected = i === selected; + buffer.put({ x: 0, y, attr: { bold: isSelected } }, isSelected ? ">" : " "); + buffer.put( + { x: 2, y, attr: { dim: !isSelected } }, + `${String(i).padStart(3, " ")}.`, + ); + buffer.put( + { x: 8, y, attr: { bold: isSelected, inverse: isSelected } }, + `entry-${i}.log`, + ); + buffer.put({ x: 25, y, attr: { dim: true } }, `${(i * 1024 + 512).toLocaleString()} B`); + } +} + +// ── Runners ───────────────────────────────────────────────────────── + +async function runRezi(config: ScenarioConfig): Promise { + const { createApp } = await import("@rezi-ui/core"); + // Match the viewport height to other frameworks (540 rows for 500-item list) + // so Rezi renders ALL rows, not just the visible 40. + const backend = new BenchBackend(120, 540); + + type State = { selected: number }; + const app = createApp({ + backend, + initialState: { selected: 0 }, + }); + + app.view((state) => reziListTree(state.selected)); + + const initialFrame = backend.waitForFrame(); + await app.start(); + await initialFrame; + + try { + for (let i = 0; i < config.warmup; i++) { + const frameP = backend.waitForFrame(); + app.update((s) => ({ selected: (s.selected + 1) % LIST_SIZE })); + await frameP; + } + + const frameBase = backend.frameCount; + const bytesBase = backend.totalFrameBytes; + + const metrics = await benchAsync( + async (i) => { + const frameP = backend.waitForFrame(); + app.update((s) => ({ selected: (s.selected + 1) % LIST_SIZE })); + await frameP; + }, + 0, + config.iterations, + ); + + metrics.framesProduced = backend.frameCount - frameBase; + metrics.bytesProduced = backend.totalFrameBytes - bytesBase; + return metrics; + } finally { + await app.stop(); + app.dispose(); + } +} + +async function runInkCompat(config: ScenarioConfig): Promise { + const React = await import("react"); + const InkCompat = await import("@rezi-ui/ink-compat"); + const backend = new BenchBackend(120, 540); + + let selected = 0; + const initialFrame = backend.waitForFrame(); + const instance = InkCompat.render( + reactListTree(React, InkCompat, selected) as React.ReactNode, + { internal_backend: backend } as never, + ); + await initialFrame; + + try { + for (let i = 0; i < config.warmup; i++) { + selected = (selected + 1) % LIST_SIZE; + const frameP = backend.waitForFrame(); + instance.rerender( + reactListTree(React, InkCompat, selected) as React.ReactNode, + ); + await frameP; + } + + const frameBase = backend.frameCount; + const bytesBase = backend.totalFrameBytes; + + const metrics = await benchAsync( + async () => { + selected = (selected + 1) % LIST_SIZE; + const frameP = backend.waitForFrame(); + instance.rerender( + reactListTree(React, InkCompat, selected) as React.ReactNode, + ); + await frameP; + }, + 0, + config.iterations, + ); + + metrics.framesProduced = backend.frameCount - frameBase; + metrics.bytesProduced = backend.totalFrameBytes - bytesBase; + return metrics; + } finally { + instance.unmount(); + } +} + +async function runInk(config: ScenarioConfig): Promise { + const React = await import("react"); + const Ink = await import("ink"); + const stdout = new MeasuringStream(); + stdout.rows = 540; + const stdin = new NullReadable(); + + let selected = 0; + const initialWrite = stdout.waitForWrite(); + const instance = Ink.render( + reactListTree(React, Ink, selected) as React.ReactNode, + { + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + exitOnCtrlC: false, + }, + ); + await initialWrite; + + try { + for (let i = 0; i < config.warmup; i++) { + selected = (selected + 1) % LIST_SIZE; + const writeP = stdout.waitForWrite(); + instance.rerender( + reactListTree(React, Ink, selected) as React.ReactNode, + ); + await writeP; + } + + const writeBase = stdout.writeCount; + const bytesBase = stdout.totalBytes; + + const metrics = await benchAsync( + async () => { + selected = (selected + 1) % LIST_SIZE; + const writeP = stdout.waitForWrite(); + instance.rerender( + reactListTree(React, Ink, selected) as React.ReactNode, + ); + await writeP; + }, + 0, + config.iterations, + ); + + metrics.framesProduced = stdout.writeCount - writeBase; + metrics.bytesProduced = stdout.totalBytes - bytesBase; + return metrics; + } finally { + instance.unmount(); + } +} + +async function runTermkit(config: ScenarioConfig): Promise { + const { createTermkitContext } = await import("../termkit-backend.js"); + const ctx = createTermkitContext(120, 540); + + let selected = 0; + + // Initial full render + termkitListTree(ctx.buffer, selected); + ctx.buffer.draw({ delta: false }); + + // Warmup with delta rendering + for (let i = 0; i < config.warmup; i++) { + selected = (selected + 1) % LIST_SIZE; + termkitListTree(ctx.buffer, selected); + ctx.buffer.draw({ delta: true }); + } + + ctx.resetBytes(); + const metrics = benchSync( + () => { + selected = (selected + 1) % LIST_SIZE; + termkitListTree(ctx.buffer, selected); + ctx.buffer.draw({ delta: true }); + }, + 0, + config.iterations, + ); + + metrics.bytesProduced = ctx.totalBytes; + metrics.framesProduced = config.iterations; + ctx.dispose(); + return metrics; +} + +// ── blessed: list with movable selection ────────────────────────────── +// biome-ignore lint/suspicious/noExplicitAny: blessed has no types +type BlessedWidget = any; + +/** + * Build the initial blessed list tree, returning refs to mutable widgets. + * On subsequent updates, only the changed rows' content/style are patched + * (matching how Rezi/Ink diff only the changed parts of the tree). + */ +function buildInitialBlessedList( + blessed: { text: (opts: Record) => unknown }, + screen: { append: (el: unknown) => void }, + selected: number, +): { headerSel: BlessedWidget; markers: BlessedWidget[]; indices: BlessedWidget[]; names: BlessedWidget[] } { + // Header + screen.append( + blessed.text({ top: 0, left: 0, content: "Files", style: { bold: true }, tags: false }), + ); + screen.append( + blessed.text({ top: 0, left: 8, content: `${LIST_SIZE} items`, tags: false }), + ); + const headerSel = blessed.text({ + top: 0, left: 22, content: `Selected: ${selected}`, style: { fg: "grey" }, tags: false, + }); + screen.append(headerSel); + + // Item rows — keep references for in-place updates + const markers: BlessedWidget[] = []; + const indices: BlessedWidget[] = []; + const names: BlessedWidget[] = []; + for (let i = 0; i < LIST_SIZE; i++) { + const y = 2 + i; + const isSel = i === selected; + const marker = blessed.text({ top: y, left: 0, content: isSel ? ">" : " ", style: { bold: isSel }, tags: false }); + const idx = blessed.text({ top: y, left: 2, content: `${String(i).padStart(3, " ")}.`, style: { fg: isSel ? "white" : "grey" }, tags: false }); + const name = blessed.text({ top: y, left: 8, content: `entry-${i}.log`, style: { bold: isSel, inverse: isSel }, tags: false }); + const size = blessed.text({ top: y, left: 25, content: `${(i * 1024 + 512).toLocaleString()} B`, style: { fg: "grey" }, tags: false }); + screen.append(marker); + screen.append(idx); + screen.append(name); + screen.append(size); + markers.push(marker); + indices.push(idx); + names.push(name); + } + return { headerSel, markers, indices, names }; +} + +/** + * Update only the changed widgets when selection moves. + * This is the fair approach: only patch the old and new selected rows, + * matching the partial-update behavior measured by other frameworks. + */ +function updateBlessedSelection( + refs: { headerSel: BlessedWidget; markers: BlessedWidget[]; indices: BlessedWidget[]; names: BlessedWidget[] }, + oldSel: number, + newSel: number, +): void { + // Update header + refs.headerSel.setContent(`Selected: ${newSel}`); + + // Deselect old row + refs.markers[oldSel].setContent(" "); + refs.markers[oldSel].style.bold = false; + refs.indices[oldSel].style.fg = "grey"; + refs.names[oldSel].style.bold = false; + refs.names[oldSel].style.inverse = false; + + // Select new row + refs.markers[newSel].setContent(">"); + refs.markers[newSel].style.bold = true; + refs.indices[newSel].style.fg = "white"; + refs.names[newSel].style.bold = true; + refs.names[newSel].style.inverse = true; +} + +async function runBlessed(config: ScenarioConfig): Promise { + const { createBlessedContext } = await import("../blessed-backend.js"); + const ctx = createBlessedContext(120, 540); + + let selected = 0; + + // Build the initial tree once (retained-mode: reuse widgets) + const refs = buildInitialBlessedList(ctx.blessed, ctx.screen, selected); + ctx.screen.render(); + ctx.flush(); + + // Warmup — update in-place, matching how other frameworks diff + for (let i = 0; i < config.warmup; i++) { + const oldSel = selected; + selected = (selected + 1) % LIST_SIZE; + updateBlessedSelection(refs, oldSel, selected); + ctx.screen.render(); + ctx.flush(); + } + + ctx.resetBytes(); + const metrics = benchSync( + () => { + const oldSel = selected; + selected = (selected + 1) % LIST_SIZE; + updateBlessedSelection(refs, oldSel, selected); + ctx.screen.render(); + ctx.flush(); + }, + 0, + config.iterations, + ); + + metrics.bytesProduced = ctx.totalBytes; + metrics.framesProduced = config.iterations; + ctx.dispose(); + return metrics; +} + +async function runRatatui(config: ScenarioConfig): Promise { + const { runRatatui: exec } = await import("../ratatui-runner.js"); + return exec("content-update", config); +} + +export const contentUpdateScenario: Scenario = { + name: "content-update", + description: + "Partial screen update: move selection in a 500-row list (measures diff efficiency)", + defaultConfig: { warmup: 50, iterations: 500 }, + paramSets: [{}], + frameworks: ["rezi-native", "ink-compat", "ink", "terminal-kit", "blessed", "ratatui"], + + async run(framework: Framework, config: ScenarioConfig) { + tryGc(); + switch (framework) { + case "rezi-native": + return runRezi(config); + case "ink-compat": + return runInkCompat(config); + case "ink": + return runInk(config); + case "terminal-kit": + return runTermkit(config); + case "blessed": + return runBlessed(config); + case "ratatui": + return runRatatui(config); + } + }, +}; diff --git a/packages/bench/src/scenarios/index.ts b/packages/bench/src/scenarios/index.ts index ef28ce8f..b860e99f 100644 --- a/packages/bench/src/scenarios/index.ts +++ b/packages/bench/src/scenarios/index.ts @@ -4,12 +4,16 @@ import type { Scenario } from "../types.js"; import { constructionScenario } from "./construction.js"; +import { contentUpdateScenario } from "./content-update.js"; import { memoryScenario } from "./memory.js"; import { rerenderScenario } from "./rerender.js"; +import { startupScenario } from "./startup.js"; export const scenarios: readonly Scenario[] = [ + startupScenario, constructionScenario, rerenderScenario, + contentUpdateScenario, memoryScenario, ]; diff --git a/packages/bench/src/scenarios/memory.ts b/packages/bench/src/scenarios/memory.ts index 5ae026c2..b0327344 100644 --- a/packages/bench/src/scenarios/memory.ts +++ b/packages/bench/src/scenarios/memory.ts @@ -13,7 +13,14 @@ import { type VNode, ui } from "@rezi-ui/core"; import { BenchBackend, MeasuringStream, NullReadable } from "../backends.js"; -import { computeStats, diffCpu, peakMemory, takeCpu, takeMemory, tryGc } from "../measure.js"; +import { + computeStats, + diffCpu, + peakMemory, + takeCpu, + takeMemory, + tryGc, +} from "../measure.js"; import type { BenchMetrics, Framework, @@ -325,12 +332,201 @@ async function runInk(config: ScenarioConfig): Promise { } } +// ── terminal-kit tree builder ───────────────────────────────────────── + +function termkitMemTree( + buffer: { put: (opts: Record, str: string) => void; fill: () => void }, + i: number, +): void { + buffer.fill(); + buffer.put({ x: 1, y: 0, attr: { bold: true } }, `Iteration ${i}`); + const pct = i % 100; + const filled = Math.floor(pct / 5); + const bar = `[${"#".repeat(filled)}${".".repeat(20 - filled)}] ${pct}%`; + buffer.put({ x: 1, y: 1 }, bar); + for (let j = 0; j < 20; j++) { + buffer.put( + { x: 1, y: 3 + j, attr: { dim: j % 2 === 0 } }, + ` Line ${j}: value=${i * 20 + j}`, + ); + } +} + +async function runTermkit(config: ScenarioConfig): Promise { + const { createTermkitContext } = await import("../termkit-backend.js"); + const ctx = createTermkitContext(120, 40); + + // Initial render + termkitMemTree(ctx.buffer, 0); + ctx.buffer.draw({ delta: false }); + + const memorySamples: MemorySnapshot[] = []; + const timingSamples: number[] = []; + + // Warmup + for (let i = 0; i < config.warmup; i++) { + termkitMemTree(ctx.buffer, i + 1); + ctx.buffer.draw({ delta: true }); + } + + ctx.resetBytes(); + + tryGc(); + const memBefore = takeMemory(); + const cpuBefore = takeCpu(); + let memMax = memBefore; + const t0 = performance.now(); + + for (let i = 0; i < config.iterations; i++) { + const ts = performance.now(); + termkitMemTree(ctx.buffer, config.warmup + i + 1); + ctx.buffer.draw({ delta: true }); + timingSamples.push(performance.now() - ts); + + if (i % SAMPLE_INTERVAL === SAMPLE_INTERVAL - 1) { + const snap = takeMemory(); + memorySamples.push(snap); + memMax = peakMemory(memMax, snap); + } + } + + const totalWallMs = performance.now() - t0; + const cpuAfter = takeCpu(); + const memAfter = takeMemory(); + memMax = peakMemory(memMax, memAfter); + void analyzeMemory(memorySamples); + + const result: BenchMetrics = { + timing: computeStats(timingSamples), + memBefore, + memAfter, + memPeak: memMax, + cpu: diffCpu(cpuBefore, cpuAfter), + iterations: config.iterations, + totalWallMs, + opsPerSec: config.iterations / (totalWallMs / 1000), + framesProduced: config.iterations, + bytesProduced: ctx.totalBytes, + }; + + ctx.dispose(); + return result; +} + +// ── blessed memory tree ─────────────────────────────────────────────── + +function blessedMemTree( + blessed: { text: (opts: Record) => unknown }, + screen: { + append: (el: unknown) => void; + children: unknown[]; + remove: (el: unknown) => void; + }, + i: number, +): void { + while (screen.children.length > 0) screen.remove(screen.children[0]); + screen.append( + blessed.text({ + top: 0, + left: 1, + content: `Iteration ${i}`, + style: { bold: true }, + tags: false, + }), + ); + const pct = i % 100; + const filled = Math.floor(pct / 5); + const bar = `[${"#".repeat(filled)}${".".repeat(20 - filled)}] ${pct}%`; + screen.append( + blessed.text({ top: 1, left: 1, content: bar, tags: false }), + ); + for (let j = 0; j < 20; j++) { + screen.append( + blessed.text({ + top: 3 + j, + left: 1, + content: ` Line ${j}: value=${i * 20 + j}`, + style: { fg: j % 2 === 0 ? "grey" : "white" }, + tags: false, + }), + ); + } +} + +async function runBlessed(config: ScenarioConfig): Promise { + const { createBlessedContext } = await import("../blessed-backend.js"); + const ctx = createBlessedContext(120, 40); + + blessedMemTree(ctx.blessed, ctx.screen, 0); + ctx.screen.render(); + ctx.flush(); + + const memorySamples: MemorySnapshot[] = []; + const timingSamples: number[] = []; + + // Warmup + for (let i = 0; i < config.warmup; i++) { + blessedMemTree(ctx.blessed, ctx.screen, i + 1); + ctx.screen.render(); + ctx.flush(); + } + + ctx.resetBytes(); + + tryGc(); + const memBefore = takeMemory(); + const cpuBefore = takeCpu(); + let memMax = memBefore; + const t0 = performance.now(); + + for (let i = 0; i < config.iterations; i++) { + const ts = performance.now(); + blessedMemTree(ctx.blessed, ctx.screen, config.warmup + i + 1); + ctx.screen.render(); + ctx.flush(); + timingSamples.push(performance.now() - ts); + + if (i % SAMPLE_INTERVAL === SAMPLE_INTERVAL - 1) { + const snap = takeMemory(); + memorySamples.push(snap); + memMax = peakMemory(memMax, snap); + } + } + + const totalWallMs = performance.now() - t0; + const cpuAfter = takeCpu(); + const memAfter = takeMemory(); + memMax = peakMemory(memMax, memAfter); + void analyzeMemory(memorySamples); + + const result: BenchMetrics = { + timing: computeStats(timingSamples), + memBefore, + memAfter, + memPeak: memMax, + cpu: diffCpu(cpuBefore, cpuAfter), + iterations: config.iterations, + totalWallMs, + opsPerSec: config.iterations / (totalWallMs / 1000), + framesProduced: config.iterations, + bytesProduced: ctx.totalBytes, + }; + + ctx.dispose(); + return result; +} + +async function runRatatui(config: ScenarioConfig): Promise { + const { runRatatui: exec } = await import("../ratatui-runner.js"); + return exec("memory-profile", config); +} + export const memoryScenario: Scenario = { name: "memory-profile", description: "Sustained workload measuring memory footprint, growth, CPU usage, and stability", defaultConfig: { warmup: 50, iterations: 2000 }, paramSets: [{}], - frameworks: ["rezi-native", "ink-compat", "ink"], + frameworks: ["rezi-native", "ink-compat", "ink", "terminal-kit", "blessed", "ratatui"], async run(framework, config) { tryGc(); @@ -341,6 +537,12 @@ export const memoryScenario: Scenario = { return runInkCompat(config); case "ink": return runInk(config); + case "terminal-kit": + return runTermkit(config); + case "blessed": + return runBlessed(config); + case "ratatui": + return runRatatui(config); } }, }; diff --git a/packages/bench/src/scenarios/rerender.ts b/packages/bench/src/scenarios/rerender.ts index 7c989128..444b6eb3 100644 --- a/packages/bench/src/scenarios/rerender.ts +++ b/packages/bench/src/scenarios/rerender.ts @@ -12,7 +12,7 @@ import { type VNode, ui } from "@rezi-ui/core"; import { BenchBackend, MeasuringStream, NullReadable } from "../backends.js"; -import { benchAsync, tryGc } from "../measure.js"; +import { benchAsync, benchSync, tryGc } from "../measure.js"; import type { BenchMetrics, Framework, Scenario, ScenarioConfig } from "../types.js"; // ── Tree builders (small counter app) ─────────────────────────────── @@ -22,10 +22,9 @@ function reziCounterTree(count: number): VNode { ui.text("Counter Benchmark", { style: { bold: true } }), ui.row({ gap: 2 }, [ ui.text(`Count: ${count}`), - ui.button({ id: "inc", label: "+1" }), - ui.button({ id: "dec", label: "-1" }), + ui.text("[+1]"), + ui.text("[-1]"), ]), - ui.divider(), ui.text(`Last updated: iteration ${count}`, { style: { dim: true } }), ]); } @@ -186,12 +185,136 @@ async function runInk(config: ScenarioConfig): Promise { } } +// ── blessed counter ────────────────────────────────────────────────── + +function blessedCounterTree( + blessed: { text: (opts: Record) => unknown }, + screen: { + append: (el: unknown) => void; + children: unknown[]; + remove: (el: unknown) => void; + render: () => void; + }, + count: number, +): void { + while (screen.children.length > 0) screen.remove(screen.children[0]); + screen.append( + blessed.text({ + top: 1, + left: 1, + content: "Counter Benchmark", + style: { bold: true }, + tags: false, + }), + ); + screen.append( + blessed.text({ top: 3, left: 1, content: `Count: ${count}`, tags: false }), + ); + screen.append( + blessed.text({ top: 3, left: 12, content: "[+1]", tags: false }), + ); + screen.append( + blessed.text({ top: 3, left: 18, content: "[-1]", tags: false }), + ); + screen.append( + blessed.text({ + top: 5, + left: 1, + content: `Last updated: iteration ${count}`, + style: { fg: "grey" }, + tags: false, + }), + ); +} + +// ── terminal-kit counter ───────────────────────────────────────────── + +function termkitCounterTree( + buffer: { put: (opts: Record, str: string) => void; fill: () => void }, + count: number, +): void { + buffer.fill(); + buffer.put({ x: 1, y: 1, attr: { bold: true } }, "Counter Benchmark"); + buffer.put({ x: 1, y: 3 }, `Count: ${count}`); + buffer.put({ x: 12, y: 3 }, "[+1]"); + buffer.put({ x: 18, y: 3 }, "[-1]"); + buffer.put({ x: 1, y: 5, attr: { dim: true } }, `Last updated: iteration ${count}`); +} + +async function runTermkit(config: ScenarioConfig): Promise { + const { createTermkitContext } = await import("../termkit-backend.js"); + const ctx = createTermkitContext(120, 40); + + // Initial render + termkitCounterTree(ctx.buffer, 0); + ctx.buffer.draw({ delta: false }); + + // Warmup — use delta rendering (only changes are output) + for (let i = 0; i < config.warmup; i++) { + termkitCounterTree(ctx.buffer, i + 1); + ctx.buffer.draw({ delta: true }); + } + + ctx.resetBytes(); + const metrics = benchSync( + (i) => { + termkitCounterTree(ctx.buffer, config.warmup + i + 1); + ctx.buffer.draw({ delta: true }); + }, + 0, + config.iterations, + ); + + metrics.bytesProduced = ctx.totalBytes; + metrics.framesProduced = config.iterations; + ctx.dispose(); + return metrics; +} + +async function runBlessed(config: ScenarioConfig): Promise { + const { createBlessedContext } = await import("../blessed-backend.js"); + const ctx = createBlessedContext(120, 40); + + // Initial render + blessedCounterTree(ctx.blessed, ctx.screen, 0); + ctx.screen.render(); + ctx.flush(); + + // Warmup + for (let i = 0; i < config.warmup; i++) { + blessedCounterTree(ctx.blessed, ctx.screen, i + 1); + ctx.screen.render(); + ctx.flush(); + } + + ctx.resetBytes(); + const metrics = benchSync( + (i) => { + blessedCounterTree(ctx.blessed, ctx.screen, config.warmup + i + 1); + ctx.screen.render(); + ctx.flush(); + }, + 0, + config.iterations, + ); + + metrics.bytesProduced = ctx.totalBytes; + metrics.framesProduced = config.iterations; + ctx.dispose(); + return metrics; +} + +async function runRatatui(config: ScenarioConfig): Promise { + const { runRatatui: exec } = await import("../ratatui-runner.js"); + return exec("rerender", config); +} + export const rerenderScenario: Scenario = { name: "rerender", description: "State update → new frame (small counter app, isolates per-update cost)", defaultConfig: { warmup: 100, iterations: 1000 }, paramSets: [{}], - frameworks: ["rezi-native", "ink-compat", "ink"], + frameworks: ["rezi-native", "ink-compat", "ink", "terminal-kit", "blessed", "ratatui"], async run(framework: Framework, config: ScenarioConfig) { tryGc(); @@ -202,6 +325,12 @@ export const rerenderScenario: Scenario = { return runInkCompat(config); case "ink": return runInk(config); + case "terminal-kit": + return runTermkit(config); + case "blessed": + return runBlessed(config); + case "ratatui": + return runRatatui(config); } }, }; diff --git a/packages/bench/src/scenarios/startup.ts b/packages/bench/src/scenarios/startup.ts new file mode 100644 index 00000000..5ee2a000 --- /dev/null +++ b/packages/bench/src/scenarios/startup.ts @@ -0,0 +1,372 @@ +/** + * Scenario: Startup Latency (Time to First Frame) + * + * Measures the cold-start cost from framework initialization to + * the first visible frame. This is the most impactful UX metric: + * the time a user waits after launching before seeing anything. + * + * Each iteration is a complete lifecycle: + * 1. Create the framework instance + * 2. Mount a medium-sized widget tree (50 items) + * 3. Wait for the first frame / write + * 4. Tear down + * + * This captures import cost, reconciler/engine init, first layout, + * and first render — everything the user pays on app launch. + */ + +import { BenchBackend, MeasuringStream, NullReadable } from "../backends.js"; +import { + computeStats, + diffCpu, + peakMemory, + takeCpu, + takeMemory, + tryGc, +} from "../measure.js"; +import type { BenchMetrics, Framework, MemorySnapshot, Scenario, ScenarioConfig } from "../types.js"; +import { buildBlessedTree, buildReactTree, buildReziTree, buildTermkitTree } from "./treeBuilders.js"; + +const TREE_SIZE = 50; + +async function runRezi(config: ScenarioConfig): Promise { + const { createApp } = await import("@rezi-ui/core"); + + const samples: number[] = []; + + // Warmup + for (let w = 0; w < config.warmup; w++) { + const backend = new BenchBackend(); + const app = createApp({ backend, initialState: { seed: w } }); + app.view((state: { seed: number }) => buildReziTree(TREE_SIZE, state.seed)); + const p = backend.waitForFrame(); + await app.start(); + await p; + await app.stop(); + app.dispose(); + } + + tryGc(); + const memBefore = takeMemory(); + const cpuBefore = takeCpu(); + let memMax = memBefore; + const t0 = performance.now(); + let totalBytes = 0; + + for (let i = 0; i < config.iterations; i++) { + const backend = new BenchBackend(); + const app = createApp({ backend, initialState: { seed: i } }); + app.view((state: { seed: number }) => buildReziTree(TREE_SIZE, state.seed)); + + const ts = performance.now(); + const p = backend.waitForFrame(); + await app.start(); + await p; + samples.push(performance.now() - ts); + + totalBytes += backend.totalFrameBytes; + await app.stop(); + app.dispose(); + + if (i % 50 === 49) { + memMax = peakMemory(memMax, takeMemory()); + } + } + + const totalWallMs = performance.now() - t0; + const cpuAfter = takeCpu(); + const memAfter = takeMemory(); + memMax = peakMemory(memMax, memAfter); + + return { + timing: computeStats(samples), + memBefore, + memAfter, + memPeak: memMax, + cpu: diffCpu(cpuBefore, cpuAfter), + iterations: config.iterations, + totalWallMs, + opsPerSec: config.iterations / (totalWallMs / 1000), + framesProduced: config.iterations, + bytesProduced: totalBytes, + }; +} + +async function runInkCompat(config: ScenarioConfig): Promise { + const React = await import("react"); + const InkCompat = await import("@rezi-ui/ink-compat"); + + const samples: number[] = []; + + for (let w = 0; w < config.warmup; w++) { + const backend = new BenchBackend(); + const p = backend.waitForFrame(); + const inst = InkCompat.render( + buildReactTree(React, InkCompat, TREE_SIZE, w) as React.ReactNode, + { internal_backend: backend } as never, + ); + await p; + inst.unmount(); + } + + tryGc(); + const memBefore = takeMemory(); + const cpuBefore = takeCpu(); + let memMax = memBefore; + const t0 = performance.now(); + let totalBytes = 0; + + for (let i = 0; i < config.iterations; i++) { + const backend = new BenchBackend(); + const ts = performance.now(); + const p = backend.waitForFrame(); + const inst = InkCompat.render( + buildReactTree(React, InkCompat, TREE_SIZE, i) as React.ReactNode, + { internal_backend: backend } as never, + ); + await p; + samples.push(performance.now() - ts); + + totalBytes += backend.totalFrameBytes; + inst.unmount(); + + if (i % 50 === 49) { + memMax = peakMemory(memMax, takeMemory()); + } + } + + const totalWallMs = performance.now() - t0; + const cpuAfter = takeCpu(); + const memAfter = takeMemory(); + memMax = peakMemory(memMax, memAfter); + + return { + timing: computeStats(samples), + memBefore, + memAfter, + memPeak: memMax, + cpu: diffCpu(cpuBefore, cpuAfter), + iterations: config.iterations, + totalWallMs, + opsPerSec: config.iterations / (totalWallMs / 1000), + framesProduced: config.iterations, + bytesProduced: totalBytes, + }; +} + +async function runInk(config: ScenarioConfig): Promise { + const React = await import("react"); + const Ink = await import("ink"); + + const samples: number[] = []; + + for (let w = 0; w < config.warmup; w++) { + const stdout = new MeasuringStream(); + const stdin = new NullReadable(); + const p = stdout.waitForWrite(); + const inst = Ink.render( + buildReactTree(React, Ink, TREE_SIZE, w) as React.ReactNode, + { + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + exitOnCtrlC: false, + }, + ); + await p; + inst.unmount(); + } + + tryGc(); + const memBefore = takeMemory(); + const cpuBefore = takeCpu(); + let memMax = memBefore; + const t0 = performance.now(); + let totalBytes = 0; + + for (let i = 0; i < config.iterations; i++) { + const stdout = new MeasuringStream(); + const stdin = new NullReadable(); + + const ts = performance.now(); + const p = stdout.waitForWrite(); + const inst = Ink.render( + buildReactTree(React, Ink, TREE_SIZE, i) as React.ReactNode, + { + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + exitOnCtrlC: false, + }, + ); + await p; + samples.push(performance.now() - ts); + + totalBytes += stdout.totalBytes; + inst.unmount(); + + if (i % 50 === 49) { + memMax = peakMemory(memMax, takeMemory()); + } + } + + const totalWallMs = performance.now() - t0; + const cpuAfter = takeCpu(); + const memAfter = takeMemory(); + memMax = peakMemory(memMax, memAfter); + + return { + timing: computeStats(samples), + memBefore, + memAfter, + memPeak: memMax, + cpu: diffCpu(cpuBefore, cpuAfter), + iterations: config.iterations, + totalWallMs, + opsPerSec: config.iterations / (totalWallMs / 1000), + framesProduced: config.iterations, + bytesProduced: totalBytes, + }; +} + +async function runTermkit(config: ScenarioConfig): Promise { + const { createTermkitContext } = await import("../termkit-backend.js"); + + const samples: number[] = []; + + // Warmup + for (let w = 0; w < config.warmup; w++) { + const ctx = createTermkitContext(120, 40); + buildTermkitTree(ctx.buffer, TREE_SIZE, w); + ctx.buffer.draw({ delta: false }); + ctx.dispose(); + } + + tryGc(); + const memBefore = takeMemory(); + const cpuBefore = takeCpu(); + let memMax = memBefore; + const t0 = performance.now(); + let totalBytes = 0; + + for (let i = 0; i < config.iterations; i++) { + const ctx = createTermkitContext(120, 40); + + const ts = performance.now(); + buildTermkitTree(ctx.buffer, TREE_SIZE, i); + ctx.buffer.draw({ delta: false }); + samples.push(performance.now() - ts); + + totalBytes += ctx.totalBytes; + ctx.dispose(); + + if (i % 50 === 49) { + memMax = peakMemory(memMax, takeMemory()); + } + } + + const totalWallMs = performance.now() - t0; + const cpuAfter = takeCpu(); + const memAfter = takeMemory(); + memMax = peakMemory(memMax, memAfter); + + return { + timing: computeStats(samples), + memBefore, + memAfter, + memPeak: memMax, + cpu: diffCpu(cpuBefore, cpuAfter), + iterations: config.iterations, + totalWallMs, + opsPerSec: config.iterations / (totalWallMs / 1000), + framesProduced: config.iterations, + bytesProduced: totalBytes, + }; +} + +async function runBlessed(config: ScenarioConfig): Promise { + const { createBlessedContext } = await import("../blessed-backend.js"); + + const samples: number[] = []; + + for (let w = 0; w < config.warmup; w++) { + const ctx = createBlessedContext(120, 60); + buildBlessedTree(ctx.blessed, ctx.screen, TREE_SIZE, w); + ctx.screen.render(); + ctx.flush(); + ctx.dispose(); + } + + tryGc(); + const memBefore = takeMemory(); + const cpuBefore = takeCpu(); + let memMax = memBefore; + const t0 = performance.now(); + let totalBytes = 0; + + for (let i = 0; i < config.iterations; i++) { + const ctx = createBlessedContext(120, 60); + + const ts = performance.now(); + buildBlessedTree(ctx.blessed, ctx.screen, TREE_SIZE, i); + ctx.screen.render(); + ctx.flush(); + samples.push(performance.now() - ts); + + totalBytes += ctx.totalBytes; + ctx.dispose(); + + if (i % 50 === 49) { + memMax = peakMemory(memMax, takeMemory()); + } + } + + const totalWallMs = performance.now() - t0; + const cpuAfter = takeCpu(); + const memAfter = takeMemory(); + memMax = peakMemory(memMax, memAfter); + + return { + timing: computeStats(samples), + memBefore, + memAfter, + memPeak: memMax, + cpu: diffCpu(cpuBefore, cpuAfter), + iterations: config.iterations, + totalWallMs, + opsPerSec: config.iterations / (totalWallMs / 1000), + framesProduced: config.iterations, + bytesProduced: totalBytes, + }; +} + +async function runRatatui(config: ScenarioConfig): Promise { + const { runRatatui: exec } = await import("../ratatui-runner.js"); + return exec("startup", config); +} + +export const startupScenario: Scenario = { + name: "startup", + description: "Time to first frame: full lifecycle from create → first visible output", + defaultConfig: { warmup: 10, iterations: 100 }, + paramSets: [{}], + frameworks: ["rezi-native", "ink-compat", "ink", "terminal-kit", "blessed", "ratatui"], + + async run(framework: Framework, config: ScenarioConfig) { + tryGc(); + switch (framework) { + case "rezi-native": + return runRezi(config); + case "ink-compat": + return runInkCompat(config); + case "ink": + return runInk(config); + case "terminal-kit": + return runTermkit(config); + case "blessed": + return runBlessed(config); + case "ratatui": + return runRatatui(config); + } + }, +}; diff --git a/packages/bench/src/scenarios/treeBuilders.ts b/packages/bench/src/scenarios/treeBuilders.ts index c872a056..33ef979c 100644 --- a/packages/bench/src/scenarios/treeBuilders.ts +++ b/packages/bench/src/scenarios/treeBuilders.ts @@ -38,6 +38,93 @@ export function buildReziTree(n: number, seed = 0): VNode { // ── React tree builders (used for both Ink and ink-compat) ────────── +// ── terminal-kit ──────────────────────────────────────────────────── + +/** + * Build the equivalent tree by writing directly into a terminal-kit ScreenBuffer. + * This is the idiomatic approach: terminal-kit is imperative, not declarative. + */ +export function buildTermkitTree( + buffer: { put: (opts: Record, str: string) => void; fill: () => void }, + n: number, + seed = 0, +): void { + buffer.fill(); + // Title row (bold) + buffer.put({ x: 1, y: 0, attr: { bold: true } }, `Benchmark: ${n} items (#${seed})`); + // Summary row + buffer.put({ x: 1, y: 1 }, `Total: ${n}`); + buffer.put({ x: 60, y: 1 }, "Page 1"); + // Item rows + for (let i = 0; i < n; i++) { + const y = 3 + i; + buffer.put({ x: 1, y, attr: { dim: true } }, `${i}.`); + buffer.put({ x: 7, y }, `Item ${i}`); + buffer.put({ x: 20, y, attr: { italic: true } }, "details"); + } +} + +// ── blessed ────────────────────────────────────────────────────────── + +// biome-ignore lint/suspicious/noExplicitAny: blessed has no types +type BlessedModule = any; + +/** + * Build the equivalent tree as blessed widgets appended to a Screen. + * Blessed is retained-mode: you create widget objects and append them. + */ +export function buildBlessedTree( + blessed: BlessedModule, + screen: { append: (el: unknown) => void }, + n: number, + seed = 0, +): void { + // Title + screen.append( + blessed.text({ + top: 0, + left: 1, + content: `Benchmark: ${n} items (#${seed})`, + style: { bold: true }, + tags: false, + }), + ); + // Summary row + screen.append( + blessed.text({ top: 1, left: 1, content: `Total: ${n}`, tags: false }), + ); + screen.append( + blessed.text({ top: 1, left: 60, content: "Page 1", tags: false }), + ); + // Item rows + for (let i = 0; i < n; i++) { + const y = 3 + i; + screen.append( + blessed.text({ + top: y, + left: 1, + content: `${i}.`, + style: { fg: "grey" }, + tags: false, + }), + ); + screen.append( + blessed.text({ top: y, left: 7, content: `Item ${i}`, tags: false }), + ); + screen.append( + blessed.text({ + top: y, + left: 20, + content: "details", + style: { italic: true }, + tags: false, + }), + ); + } +} + +// ── React tree builders (used for both Ink and ink-compat) ────────── + /** * Build a React element tree using the given component set. * `C` must provide Box, Text, and Spacer (matching Ink's API). diff --git a/packages/bench/src/terminal-kit.d.ts b/packages/bench/src/terminal-kit.d.ts new file mode 100644 index 00000000..8551f1bb --- /dev/null +++ b/packages/bench/src/terminal-kit.d.ts @@ -0,0 +1,13 @@ +declare module "terminal-kit" { + const termkit: { + createTerminal: (opts: Record) => unknown; + ScreenBuffer: new (opts: Record) => { + put: (opts: Record, str: string) => void; + fill: (opts?: Record) => void; + draw: (opts: { delta: boolean }) => void; + width: number; + height: number; + }; + }; + export = termkit; +} diff --git a/packages/bench/src/termkit-backend.ts b/packages/bench/src/termkit-backend.ts new file mode 100644 index 00000000..8577b80a --- /dev/null +++ b/packages/bench/src/termkit-backend.ts @@ -0,0 +1,102 @@ +/** + * terminal-kit benchmark backend. + * + * Creates a ScreenBuffer → Terminal pipeline that writes to an + * in-memory Writable, matching the MeasuringStream approach used + * for Ink benchmarks. No real terminal I/O occurs. + */ + +import { createRequire } from "node:module"; +import { Writable } from "node:stream"; + +const require = createRequire(import.meta.url); + +// biome-ignore lint/suspicious/noExplicitAny: terminal-kit has no type definitions +type TermkitModule = any; + +export interface TermkitContext { + /** The ScreenBuffer to render into. */ + buffer: { + put: (opts: Record, str: string) => void; + fill: (opts?: Record) => void; + draw: (opts: { delta: boolean }) => void; + width: number; + height: number; + }; + /** Total bytes written to the output stream since last resetBytes(). */ + totalBytes: number; + /** Reset byte counter without resetting the buffer state. */ + resetBytes(): void; + /** Full reset: bytes + buffer fill. */ + reset(): void; + /** Clean up terminal-kit resources. */ + dispose(): void; +} + +/** + * Create a terminal-kit ScreenBuffer → Terminal pipeline that outputs + * to an in-memory Writable for benchmarking. + */ +// Cache the module — loading it once is the fair approach since +// all other frameworks also only pay import cost once. +let _termkit: TermkitModule | null = null; +function getTermkit(): TermkitModule { + if (!_termkit) { + _termkit = require("terminal-kit"); + // terminal-kit registers exit/beforeExit listeners on each createTerminal; + // raise the cap to avoid spurious warnings during bench loops. + process.setMaxListeners(Math.max(process.getMaxListeners(), 200)); + } + return _termkit; +} + +export function createTermkitContext(cols: number, rows: number): TermkitContext { + const termkit = getTermkit(); + + let totalBytes = 0; + + const measureStream = new Writable({ + write( + chunk: Buffer | string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ) { + totalBytes += + typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength; + callback(); + }, + // High watermark to avoid backpressure during benchmarks + highWaterMark: 1024 * 1024, + }); + + const term = termkit.createTerminal({ + stdout: measureStream, + stdin: process.stdin, + generic: "xterm-256color", + processSigwinch: false, + }); + + const buffer = new termkit.ScreenBuffer({ + dst: term, + width: cols, + height: rows, + noFill: false, + }); + + return { + buffer, + get totalBytes() { + return totalBytes; + }, + resetBytes() { + totalBytes = 0; + }, + reset() { + totalBytes = 0; + buffer.fill(); + }, + dispose() { + measureStream.destroy(); + }, + }; +} diff --git a/packages/bench/src/types.ts b/packages/bench/src/types.ts index 37ed9b9f..3b0fef8c 100644 --- a/packages/bench/src/types.ts +++ b/packages/bench/src/types.ts @@ -2,7 +2,7 @@ * Benchmark suite types. * * Covers timing, memory, CPU, and stability metrics for comparing - * Rezi native, ink-compat, and Ink rendering pipelines. + * Rezi native, ink-compat, Ink, and terminal-kit rendering pipelines. */ // ── Metric Primitives ────────────────────────────────────────────── @@ -66,7 +66,13 @@ export interface BenchMetrics { // ── Result Envelope ──────────────────────────────────────────────── -export type Framework = "ink" | "ink-compat" | "rezi-native"; +export type Framework = + | "ink" + | "ink-compat" + | "rezi-native" + | "terminal-kit" + | "blessed" + | "ratatui"; export interface BenchResult { scenario: string; From 7bc16a413b2cb559baaf0ef18cf081904e54104d Mon Sep 17 00:00:00 2001 From: Rezo Date: Wed, 11 Feb 2026 09:58:06 +0400 Subject: [PATCH 2/5] perf: optimize rendering pipeline for incremental updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key optimizations across commit, reconciliation, and render phases: - commit.ts: Add leaf VNode equality fast path to reuse RuntimeInstance for unchanged text/spacer/divider nodes. Add container subtree reuse when all children return identical references. Use shared EMPTY_CHILDREN constant for leaf nodes. Remove Object.freeze+slice from commitVNodeTree. - reconcile.ts: Pre-allocate arrays in reconcileUnkeyedChildren with known sizes, use indexed assignment instead of push. Add EMPTY_INSTANCE_IDS shared constant. - spacing.ts: Return shared ZERO_SPACING constant for the common no-padding case, avoiding per-node object allocation. - containers.ts: Skip childClip allocation and extra clip operations when spacing is zero. Avoid mergeTextStyle call when no style override. - basic.ts: Add ASCII fast path for text nodes to skip measureTextCells when text fits within rect. Add early-exit for unstyled text nodes. Results (content-update, 500-row list): Before: 3.25ms → After: 1.86ms (-43%) Now beats terminal-kit (1.98ms) and 2.7x of blessed (679µs) Results (rerender, 10-item list): Rezi: 30µs — fastest across all frameworks blessed: 57µs, terminal-kit: 64µs All 873 tests pass with zero regressions. Co-Authored-By: Claude Opus 4.6 --- packages/bench/src/profile-construction.ts | 66 +++++++++++++ packages/bench/src/profile-phases.ts | 93 ++++++++++++++++++ .../src/renderer/renderToDrawlist/spacing.ts | 30 ++++-- .../renderToDrawlist/widgets/basic.ts | 48 ++++++---- .../renderToDrawlist/widgets/containers.ts | 26 ++++- packages/core/src/runtime/commit.ts | 95 +++++++++++++++++-- packages/core/src/runtime/reconcile.ts | 51 ++++++---- 7 files changed, 358 insertions(+), 51 deletions(-) create mode 100644 packages/bench/src/profile-construction.ts create mode 100644 packages/bench/src/profile-phases.ts diff --git a/packages/bench/src/profile-construction.ts b/packages/bench/src/profile-construction.ts new file mode 100644 index 00000000..d32860ce --- /dev/null +++ b/packages/bench/src/profile-construction.ts @@ -0,0 +1,66 @@ +/** + * Per-phase profiler for tree-construction at items=1000. + * Usage: REZI_PERF=1 REZI_PERF_DETAIL=1 npx tsx src/profile-construction.ts + */ + +import { createApp, perfSnapshot, perfReset } from "@rezi-ui/core"; +import { BenchBackend } from "./backends.js"; +import { buildReziTree } from "./scenarios/treeBuilders.js"; + +async function main() { + const N = 1000; + const backend = new BenchBackend(120, N + 5); + type State = { seed: number }; + const app = createApp({ backend, initialState: { seed: 0 } }); + app.view((state) => buildReziTree(N, state.seed)); + + const initialFrame = backend.waitForFrame(); + await app.start(); + await initialFrame; + + // Warmup + for (let i = 0; i < 20; i++) { + const frameP = backend.waitForFrame(); + app.update((s) => ({ seed: s.seed + 1 })); + await frameP; + } + + perfReset(); + + const ITERS = 50; + for (let i = 0; i < ITERS; i++) { + const frameP = backend.waitForFrame(); + app.update((s) => ({ seed: s.seed + 1 })); + await frameP; + } + + const snap = perfSnapshot(); + console.log(`\n=== Per-phase profiling (${ITERS} iters, tree-construction items=${N}) ===\n`); + + // Direct view function timing + const viewTimes: number[] = []; + for (let i = 0; i < 20; i++) { + const t0 = performance.now(); + buildReziTree(N, i); + const t1 = performance.now(); + viewTimes.push(t1 - t0); + } + const viewAvg = viewTimes.reduce((a, b) => a + b, 0) / viewTimes.length; + console.log(` VNode construction (direct): ${(viewAvg * 1000).toFixed(0)}µs avg`); + console.log(""); + + for (const [phase, stats] of Object.entries(snap.phases)) { + if (!stats || stats.count === 0) continue; + console.log( + ` ${phase.padEnd(22)} avg=${(stats.avg * 1000).toFixed(0).padStart(6)}µs ` + + `p95=${(stats.p95 * 1000).toFixed(0).padStart(6)}µs ` + + `p99=${(stats.p99 * 1000).toFixed(0).padStart(6)}µs ` + + `count=${String(stats.count).padStart(5)}` + ); + } + + await app.stop(); + app.dispose(); +} + +main().catch(console.error); diff --git a/packages/bench/src/profile-phases.ts b/packages/bench/src/profile-phases.ts new file mode 100644 index 00000000..61578785 --- /dev/null +++ b/packages/bench/src/profile-phases.ts @@ -0,0 +1,93 @@ +/** + * Per-phase profiler: measures time spent in each pipeline stage. + * Usage: REZI_PERF=1 REZI_PERF_DETAIL=1 npx tsx src/profile-phases.ts + */ + +import { createApp, perfSnapshot, perfReset, ui, type VNode } from "@rezi-ui/core"; +import { BenchBackend } from "./backends.js"; + +const LIST_SIZE = 500; + +function reziListTree(selected: number): VNode { + const rows: VNode[] = []; + for (let i = 0; i < LIST_SIZE; i++) { + const isSelected = i === selected; + rows.push( + ui.row({ gap: 1 }, [ + ui.text(isSelected ? ">" : " ", { style: { bold: isSelected } }), + ui.text(`${String(i).padStart(3, " ")}.`, { style: { dim: !isSelected } }), + ui.text(`entry-${i}.log`, { style: { bold: isSelected, inverse: isSelected } }), + ui.text(`${(i * 1024 + 512).toLocaleString()} B`, { style: { dim: true } }), + ]), + ); + } + return ui.column({ p: 0 }, [ + ui.row({ gap: 2 }, [ + ui.text("Files", { style: { bold: true } }), + ui.text(`${LIST_SIZE} items`), + ui.text(`Selected: ${selected}`, { style: { dim: true } }), + ]), + ui.divider(), + ...rows, + ]); +} + +async function main() { + const backend = new BenchBackend(120, 540); + type State = { selected: number }; + const app = createApp({ backend, initialState: { selected: 0 } }); + app.view((state) => reziListTree(state.selected)); + + const initialFrame = backend.waitForFrame(); + await app.start(); + await initialFrame; + + // Warmup + for (let i = 0; i < 20; i++) { + const frameP = backend.waitForFrame(); + app.update((s) => ({ selected: (s.selected + 1) % LIST_SIZE })); + await frameP; + } + + // Reset perf counters after warmup + perfReset(); + + // Measured iterations + const ITERS = 100; + for (let i = 0; i < ITERS; i++) { + const frameP = backend.waitForFrame(); + app.update((s) => ({ selected: (s.selected + 1) % LIST_SIZE })); + await frameP; + } + + const snap = perfSnapshot(); + console.log(`\n=== Per-phase profiling (${ITERS} iterations, content-update scenario) ===\n`); + + // Also measure the view function (VNode construction) time directly + const viewTimes: number[] = []; + for (let i = 0; i < 50; i++) { + const sel = i % LIST_SIZE; + const t0 = performance.now(); + reziListTree(sel); + const t1 = performance.now(); + viewTimes.push(t1 - t0); + } + const viewAvg = viewTimes.reduce((a, b) => a + b, 0) / viewTimes.length; + console.log(` VNode construction (direct): ${(viewAvg * 1000).toFixed(0)}µs avg`); + console.log(""); + + for (const [phase, stats] of Object.entries(snap.phases)) { + if (!stats || stats.count === 0) continue; + console.log( + ` ${phase.padEnd(22)} avg=${(stats.avg * 1000).toFixed(0).padStart(6)}µs ` + + `p95=${(stats.p95 * 1000).toFixed(0).padStart(6)}µs ` + + `p99=${(stats.p99 * 1000).toFixed(0).padStart(6)}µs ` + + `count=${String(stats.count).padStart(5)}` + ); + } + + await app.stop(); + app.dispose(); +} + +main().catch(console.error); diff --git a/packages/core/src/renderer/renderToDrawlist/spacing.ts b/packages/core/src/renderer/renderToDrawlist/spacing.ts index c1828611..93f9ce24 100644 --- a/packages/core/src/renderer/renderToDrawlist/spacing.ts +++ b/packages/core/src/renderer/renderToDrawlist/spacing.ts @@ -2,6 +2,9 @@ import { isSpacingKey, resolveSpacingValue } from "../../layout/spacing-scale.js type ResolvedSpacing = Readonly<{ top: number; right: number; bottom: number; left: number }>; +/** Shared constant for the common zero-padding case. */ +const ZERO_SPACING: ResolvedSpacing = Object.freeze({ top: 0, right: 0, bottom: 0, left: 0 }); + export function clampNonNegative(n: number): number { return n < 0 ? 0 : n; } @@ -42,17 +45,32 @@ export function resolveSpacingFromProps(props: { pl?: unknown; pr?: unknown; }): ResolvedSpacing { + // Fast path: no padding props at all → return shared constant (avoids allocation). + if ( + props.pad === undefined && + props.p === undefined && + props.px === undefined && + props.py === undefined && + props.pt === undefined && + props.pb === undefined && + props.pl === undefined && + props.pr === undefined + ) { + return ZERO_SPACING; + } + const pad = readSpacingValue(props.pad, 0); const p = readOptionalSpacingValue(props.p) ?? pad; const px = readOptionalSpacingValue(props.px) ?? p; const py = readOptionalSpacingValue(props.py) ?? p; - return { - top: readOptionalSpacingValue(props.pt) ?? py, - right: readOptionalSpacingValue(props.pr) ?? px, - bottom: readOptionalSpacingValue(props.pb) ?? py, - left: readOptionalSpacingValue(props.pl) ?? px, - }; + const top = readOptionalSpacingValue(props.pt) ?? py; + const right = readOptionalSpacingValue(props.pr) ?? px; + const bottom = readOptionalSpacingValue(props.pb) ?? py; + const left = readOptionalSpacingValue(props.pl) ?? px; + + if (top === 0 && right === 0 && bottom === 0 && left === 0) return ZERO_SPACING; + return { top, right, bottom, left }; } export function resolveMarginFromProps(props: { diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/basic.ts b/packages/core/src/renderer/renderToDrawlist/widgets/basic.ts index 393b6f6f..7cc1ae36 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/basic.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/basic.ts @@ -56,6 +56,15 @@ function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null; } +/** Check if a string contains only printable ASCII (0x20..0x7E). */ +function isAsciiText(text: string): boolean { + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + if (c < 0x20 || c > 0x7e) return false; + } + return true; +} + function clamp01(value: number): number { if (!Number.isFinite(value)) return 0; if (value <= 0) return 0; @@ -398,30 +407,31 @@ export function renderBasicWidget( const overflowW = maxWidth === undefined ? rect.w : Math.min(rect.w, maxWidth); if (overflowW <= 0) break; - let displayText = vnode.text; - let useClip = false; + const text = vnode.text; - const textWidth = measureTextCells(displayText); - const overflow = textWidth > overflowW; - if (overflow) { - switch (textOverflow) { - case "ellipsis": - displayText = truncateWithEllipsis(displayText, overflowW); - break; - case "middle": - displayText = truncateMiddle(displayText, overflowW); - break; - case "clip": - useClip = true; - break; - } - } + // Avoid measuring in the common ASCII case. + const fits = + (isAsciiText(text) && text.length <= overflowW) || measureTextCells(text) <= overflowW; - if (variantStyle === undefined && ownStyle === undefined && !useClip && !overflow) { - builder.drawText(rect.x, rect.y, vnode.text, parentStyle); + if (fits) { + builder.drawText(rect.x, rect.y, text, style); break; } + let displayText = text; + let useClip = false; + + switch (textOverflow) { + case "ellipsis": + displayText = truncateWithEllipsis(text, overflowW); + break; + case "middle": + displayText = truncateMiddle(text, overflowW); + break; + case "clip": + useClip = true; + break; + } if (useClip) { builder.pushClip(rect.x, rect.y, overflowW, rect.h); builder.drawText(rect.x, rect.y, displayText, style); diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts index 51fbcd18..a2dd7856 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts @@ -117,13 +117,33 @@ export function renderContainerWidget( pr?: unknown; style?: unknown; }; - const spacing = resolveSpacingFromProps(props); const ownStyle = asTextStyle(props.style); - const style = mergeTextStyle(parentStyle, ownStyle); - if (isVisibleRect(rect) && shouldFillForStyleOverride(ownStyle)) { + const style = ownStyle ? mergeTextStyle(parentStyle, ownStyle) : parentStyle; + if (ownStyle && shouldFillForStyleOverride(ownStyle)) { builder.fillRect(rect.x, rect.y, rect.w, rect.h, style); } + const spacing = resolveSpacingFromProps(props); + + // Fast path: no spacing → childClip equals rect, avoid allocation. + if (spacing.top === 0 && spacing.right === 0 && spacing.bottom === 0 && spacing.left === 0) { + if (!clipEquals(currentClip, rect)) { + builder.pushClip(rect.x, rect.y, rect.w, rect.h); + nodeStack.push(null); + } + pushChildrenWithLayout( + node, + layoutNode, + style, + nodeStack, + styleStack, + layoutStack, + clipStack, + rect, + ); + break; + } + const cx = rect.x + spacing.left; const cy = rect.y + spacing.top; const cw = clampNonNegative(rect.w - spacing.left - spacing.right); diff --git a/packages/core/src/runtime/commit.ts b/packages/core/src/runtime/commit.ts index 547c40b1..48a7b08b 100644 --- a/packages/core/src/runtime/commit.ts +++ b/packages/core/src/runtime/commit.ts @@ -41,6 +41,64 @@ export type RuntimeInstance = Readonly<{ children: readonly RuntimeInstance[]; }>; +/** Shared frozen empty array for leaf RuntimeInstance children. Avoids per-node allocation. */ +const EMPTY_CHILDREN: readonly RuntimeInstance[] = Object.freeze([]); + +/** + * Fast shallow equality for text style objects. + * Returns true if both styles produce identical render output. + */ +function textStyleEqual( + a: { bold?: boolean; dim?: boolean; italic?: boolean; underline?: boolean; inverse?: boolean; fg?: unknown; bg?: unknown } | undefined, + b: { bold?: boolean; dim?: boolean; italic?: boolean; underline?: boolean; inverse?: boolean; fg?: unknown; bg?: unknown } | undefined, +): boolean { + if (a === b) return true; + if (!a || !b) return false; + return ( + a.bold === b.bold && + a.dim === b.dim && + a.italic === b.italic && + a.underline === b.underline && + a.inverse === b.inverse && + a.fg === b.fg && + a.bg === b.bg + ); +} + +/** + * Check if two leaf VNodes are semantically equal (same render output). + * Used to skip allocating new RuntimeInstance objects for unchanged leaves. + * Only covers common leaf kinds; returns false for unknown kinds (safe fallback). + */ +function leafVNodeEqual(a: VNode, b: VNode): boolean { + switch (a.kind) { + case "text": { + if (b.kind !== "text") return false; + if (a.text !== b.text) return false; + const ap = a.props as { style?: unknown; key?: string }; + const bp = b.props as { style?: unknown; key?: string }; + return textStyleEqual( + ap.style as Parameters[0], + bp.style as Parameters[0], + ); + } + case "spacer": { + if (b.kind !== "spacer") return false; + const ap = a.props as { size?: number; flex?: number }; + const bp = b.props as { size?: number; flex?: number }; + return ap.size === bp.size && ap.flex === bp.flex; + } + case "divider": { + if (b.kind !== "divider") return false; + const ap = a.props as { direction?: string }; + const bp = b.props as { direction?: string }; + return ap.direction === bp.direction; + } + default: + return false; + } +} + /** Fatal errors from tree commitment. */ export type CommitFatal = | ReconcileFatal @@ -393,14 +451,33 @@ function commitNode( for (const c of prevChildren) byPrevInstanceId.set(c.instanceId, c); } + // Container fast path: when reconciliation reuses all children with no + // additions/removals, commit each child and check if all return the exact + // same RuntimeInstance reference. If so, reuse the parent's RuntimeInstance, + // avoiding new arrays, VNode spreads, and RuntimeInstance allocation. + const canTryFastReuse = + prev !== null && + res.value.newInstanceIds.length === 0 && + res.value.unmountedInstanceIds.length === 0 && + res.value.nextChildren.length === prevChildren.length; + const nextChildren: RuntimeInstance[] = []; const committedChildVNodes: VNode[] = []; + let allChildrenSame = canTryFastReuse; for (const child of res.value.nextChildren) { const prevChild = child.prevIndex !== null ? byPrevIndex[child.prevIndex] : null; const committed = commitNode(prevChild ?? null, child.instanceId, child.vnode, ctx); if (!committed.ok) return committed; nextChildren.push(committed.value.root); committedChildVNodes.push(committed.value.root.vnode); + if (allChildrenSame && committed.value.root !== prevChild) { + allChildrenSame = false; + } + } + + if (allChildrenSame && prev !== null) { + // All children are identical references → reuse parent entirely. + return { ok: true, value: { root: prev } }; } for (const unmountedId of res.value.unmountedInstanceIds) { @@ -422,11 +499,17 @@ function commitNode( }; } - // Leaf nodes + // Leaf nodes — fast path: reuse previous RuntimeInstance when content is unchanged. + // This avoids allocating a new RuntimeInstance object + result wrapper per frame + // for the majority of leaf nodes that don't change (e.g., 498/500 rows in a list update). + if (prev && prev.vnode.kind === vnode.kind && leafVNodeEqual(prev.vnode, vnode)) { + return { ok: true, value: { root: prev } }; + } + return { ok: true, value: { - root: { instanceId, vnode, children: [] }, + root: { instanceId, vnode, children: EMPTY_CHILDREN }, }, }; } @@ -488,10 +571,10 @@ export function commitVNodeTree( ok: true, value: { root: committedRoot.value.root, - mountedInstanceIds: Object.freeze(ctx.lists.mounted.slice()), - reusedInstanceIds: Object.freeze(ctx.lists.reused.slice()), - unmountedInstanceIds: Object.freeze(ctx.lists.unmounted.slice()), - pendingEffects: Object.freeze(ctx.pendingEffects.slice()), + mountedInstanceIds: ctx.lists.mounted, + reusedInstanceIds: ctx.lists.reused, + unmountedInstanceIds: ctx.lists.unmounted, + pendingEffects: ctx.pendingEffects, }, }; } diff --git a/packages/core/src/runtime/reconcile.ts b/packages/core/src/runtime/reconcile.ts index b1080e3e..02ba97ea 100644 --- a/packages/core/src/runtime/reconcile.ts +++ b/packages/core/src/runtime/reconcile.ts @@ -128,17 +128,27 @@ function containsAnyKeyInNextChildren(nextVChildren: readonly VNode[]): boolean return false; } +/** Shared empty InstanceId arrays to avoid allocation in common reconciliation cases. */ +const EMPTY_INSTANCE_IDS: readonly InstanceId[] = Object.freeze([]); + function reconcileUnkeyedChildren( prevChildren: readonly PrevChild[], nextVChildren: readonly VNode[], allocator: InstanceIdAllocator, ): ReconcileChildrenOk { - const nextChildren: ReconciledChild[] = []; - const reusedInstanceIds: InstanceId[] = []; - const newInstanceIds: InstanceId[] = []; - const unmountedInstanceIds: InstanceId[] = []; + const prevLen = prevChildren.length; + const nextLen = nextVChildren.length; + const sharedLength = Math.min(prevLen, nextLen); + + // Pre-allocate with known sizes to avoid array growth. + const nextChildren: ReconciledChild[] = new Array(nextLen); + let nextChildrenCount = 0; + const reusedInstanceIds: InstanceId[] = new Array(sharedLength); + let reusedCount = 0; + const hasNewChildren = nextLen > prevLen; + const newInstanceIds: InstanceId[] = hasNewChildren ? new Array(nextLen - prevLen) : []; + let newCount = 0; - const sharedLength = Math.min(prevChildren.length, nextVChildren.length); for (let i = 0; i < sharedLength; i++) { const prev = prevChildren[i]; const vnode = nextVChildren[i]; @@ -146,35 +156,42 @@ function reconcileUnkeyedChildren( const slotId: SlotId = `i:${i}`; if (prev) { const instanceId = prev.instanceId; - reusedInstanceIds.push(instanceId); - nextChildren.push({ slotId, vnode, instanceId, kind: "reused", prevIndex: i }); + reusedInstanceIds[reusedCount++] = instanceId; + nextChildren[nextChildrenCount++] = { slotId, vnode, instanceId, kind: "reused", prevIndex: i }; continue; } const instanceId = allocator.allocate(); - newInstanceIds.push(instanceId); - nextChildren.push({ slotId, vnode, instanceId, kind: "new", prevIndex: null }); + newInstanceIds[newCount++] = instanceId; + nextChildren[nextChildrenCount++] = { slotId, vnode, instanceId, kind: "new", prevIndex: null }; } - for (let i = sharedLength; i < nextVChildren.length; i++) { + for (let i = sharedLength; i < nextLen; i++) { const vnode = nextVChildren[i]; if (!vnode) continue; const slotId: SlotId = `i:${i}`; const instanceId = allocator.allocate(); - newInstanceIds.push(instanceId); - nextChildren.push({ slotId, vnode, instanceId, kind: "new", prevIndex: null }); + newInstanceIds[newCount++] = instanceId; + nextChildren[nextChildrenCount++] = { slotId, vnode, instanceId, kind: "new", prevIndex: null }; } - for (let i = sharedLength; i < prevChildren.length; i++) { - const prev = prevChildren[i]; - if (!prev) continue; - unmountedInstanceIds.push(prev.instanceId); + // Trim pre-allocated arrays to actual size. + nextChildren.length = nextChildrenCount; + reusedInstanceIds.length = reusedCount; + newInstanceIds.length = newCount; + + const unmountedInstanceIds: InstanceId[] = []; + if (prevLen > nextLen) { + for (let i = sharedLength; i < prevLen; i++) { + const prev = prevChildren[i]; + if (prev) unmountedInstanceIds.push(prev.instanceId); + } } return { nextChildren, reusedInstanceIds, newInstanceIds, - unmountedInstanceIds, + unmountedInstanceIds: unmountedInstanceIds.length === 0 ? (EMPTY_INSTANCE_IDS as InstanceId[]) : unmountedInstanceIds, }; } From ce5663172b64319dac1de7258605982e239d0045 Mon Sep 17 00:00:00 2001 From: Rezo Date: Wed, 11 Feb 2026 13:46:24 +0400 Subject: [PATCH 3/5] perf(core): incremental drawlist emission Add damage-based rendering in WidgetRenderer for render-only updates and content-update bench. Includes identity-diff damage tracking, damage-rect culling, and regression tests. --- packages/bench/src/ratatui-runner.ts | 37 +- .../__tests__/partialDrawlistEmission.test.ts | 593 +++++++++++++++++ packages/core/src/app/widgetRenderer.ts | 620 ++++++++++++++++-- .../app/widgetRenderer/submitFramePipeline.ts | 14 +- packages/core/src/perf/perf.ts | 2 + .../renderer/renderToDrawlist/renderTree.ts | 10 + .../renderer/renderToDrawlist/textStyle.ts | 41 ++ .../renderToDrawlist/widgets/containers.ts | 76 ++- .../commit.fastReuse.regression.test.ts | 39 ++ packages/core/src/runtime/commit.ts | 314 ++++++++- packages/core/src/widgets/ui.ts | 11 +- 11 files changed, 1666 insertions(+), 91 deletions(-) create mode 100644 packages/core/src/app/__tests__/partialDrawlistEmission.test.ts create mode 100644 packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts diff --git a/packages/bench/src/ratatui-runner.ts b/packages/bench/src/ratatui-runner.ts index b6232051..a9784bd9 100644 --- a/packages/bench/src/ratatui-runner.ts +++ b/packages/bench/src/ratatui-runner.ts @@ -26,17 +26,42 @@ export function checkRatatui(): boolean { } let _built = false; +let _buildError: Error | null = null; /** Build the ratatui-bench binary in release mode. */ export function ensureBuilt(): void { if (_built && existsSync(BINARY)) return; + if (existsSync(BINARY)) { + _built = true; + return; + } + if (_buildError) throw _buildError; console.log("\n [ratatui] Building release binary..."); - execSync("cargo build --release", { - cwd: BENCH_DIR, - stdio: ["ignore", "ignore", "inherit"], - timeout: 120_000, - }); - _built = true; + try { + execSync("cargo build --release", { + cwd: BENCH_DIR, + stdio: ["ignore", "ignore", "inherit"], + timeout: 120_000, + }); + _built = true; + } catch (err) { + // We sometimes end up with a corrupt/half-populated `target/` (e.g., checked-in artifacts, + // interrupted builds). A clean rebuild is a pragmatic recovery path. + console.log(" [ratatui] Build failed; running `cargo clean` and retrying once..."); + try { + execSync("cargo clean", { cwd: BENCH_DIR, stdio: ["ignore", "ignore", "inherit"] }); + execSync("cargo build --release", { + cwd: BENCH_DIR, + stdio: ["ignore", "ignore", "inherit"], + timeout: 120_000, + }); + _built = true; + } catch (err2) { + _buildError = + err2 instanceof Error ? err2 : new Error(`ratatui ensureBuilt failed: ${String(err2)}`); + throw _buildError; + } + } } interface RatatuiOutput { diff --git a/packages/core/src/app/__tests__/partialDrawlistEmission.test.ts b/packages/core/src/app/__tests__/partialDrawlistEmission.test.ts new file mode 100644 index 00000000..b9c86747 --- /dev/null +++ b/packages/core/src/app/__tests__/partialDrawlistEmission.test.ts @@ -0,0 +1,593 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { RuntimeBackend } from "../../backend.js"; +import type { + DrawlistBuildResult, + DrawlistBuilderV1, + DrawlistBuilderV2, +} from "../../drawlist/index.js"; +import type { CursorState } from "../../drawlist/index.js"; +import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; +import { ZR_KEY_TAB } from "../../keybindings/keyCodes.js"; +import type { TextStyle, VNode } from "../../index.js"; +import { ui } from "../../index.js"; +import { DEFAULT_TERMINAL_CAPS } from "../../terminalCaps.js"; +import { defaultTheme } from "../../theme/defaultTheme.js"; +import type { Viewport } from "../widgetRenderer.js"; +import { WidgetRenderer, type WidgetRenderPlan } from "../widgetRenderer.js"; + +type RecordedOp = + | Readonly<{ kind: "clear" }> + | Readonly<{ kind: "clearTo"; cols: number; rows: number; style?: TextStyle }> + | Readonly<{ kind: "fillRect"; x: number; y: number; w: number; h: number; style?: TextStyle }> + | Readonly<{ kind: "drawText"; x: number; y: number; text: string; style?: TextStyle }> + | Readonly<{ kind: "pushClip"; x: number; y: number; w: number; h: number }> + | Readonly<{ kind: "popClip" }> + | Readonly<{ kind: "setCursor"; state: CursorState }> + | Readonly<{ kind: "hideCursor" }>; + +class RecordingBuilder implements DrawlistBuilderV1 { + protected ops: RecordedOp[] = []; + private lastBuiltOps: readonly RecordedOp[] = Object.freeze([]); + + getLastBuiltOps(): readonly RecordedOp[] { + return this.lastBuiltOps; + } + + clear(): void { + this.ops.push({ kind: "clear" }); + } + + clearTo(cols: number, rows: number, style?: TextStyle): void { + this.ops.push({ kind: "clearTo", cols, rows, ...(style ? { style } : {}) }); + } + + fillRect(x: number, y: number, w: number, h: number, style?: TextStyle): void { + this.ops.push({ kind: "fillRect", x, y, w, h, ...(style ? { style } : {}) }); + } + + drawText(x: number, y: number, text: string, style?: TextStyle): void { + this.ops.push({ kind: "drawText", x, y, text, ...(style ? { style } : {}) }); + } + + pushClip(x: number, y: number, w: number, h: number): void { + this.ops.push({ kind: "pushClip", x, y, w, h }); + } + + popClip(): void { + this.ops.push({ kind: "popClip" }); + } + + addBlob(_bytes: Uint8Array): number | null { + return null; + } + + addTextRunBlob(_segments: readonly DrawlistTextRunSegment[]): number | null { + // Force renderers down the drawText fallback path for deterministic tests. + return null; + } + + drawTextRun(_x: number, _y: number, _blobIndex: number): void {} + + build(): DrawlistBuildResult { + this.lastBuiltOps = this.ops.slice(); + return { ok: true, bytes: new Uint8Array([this.ops.length & 0xff]) }; + } + + reset(): void { + this.ops = []; + } +} + +class RecordingBuilderV2 extends RecordingBuilder implements DrawlistBuilderV2 { + setCursor(state: CursorState): void { + this.ops.push({ kind: "setCursor", state }); + } + + hideCursor(): void { + this.ops.push({ kind: "hideCursor" }); + } +} + +type Framebuffer = Readonly<{ + cols: number; + rows: number; + chars: string[]; + styles: string[]; +}>; + +function createNoopBackend(): RuntimeBackend { + return { + start: async () => {}, + stop: async () => {}, + dispose: () => {}, + requestFrame: async () => {}, + pollEvents: async () => + new Promise((_) => { + // Unit tests use submitFrame directly. + }), + postUserEvent: () => {}, + getCaps: async () => DEFAULT_TERMINAL_CAPS, + }; +} + +function noRenderHooks(): { enterRender: () => void; exitRender: () => void } { + return { enterRender: () => {}, exitRender: () => {} }; +} + +function colorKey(value: unknown): string { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return `s:${value}`; + if (typeof value === "object") { + const rgb = value as { r?: unknown; g?: unknown; b?: unknown }; + if ( + typeof rgb.r === "number" && + Number.isFinite(rgb.r) && + typeof rgb.g === "number" && + Number.isFinite(rgb.g) && + typeof rgb.b === "number" && + Number.isFinite(rgb.b) + ) { + return `rgb:${String(rgb.r)},${String(rgb.g)},${String(rgb.b)}`; + } + } + return "?"; +} + +function styleKey(style: TextStyle | undefined): string { + if (!style) return ""; + return [ + style.bold ? "b1" : "b0", + style.dim ? "d1" : "d0", + style.italic ? "i1" : "i0", + style.underline ? "u1" : "u0", + style.inverse ? "v1" : "v0", + `fg:${colorKey(style.fg)}`, + `bg:${colorKey(style.bg)}`, + ].join("|"); +} + +function createFramebuffer(viewport: Viewport): Framebuffer { + const total = viewport.cols * viewport.rows; + return { + cols: viewport.cols, + rows: viewport.rows, + chars: new Array(total).fill(" "), + styles: new Array(total).fill(""), + }; +} + +function cloneFramebuffer(framebuffer: Framebuffer): Framebuffer { + return { + cols: framebuffer.cols, + rows: framebuffer.rows, + chars: framebuffer.chars.slice(), + styles: framebuffer.styles.slice(), + }; +} + +function applyOps(framebuffer: Framebuffer, ops: readonly RecordedOp[]): Framebuffer { + const out = cloneFramebuffer(framebuffer); + const clipStack: Array> = []; + const inViewport = (x: number, y: number) => x >= 0 && x < out.cols && y >= 0 && y < out.rows; + const inClip = (x: number, y: number) => { + for (const clip of clipStack) { + if (x < clip.x || x >= clip.x + clip.w || y < clip.y || y >= clip.y + clip.h) return false; + } + return true; + }; + const writeCell = (x: number, y: number, ch: string, style: string) => { + if (!inViewport(x, y) || !inClip(x, y)) return; + const idx = y * out.cols + x; + out.chars[idx] = ch; + out.styles[idx] = style; + }; + const fillViewport = (cols: number, rows: number, style: string) => { + const w = Math.min(cols, out.cols); + const h = Math.min(rows, out.rows); + for (let y = 0; y < h; y++) { + const base = y * out.cols; + for (let x = 0; x < w; x++) { + out.chars[base + x] = " "; + out.styles[base + x] = style; + } + } + }; + + for (const op of ops) { + switch (op.kind) { + case "clear": + fillViewport(out.cols, out.rows, ""); + break; + case "clearTo": + fillViewport(op.cols, op.rows, styleKey(op.style)); + break; + case "fillRect": { + const key = styleKey(op.style); + for (let y = op.y; y < op.y + op.h; y++) { + for (let x = op.x; x < op.x + op.w; x++) { + writeCell(x, y, " ", key); + } + } + break; + } + case "drawText": { + const key = styleKey(op.style); + for (let i = 0; i < op.text.length; i++) { + writeCell(op.x + i, op.y, op.text[i] ?? " ", key); + } + break; + } + case "pushClip": + clipStack.push({ x: op.x, y: op.y, w: op.w, h: op.h }); + break; + case "popClip": + if (clipStack.length > 0) clipStack.pop(); + break; + default: + break; + } + } + + return out; +} + +function assertFramebuffersEqual(actual: Framebuffer, expected: Framebuffer): void { + assert.equal(actual.cols, expected.cols); + assert.equal(actual.rows, expected.rows); + assert.equal(actual.chars.length, expected.chars.length); + assert.equal(actual.styles.length, expected.styles.length); + for (let i = 0; i < actual.chars.length; i++) { + assert.equal(actual.chars[i], expected.chars[i], `char mismatch at cell ${String(i)}`); + assert.equal(actual.styles[i], expected.styles[i], `style mismatch at cell ${String(i)}`); + } +} + +function submitOps( + renderer: WidgetRenderer, + builder: RecordingBuilder, + viewFn: (snapshot: Readonly) => VNode, + snapshot: Readonly, + viewport: Viewport, + plan: WidgetRenderPlan, +): readonly RecordedOp[] { + const res = renderer.submitFrame(viewFn, snapshot, viewport, defaultTheme, noRenderHooks(), plan); + assert.equal(res.ok, true); + return builder.getLastBuiltOps(); +} + +const CONTENT_LIST_SIZE = 500; + +function contentUpdateTree(selected: number): VNode { + const rows: VNode[] = []; + for (let i = 0; i < CONTENT_LIST_SIZE; i++) { + const isSelected = i === selected; + rows.push( + ui.row({ gap: 1 }, [ + ui.text(isSelected ? ">" : " ", { style: { bold: isSelected } }), + ui.text(`${String(i).padStart(3, " ")}.`, { style: { dim: !isSelected } }), + ui.text(`entry-${i}.log`, { style: { bold: isSelected, inverse: isSelected } }), + ui.text(`${(i * 1024 + 512).toLocaleString()} B`, { style: { dim: true } }), + ]), + ); + } + return ui.column({ p: 0 }, [ + ui.row({ gap: 2 }, [ + ui.text("Files", { style: { bold: true } }), + ui.text(`${CONTENT_LIST_SIZE} items`), + ui.text(`Selected: ${selected}`, { style: { dim: true } }), + ]), + ui.divider(), + ...rows, + ]); +} + +describe("WidgetRenderer incremental drawlist emission", () => { + test("content-update frame matches full render from same prior framebuffer", () => { + const viewport: Viewport = { cols: 120, rows: 540 }; + const backend = createNoopBackend(); + + const partialBuilder = new RecordingBuilder(); + const partialRenderer = new WidgetRenderer<{ selected: number }>({ + backend, + builder: partialBuilder, + requestRender: () => {}, + }); + const fullBuilder = new RecordingBuilder(); + const fullRenderer = new WidgetRenderer<{ selected: number }>({ + backend, + builder: fullBuilder, + requestRender: () => {}, + }); + + const planBootstrap = { commit: true, layout: true, checkLayoutStability: true } as const; + const planUpdate = { commit: true, layout: false, checkLayoutStability: false } as const; + + const opsA = submitOps( + partialRenderer, + partialBuilder, + (s) => contentUpdateTree(s.selected), + { selected: 10 }, + viewport, + planBootstrap, + ); + const framebufferA = applyOps(createFramebuffer(viewport), opsA); + + const opsBExpected = submitOps( + fullRenderer, + fullBuilder, + (s) => contentUpdateTree(s.selected), + { selected: 11 }, + viewport, + planBootstrap, + ); + const framebufferBExpected = applyOps(createFramebuffer(viewport), opsBExpected); + + const opsBPartial = submitOps( + partialRenderer, + partialBuilder, + (s) => contentUpdateTree(s.selected), + { selected: 11 }, + viewport, + planUpdate, + ); + const framebufferBPartial = applyOps(framebufferA, opsBPartial); + + assertFramebuffersEqual(framebufferBPartial, framebufferBExpected); + assert.equal(opsBPartial.some((op) => op.kind === "clearTo"), false); + + const viewportArea = viewport.cols * viewport.rows; + let damagedArea = 0; + for (const op of opsBPartial) { + if (op.kind === "fillRect") damagedArea += op.w * op.h; + } + assert.equal(damagedArea < Math.floor(viewportArea * 0.2), true); + }); + + test("focus change without commit is equivalent to full frame", () => { + const viewport: Viewport = { cols: 50, rows: 10 }; + const backend = createNoopBackend(); + const viewFn = () => + ui.column({}, [ui.button({ id: "a", label: "Alpha" }), ui.button({ id: "b", label: "Beta" })]); + + const partialBuilder = new RecordingBuilder(); + const partialRenderer = new WidgetRenderer({ + backend, + builder: partialBuilder, + requestRender: () => {}, + }); + const fullBuilder = new RecordingBuilder(); + const fullRenderer = new WidgetRenderer({ + backend, + builder: fullBuilder, + requestRender: () => {}, + }); + + const initialPlan = { commit: true, layout: true, checkLayoutStability: true } as const; + const partialPlan = { commit: false, layout: false, checkLayoutStability: false } as const; + const forcedFullPlan = { commit: false, layout: true, checkLayoutStability: false } as const; + + const opsA = submitOps(partialRenderer, partialBuilder, viewFn, undefined, viewport, initialPlan); + const framebufferA = applyOps(createFramebuffer(viewport), opsA); + + partialRenderer.routeEngineEvent({ + kind: "key", + timeMs: 0, + key: ZR_KEY_TAB, + mods: 0, + action: "down", + }); + const opsFocusedPartial = submitOps( + partialRenderer, + partialBuilder, + viewFn, + undefined, + viewport, + partialPlan, + ); + const framebufferFocusedPartial = applyOps(framebufferA, opsFocusedPartial); + + submitOps(fullRenderer, fullBuilder, viewFn, undefined, viewport, initialPlan); + fullRenderer.routeEngineEvent({ + kind: "key", + timeMs: 0, + key: ZR_KEY_TAB, + mods: 0, + action: "down", + }); + const opsFocusedFull = submitOps( + fullRenderer, + fullBuilder, + viewFn, + undefined, + viewport, + forcedFullPlan, + ); + const framebufferFocusedExpected = applyOps(createFramebuffer(viewport), opsFocusedFull); + + assertFramebuffersEqual(framebufferFocusedPartial, framebufferFocusedExpected); + if (!opsFocusedPartial.some((op) => op.kind === "clearTo")) { + assert.equal(opsFocusedPartial.some((op) => op.kind === "fillRect"), true); + } + }); + + test("spinner tick updates correctly in partial mode", () => { + const viewport: Viewport = { cols: 40, rows: 8 }; + const backend = createNoopBackend(); + const viewFn = () => ui.column({}, [ui.spinner({ variant: "line", label: "Loading" })]); + + const partialBuilder = new RecordingBuilder(); + const partialRenderer = new WidgetRenderer({ + backend, + builder: partialBuilder, + requestRender: () => {}, + }); + const fullBuilder = new RecordingBuilder(); + const fullRenderer = new WidgetRenderer({ + backend, + builder: fullBuilder, + requestRender: () => {}, + }); + + const initialPlan = { commit: true, layout: true, checkLayoutStability: true } as const; + const partialTickPlan = { commit: false, layout: false, checkLayoutStability: false } as const; + const forcedFullTickPlan = { commit: false, layout: true, checkLayoutStability: false } as const; + + const opsTick0 = submitOps(partialRenderer, partialBuilder, viewFn, undefined, viewport, initialPlan); + const framebufferTick0 = applyOps(createFramebuffer(viewport), opsTick0); + const opsTick1Partial = submitOps( + partialRenderer, + partialBuilder, + viewFn, + undefined, + viewport, + partialTickPlan, + ); + const framebufferTick1Partial = applyOps(framebufferTick0, opsTick1Partial); + + submitOps(fullRenderer, fullBuilder, viewFn, undefined, viewport, initialPlan); + const opsTick1Expected = submitOps( + fullRenderer, + fullBuilder, + viewFn, + undefined, + viewport, + forcedFullTickPlan, + ); + const framebufferTick1Expected = applyOps(createFramebuffer(viewport), opsTick1Expected); + + assertFramebuffersEqual(framebufferTick1Partial, framebufferTick1Expected); + assert.equal(opsTick1Partial.some((op) => op.kind === "clearTo"), false); + }); + + test("incremental v2 cursor is emitted even when focused input is outside damage rect", () => { + const viewport: Viewport = { cols: 40, rows: 8 }; + const backend = createNoopBackend(); + const builder = new RecordingBuilderV2(); + const renderer = new WidgetRenderer({ + backend, + builder, + useV2Cursor: true, + requestRender: () => {}, + }); + const viewFn = () => + ui.column({}, [ui.input({ id: "q", value: "hello" }), ui.spinner({ variant: "line" })]); + + submitOps( + renderer, + builder, + viewFn, + undefined, + viewport, + { commit: true, layout: true, checkLayoutStability: true }, + ); + + renderer.routeEngineEvent({ + kind: "key", + timeMs: 0, + key: ZR_KEY_TAB, + mods: 0, + action: "down", + }); + submitOps( + renderer, + builder, + viewFn, + undefined, + viewport, + { commit: false, layout: false, checkLayoutStability: false }, + ); + + const opsTick = submitOps( + renderer, + builder, + viewFn, + undefined, + viewport, + { commit: false, layout: false, checkLayoutStability: false }, + ); + assert.equal(opsTick.some((op) => op.kind === "clearTo"), false); + assert.equal(opsTick.some((op) => op.kind === "hideCursor"), false); + const setCursorOp = opsTick.find((op) => op.kind === "setCursor"); + assert.equal(setCursorOp !== undefined, true); + if (setCursorOp && setCursorOp.kind === "setCursor") { + assert.equal(setCursorOp.state.visible, true); + assert.equal(setCursorOp.state.x, 6); + assert.equal(setCursorOp.state.y, 0); + } + }); + + test("metadata collection is re-enabled when routing widgets are introduced", () => { + const viewport: Viewport = { cols: 60, rows: 10 }; + const backend = createNoopBackend(); + const builder = new RecordingBuilder(); + const renderer = new WidgetRenderer<{ showButton: boolean }>({ + backend, + builder, + requestRender: () => {}, + }); + + const viewFn = (s: Readonly<{ showButton: boolean }>) => + ui.column( + {}, + s.showButton + ? [ui.text("Header"), ui.button({ id: "btn", label: "Press" })] + : [ui.text("Header")], + ); + + submitOps( + renderer, + builder, + viewFn, + { showButton: false }, + viewport, + { commit: true, layout: true, checkLayoutStability: true }, + ); + submitOps( + renderer, + builder, + viewFn, + { showButton: true }, + viewport, + { commit: true, layout: false, checkLayoutStability: true }, + ); + + const route = renderer.routeEngineEvent({ + kind: "key", + timeMs: 1, + key: ZR_KEY_TAB, + mods: 0, + action: "down", + }); + assert.equal(route.needsRender, true); + assert.equal(renderer.getFocusedId(), "btn"); + }); + + test("doLayout=true path falls back to full clear", () => { + const viewport: Viewport = { cols: 40, rows: 8 }; + const backend = createNoopBackend(); + const builder = new RecordingBuilder(); + const renderer = new WidgetRenderer<{ selected: number }>({ + backend, + builder, + requestRender: () => {}, + }); + + submitOps( + renderer, + builder, + (s) => contentUpdateTree(s.selected), + { selected: 0 }, + viewport, + { commit: true, layout: true, checkLayoutStability: true }, + ); + const secondOps = submitOps( + renderer, + builder, + (s) => contentUpdateTree(s.selected), + { selected: 1 }, + viewport, + { commit: true, layout: true, checkLayoutStability: true }, + ); + assert.equal(secondOps.some((op) => op.kind === "clearTo"), true); + }); +}); diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index 8f5ef12a..772c8ef1 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -58,6 +58,8 @@ import { measureTextCells } from "../layout/textMeasure.js"; import type { Rect } from "../layout/types.js"; import { PERF_DETAIL_ENABLED, PERF_ENABLED, perfMarkEnd, perfMarkStart } from "../perf/perf.js"; import { type CursorInfo, renderToDrawlist } from "../renderer/renderToDrawlist.js"; +import { renderTree } from "../renderer/renderToDrawlist/renderTree.js"; +import { DEFAULT_BASE_STYLE } from "../renderer/renderToDrawlist/textStyle.js"; import { type CommitOk, type RuntimeInstance, commitVNodeTree } from "../runtime/commit.js"; import { type FocusManagerState, @@ -243,6 +245,118 @@ const EMPTY_STRING_ARRAY: readonly string[] = Object.freeze([]); const ROUTE_RENDER: WidgetRoutingOutcome = Object.freeze({ needsRender: true }); const ROUTE_NO_RENDER: WidgetRoutingOutcome = Object.freeze({ needsRender: false }); const ZERO_RECT: Rect = Object.freeze({ x: 0, y: 0, w: 0, h: 0 }); +const INCREMENTAL_DAMAGE_AREA_FRACTION = 0.45; + +function clipRectToViewport(rect: Rect, viewport: Viewport): Rect | null { + const x0 = Math.max(0, rect.x); + const y0 = Math.max(0, rect.y); + const x1 = Math.min(viewport.cols, rect.x + rect.w); + const y1 = Math.min(viewport.rows, rect.y + rect.h); + const w = x1 - x0; + const h = y1 - y0; + if (w <= 0 || h <= 0) return null; + return { x: x0, y: y0, w, h }; +} + +function rectOverlapsOrTouches(a: Rect, b: Rect): boolean { + return a.x <= b.x + b.w && a.x + a.w >= b.x && a.y <= b.y + b.h && a.y + a.h >= b.y; +} + +function unionRect(a: Rect, b: Rect): Rect { + const x0 = Math.min(a.x, b.x); + const y0 = Math.min(a.y, b.y); + const x1 = Math.max(a.x + a.w, b.x + b.w); + const y1 = Math.max(a.y + a.h, b.y + b.h); + return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; +} + +function isV2Builder(builder: DrawlistBuilderV1 | DrawlistBuilderV2): builder is DrawlistBuilderV2 { + return typeof (builder as DrawlistBuilderV2).setCursor === "function"; +} + +type WidgetKind = RuntimeInstance["vnode"]["kind"]; +type IdentityDiffDamageResult = Readonly<{ + changedInstanceIds: readonly InstanceId[]; + removedInstanceIds: readonly InstanceId[]; + routingRelevantChanged: boolean; +}>; + +function isRoutingRelevantKind(kind: WidgetKind): boolean { + switch (kind) { + case "button": + case "input": + case "focusZone": + case "focusTrap": + case "virtualList": + case "layers": + case "modal": + case "dropdown": + case "layer": + case "table": + case "tree": + case "select": + case "checkbox": + case "radioGroup": + case "commandPalette": + case "filePicker": + case "fileTreeExplorer": + case "splitPane": + case "panelGroup": + case "codeEditor": + case "diffViewer": + case "toolApprovalDialog": + case "logsConsole": + case "toastContainer": + return true; + default: + return false; + } +} + +function isDamageGranularityKind(kind: WidgetKind): boolean { + if (kind === "row") return true; + switch (kind) { + case "text": + case "divider": + case "spacer": + case "button": + case "input": + case "select": + case "checkbox": + case "radioGroup": + case "richText": + case "badge": + case "spinner": + case "progress": + case "skeleton": + case "icon": + case "kbd": + case "status": + case "tag": + case "gauge": + case "empty": + case "errorDisplay": + case "callout": + case "sparkline": + case "barChart": + case "miniChart": + case "virtualList": + case "table": + case "tree": + case "dropdown": + case "commandPalette": + case "filePicker": + case "fileTreeExplorer": + case "codeEditor": + case "diffViewer": + case "toolApprovalDialog": + case "logsConsole": + case "toastContainer": + return true; + default: + return false; + } +} /** * Renderer for widget view mode. @@ -397,8 +511,18 @@ export class WidgetRenderer { private readonly _pooledRectByInstanceId = new Map(); private readonly _pooledLayoutSigByInstanceId = new Map(); private readonly _pooledNextLayoutSigByInstanceId = new Map(); + private readonly _pooledChangedRenderInstanceIds: InstanceId[] = []; + private readonly _pooledRemovedRenderInstanceIds: InstanceId[] = []; private readonly _pooledRectById = new Map(); private readonly _pooledSplitPaneChildRectsById = new Map(); + private readonly _prevFrameRectByInstanceId = new Map(); + private readonly _prevFrameRectById = new Map(); + private readonly _pooledDamageRects: Rect[] = []; + private readonly _pooledMergedDamageRects: Rect[] = []; + private _hasRenderedFrame = false; + private _lastRenderedViewport: Viewport = Object.freeze({ cols: 0, rows: 0 }); + private _lastRenderedThemeRef: Theme | null = null; + private _lastRenderedFocusedId: string | null = null; private _layoutMeasureCache: WeakMap = new WeakMap(); private readonly _pooledCloseOnEscape = new Map(); private readonly _pooledCloseOnBackdrop = new Map(); @@ -406,6 +530,8 @@ export class WidgetRenderer { private readonly _pooledToastActionByFocusId = new Map void>(); private readonly _pooledLayoutStack: LayoutTree[] = []; private readonly _pooledRuntimeStack: RuntimeInstance[] = []; + private readonly _pooledPrevRuntimeStack: RuntimeInstance[] = []; + private readonly _pooledDamageRuntimeStack: RuntimeInstance[] = []; private readonly _pooledDropdownStack: string[] = []; private readonly _pooledToastContainers: { rect: Rect; props: ToastContainerProps }[] = []; private readonly _pooledToastFocusableActionIds: string[] = []; @@ -2135,6 +2261,293 @@ export class WidgetRenderer { return pos.rect; } + private shouldAttemptIncrementalRender( + doLayout: boolean, + viewport: Viewport, + theme: Theme, + ): boolean { + if (!this._hasRenderedFrame) return false; + if (doLayout) return false; + if ( + this._lastRenderedViewport.cols !== viewport.cols || + this._lastRenderedViewport.rows !== viewport.rows + ) { + return false; + } + if (this._lastRenderedThemeRef !== theme) return false; + + // Conservative correctness: overlays can draw outside local rects. + if (this.dropdownStack.length > 0 || this.layerStack.length > 0 || this.toastContainers.length > 0) { + return false; + } + return true; + } + + private collectSubtreeDamageAndRouting( + root: RuntimeInstance, + outInstanceIds: InstanceId[], + ): boolean { + let routingRelevant = false; + this._pooledDamageRuntimeStack.length = 0; + this._pooledDamageRuntimeStack.push(root); + while (this._pooledDamageRuntimeStack.length > 0) { + const node = this._pooledDamageRuntimeStack.pop(); + if (!node) continue; + const kind = node.vnode.kind; + if (isRoutingRelevantKind(kind)) routingRelevant = true; + if (isDamageGranularityKind(kind) || node.children.length === 0) { + outInstanceIds.push(node.instanceId); + continue; + } + for (let i = node.children.length - 1; i >= 0; i--) { + const child = node.children[i]; + if (child) this._pooledDamageRuntimeStack.push(child); + } + } + return routingRelevant; + } + + private computeIdentityDiffDamage( + prevRoot: RuntimeInstance | null, + nextRoot: RuntimeInstance, + ): IdentityDiffDamageResult { + this._pooledChangedRenderInstanceIds.length = 0; + this._pooledRemovedRenderInstanceIds.length = 0; + + if (prevRoot === null) { + const routingRelevantChanged = this.collectSubtreeDamageAndRouting( + nextRoot, + this._pooledChangedRenderInstanceIds, + ); + return { + changedInstanceIds: this._pooledChangedRenderInstanceIds, + removedInstanceIds: this._pooledRemovedRenderInstanceIds, + routingRelevantChanged, + }; + } + + let routingRelevantChanged = false; + this._pooledPrevRuntimeStack.length = 0; + this._pooledRuntimeStack.length = 0; + this._pooledPrevRuntimeStack.push(prevRoot); + this._pooledRuntimeStack.push(nextRoot); + + while (this._pooledPrevRuntimeStack.length > 0 && this._pooledRuntimeStack.length > 0) { + const prevNode = this._pooledPrevRuntimeStack.pop(); + const nextNode = this._pooledRuntimeStack.pop(); + if (!prevNode || !nextNode) continue; + if (prevNode === nextNode) continue; + + const prevKind = prevNode.vnode.kind; + const nextKind = nextNode.vnode.kind; + + if (prevNode.instanceId !== nextNode.instanceId || prevKind !== nextKind) { + routingRelevantChanged = + this.collectSubtreeDamageAndRouting(prevNode, this._pooledRemovedRenderInstanceIds) || + routingRelevantChanged; + routingRelevantChanged = + this.collectSubtreeDamageAndRouting(nextNode, this._pooledChangedRenderInstanceIds) || + routingRelevantChanged; + continue; + } + + if (isRoutingRelevantKind(nextKind)) routingRelevantChanged = true; + + if (isDamageGranularityKind(nextKind)) { + this._pooledChangedRenderInstanceIds.push(nextNode.instanceId); + continue; + } + + const prevChildren = prevNode.children; + const nextChildren = nextNode.children; + const sharedCount = Math.min(prevChildren.length, nextChildren.length); + let hadChildChanges = prevChildren.length !== nextChildren.length; + + for (let i = sharedCount - 1; i >= 0; i--) { + const prevChild = prevChildren[i]; + const nextChild = nextChildren[i]; + if (!prevChild || !nextChild || prevChild === nextChild) continue; + hadChildChanges = true; + this._pooledPrevRuntimeStack.push(prevChild); + this._pooledRuntimeStack.push(nextChild); + } + + if (nextChildren.length > sharedCount) { + hadChildChanges = true; + for (let i = sharedCount; i < nextChildren.length; i++) { + const child = nextChildren[i]; + if (!child) continue; + routingRelevantChanged = + this.collectSubtreeDamageAndRouting(child, this._pooledChangedRenderInstanceIds) || + routingRelevantChanged; + } + } + + if (prevChildren.length > sharedCount) { + hadChildChanges = true; + for (let i = sharedCount; i < prevChildren.length; i++) { + const child = prevChildren[i]; + if (!child) continue; + routingRelevantChanged = + this.collectSubtreeDamageAndRouting(child, this._pooledRemovedRenderInstanceIds) || + routingRelevantChanged; + } + } + + // If only this node changed (children are reference-identical), treat as self-damage. + if (!hadChildChanges) { + this._pooledChangedRenderInstanceIds.push(nextNode.instanceId); + } + } + + this._pooledPrevRuntimeStack.length = 0; + this._pooledRuntimeStack.length = 0; + + return { + changedInstanceIds: this._pooledChangedRenderInstanceIds, + removedInstanceIds: this._pooledRemovedRenderInstanceIds, + routingRelevantChanged, + }; + } + + private emitIncrementalCursor(cursorInfo: CursorInfo | undefined): void { + if (!cursorInfo || !this.useV2Cursor || !isV2Builder(this.builder)) return; + + const focusedId = this.focusState.focusedId; + if (!focusedId) { + this.builder.hideCursor(); + return; + } + + const input = this.inputById.get(focusedId); + if (!input || input.disabled) { + this.builder.hideCursor(); + return; + } + + const rect = this._pooledRectByInstanceId.get(input.instanceId); + if (!rect || rect.w <= 0 || rect.h <= 0) { + this.builder.hideCursor(); + return; + } + + const graphemeOffset = this.inputCursorByInstanceId.get(input.instanceId) ?? input.value.length; + const cursorX = measureTextCells(input.value.slice(0, graphemeOffset)); + this.builder.setCursor({ + x: rect.x + 1 + cursorX, + y: rect.y, + shape: cursorInfo.shape, + visible: true, + blink: cursorInfo.blink, + }); + } + + private appendDamageRectForInstanceId(instanceId: InstanceId): boolean { + const current = this._pooledRectByInstanceId.get(instanceId); + if (current && current.w > 0 && current.h > 0) { + this._pooledDamageRects.push(current); + return true; + } + const prev = this._prevFrameRectByInstanceId.get(instanceId); + if (prev && prev.w > 0 && prev.h > 0) { + this._pooledDamageRects.push(prev); + return true; + } + return false; + } + + private appendDamageRectForId(id: string): boolean { + const current = this._pooledRectById.get(id); + if (current && current.w > 0 && current.h > 0) { + this._pooledDamageRects.push(current); + return true; + } + const prev = this._prevFrameRectById.get(id); + if (prev && prev.w > 0 && prev.h > 0) { + this._pooledDamageRects.push(prev); + return true; + } + return false; + } + + private collectSpinnerDamageRects(runtimeRoot: RuntimeInstance, layoutRoot: LayoutTree): void { + this._pooledRuntimeStack.length = 0; + this._pooledLayoutStack.length = 0; + this._pooledRuntimeStack.push(runtimeRoot); + this._pooledLayoutStack.push(layoutRoot); + while (this._pooledRuntimeStack.length > 0 && this._pooledLayoutStack.length > 0) { + const runtimeNode = this._pooledRuntimeStack.pop(); + const layoutNode = this._pooledLayoutStack.pop(); + if (!runtimeNode || !layoutNode) continue; + if (runtimeNode.vnode.kind === "spinner") { + const rect = layoutNode.rect; + if (rect.w > 0 && rect.h > 0) this._pooledDamageRects.push(rect); + } + const childCount = Math.min(runtimeNode.children.length, layoutNode.children.length); + for (let i = childCount - 1; i >= 0; i--) { + const runtimeChild = runtimeNode.children[i]; + const layoutChild = layoutNode.children[i]; + if (runtimeChild && layoutChild) { + this._pooledRuntimeStack.push(runtimeChild); + this._pooledLayoutStack.push(layoutChild); + } + } + } + } + + private normalizeDamageRects(viewport: Viewport): readonly Rect[] { + this._pooledMergedDamageRects.length = 0; + for (const raw of this._pooledDamageRects) { + const clipped = clipRectToViewport(raw, viewport); + if (!clipped) continue; + + let merged = clipped; + let expanded = true; + while (expanded) { + expanded = false; + for (let i = 0; i < this._pooledMergedDamageRects.length; i++) { + const existing = this._pooledMergedDamageRects[i]; + if (!existing) continue; + if (!rectOverlapsOrTouches(existing, merged)) continue; + merged = unionRect(existing, merged); + this._pooledMergedDamageRects.splice(i, 1); + expanded = true; + break; + } + } + this._pooledMergedDamageRects.push(merged); + } + this._pooledMergedDamageRects.sort((a, b) => (a.y - b.y) || (a.x - b.x)); + return this._pooledMergedDamageRects; + } + + private isDamageAreaTooLarge(viewport: Viewport): boolean { + const totalCells = viewport.cols * viewport.rows; + if (totalCells <= 0) return true; + let area = 0; + for (const rect of this._pooledMergedDamageRects) { + area += rect.w * rect.h; + } + return area > totalCells * INCREMENTAL_DAMAGE_AREA_FRACTION; + } + + private snapshotRenderedFrameState(viewport: Viewport, theme: Theme, doLayout: boolean): void { + if (doLayout) { + this._prevFrameRectByInstanceId.clear(); + for (const [instanceId, rect] of this._pooledRectByInstanceId) { + this._prevFrameRectByInstanceId.set(instanceId, rect); + } + this._prevFrameRectById.clear(); + for (const [id, rect] of this._pooledRectById) { + this._prevFrameRectById.set(id, rect); + } + } + this._hasRenderedFrame = true; + this._lastRenderedViewport = Object.freeze({ cols: viewport.cols, rows: viewport.rows }); + this._lastRenderedThemeRef = theme; + this._lastRenderedFocusedId = this.focusState.focusedId; + } + /** * Execute view function, commit tree, compute layout, and render to drawlist. * @@ -2186,9 +2599,11 @@ export class WidgetRenderer { let prevFocusedIdBeforeFinalize: string | null = null; const prevActiveZoneIdBeforeSubmit = this.focusState.activeZoneId; const prevZoneMetaByIdBeforeSubmit = this.zoneMetaById; + const prevCommittedRoot = this.committedRoot; const hadRoutingWidgets = this.hadRoutingWidgets; let hasRoutingWidgets = hadRoutingWidgets; let didRoutingRebuild = false; + let identityDamageFromCommit: IdentityDiffDamageResult | null = null; if (doCommit) { const viewToken = PERF_DETAIL_ENABLED ? perfMarkStart("view") : 0; @@ -2198,6 +2613,7 @@ export class WidgetRenderer { const commitToken = PERF_DETAIL_ENABLED ? perfMarkStart("vnode_commit") : 0; const commitRes0 = commitVNodeTree(this.committedRoot, vnode, { allocator: this.allocator, + collectLifecycleInstanceIds: false, composite: { registry: this.compositeRegistry, appState: snapshot, @@ -2211,6 +2627,10 @@ export class WidgetRenderer { commitRes = commitRes0.value; this.committedRoot = commitRes.root; + const damageToken = PERF_DETAIL_ENABLED ? perfMarkStart("damage_identity_diff") : 0; + identityDamageFromCommit = this.computeIdentityDiffDamage(prevCommittedRoot, this.committedRoot); + if (PERF_DETAIL_ENABLED) perfMarkEnd("damage_identity_diff", damageToken); + if (!doLayout && plan.checkLayoutStability) { // Detect layout-relevant commit changes (including child order changes) // using per-instance stability signatures. @@ -2291,29 +2711,37 @@ export class WidgetRenderer { } if (doCommit) { - const metaToken = PERF_DETAIL_ENABLED ? perfMarkStart("metadata_collect") : 0; - // Single-pass metadata collection using pooled collector (avoids per-frame allocations) - const widgetMeta = this._metadataCollector.collect(this.committedRoot); - hasRoutingWidgets = widgetMeta.hasRoutingWidgets; - - const nextZoneMetaById = new Map(widgetMeta.zones); - - prevFocusedIdBeforeFinalize = this.focusState.focusedId; - this.focusState = finalizeFocusWithPreCollectedMetadata( - this.focusState, - widgetMeta.focusableIds, - widgetMeta.zones, - widgetMeta.traps, - ); - this.baseFocusList = widgetMeta.focusableIds; - this.baseEnabledById = widgetMeta.enabledById; - this.focusList = widgetMeta.focusableIds; - this.enabledById = widgetMeta.enabledById; - this.pressableIds = widgetMeta.pressableIds; - this.inputById = widgetMeta.inputById; - this.traps = widgetMeta.traps; - this.zoneMetaById = nextZoneMetaById; - if (PERF_DETAIL_ENABLED) perfMarkEnd("metadata_collect", metaToken); + const canSkipMetadataCollect = + prevCommittedRoot !== null && + hadRoutingWidgets === false && + identityDamageFromCommit !== null && + identityDamageFromCommit.routingRelevantChanged === false; + + if (!canSkipMetadataCollect) { + const metaToken = PERF_DETAIL_ENABLED ? perfMarkStart("metadata_collect") : 0; + // Single-pass metadata collection using pooled collector (avoids per-frame allocations) + const widgetMeta = this._metadataCollector.collect(this.committedRoot); + hasRoutingWidgets = widgetMeta.hasRoutingWidgets; + + const nextZoneMetaById = new Map(widgetMeta.zones); + + prevFocusedIdBeforeFinalize = this.focusState.focusedId; + this.focusState = finalizeFocusWithPreCollectedMetadata( + this.focusState, + widgetMeta.focusableIds, + widgetMeta.zones, + widgetMeta.traps, + ); + this.baseFocusList = widgetMeta.focusableIds; + this.baseEnabledById = widgetMeta.enabledById; + this.focusList = widgetMeta.focusableIds; + this.enabledById = widgetMeta.enabledById; + this.pressableIds = widgetMeta.pressableIds; + this.inputById = widgetMeta.inputById; + this.traps = widgetMeta.traps; + this.zoneMetaById = nextZoneMetaById; + if (PERF_DETAIL_ENABLED) perfMarkEnd("metadata_collect", metaToken); + } } if (doCommit && (hasRoutingWidgets || hadRoutingWidgets)) { @@ -2954,35 +3382,126 @@ export class WidgetRenderer { ); } - const renderToken = perfMarkStart("render"); const tick = this.renderTick; this.renderTick = (this.renderTick + 1) >>> 0; - renderToDrawlist({ - tree: this.committedRoot, - layout: this.layoutTree, - viewport, - focusState: this.focusState, - builder: this.builder, - tick, - theme, - cursorInfo, - virtualListStore: this.virtualListStore, - tableStore: this.tableStore, - treeStore: this.treeStore, - loadedTreeChildrenById: this.loadedTreeChildrenByTreeId, - commandPaletteItemsById: this.commandPaletteItemsById, - commandPaletteLoadingById: this.commandPaletteLoadingById, - toolApprovalFocusedActionById: this.toolApprovalFocusedActionById, - dropdownSelectedIndexById: this.dropdownSelectedIndexById, - diffViewerFocusedHunkById: this.diffViewerFocusedHunkById, - diffViewerExpandedHunksById: this.diffViewerExpandedHunksById, - layoutIndex: this._pooledRectByInstanceId, - idRectIndex: this._pooledRectById, - tableRenderCacheById: this.tableRenderCacheById, - logsConsoleRenderCacheById: this.logsConsoleRenderCacheById, - diffRenderCacheById: this.diffRenderCacheById, - codeEditorRenderCacheById: this.codeEditorRenderCacheById, - }); + + const renderToken = perfMarkStart("render"); + let usedIncrementalRender = false; + if (this.shouldAttemptIncrementalRender(doLayout, viewport, theme)) { + this._pooledDamageRects.length = 0; + let missingDamageRect = false; + + if (doCommit) { + if (!identityDamageFromCommit) { + missingDamageRect = true; + } else { + for (const instanceId of identityDamageFromCommit.changedInstanceIds) { + if (!this.appendDamageRectForInstanceId(instanceId)) { + missingDamageRect = true; + break; + } + } + if (!missingDamageRect) { + for (const instanceId of identityDamageFromCommit.removedInstanceIds) { + if (!this.appendDamageRectForInstanceId(instanceId)) { + missingDamageRect = true; + break; + } + } + } + } + } else { + this.collectSpinnerDamageRects(this.committedRoot, this.layoutTree); + } + + const prevFocusedId = this._lastRenderedFocusedId; + const nextFocusedId = this.focusState.focusedId; + if (!missingDamageRect && prevFocusedId !== nextFocusedId) { + if (prevFocusedId !== null && !this.appendDamageRectForId(prevFocusedId)) { + missingDamageRect = true; + } + if (!missingDamageRect && nextFocusedId !== null && !this.appendDamageRectForId(nextFocusedId)) { + missingDamageRect = true; + } + } + + if (!missingDamageRect) { + const damageRects = this.normalizeDamageRects(viewport); + if (damageRects.length > 0 && !this.isDamageAreaTooLarge(viewport)) { + for (const damageRect of damageRects) { + this.builder.fillRect( + damageRect.x, + damageRect.y, + damageRect.w, + damageRect.h, + DEFAULT_BASE_STYLE, + ); + } + for (const damageRect of damageRects) { + this.builder.pushClip(damageRect.x, damageRect.y, damageRect.w, damageRect.h); + renderTree( + this.builder, + this.focusState, + this.layoutTree, + this._pooledRectById, + viewport, + theme, + tick, + DEFAULT_BASE_STYLE, + this.committedRoot, + cursorInfo, + this.virtualListStore, + this.tableStore, + this.treeStore, + this.loadedTreeChildrenByTreeId, + this.commandPaletteItemsById, + this.commandPaletteLoadingById, + this.toolApprovalFocusedActionById, + this.dropdownSelectedIndexById, + this.diffViewerFocusedHunkById, + this.diffViewerExpandedHunksById, + this.tableRenderCacheById, + this.logsConsoleRenderCacheById, + this.diffRenderCacheById, + this.codeEditorRenderCacheById, + { damageRect }, + ); + this.builder.popClip(); + } + this.emitIncrementalCursor(cursorInfo); + usedIncrementalRender = true; + } + } + } + + if (!usedIncrementalRender) { + renderToDrawlist({ + tree: this.committedRoot, + layout: this.layoutTree, + viewport, + focusState: this.focusState, + builder: this.builder, + tick, + theme, + cursorInfo, + virtualListStore: this.virtualListStore, + tableStore: this.tableStore, + treeStore: this.treeStore, + loadedTreeChildrenById: this.loadedTreeChildrenByTreeId, + commandPaletteItemsById: this.commandPaletteItemsById, + commandPaletteLoadingById: this.commandPaletteLoadingById, + toolApprovalFocusedActionById: this.toolApprovalFocusedActionById, + dropdownSelectedIndexById: this.dropdownSelectedIndexById, + diffViewerFocusedHunkById: this.diffViewerFocusedHunkById, + diffViewerExpandedHunksById: this.diffViewerExpandedHunksById, + layoutIndex: this._pooledRectByInstanceId, + idRectIndex: this._pooledRectById, + tableRenderCacheById: this.tableRenderCacheById, + logsConsoleRenderCacheById: this.logsConsoleRenderCacheById, + diffRenderCacheById: this.diffRenderCacheById, + codeEditorRenderCacheById: this.codeEditorRenderCacheById, + }); + } perfMarkEnd("render", renderToken); const buildToken = perfMarkStart("drawlist_build"); @@ -2995,6 +3514,7 @@ export class WidgetRenderer { detail: `${built.error.code}: ${built.error.detail}`, }; } + this.snapshotRenderedFrameState(viewport, theme, doLayout); // Render hooks are for preventing re-entrant app API calls during user render. hooks.exitRender(); diff --git a/packages/core/src/app/widgetRenderer/submitFramePipeline.ts b/packages/core/src/app/widgetRenderer/submitFramePipeline.ts index 1fbbeae8..d6c411e5 100644 --- a/packages/core/src/app/widgetRenderer/submitFramePipeline.ts +++ b/packages/core/src/app/widgetRenderer/submitFramePipeline.ts @@ -77,6 +77,14 @@ function hashString(hash: number, value: string): number { return out; } +function measureTextCellsFast(text: string): number { + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code < 0x20 || code > 0x7e) return measureTextCells(text); + } + return text.length; +} + function hashLayoutPropValue(hash: number, value: unknown): number | null { if (value === undefined) return hashU32(hash, 0); if (value === null) return hashU32(hash, 1); @@ -125,7 +133,7 @@ function computeLayoutStabilitySignature(node: RuntimeInstance): number | null { : null; if (maxWidth === null) return null; - const measuredWidth = measureTextCells(node.vnode.text); + const measuredWidth = measureTextCellsFast(node.vnode.text); const cappedWidth = maxWidth === undefined ? measuredWidth : Math.min(measuredWidth, maxWidth); @@ -145,7 +153,7 @@ function computeLayoutStabilitySignature(node: RuntimeInstance): number | null { ? Math.trunc(props.px) : 1; let hash = hashString(HASH_FNV_OFFSET, "button"); - hash = hashNumber(hash, measureTextCells(props.label)); + hash = hashNumber(hash, measureTextCellsFast(props.label)); hash = hashNumber(hash, px); return hash; } @@ -153,7 +161,7 @@ function computeLayoutStabilitySignature(node: RuntimeInstance): number | null { const props = node.vnode.props as Readonly<{ value?: unknown }>; if (typeof props.value !== "string") return null; let hash = hashString(HASH_FNV_OFFSET, "input"); - hash = hashNumber(hash, measureTextCells(props.value)); + hash = hashNumber(hash, measureTextCellsFast(props.value)); return hash; } case "spacer": { diff --git a/packages/core/src/perf/perf.ts b/packages/core/src/perf/perf.ts index ae770a69..2f5b19f9 100644 --- a/packages/core/src/perf/perf.ts +++ b/packages/core/src/perf/perf.ts @@ -26,6 +26,7 @@ export type InstrumentationPhase = | "view" | "vnode_commit" | "layout_indexes" + | "damage_identity_diff" | "metadata_collect" | "routing_rebuild" | "effects" @@ -47,6 +48,7 @@ export const PERF_PHASES: readonly InstrumentationPhase[] = Object.freeze([ "view", "vnode_commit", "layout_indexes", + "damage_identity_diff", "metadata_collect", "routing_rebuild", "effects", diff --git a/packages/core/src/renderer/renderToDrawlist/renderTree.ts b/packages/core/src/renderer/renderToDrawlist/renderTree.ts index dea0fead..ae72c7fd 100644 --- a/packages/core/src/renderer/renderToDrawlist/renderTree.ts +++ b/packages/core/src/renderer/renderToDrawlist/renderTree.ts @@ -28,6 +28,7 @@ import { renderOverlayWidget } from "./widgets/overlays.js"; type RenderNodeTask = RuntimeInstance | null; type ClipRect = Readonly; +export type RenderTreeOptions = Readonly<{ damageRect?: Rect | undefined }>; export type ResolvedCursor = Readonly<{ x: number; @@ -36,6 +37,11 @@ export type ResolvedCursor = Readonly<{ blink: boolean; }>; +function rectIntersects(a: Rect, b: Rect): boolean { + if (a.w <= 0 || a.h <= 0 || b.w <= 0 || b.h <= 0) return false; + return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; +} + export function renderTree( builder: DrawlistBuilderV1, focusState: FocusState, @@ -61,8 +67,10 @@ export function renderTree( logsConsoleRenderCacheById: ReadonlyMap | undefined, diffRenderCacheById: ReadonlyMap | undefined, codeEditorRenderCacheById: ReadonlyMap | undefined, + opts: RenderTreeOptions | undefined = undefined, ): ResolvedCursor | null { let resolvedCursor: ResolvedCursor | null = null; + const damageRect = opts?.damageRect; const nodeStack: RenderNodeTask[] = [tree]; const styleStack: ResolvedTextStyle[] = [inheritedStyle]; @@ -85,6 +93,7 @@ export function renderTree( const node = nodeOrPop; const vnode = node.vnode; const rect: Rect = layoutNode.rect; + if (damageRect && !rectIntersects(rect, damageRect)) continue; // Depth-first preorder: render node, then its children. switch (vnode.kind) { @@ -113,6 +122,7 @@ export function renderTree( styleStack, layoutStack, clipStack, + damageRect, ); break; } diff --git a/packages/core/src/renderer/renderToDrawlist/textStyle.ts b/packages/core/src/renderer/renderToDrawlist/textStyle.ts index fe4e46b7..478a7f22 100644 --- a/packages/core/src/renderer/renderToDrawlist/textStyle.ts +++ b/packages/core/src/renderer/renderToDrawlist/textStyle.ts @@ -12,11 +12,52 @@ export const DEFAULT_BASE_STYLE: ResolvedTextStyle = Object.freeze({ bg: Object.freeze({ r: 7, g: 10, b: 12 }), }); +// Fast path cache for `mergeTextStyle(DEFAULT_BASE_STYLE, override)` when override only toggles +// boolean attrs (no fg/bg). This is a hot path in large lists where style objects are frequently +// recreated but only take on a few distinct shapes. +const BASE_BOOL_STYLE_CACHE: Array = new Array(1024).fill(null); + +function encTriBool(v: boolean | undefined): number { + // 0 = inherit (undefined), 1 = false, 2 = true + if (v === undefined) return 0; + return v ? 2 : 1; +} + export function mergeTextStyle( base: ResolvedTextStyle, override: TextStyle | undefined, ): ResolvedTextStyle { if (!override) return base; + if (base === DEFAULT_BASE_STYLE && override.fg === undefined && override.bg === undefined) { + const b = encTriBool(override.bold); + const d = encTriBool(override.dim); + const i = encTriBool(override.italic); + const u = encTriBool(override.underline); + const inv = encTriBool(override.inverse); + const key = b | (d << 2) | (i << 4) | (u << 6) | (inv << 8); + if (key === 0) return base; + const cached = BASE_BOOL_STYLE_CACHE[key]; + if (cached) return cached; + + const merged: { + fg: NonNullable; + bg: NonNullable; + bold?: boolean; + dim?: boolean; + italic?: boolean; + underline?: boolean; + inverse?: boolean; + } = { fg: base.fg, bg: base.bg }; + + if (override.bold !== undefined) merged.bold = override.bold; + if (override.dim !== undefined) merged.dim = override.dim; + if (override.italic !== undefined) merged.italic = override.italic; + if (override.underline !== undefined) merged.underline = override.underline; + if (override.inverse !== undefined) merged.inverse = override.inverse; + + BASE_BOOL_STYLE_CACHE[key] = merged; + return merged; + } if ( override.fg === undefined && override.bg === undefined && diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts index a2dd7856..d65080f5 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts @@ -17,6 +17,50 @@ function clipEquals(a: ClipRect | undefined, b: ClipRect): boolean { return a !== undefined && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h; } +function rectIntersects(a: Rect, b: Rect): boolean { + if (a.w <= 0 || a.h <= 0 || b.w <= 0 || b.h <= 0) return false; + return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; +} + +function findStackChildRange( + children: readonly LayoutTree[], + childCount: number, + direction: "row" | "column", + damageRect: Rect, +): Readonly<{ start: number; end: number }> | null { + const damageStart = direction === "column" ? damageRect.y : damageRect.x; + const damageEnd = direction === "column" ? damageRect.y + damageRect.h : damageRect.x + damageRect.w; + + let start = 0; + while (start < childCount) { + const child = children[start]; + if (!child) { + start++; + continue; + } + const childStart = direction === "column" ? child.rect.y : child.rect.x; + const childSize = direction === "column" ? child.rect.h : child.rect.w; + const childEnd = childStart + childSize; + if (childEnd > damageStart) break; + start++; + } + if (start >= childCount) return null; + + let endExclusive = start; + while (endExclusive < childCount) { + const child = children[endExclusive]; + if (!child) { + endExclusive++; + continue; + } + const childStart = direction === "column" ? child.rect.y : child.rect.x; + if (childStart >= damageEnd) break; + endExclusive++; + } + if (endExclusive <= start) return null; + return { start, end: endExclusive - 1 }; +} + function pushChildrenWithLayout( node: RuntimeInstance, layoutNode: LayoutTree, @@ -26,12 +70,27 @@ function pushChildrenWithLayout( layoutStack: LayoutTree[], clipStack: (ClipRect | undefined)[], clip: ClipRect | undefined, + damageRect: Rect | undefined, + stackDirection: "row" | "column" | undefined = undefined, ): void { const childCount = Math.min(node.children.length, layoutNode.children.length); - for (let i = childCount - 1; i >= 0; i--) { + if (childCount <= 0) return; + + let rangeStart = 0; + let rangeEnd = childCount - 1; + if (damageRect) { + if (stackDirection) { + const range = findStackChildRange(layoutNode.children, childCount, stackDirection, damageRect); + if (!range) return; + rangeStart = range.start; + rangeEnd = range.end; + } + } + + for (let i = rangeEnd; i >= rangeStart; i--) { const c = node.children[i]; const lc = layoutNode.children[i]; - if (c && lc) { + if (c && lc && (!damageRect || rectIntersects(lc.rect, damageRect))) { nodeStack.push(c); styleStack.push(style); layoutStack.push(lc); @@ -99,6 +158,7 @@ export function renderContainerWidget( styleStack: ResolvedTextStyle[], layoutStack: LayoutTree[], clipStack: (ClipRect | undefined)[], + damageRect: Rect | undefined, ): void { const vnode = node.vnode; @@ -140,6 +200,8 @@ export function renderContainerWidget( layoutStack, clipStack, rect, + damageRect, + vnode.kind, ); break; } @@ -163,6 +225,8 @@ export function renderContainerWidget( layoutStack, clipStack, childClip, + damageRect, + vnode.kind, ); break; } @@ -238,6 +302,7 @@ export function renderContainerWidget( layoutStack, clipStack, childClip, + damageRect, ); break; } @@ -283,6 +348,7 @@ export function renderContainerWidget( layoutStack, clipStack, childClip, + damageRect, ); break; } @@ -298,6 +364,7 @@ export function renderContainerWidget( layoutStack, clipStack, currentClip, + damageRect, ); break; } @@ -313,6 +380,7 @@ export function renderContainerWidget( layoutStack, clipStack, currentClip, + damageRect, ); break; } @@ -346,6 +414,7 @@ export function renderContainerWidget( layoutStack, clipStack, currentClip, + damageRect, ); break; } @@ -372,6 +441,7 @@ export function renderContainerWidget( layoutStack, clipStack, childClip, + damageRect, ); // Render dividers between panels @@ -428,6 +498,7 @@ export function renderContainerWidget( layoutStack, clipStack, childClip, + damageRect, ); break; } @@ -449,6 +520,7 @@ export function renderContainerWidget( layoutStack, clipStack, childClip, + damageRect, ); break; } diff --git a/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts b/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts new file mode 100644 index 00000000..0b2f93bd --- /dev/null +++ b/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts @@ -0,0 +1,39 @@ +import { assert, test } from "@rezi-ui/testkit"; +import { ui } from "../../index.js"; +import { commitVNodeTree } from "../commit.js"; +import { createInstanceIdAllocator } from "../instance.js"; + +test("commit: leaf fast reuse does not ignore textOverflow changes", () => { + const allocator = createInstanceIdAllocator(1); + + const v0 = ui.text("hello"); + const c0 = commitVNodeTree(null, v0, { allocator }); + if (!c0.ok) assert.fail(`commit failed: ${c0.fatal.code}: ${c0.fatal.detail}`); + + const v1 = ui.text("hello", { textOverflow: "ellipsis" }); + const c1 = commitVNodeTree(c0.value.root, v1, { allocator }); + if (!c1.ok) assert.fail(`commit failed: ${c1.fatal.code}: ${c1.fatal.detail}`); + + assert.notEqual(c1.value.root, c0.value.root); + const nextProps = c1.value.root.vnode.props as { textOverflow?: unknown }; + assert.equal(nextProps.textOverflow, "ellipsis"); +}); + +test("commit: container fast reuse does not ignore parent prop changes", () => { + const allocator = createInstanceIdAllocator(1); + + const v0 = ui.box({ border: "none" }, [ui.text("x")]); + const c0 = commitVNodeTree(null, v0, { allocator }); + if (!c0.ok) assert.fail(`commit failed: ${c0.fatal.code}: ${c0.fatal.detail}`); + + const v1 = ui.box({ border: "double" }, [ui.text("x")]); + const c1 = commitVNodeTree(c0.value.root, v1, { allocator }); + if (!c1.ok) assert.fail(`commit failed: ${c1.fatal.code}: ${c1.fatal.detail}`); + + assert.notEqual(c1.value.root, c0.value.root); + const nextBoxProps = c1.value.root.vnode.props as { border?: unknown }; + assert.equal(nextBoxProps.border, "double"); + + // Child leaf is unchanged, so it should still be eligible for leaf fast reuse. + assert.equal(c1.value.root.children[0], c0.value.root.children[0]); +}); diff --git a/packages/core/src/runtime/commit.ts b/packages/core/src/runtime/commit.ts index 48a7b08b..36e58b56 100644 --- a/packages/core/src/runtime/commit.ts +++ b/packages/core/src/runtime/commit.ts @@ -75,8 +75,11 @@ function leafVNodeEqual(a: VNode, b: VNode): boolean { case "text": { if (b.kind !== "text") return false; if (a.text !== b.text) return false; - const ap = a.props as { style?: unknown; key?: string }; - const bp = b.props as { style?: unknown; key?: string }; + const ap = a.props as { style?: unknown; textOverflow?: unknown; variant?: unknown; maxWidth?: unknown }; + const bp = b.props as { style?: unknown; textOverflow?: unknown; variant?: unknown; maxWidth?: unknown }; + if (ap.textOverflow !== bp.textOverflow) return false; + if (ap.variant !== bp.variant) return false; + if (ap.maxWidth !== bp.maxWidth) return false; return textStyleEqual( ap.style as Parameters[0], bp.style as Parameters[0], @@ -90,15 +93,209 @@ function leafVNodeEqual(a: VNode, b: VNode): boolean { } case "divider": { if (b.kind !== "divider") return false; - const ap = a.props as { direction?: string }; - const bp = b.props as { direction?: string }; - return ap.direction === bp.direction; + const ap = a.props as { direction?: unknown; char?: unknown; label?: unknown; color?: unknown }; + const bp = b.props as { direction?: unknown; char?: unknown; label?: unknown; color?: unknown }; + return ( + ap.direction === bp.direction && + ap.char === bp.char && + ap.label === bp.label && + ap.color === bp.color + ); } default: return false; } } +function boxShadowEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + if (typeof a === "boolean" || typeof b === "boolean") return a === b; + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false; + const ao = a as { offsetX?: unknown; offsetY?: unknown; density?: unknown }; + const bo = b as { offsetX?: unknown; offsetY?: unknown; density?: unknown }; + return ao.offsetX === bo.offsetX && ao.offsetY === bo.offsetY && ao.density === bo.density; +} + +function layoutConstraintsEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + const ao = (a ?? {}) as { + width?: unknown; + height?: unknown; + minWidth?: unknown; + maxWidth?: unknown; + minHeight?: unknown; + maxHeight?: unknown; + flex?: unknown; + aspectRatio?: unknown; + }; + const bo = (b ?? {}) as typeof ao; + return ( + ao.width === bo.width && + ao.height === bo.height && + ao.minWidth === bo.minWidth && + ao.maxWidth === bo.maxWidth && + ao.minHeight === bo.minHeight && + ao.maxHeight === bo.maxHeight && + ao.flex === bo.flex && + ao.aspectRatio === bo.aspectRatio + ); +} + +function spacingPropsEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + const ao = (a ?? {}) as { + p?: unknown; + px?: unknown; + py?: unknown; + pt?: unknown; + pb?: unknown; + pl?: unknown; + pr?: unknown; + m?: unknown; + mx?: unknown; + my?: unknown; + mt?: unknown; + mr?: unknown; + mb?: unknown; + ml?: unknown; + }; + const bo = (b ?? {}) as typeof ao; + return ( + ao.p === bo.p && + ao.px === bo.px && + ao.py === bo.py && + ao.pt === bo.pt && + ao.pb === bo.pb && + ao.pl === bo.pl && + ao.pr === bo.pr && + ao.m === bo.m && + ao.mx === bo.mx && + ao.my === bo.my && + ao.mt === bo.mt && + ao.mr === bo.mr && + ao.mb === bo.mb && + ao.ml === bo.ml + ); +} + +function boxPropsEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + const ao = (a ?? {}) as { + title?: unknown; + titleAlign?: unknown; + pad?: unknown; + border?: unknown; + borderTop?: unknown; + borderRight?: unknown; + borderBottom?: unknown; + borderLeft?: unknown; + shadow?: unknown; + style?: unknown; + }; + const bo = (b ?? {}) as typeof ao; + return ( + ao.title === bo.title && + ao.titleAlign === bo.titleAlign && + ao.pad === bo.pad && + ao.border === bo.border && + ao.borderTop === bo.borderTop && + ao.borderRight === bo.borderRight && + ao.borderBottom === bo.borderBottom && + ao.borderLeft === bo.borderLeft && + boxShadowEqual(ao.shadow, bo.shadow) && + textStyleEqual( + ao.style as Parameters[0], + bo.style as Parameters[0], + ) && + spacingPropsEqual(ao, bo) && + layoutConstraintsEqual(ao, bo) + ); +} + +function stackPropsEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + const ao = (a ?? {}) as { + pad?: unknown; + gap?: unknown; + align?: unknown; + justify?: unknown; + items?: unknown; + style?: unknown; + }; + const bo = (b ?? {}) as typeof ao; + return ( + ao.pad === bo.pad && + ao.gap === bo.gap && + ao.align === bo.align && + ao.justify === bo.justify && + ao.items === bo.items && + textStyleEqual( + ao.style as Parameters[0], + bo.style as Parameters[0], + ) && + spacingPropsEqual(ao, bo) && + layoutConstraintsEqual(ao, bo) + ); +} + +function focusZonePropsEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + const ao = (a ?? {}) as { + id?: unknown; + tabIndex?: unknown; + navigation?: unknown; + columns?: unknown; + wrapAround?: unknown; + onEnter?: unknown; + onExit?: unknown; + }; + const bo = (b ?? {}) as typeof ao; + return ( + ao.id === bo.id && + ao.tabIndex === bo.tabIndex && + ao.navigation === bo.navigation && + ao.columns === bo.columns && + ao.wrapAround === bo.wrapAround && + ao.onEnter === bo.onEnter && + ao.onExit === bo.onExit + ); +} + +function focusTrapPropsEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + const ao = (a ?? {}) as { + id?: unknown; + active?: unknown; + returnFocusTo?: unknown; + initialFocus?: unknown; + }; + const bo = (b ?? {}) as typeof ao; + return ( + ao.id === bo.id && + ao.active === bo.active && + ao.returnFocusTo === bo.returnFocusTo && + ao.initialFocus === bo.initialFocus + ); +} + +function canFastReuseContainerSelf(prev: VNode, next: VNode): boolean { + if (prev.kind !== next.kind) return false; + switch (prev.kind) { + case "box": + return boxPropsEqual(prev.props, (next as typeof prev).props); + case "row": + case "column": + return stackPropsEqual(prev.props, (next as typeof prev).props); + case "focusZone": + return focusZonePropsEqual(prev.props, (next as typeof prev).props); + case "focusTrap": + return focusTrapPropsEqual(prev.props, (next as typeof prev).props); + default: + return false; + } +} + /** Fatal errors from tree commitment. */ export type CommitFatal = | ReconcileFatal @@ -255,6 +452,7 @@ type CommitCtx = Readonly<{ localState: RuntimeLocalStateStore | undefined; seenInteractiveIds: Map; lists: MutableLists; + collectLifecycleInstanceIds: boolean; composite: Readonly<{ registry: CompositeInstanceRegistry; appState: unknown; @@ -269,11 +467,20 @@ function commitNode( vnode: VNode, ctx: CommitCtx, ): CommitNodeResult { + // Leaf nodes — fast path: reuse previous RuntimeInstance when content is unchanged. + // Do this before any bookkeeping so unchanged leaf-heavy subtrees (lists, tables) + // don't pay per-node validation overhead. + if (prev && prev.vnode.kind === vnode.kind && leafVNodeEqual(prev.vnode, vnode)) { + return { ok: true, value: { root: prev } }; + } + const idFatal = ensureInteractiveId(ctx.seenInteractiveIds, instanceId, vnode); if (idFatal) return { ok: false, fatal: idFatal }; - if (prev) ctx.lists.reused.push(instanceId); - else ctx.lists.mounted.push(instanceId); + if (ctx.collectLifecycleInstanceIds) { + if (prev) ctx.lists.reused.push(instanceId); + else ctx.lists.mounted.push(instanceId); + } // Composite widgets: execute render function and treat result as the node's children. // This integrates defineWidget() into the commit pipeline. @@ -461,23 +668,69 @@ function commitNode( res.value.unmountedInstanceIds.length === 0 && res.value.nextChildren.length === prevChildren.length; - const nextChildren: RuntimeInstance[] = []; - const committedChildVNodes: VNode[] = []; - let allChildrenSame = canTryFastReuse; - for (const child of res.value.nextChildren) { - const prevChild = child.prevIndex !== null ? byPrevIndex[child.prevIndex] : null; - const committed = commitNode(prevChild ?? null, child.instanceId, child.vnode, ctx); - if (!committed.ok) return committed; - nextChildren.push(committed.value.root); - committedChildVNodes.push(committed.value.root.vnode); - if (allChildrenSame && committed.value.root !== prevChild) { - allChildrenSame = false; + // Avoid allocating nextChildren/committedChildVNodes for the common case where + // everything is reused (e.g., list updates where only a couple rows change). + let nextChildren: readonly RuntimeInstance[] | null = null; + let committedChildVNodes: readonly VNode[] | null = null; + + if (canTryFastReuse) { + let allChildrenSame = true; + for (let i = 0; i < res.value.nextChildren.length; i++) { + const child = res.value.nextChildren[i]; + if (!child) continue; + const prevChild = child.prevIndex !== null ? byPrevIndex[child.prevIndex] : null; + const committed = commitNode(prevChild ?? null, child.instanceId, child.vnode, ctx); + if (!committed.ok) return committed; + + if (allChildrenSame && committed.value.root !== prevChild) { + allChildrenSame = false; + // First mismatch: allocate arrays and backfill prior entries with the prevChild refs + // we already proved were identical in earlier iterations. + const len = res.value.nextChildren.length; + const nextChildrenArr: RuntimeInstance[] = new Array(len); + const committedChildVNodesArr: VNode[] = new Array(len); + nextChildren = nextChildrenArr; + committedChildVNodes = committedChildVNodesArr; + for (let j = 0; j < i; j++) { + const plan = res.value.nextChildren[j]; + if (!plan) continue; + const pc = plan.prevIndex !== null ? byPrevIndex[plan.prevIndex] : null; + if (!pc) continue; + nextChildrenArr[j] = pc; + committedChildVNodesArr[j] = pc.vnode; + } + } + + if (!allChildrenSame) { + // Arrays are allocated after the first mismatch. + if (!nextChildren || !committedChildVNodes) { + return { + ok: false, + fatal: { code: "ZRUI_INVALID_PROPS", detail: "commitNode: internal fast-reuse invariant" }, + }; + } + (nextChildren as RuntimeInstance[])[i] = committed.value.root; + (committedChildVNodes as VNode[])[i] = committed.value.root.vnode; + } } - } - if (allChildrenSame && prev !== null) { - // All children are identical references → reuse parent entirely. - return { ok: true, value: { root: prev } }; + if (allChildrenSame && prev !== null && canFastReuseContainerSelf(prev.vnode, vnodeForCommit)) { + // All children are identical references → reuse parent entirely. + return { ok: true, value: { root: prev } }; + } + } else { + // General path: commit children and build next arrays. + const nextChildrenArr: RuntimeInstance[] = []; + const committedChildVNodesArr: VNode[] = []; + for (const child of res.value.nextChildren) { + const prevChild = child.prevIndex !== null ? byPrevIndex[child.prevIndex] : null; + const committed = commitNode(prevChild ?? null, child.instanceId, child.vnode, ctx); + if (!committed.ok) return committed; + nextChildrenArr.push(committed.value.root); + committedChildVNodesArr.push(committed.value.root.vnode); + } + nextChildren = nextChildrenArr; + committedChildVNodes = committedChildVNodesArr; } for (const unmountedId of res.value.unmountedInstanceIds) { @@ -487,6 +740,12 @@ function commitNode( collectSubtreeInstanceIds(prevNode, ctx.lists.unmounted); } + if (!nextChildren || !committedChildVNodes) { + // canTryFastReuse=true and there was at least one mismatch, so arrays must exist. + nextChildren = prevChildren; + committedChildVNodes = prevChildren.map((c) => c.vnode); + } + return { ok: true, value: { @@ -499,13 +758,6 @@ function commitNode( }; } - // Leaf nodes — fast path: reuse previous RuntimeInstance when content is unchanged. - // This avoids allocating a new RuntimeInstance object + result wrapper per frame - // for the majority of leaf nodes that don't change (e.g., 498/500 rows in a list update). - if (prev && prev.vnode.kind === vnode.kind && leafVNodeEqual(prev.vnode, vnode)) { - return { ok: true, value: { root: prev } }; - } - return { ok: true, value: { @@ -528,6 +780,8 @@ export function commitVNodeTree( opts: Readonly<{ allocator: InstanceIdAllocator; localState?: RuntimeLocalStateStore; + /** Skip mounted/reused instanceId tracking (unmounted tracking remains). */ + collectLifecycleInstanceIds?: boolean; composite?: Readonly<{ registry: CompositeInstanceRegistry; appState: unknown; @@ -535,11 +789,13 @@ export function commitVNodeTree( }>; }>, ): CommitResult { + const collectLifecycleInstanceIds = opts.collectLifecycleInstanceIds !== false; const ctx: CommitCtx = { allocator: opts.allocator, localState: opts.localState, seenInteractiveIds: new Map(), lists: { mounted: [], reused: [], unmounted: [] }, + collectLifecycleInstanceIds, composite: opts.composite ?? null, pendingEffects: [], }; diff --git a/packages/core/src/widgets/ui.ts b/packages/core/src/widgets/ui.ts index 3f841293..8ba46f84 100644 --- a/packages/core/src/widgets/ui.ts +++ b/packages/core/src/widgets/ui.ts @@ -73,7 +73,16 @@ function filterChildren(children: readonly UiChild[]): readonly VNode[] { } function isTextProps(v: TextStyle | TextProps): v is TextProps { - return typeof v === "object" && v !== null && ("style" in v || "id" in v || "key" in v); + return ( + typeof v === "object" && + v !== null && + ("style" in v || + "id" in v || + "key" in v || + "variant" in v || + "textOverflow" in v || + "maxWidth" in v) + ); } function text(content: string): VNode; From 851cb6d18938cc4ff419fdb70907bb65e5bbab1d Mon Sep 17 00:00:00 2001 From: Rezo Date: Wed, 11 Feb 2026 14:11:11 +0400 Subject: [PATCH 4/5] fix: leaf reuse correctness + lint --- packages/bench/src/blessed-backend.ts | 11 +- packages/bench/src/profile-construction.ts | 8 +- packages/bench/src/profile-phases.ts | 8 +- packages/bench/src/ratatui-runner.ts | 8 +- packages/bench/src/report.ts | 13 +- packages/bench/src/scenarios/construction.ts | 12 +- .../bench/src/scenarios/content-update.ts | 106 +++++++------ packages/bench/src/scenarios/memory.ts | 18 +-- packages/bench/src/scenarios/rerender.ts | 18 +-- packages/bench/src/scenarios/startup.ts | 52 ++++--- packages/bench/src/scenarios/treeBuilders.ts | 12 +- packages/bench/src/terminal-kit.d.ts | 4 +- packages/bench/src/termkit-backend.ts | 3 +- .../__tests__/partialDrawlistEmission.test.ts | 139 ++++++++++-------- packages/core/src/app/widgetRenderer.ts | 19 ++- .../renderToDrawlist/widgets/containers.ts | 10 +- .../commit.fastReuse.regression.test.ts | 32 ++++ packages/core/src/runtime/commit.ts | 69 ++++++++- packages/core/src/runtime/reconcile.ts | 13 +- 19 files changed, 342 insertions(+), 213 deletions(-) diff --git a/packages/bench/src/blessed-backend.ts b/packages/bench/src/blessed-backend.ts index a42224c6..695c3f85 100644 --- a/packages/bench/src/blessed-backend.ts +++ b/packages/bench/src/blessed-backend.ts @@ -57,8 +57,7 @@ export function createBlessedContext(cols: number, rows: number): BlessedContext _encoding: BufferEncoding, callback: (error?: Error | null) => void, ) { - totalBytes += - typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength; + totalBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength; callback(); }, highWaterMark: 1024 * 1024, @@ -69,10 +68,12 @@ export function createBlessedContext(cols: number, rows: number): BlessedContext Object.defineProperty(measureStream, "rows", { value: rows, writable: true }); Object.defineProperty(measureStream, "isTTY", { value: true }); - const nullInput = new Readable({ read() {} }); + const nullInput = new Readable({ read() {} }) as Readable & { + setRawMode?: (mode: boolean) => unknown; + }; Object.defineProperty(nullInput, "isTTY", { value: false }); // blessed calls setRawMode on stdin - (nullInput as unknown as Record)["setRawMode"] = () => nullInput; + nullInput.setRawMode = () => nullInput; const screen = blessed.screen({ output: measureStream, @@ -91,7 +92,7 @@ export function createBlessedContext(cols: number, rows: number): BlessedContext screen, blessed, flush() { - if (screen.program && screen.program.flush) { + if (screen.program?.flush) { screen.program.flush(); } }, diff --git a/packages/bench/src/profile-construction.ts b/packages/bench/src/profile-construction.ts index d32860ce..8a087c46 100644 --- a/packages/bench/src/profile-construction.ts +++ b/packages/bench/src/profile-construction.ts @@ -3,7 +3,7 @@ * Usage: REZI_PERF=1 REZI_PERF_DETAIL=1 npx tsx src/profile-construction.ts */ -import { createApp, perfSnapshot, perfReset } from "@rezi-ui/core"; +import { createApp, perfReset, perfSnapshot } from "@rezi-ui/core"; import { BenchBackend } from "./backends.js"; import { buildReziTree } from "./scenarios/treeBuilders.js"; @@ -53,9 +53,9 @@ async function main() { if (!stats || stats.count === 0) continue; console.log( ` ${phase.padEnd(22)} avg=${(stats.avg * 1000).toFixed(0).padStart(6)}µs ` + - `p95=${(stats.p95 * 1000).toFixed(0).padStart(6)}µs ` + - `p99=${(stats.p99 * 1000).toFixed(0).padStart(6)}µs ` + - `count=${String(stats.count).padStart(5)}` + `p95=${(stats.p95 * 1000).toFixed(0).padStart(6)}µs ` + + `p99=${(stats.p99 * 1000).toFixed(0).padStart(6)}µs ` + + `count=${String(stats.count).padStart(5)}`, ); } diff --git a/packages/bench/src/profile-phases.ts b/packages/bench/src/profile-phases.ts index 61578785..59f5cc7b 100644 --- a/packages/bench/src/profile-phases.ts +++ b/packages/bench/src/profile-phases.ts @@ -3,7 +3,7 @@ * Usage: REZI_PERF=1 REZI_PERF_DETAIL=1 npx tsx src/profile-phases.ts */ -import { createApp, perfSnapshot, perfReset, ui, type VNode } from "@rezi-ui/core"; +import { type VNode, createApp, perfReset, perfSnapshot, ui } from "@rezi-ui/core"; import { BenchBackend } from "./backends.js"; const LIST_SIZE = 500; @@ -80,9 +80,9 @@ async function main() { if (!stats || stats.count === 0) continue; console.log( ` ${phase.padEnd(22)} avg=${(stats.avg * 1000).toFixed(0).padStart(6)}µs ` + - `p95=${(stats.p95 * 1000).toFixed(0).padStart(6)}µs ` + - `p99=${(stats.p99 * 1000).toFixed(0).padStart(6)}µs ` + - `count=${String(stats.count).padStart(5)}` + `p95=${(stats.p95 * 1000).toFixed(0).padStart(6)}µs ` + + `p99=${(stats.p99 * 1000).toFixed(0).padStart(6)}µs ` + + `count=${String(stats.count).padStart(5)}`, ); } diff --git a/packages/bench/src/ratatui-runner.ts b/packages/bench/src/ratatui-runner.ts index a9784bd9..714e7f4b 100644 --- a/packages/bench/src/ratatui-runner.ts +++ b/packages/bench/src/ratatui-runner.ts @@ -6,7 +6,7 @@ * back into BenchMetrics. */ -import { execSync, execFileSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { computeStats, takeMemory } from "./measure.js"; @@ -87,7 +87,7 @@ interface RatatuiOutput { export function runRatatui( scenario: string, config: ScenarioConfig, - params: Record = {}, + params: Readonly<{ items?: number | string }> = {}, ): BenchMetrics { ensureBuilt(); @@ -100,8 +100,8 @@ export function runRatatui( String(config.iterations), ]; - if (params["items"] !== undefined) { - args.push("--items", String(params["items"])); + if (params.items !== undefined) { + args.push("--items", String(params.items)); } const stdout = execFileSync(BINARY, args, { diff --git a/packages/bench/src/report.ts b/packages/bench/src/report.ts index 4e739abc..cfb6880b 100644 --- a/packages/bench/src/report.ts +++ b/packages/bench/src/report.ts @@ -123,7 +123,14 @@ export function toMarkdown(results: readonly BenchResult[]): string { ); lines.push("|---|---:|---:|---:|---:|---:|---:|---:|---:|"); - const order: Framework[] = ["rezi-native", "ink-compat", "ink", "terminal-kit", "blessed", "ratatui"]; + const order: Framework[] = [ + "rezi-native", + "ink-compat", + "ink", + "terminal-kit", + "blessed", + "ratatui", + ]; const sorted = [...items].sort( (a, b) => order.indexOf(a.framework) - order.indexOf(b.framework), ); @@ -139,7 +146,9 @@ export function toMarkdown(results: readonly BenchResult[]): string { // Summary: relative performance (ratio = other / rezi; >1 means rezi is faster) lines.push("## Relative Performance (vs Rezi native)\n"); - lines.push("> \"Xx slower\" = Rezi native is X times faster. \"Xx faster\" = other framework is faster.\n"); + lines.push( + '> "Xx slower" = Rezi native is X times faster. "Xx faster" = other framework is faster.\n', + ); // Determine which frameworks have results const allFws: Framework[] = ["ink", "ink-compat", "terminal-kit", "blessed", "ratatui"]; diff --git a/packages/bench/src/scenarios/construction.ts b/packages/bench/src/scenarios/construction.ts index e068c4fc..0dcee39e 100644 --- a/packages/bench/src/scenarios/construction.ts +++ b/packages/bench/src/scenarios/construction.ts @@ -13,7 +13,12 @@ import { BenchBackend, MeasuringStream, NullReadable } from "../backends.js"; import { benchAsync, benchSync, tryGc } from "../measure.js"; import type { BenchMetrics, Framework, Scenario, ScenarioConfig } from "../types.js"; -import { buildBlessedTree, buildReactTree, buildReziTree, buildTermkitTree } from "./treeBuilders.js"; +import { + buildBlessedTree, + buildReactTree, + buildReziTree, + buildTermkitTree, +} from "./treeBuilders.js"; async function runRezi(config: ScenarioConfig, n: number): Promise { const { createApp } = await import("@rezi-ui/core"); @@ -225,10 +230,7 @@ async function runBlessed(config: ScenarioConfig, n: number): Promise { +async function runRatatui(config: ScenarioConfig, n: number): Promise { const { runRatatui: exec } = await import("../ratatui-runner.js"); return exec("construction", config, { items: n }); } diff --git a/packages/bench/src/scenarios/content-update.ts b/packages/bench/src/scenarios/content-update.ts index c6006d55..996fe6ac 100644 --- a/packages/bench/src/scenarios/content-update.ts +++ b/packages/bench/src/scenarios/content-update.ts @@ -68,11 +68,7 @@ function reactListTree( { key: String(i), flexDirection: "row", gap: 1 }, h(C.Text as string, { bold: isSelected }, isSelected ? ">" : " "), h(C.Text as string, { dimColor: !isSelected }, `${String(i).padStart(3, " ")}.`), - h( - C.Text as string, - { bold: isSelected, inverse: isSelected }, - `entry-${i}.log`, - ), + h(C.Text as string, { bold: isSelected, inverse: isSelected }, `entry-${i}.log`), h(C.Text as string, { dimColor: true }, `${(i * 1024 + 512).toLocaleString()} B`), ), ); @@ -106,14 +102,8 @@ function termkitListTree( const y = 2 + i; const isSelected = i === selected; buffer.put({ x: 0, y, attr: { bold: isSelected } }, isSelected ? ">" : " "); - buffer.put( - { x: 2, y, attr: { dim: !isSelected } }, - `${String(i).padStart(3, " ")}.`, - ); - buffer.put( - { x: 8, y, attr: { bold: isSelected, inverse: isSelected } }, - `entry-${i}.log`, - ); + buffer.put({ x: 2, y, attr: { dim: !isSelected } }, `${String(i).padStart(3, " ")}.`); + buffer.put({ x: 8, y, attr: { bold: isSelected, inverse: isSelected } }, `entry-${i}.log`); buffer.put({ x: 25, y, attr: { dim: true } }, `${(i * 1024 + 512).toLocaleString()} B`); } } @@ -184,9 +174,7 @@ async function runInkCompat(config: ScenarioConfig): Promise { for (let i = 0; i < config.warmup; i++) { selected = (selected + 1) % LIST_SIZE; const frameP = backend.waitForFrame(); - instance.rerender( - reactListTree(React, InkCompat, selected) as React.ReactNode, - ); + instance.rerender(reactListTree(React, InkCompat, selected) as React.ReactNode); await frameP; } @@ -197,9 +185,7 @@ async function runInkCompat(config: ScenarioConfig): Promise { async () => { selected = (selected + 1) % LIST_SIZE; const frameP = backend.waitForFrame(); - instance.rerender( - reactListTree(React, InkCompat, selected) as React.ReactNode, - ); + instance.rerender(reactListTree(React, InkCompat, selected) as React.ReactNode); await frameP; }, 0, @@ -223,24 +209,19 @@ async function runInk(config: ScenarioConfig): Promise { let selected = 0; const initialWrite = stdout.waitForWrite(); - const instance = Ink.render( - reactListTree(React, Ink, selected) as React.ReactNode, - { - stdout: stdout as unknown as NodeJS.WriteStream, - stdin: stdin as unknown as NodeJS.ReadStream, - patchConsole: false, - exitOnCtrlC: false, - }, - ); + const instance = Ink.render(reactListTree(React, Ink, selected) as React.ReactNode, { + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + exitOnCtrlC: false, + }); await initialWrite; try { for (let i = 0; i < config.warmup; i++) { selected = (selected + 1) % LIST_SIZE; const writeP = stdout.waitForWrite(); - instance.rerender( - reactListTree(React, Ink, selected) as React.ReactNode, - ); + instance.rerender(reactListTree(React, Ink, selected) as React.ReactNode); await writeP; } @@ -251,9 +232,7 @@ async function runInk(config: ScenarioConfig): Promise { async () => { selected = (selected + 1) % LIST_SIZE; const writeP = stdout.waitForWrite(); - instance.rerender( - reactListTree(React, Ink, selected) as React.ReactNode, - ); + instance.rerender(reactListTree(React, Ink, selected) as React.ReactNode); await writeP; }, 0, @@ -315,16 +294,23 @@ function buildInitialBlessedList( blessed: { text: (opts: Record) => unknown }, screen: { append: (el: unknown) => void }, selected: number, -): { headerSel: BlessedWidget; markers: BlessedWidget[]; indices: BlessedWidget[]; names: BlessedWidget[] } { +): { + headerSel: BlessedWidget; + markers: BlessedWidget[]; + indices: BlessedWidget[]; + names: BlessedWidget[]; +} { // Header screen.append( blessed.text({ top: 0, left: 0, content: "Files", style: { bold: true }, tags: false }), ); - screen.append( - blessed.text({ top: 0, left: 8, content: `${LIST_SIZE} items`, tags: false }), - ); + screen.append(blessed.text({ top: 0, left: 8, content: `${LIST_SIZE} items`, tags: false })); const headerSel = blessed.text({ - top: 0, left: 22, content: `Selected: ${selected}`, style: { fg: "grey" }, tags: false, + top: 0, + left: 22, + content: `Selected: ${selected}`, + style: { fg: "grey" }, + tags: false, }); screen.append(headerSel); @@ -335,10 +321,34 @@ function buildInitialBlessedList( for (let i = 0; i < LIST_SIZE; i++) { const y = 2 + i; const isSel = i === selected; - const marker = blessed.text({ top: y, left: 0, content: isSel ? ">" : " ", style: { bold: isSel }, tags: false }); - const idx = blessed.text({ top: y, left: 2, content: `${String(i).padStart(3, " ")}.`, style: { fg: isSel ? "white" : "grey" }, tags: false }); - const name = blessed.text({ top: y, left: 8, content: `entry-${i}.log`, style: { bold: isSel, inverse: isSel }, tags: false }); - const size = blessed.text({ top: y, left: 25, content: `${(i * 1024 + 512).toLocaleString()} B`, style: { fg: "grey" }, tags: false }); + const marker = blessed.text({ + top: y, + left: 0, + content: isSel ? ">" : " ", + style: { bold: isSel }, + tags: false, + }); + const idx = blessed.text({ + top: y, + left: 2, + content: `${String(i).padStart(3, " ")}.`, + style: { fg: isSel ? "white" : "grey" }, + tags: false, + }); + const name = blessed.text({ + top: y, + left: 8, + content: `entry-${i}.log`, + style: { bold: isSel, inverse: isSel }, + tags: false, + }); + const size = blessed.text({ + top: y, + left: 25, + content: `${(i * 1024 + 512).toLocaleString()} B`, + style: { fg: "grey" }, + tags: false, + }); screen.append(marker); screen.append(idx); screen.append(name); @@ -356,7 +366,12 @@ function buildInitialBlessedList( * matching the partial-update behavior measured by other frameworks. */ function updateBlessedSelection( - refs: { headerSel: BlessedWidget; markers: BlessedWidget[]; indices: BlessedWidget[]; names: BlessedWidget[] }, + refs: { + headerSel: BlessedWidget; + markers: BlessedWidget[]; + indices: BlessedWidget[]; + names: BlessedWidget[]; + }, oldSel: number, newSel: number, ): void { @@ -424,8 +439,7 @@ async function runRatatui(config: ScenarioConfig): Promise { export const contentUpdateScenario: Scenario = { name: "content-update", - description: - "Partial screen update: move selection in a 500-row list (measures diff efficiency)", + description: "Partial screen update: move selection in a 500-row list (measures diff efficiency)", defaultConfig: { warmup: 50, iterations: 500 }, paramSets: [{}], frameworks: ["rezi-native", "ink-compat", "ink", "terminal-kit", "blessed", "ratatui"], diff --git a/packages/bench/src/scenarios/memory.ts b/packages/bench/src/scenarios/memory.ts index b0327344..88859951 100644 --- a/packages/bench/src/scenarios/memory.ts +++ b/packages/bench/src/scenarios/memory.ts @@ -13,14 +13,7 @@ import { type VNode, ui } from "@rezi-ui/core"; import { BenchBackend, MeasuringStream, NullReadable } from "../backends.js"; -import { - computeStats, - diffCpu, - peakMemory, - takeCpu, - takeMemory, - tryGc, -} from "../measure.js"; +import { computeStats, diffCpu, peakMemory, takeCpu, takeMemory, tryGc } from "../measure.js"; import type { BenchMetrics, Framework, @@ -345,10 +338,7 @@ function termkitMemTree( const bar = `[${"#".repeat(filled)}${".".repeat(20 - filled)}] ${pct}%`; buffer.put({ x: 1, y: 1 }, bar); for (let j = 0; j < 20; j++) { - buffer.put( - { x: 1, y: 3 + j, attr: { dim: j % 2 === 0 } }, - ` Line ${j}: value=${i * 20 + j}`, - ); + buffer.put({ x: 1, y: 3 + j, attr: { dim: j % 2 === 0 } }, ` Line ${j}: value=${i * 20 + j}`); } } @@ -437,9 +427,7 @@ function blessedMemTree( const pct = i % 100; const filled = Math.floor(pct / 5); const bar = `[${"#".repeat(filled)}${".".repeat(20 - filled)}] ${pct}%`; - screen.append( - blessed.text({ top: 1, left: 1, content: bar, tags: false }), - ); + screen.append(blessed.text({ top: 1, left: 1, content: bar, tags: false })); for (let j = 0; j < 20; j++) { screen.append( blessed.text({ diff --git a/packages/bench/src/scenarios/rerender.ts b/packages/bench/src/scenarios/rerender.ts index 444b6eb3..1ebae981 100644 --- a/packages/bench/src/scenarios/rerender.ts +++ b/packages/bench/src/scenarios/rerender.ts @@ -20,11 +20,7 @@ import type { BenchMetrics, Framework, Scenario, ScenarioConfig } from "../types function reziCounterTree(count: number): VNode { return ui.column({ p: 1, gap: 1 }, [ ui.text("Counter Benchmark", { style: { bold: true } }), - ui.row({ gap: 2 }, [ - ui.text(`Count: ${count}`), - ui.text("[+1]"), - ui.text("[-1]"), - ]), + ui.row({ gap: 2 }, [ui.text(`Count: ${count}`), ui.text("[+1]"), ui.text("[-1]")]), ui.text(`Last updated: iteration ${count}`, { style: { dim: true } }), ]); } @@ -207,15 +203,9 @@ function blessedCounterTree( tags: false, }), ); - screen.append( - blessed.text({ top: 3, left: 1, content: `Count: ${count}`, tags: false }), - ); - screen.append( - blessed.text({ top: 3, left: 12, content: "[+1]", tags: false }), - ); - screen.append( - blessed.text({ top: 3, left: 18, content: "[-1]", tags: false }), - ); + screen.append(blessed.text({ top: 3, left: 1, content: `Count: ${count}`, tags: false })); + screen.append(blessed.text({ top: 3, left: 12, content: "[+1]", tags: false })); + screen.append(blessed.text({ top: 3, left: 18, content: "[-1]", tags: false })); screen.append( blessed.text({ top: 5, diff --git a/packages/bench/src/scenarios/startup.ts b/packages/bench/src/scenarios/startup.ts index 5ee2a000..701c539e 100644 --- a/packages/bench/src/scenarios/startup.ts +++ b/packages/bench/src/scenarios/startup.ts @@ -16,16 +16,20 @@ */ import { BenchBackend, MeasuringStream, NullReadable } from "../backends.js"; +import { computeStats, diffCpu, peakMemory, takeCpu, takeMemory, tryGc } from "../measure.js"; +import type { + BenchMetrics, + Framework, + MemorySnapshot, + Scenario, + ScenarioConfig, +} from "../types.js"; import { - computeStats, - diffCpu, - peakMemory, - takeCpu, - takeMemory, - tryGc, -} from "../measure.js"; -import type { BenchMetrics, Framework, MemorySnapshot, Scenario, ScenarioConfig } from "../types.js"; -import { buildBlessedTree, buildReactTree, buildReziTree, buildTermkitTree } from "./treeBuilders.js"; + buildBlessedTree, + buildReactTree, + buildReziTree, + buildTermkitTree, +} from "./treeBuilders.js"; const TREE_SIZE = 50; @@ -164,15 +168,12 @@ async function runInk(config: ScenarioConfig): Promise { const stdout = new MeasuringStream(); const stdin = new NullReadable(); const p = stdout.waitForWrite(); - const inst = Ink.render( - buildReactTree(React, Ink, TREE_SIZE, w) as React.ReactNode, - { - stdout: stdout as unknown as NodeJS.WriteStream, - stdin: stdin as unknown as NodeJS.ReadStream, - patchConsole: false, - exitOnCtrlC: false, - }, - ); + const inst = Ink.render(buildReactTree(React, Ink, TREE_SIZE, w) as React.ReactNode, { + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + exitOnCtrlC: false, + }); await p; inst.unmount(); } @@ -190,15 +191,12 @@ async function runInk(config: ScenarioConfig): Promise { const ts = performance.now(); const p = stdout.waitForWrite(); - const inst = Ink.render( - buildReactTree(React, Ink, TREE_SIZE, i) as React.ReactNode, - { - stdout: stdout as unknown as NodeJS.WriteStream, - stdin: stdin as unknown as NodeJS.ReadStream, - patchConsole: false, - exitOnCtrlC: false, - }, - ); + const inst = Ink.render(buildReactTree(React, Ink, TREE_SIZE, i) as React.ReactNode, { + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + exitOnCtrlC: false, + }); await p; samples.push(performance.now() - ts); diff --git a/packages/bench/src/scenarios/treeBuilders.ts b/packages/bench/src/scenarios/treeBuilders.ts index 33ef979c..42d3430e 100644 --- a/packages/bench/src/scenarios/treeBuilders.ts +++ b/packages/bench/src/scenarios/treeBuilders.ts @@ -90,12 +90,8 @@ export function buildBlessedTree( }), ); // Summary row - screen.append( - blessed.text({ top: 1, left: 1, content: `Total: ${n}`, tags: false }), - ); - screen.append( - blessed.text({ top: 1, left: 60, content: "Page 1", tags: false }), - ); + screen.append(blessed.text({ top: 1, left: 1, content: `Total: ${n}`, tags: false })); + screen.append(blessed.text({ top: 1, left: 60, content: "Page 1", tags: false })); // Item rows for (let i = 0; i < n; i++) { const y = 3 + i; @@ -108,9 +104,7 @@ export function buildBlessedTree( tags: false, }), ); - screen.append( - blessed.text({ top: y, left: 7, content: `Item ${i}`, tags: false }), - ); + screen.append(blessed.text({ top: y, left: 7, content: `Item ${i}`, tags: false })); screen.append( blessed.text({ top: y, diff --git a/packages/bench/src/terminal-kit.d.ts b/packages/bench/src/terminal-kit.d.ts index 8551f1bb..dd5f6c7d 100644 --- a/packages/bench/src/terminal-kit.d.ts +++ b/packages/bench/src/terminal-kit.d.ts @@ -1,7 +1,9 @@ declare module "terminal-kit" { const termkit: { createTerminal: (opts: Record) => unknown; - ScreenBuffer: new (opts: Record) => { + ScreenBuffer: new ( + opts: Record, + ) => { put: (opts: Record, str: string) => void; fill: (opts?: Record) => void; draw: (opts: { delta: boolean }) => void; diff --git a/packages/bench/src/termkit-backend.ts b/packages/bench/src/termkit-backend.ts index 8577b80a..99143ca0 100644 --- a/packages/bench/src/termkit-backend.ts +++ b/packages/bench/src/termkit-backend.ts @@ -61,8 +61,7 @@ export function createTermkitContext(cols: number, rows: number): TermkitContext _encoding: BufferEncoding, callback: (error?: Error | null) => void, ) { - totalBytes += - typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength; + totalBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength; callback(); }, // High watermark to avoid backpressure during benchmarks diff --git a/packages/core/src/app/__tests__/partialDrawlistEmission.test.ts b/packages/core/src/app/__tests__/partialDrawlistEmission.test.ts index b9c86747..bd9b9165 100644 --- a/packages/core/src/app/__tests__/partialDrawlistEmission.test.ts +++ b/packages/core/src/app/__tests__/partialDrawlistEmission.test.ts @@ -7,13 +7,13 @@ import type { } from "../../drawlist/index.js"; import type { CursorState } from "../../drawlist/index.js"; import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; -import { ZR_KEY_TAB } from "../../keybindings/keyCodes.js"; import type { TextStyle, VNode } from "../../index.js"; import { ui } from "../../index.js"; +import { ZR_KEY_TAB } from "../../keybindings/keyCodes.js"; import { DEFAULT_TERMINAL_CAPS } from "../../terminalCaps.js"; import { defaultTheme } from "../../theme/defaultTheme.js"; import type { Viewport } from "../widgetRenderer.js"; -import { WidgetRenderer, type WidgetRenderPlan } from "../widgetRenderer.js"; +import { type WidgetRenderPlan, WidgetRenderer } from "../widgetRenderer.js"; type RecordedOp = | Readonly<{ kind: "clear" }> @@ -333,7 +333,10 @@ describe("WidgetRenderer incremental drawlist emission", () => { const framebufferBPartial = applyOps(framebufferA, opsBPartial); assertFramebuffersEqual(framebufferBPartial, framebufferBExpected); - assert.equal(opsBPartial.some((op) => op.kind === "clearTo"), false); + assert.equal( + opsBPartial.some((op) => op.kind === "clearTo"), + false, + ); const viewportArea = viewport.cols * viewport.rows; let damagedArea = 0; @@ -347,7 +350,10 @@ describe("WidgetRenderer incremental drawlist emission", () => { const viewport: Viewport = { cols: 50, rows: 10 }; const backend = createNoopBackend(); const viewFn = () => - ui.column({}, [ui.button({ id: "a", label: "Alpha" }), ui.button({ id: "b", label: "Beta" })]); + ui.column({}, [ + ui.button({ id: "a", label: "Alpha" }), + ui.button({ id: "b", label: "Beta" }), + ]); const partialBuilder = new RecordingBuilder(); const partialRenderer = new WidgetRenderer({ @@ -366,7 +372,14 @@ describe("WidgetRenderer incremental drawlist emission", () => { const partialPlan = { commit: false, layout: false, checkLayoutStability: false } as const; const forcedFullPlan = { commit: false, layout: true, checkLayoutStability: false } as const; - const opsA = submitOps(partialRenderer, partialBuilder, viewFn, undefined, viewport, initialPlan); + const opsA = submitOps( + partialRenderer, + partialBuilder, + viewFn, + undefined, + viewport, + initialPlan, + ); const framebufferA = applyOps(createFramebuffer(viewport), opsA); partialRenderer.routeEngineEvent({ @@ -406,7 +419,10 @@ describe("WidgetRenderer incremental drawlist emission", () => { assertFramebuffersEqual(framebufferFocusedPartial, framebufferFocusedExpected); if (!opsFocusedPartial.some((op) => op.kind === "clearTo")) { - assert.equal(opsFocusedPartial.some((op) => op.kind === "fillRect"), true); + assert.equal( + opsFocusedPartial.some((op) => op.kind === "fillRect"), + true, + ); } }); @@ -430,9 +446,20 @@ describe("WidgetRenderer incremental drawlist emission", () => { const initialPlan = { commit: true, layout: true, checkLayoutStability: true } as const; const partialTickPlan = { commit: false, layout: false, checkLayoutStability: false } as const; - const forcedFullTickPlan = { commit: false, layout: true, checkLayoutStability: false } as const; + const forcedFullTickPlan = { + commit: false, + layout: true, + checkLayoutStability: false, + } as const; - const opsTick0 = submitOps(partialRenderer, partialBuilder, viewFn, undefined, viewport, initialPlan); + const opsTick0 = submitOps( + partialRenderer, + partialBuilder, + viewFn, + undefined, + viewport, + initialPlan, + ); const framebufferTick0 = applyOps(createFramebuffer(viewport), opsTick0); const opsTick1Partial = submitOps( partialRenderer, @@ -456,7 +483,10 @@ describe("WidgetRenderer incremental drawlist emission", () => { const framebufferTick1Expected = applyOps(createFramebuffer(viewport), opsTick1Expected); assertFramebuffersEqual(framebufferTick1Partial, framebufferTick1Expected); - assert.equal(opsTick1Partial.some((op) => op.kind === "clearTo"), false); + assert.equal( + opsTick1Partial.some((op) => op.kind === "clearTo"), + false, + ); }); test("incremental v2 cursor is emitted even when focused input is outside damage rect", () => { @@ -472,14 +502,11 @@ describe("WidgetRenderer incremental drawlist emission", () => { const viewFn = () => ui.column({}, [ui.input({ id: "q", value: "hello" }), ui.spinner({ variant: "line" })]); - submitOps( - renderer, - builder, - viewFn, - undefined, - viewport, - { commit: true, layout: true, checkLayoutStability: true }, - ); + submitOps(renderer, builder, viewFn, undefined, viewport, { + commit: true, + layout: true, + checkLayoutStability: true, + }); renderer.routeEngineEvent({ kind: "key", @@ -488,25 +515,25 @@ describe("WidgetRenderer incremental drawlist emission", () => { mods: 0, action: "down", }); - submitOps( - renderer, - builder, - viewFn, - undefined, - viewport, - { commit: false, layout: false, checkLayoutStability: false }, - ); + submitOps(renderer, builder, viewFn, undefined, viewport, { + commit: false, + layout: false, + checkLayoutStability: false, + }); - const opsTick = submitOps( - renderer, - builder, - viewFn, - undefined, - viewport, - { commit: false, layout: false, checkLayoutStability: false }, + const opsTick = submitOps(renderer, builder, viewFn, undefined, viewport, { + commit: false, + layout: false, + checkLayoutStability: false, + }); + assert.equal( + opsTick.some((op) => op.kind === "clearTo"), + false, + ); + assert.equal( + opsTick.some((op) => op.kind === "hideCursor"), + false, ); - assert.equal(opsTick.some((op) => op.kind === "clearTo"), false); - assert.equal(opsTick.some((op) => op.kind === "hideCursor"), false); const setCursorOp = opsTick.find((op) => op.kind === "setCursor"); assert.equal(setCursorOp !== undefined, true); if (setCursorOp && setCursorOp.kind === "setCursor") { @@ -534,22 +561,16 @@ describe("WidgetRenderer incremental drawlist emission", () => { : [ui.text("Header")], ); - submitOps( - renderer, - builder, - viewFn, - { showButton: false }, - viewport, - { commit: true, layout: true, checkLayoutStability: true }, - ); - submitOps( - renderer, - builder, - viewFn, - { showButton: true }, - viewport, - { commit: true, layout: false, checkLayoutStability: true }, - ); + submitOps(renderer, builder, viewFn, { showButton: false }, viewport, { + commit: true, + layout: true, + checkLayoutStability: true, + }); + submitOps(renderer, builder, viewFn, { showButton: true }, viewport, { + commit: true, + layout: false, + checkLayoutStability: true, + }); const route = renderer.routeEngineEvent({ kind: "key", @@ -572,14 +593,11 @@ describe("WidgetRenderer incremental drawlist emission", () => { requestRender: () => {}, }); - submitOps( - renderer, - builder, - (s) => contentUpdateTree(s.selected), - { selected: 0 }, - viewport, - { commit: true, layout: true, checkLayoutStability: true }, - ); + submitOps(renderer, builder, (s) => contentUpdateTree(s.selected), { selected: 0 }, viewport, { + commit: true, + layout: true, + checkLayoutStability: true, + }); const secondOps = submitOps( renderer, builder, @@ -588,6 +606,9 @@ describe("WidgetRenderer incremental drawlist emission", () => { viewport, { commit: true, layout: true, checkLayoutStability: true }, ); - assert.equal(secondOps.some((op) => op.kind === "clearTo"), true); + assert.equal( + secondOps.some((op) => op.kind === "clearTo"), + true, + ); }); }); diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index 772c8ef1..0def18bb 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -2277,7 +2277,11 @@ export class WidgetRenderer { if (this._lastRenderedThemeRef !== theme) return false; // Conservative correctness: overlays can draw outside local rects. - if (this.dropdownStack.length > 0 || this.layerStack.length > 0 || this.toastContainers.length > 0) { + if ( + this.dropdownStack.length > 0 || + this.layerStack.length > 0 || + this.toastContainers.length > 0 + ) { return false; } return true; @@ -2517,7 +2521,7 @@ export class WidgetRenderer { } this._pooledMergedDamageRects.push(merged); } - this._pooledMergedDamageRects.sort((a, b) => (a.y - b.y) || (a.x - b.x)); + this._pooledMergedDamageRects.sort((a, b) => a.y - b.y || a.x - b.x); return this._pooledMergedDamageRects; } @@ -2628,7 +2632,10 @@ export class WidgetRenderer { this.committedRoot = commitRes.root; const damageToken = PERF_DETAIL_ENABLED ? perfMarkStart("damage_identity_diff") : 0; - identityDamageFromCommit = this.computeIdentityDiffDamage(prevCommittedRoot, this.committedRoot); + identityDamageFromCommit = this.computeIdentityDiffDamage( + prevCommittedRoot, + this.committedRoot, + ); if (PERF_DETAIL_ENABLED) perfMarkEnd("damage_identity_diff", damageToken); if (!doLayout && plan.checkLayoutStability) { @@ -3420,7 +3427,11 @@ export class WidgetRenderer { if (prevFocusedId !== null && !this.appendDamageRectForId(prevFocusedId)) { missingDamageRect = true; } - if (!missingDamageRect && nextFocusedId !== null && !this.appendDamageRectForId(nextFocusedId)) { + if ( + !missingDamageRect && + nextFocusedId !== null && + !this.appendDamageRectForId(nextFocusedId) + ) { missingDamageRect = true; } } diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts index d65080f5..5706cd66 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts @@ -29,7 +29,8 @@ function findStackChildRange( damageRect: Rect, ): Readonly<{ start: number; end: number }> | null { const damageStart = direction === "column" ? damageRect.y : damageRect.x; - const damageEnd = direction === "column" ? damageRect.y + damageRect.h : damageRect.x + damageRect.w; + const damageEnd = + direction === "column" ? damageRect.y + damageRect.h : damageRect.x + damageRect.w; let start = 0; while (start < childCount) { @@ -80,7 +81,12 @@ function pushChildrenWithLayout( let rangeEnd = childCount - 1; if (damageRect) { if (stackDirection) { - const range = findStackChildRange(layoutNode.children, childCount, stackDirection, damageRect); + const range = findStackChildRange( + layoutNode.children, + childCount, + stackDirection, + damageRect, + ); if (!range) return; rangeStart = range.start; rangeEnd = range.end; diff --git a/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts b/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts index 0b2f93bd..77c8b907 100644 --- a/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts +++ b/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts @@ -19,6 +19,38 @@ test("commit: leaf fast reuse does not ignore textOverflow changes", () => { assert.equal(nextProps.textOverflow, "ellipsis"); }); +test("commit: leaf fast reuse does not ignore text id changes", () => { + const allocator = createInstanceIdAllocator(1); + + const v0 = ui.text("hello", { id: "a" }); + const c0 = commitVNodeTree(null, v0, { allocator }); + if (!c0.ok) assert.fail(`commit failed: ${c0.fatal.code}: ${c0.fatal.detail}`); + + const v1 = ui.text("hello", { id: "b" }); + const c1 = commitVNodeTree(c0.value.root, v1, { allocator }); + if (!c1.ok) assert.fail(`commit failed: ${c1.fatal.code}: ${c1.fatal.detail}`); + + assert.notEqual(c1.value.root, c0.value.root); + const nextProps = c1.value.root.vnode.props as { id?: unknown }; + assert.equal(nextProps.id, "b"); +}); + +test("commit: leaf fast reuse records reusedInstanceIds", () => { + const allocator = createInstanceIdAllocator(1); + + const v0 = ui.box({ border: "none" }, [ui.text("x")]); + const c0 = commitVNodeTree(null, v0, { allocator }); + if (!c0.ok) assert.fail(`commit failed: ${c0.fatal.code}: ${c0.fatal.detail}`); + + const v1 = ui.box({ border: "none" }, [ui.text("x")]); + const c1 = commitVNodeTree(c0.value.root, v1, { allocator }); + if (!c1.ok) assert.fail(`commit failed: ${c1.fatal.code}: ${c1.fatal.detail}`); + + const childId = c0.value.root.children[0]?.instanceId; + assert.ok(childId !== undefined, "expected box child to exist"); + assert.ok(c1.value.reusedInstanceIds.includes(childId)); +}); + test("commit: container fast reuse does not ignore parent prop changes", () => { const allocator = createInstanceIdAllocator(1); diff --git a/packages/core/src/runtime/commit.ts b/packages/core/src/runtime/commit.ts index 36e58b56..a8bc4cb0 100644 --- a/packages/core/src/runtime/commit.ts +++ b/packages/core/src/runtime/commit.ts @@ -49,8 +49,28 @@ const EMPTY_CHILDREN: readonly RuntimeInstance[] = Object.freeze([]); * Returns true if both styles produce identical render output. */ function textStyleEqual( - a: { bold?: boolean; dim?: boolean; italic?: boolean; underline?: boolean; inverse?: boolean; fg?: unknown; bg?: unknown } | undefined, - b: { bold?: boolean; dim?: boolean; italic?: boolean; underline?: boolean; inverse?: boolean; fg?: unknown; bg?: unknown } | undefined, + a: + | { + bold?: boolean; + dim?: boolean; + italic?: boolean; + underline?: boolean; + inverse?: boolean; + fg?: unknown; + bg?: unknown; + } + | undefined, + b: + | { + bold?: boolean; + dim?: boolean; + italic?: boolean; + underline?: boolean; + inverse?: boolean; + fg?: unknown; + bg?: unknown; + } + | undefined, ): boolean { if (a === b) return true; if (!a || !b) return false; @@ -75,8 +95,23 @@ function leafVNodeEqual(a: VNode, b: VNode): boolean { case "text": { if (b.kind !== "text") return false; if (a.text !== b.text) return false; - const ap = a.props as { style?: unknown; textOverflow?: unknown; variant?: unknown; maxWidth?: unknown }; - const bp = b.props as { style?: unknown; textOverflow?: unknown; variant?: unknown; maxWidth?: unknown }; + const ap = a.props as { + id?: unknown; + style?: unknown; + textOverflow?: unknown; + variant?: unknown; + maxWidth?: unknown; + }; + const bp = b.props as { + id?: unknown; + style?: unknown; + textOverflow?: unknown; + variant?: unknown; + maxWidth?: unknown; + }; + // Even when render output is identical, `id` changes must re-commit so downstream + // id-based lookups (layout rect indexing, anchors, etc) don't observe stale ids. + if (ap.id !== bp.id) return false; if (ap.textOverflow !== bp.textOverflow) return false; if (ap.variant !== bp.variant) return false; if (ap.maxWidth !== bp.maxWidth) return false; @@ -93,8 +128,18 @@ function leafVNodeEqual(a: VNode, b: VNode): boolean { } case "divider": { if (b.kind !== "divider") return false; - const ap = a.props as { direction?: unknown; char?: unknown; label?: unknown; color?: unknown }; - const bp = b.props as { direction?: unknown; char?: unknown; label?: unknown; color?: unknown }; + const ap = a.props as { + direction?: unknown; + char?: unknown; + label?: unknown; + color?: unknown; + }; + const bp = b.props as { + direction?: unknown; + char?: unknown; + label?: unknown; + color?: unknown; + }; return ( ap.direction === bp.direction && ap.char === bp.char && @@ -471,6 +516,7 @@ function commitNode( // Do this before any bookkeeping so unchanged leaf-heavy subtrees (lists, tables) // don't pay per-node validation overhead. if (prev && prev.vnode.kind === vnode.kind && leafVNodeEqual(prev.vnode, vnode)) { + if (ctx.collectLifecycleInstanceIds) ctx.lists.reused.push(instanceId); return { ok: true, value: { root: prev } }; } @@ -706,7 +752,10 @@ function commitNode( if (!nextChildren || !committedChildVNodes) { return { ok: false, - fatal: { code: "ZRUI_INVALID_PROPS", detail: "commitNode: internal fast-reuse invariant" }, + fatal: { + code: "ZRUI_INVALID_PROPS", + detail: "commitNode: internal fast-reuse invariant", + }, }; } (nextChildren as RuntimeInstance[])[i] = committed.value.root; @@ -714,7 +763,11 @@ function commitNode( } } - if (allChildrenSame && prev !== null && canFastReuseContainerSelf(prev.vnode, vnodeForCommit)) { + if ( + allChildrenSame && + prev !== null && + canFastReuseContainerSelf(prev.vnode, vnodeForCommit) + ) { // All children are identical references → reuse parent entirely. return { ok: true, value: { root: prev } }; } diff --git a/packages/core/src/runtime/reconcile.ts b/packages/core/src/runtime/reconcile.ts index 02ba97ea..3effcafe 100644 --- a/packages/core/src/runtime/reconcile.ts +++ b/packages/core/src/runtime/reconcile.ts @@ -157,7 +157,13 @@ function reconcileUnkeyedChildren( if (prev) { const instanceId = prev.instanceId; reusedInstanceIds[reusedCount++] = instanceId; - nextChildren[nextChildrenCount++] = { slotId, vnode, instanceId, kind: "reused", prevIndex: i }; + nextChildren[nextChildrenCount++] = { + slotId, + vnode, + instanceId, + kind: "reused", + prevIndex: i, + }; continue; } const instanceId = allocator.allocate(); @@ -191,7 +197,10 @@ function reconcileUnkeyedChildren( nextChildren, reusedInstanceIds, newInstanceIds, - unmountedInstanceIds: unmountedInstanceIds.length === 0 ? (EMPTY_INSTANCE_IDS as InstanceId[]) : unmountedInstanceIds, + unmountedInstanceIds: + unmountedInstanceIds.length === 0 + ? (EMPTY_INSTANCE_IDS as InstanceId[]) + : unmountedInstanceIds, }; } From a4cab7c33fc648f303fa2ad111345262fd99407d Mon Sep 17 00:00:00 2001 From: Rezo Date: Wed, 11 Feb 2026 14:20:38 +0400 Subject: [PATCH 5/5] fix(node): avoid timer SAB mailbox polling --- packages/node/src/worker/engineWorker.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/node/src/worker/engineWorker.ts b/packages/node/src/worker/engineWorker.ts index baec2cee..70b60c87 100644 --- a/packages/node/src/worker/engineWorker.ts +++ b/packages/node/src/worker/engineWorker.ts @@ -426,6 +426,9 @@ function armSabFrameWake(): void { const h = frameTransport.controlHeader; const seq = Atomics.load(h, FRAME_SAB_CONTROL_PUBLISHED_SEQ_WORD); if (seq > lastConsumedSabPublishedSeq) { + // Avoid timer-driven SAB mailbox polling; when we can observe that a newer + // frame is available, sync it now and schedule an immediate submit tick. + syncPendingSabFrameFromMailbox(); scheduleTickNow(); return; } @@ -449,6 +452,7 @@ function armSabFrameWake(): void { if (waiter.async !== true) { if (!running) return; if (epoch !== sabWakeEpoch) return; + syncPendingSabFrameFromMailbox(); scheduleTickNow(); return; } @@ -460,6 +464,7 @@ function armSabFrameWake(): void { if (epoch !== sabWakeEpoch) return; sabWakeArmed = false; if (!running) return; + syncPendingSabFrameFromMailbox(); scheduleTickNow(); }, () => { @@ -616,9 +621,6 @@ function shutdownNow(): void { function tick(): void { if (!running) return; if (engineId === null) return; - if (frameTransport.kind === FRAME_TRANSPORT_SAB_V1) { - syncPendingSabFrameFromMailbox(); - } let didSubmitDrawlistThisTick = false; let didFrameWork = false;