diff --git a/.gitignore b/.gitignore index 722d333..7112704 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ dist/ # WASM build output (built locally and in CI) ghostty-vt.wasm + +# Visual render test failure artifacts +demo/baselines/*.fail.png diff --git a/bun.lock b/bun.lock index 20a486d..41d2d72 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@xterm/xterm": "^5.5.0", "mitata": "^1.0.34", "prettier": "^3.6.2", + "puppeteer": "^24.37.5", "typescript": "^5.9.3", "vite": "^4.5.0", "vite-plugin-dts": "^4.5.4", @@ -22,6 +23,8 @@ }, }, "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], @@ -108,6 +111,8 @@ "@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.18.0", "", { "dependencies": { "@microsoft/tsdoc": "0.16.0", "ajv": "~8.12.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-8N/vClYyfOH+l4fLkkr9+myAoR6M7akc8ntBJ4DJdWH2b09uVfr71+LTMpNyG19fNqWDg8KEDZhx5wxuqHyGjw=="], + "@puppeteer/browsers": ["@puppeteer/browsers@2.13.2", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw=="], + "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], "@rushstack/node-core-library": ["@rushstack/node-core-library@5.18.0", "", { "dependencies": { "ajv": "~8.13.0", "ajv-draft-04": "~1.0.0", "ajv-formats": "~3.0.1", "fs-extra": "~11.3.0", "import-lazy": "~4.0.0", "jju": "~1.4.0", "resolve": "~1.22.1", "semver": "~7.5.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-XDebtBdw5S3SuZIt+Ra2NieT8kQ3D2Ow1HxhDQ/2soinswnOu9e7S69VSwTOLlQnx5mpWbONu+5JJjDxMAb6Fw=="], @@ -120,6 +125,8 @@ "@rushstack/ts-command-line": ["@rushstack/ts-command-line@5.1.3", "", { "dependencies": { "@rushstack/terminal": "0.19.3", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" } }, "sha512-Kdv0k/BnnxIYFlMVC1IxrIS0oGQd4T4b7vKfx52Y2+wk2WZSDFIvedr7JrhenzSlm3ou5KwtoTGTGd5nbODRug=="], + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], + "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], @@ -132,6 +139,8 @@ "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + "@volar/language-core": ["@volar/language-core@2.4.23", "", { "dependencies": { "@volar/source-map": "2.4.23" } }, "sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ=="], "@volar/source-map": ["@volar/source-map@2.4.23", "", {}, "sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q=="], @@ -154,6 +163,8 @@ "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], @@ -162,44 +173,118 @@ "alien-signals": ["alien-signals@0.4.14", "", {}, "sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q=="], - "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + + "b4a": ["b4a@1.8.1", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "bare-events": ["bare-events@2.8.3", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw=="], + + "bare-fs": ["bare-fs@4.7.1", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw=="], + + "bare-os": ["bare-os@3.9.1", "", {}, "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ=="], + + "bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="], + + "bare-stream": ["bare-stream@2.13.1", "", { "dependencies": { "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow=="], + + "bare-url": ["bare-url@2.4.3", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ=="], + + "basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="], + "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + "bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "chromium-bidi": ["chromium-bidi@14.0.0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], + "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + "de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + + "devtools-protocol": ["devtools-protocol@0.0.1608973", "", {}, "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ=="], + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + "fast-png": ["fast-png@7.0.1", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^6.0.0", "pako": "^2.1.0" } }, "sha512-aD5BELuxRrAPlRhb9V/z1PVMFJy3cUXqIvoxM3IQ+7Rku+T4cbXxWclZ47f1XwhViEl4n30TAN8JmvTJKKc2Dw=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + "fs-extra": ["fs-extra@11.3.2", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + + "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "happy-dom": ["happy-dom@15.11.7", "", { "dependencies": { "entities": "^4.5.0", "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg=="], @@ -210,25 +295,45 @@ "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "import-lazy": ["import-lazy@4.0.0", "", {}, "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw=="], "iobuffer": ["iobuffer@6.0.1", "", {}, "sha512-SZWYkWNfjIXIBYSDpXDYIgshqtbOPsi4lviawAEceR1Kqk+sHDlcQjWrzNQsii80AyBY0q5c8HCTNjqo74ul+Q=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "local-pkg": ["local-pkg@1.1.2", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="], "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -236,6 +341,8 @@ "mitata": ["mitata@1.0.34", "", {}, "sha512-Mc3zrtNBKIMeHSCQ0XqRLo1vbdIx1wvFV9c8NJAiyho6AjNfMY8bVhbS12bwciUdd1t4rj8099CH3N3NFahaUA=="], + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -244,14 +351,28 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], + + "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], + "pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -262,17 +383,39 @@ "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + + "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "puppeteer": ["puppeteer@24.43.1", "", { "dependencies": { "@puppeteer/browsers": "2.13.2", "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1608973", "puppeteer-core": "24.43.1", "typed-query-selector": "^2.12.2" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-/FSOViCrqRdb1HDocpsM9Z1giA71gTQPUt3SpHGVRALKAy/rJr1fLFYZW9F23qPxqVxTHQnbh/5B5opJST3kAw=="], + + "puppeteer-core": ["puppeteer-core@24.43.1", "", { "dependencies": { "@puppeteer/browsers": "2.13.2", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1608973", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.20.0" } }, "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "rollup": ["rollup@3.29.5", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w=="], - "semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -280,14 +423,32 @@ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="], + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="], + + "tar-stream": ["tar-stream@3.2.0", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="], + + "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], + + "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], @@ -304,16 +465,42 @@ "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="], + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@microsoft/api-extractor/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], + "@microsoft/api-extractor/typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], "@rushstack/node-core-library/ajv": ["ajv@8.13.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.4.1" } }, "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA=="], + "@rushstack/node-core-library/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], + + "@rushstack/ts-command-line/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "@types/yauzl/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "@vue/language-core/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "ajv-formats/ajv": ["ajv@8.13.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.4.1" } }, "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA=="], @@ -322,6 +509,12 @@ "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "@microsoft/api-extractor/semver/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "@rushstack/node-core-library/semver/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "@types/yauzl/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], diff --git a/demo/baselines/ansi-colors.png b/demo/baselines/ansi-colors.png new file mode 100644 index 0000000..348f11f Binary files /dev/null and b/demo/baselines/ansi-colors.png differ diff --git a/demo/baselines/basic-text.png b/demo/baselines/basic-text.png new file mode 100644 index 0000000..6b2ea9a Binary files /dev/null and b/demo/baselines/basic-text.png differ diff --git a/demo/baselines/cell-backgrounds.png b/demo/baselines/cell-backgrounds.png new file mode 100644 index 0000000..295f4f9 Binary files /dev/null and b/demo/baselines/cell-backgrounds.png differ diff --git a/demo/baselines/colorscript-art.png b/demo/baselines/colorscript-art.png new file mode 100644 index 0000000..ee97033 Binary files /dev/null and b/demo/baselines/colorscript-art.png differ diff --git a/demo/baselines/combined-styles.png b/demo/baselines/combined-styles.png new file mode 100644 index 0000000..5c38b49 Binary files /dev/null and b/demo/baselines/combined-styles.png differ diff --git a/demo/baselines/cursor-bar.png b/demo/baselines/cursor-bar.png new file mode 100644 index 0000000..14416ef Binary files /dev/null and b/demo/baselines/cursor-bar.png differ diff --git a/demo/baselines/cursor-block.png b/demo/baselines/cursor-block.png new file mode 100644 index 0000000..1b07cb0 Binary files /dev/null and b/demo/baselines/cursor-block.png differ diff --git a/demo/baselines/cursor-underline.png b/demo/baselines/cursor-underline.png new file mode 100644 index 0000000..813f598 Binary files /dev/null and b/demo/baselines/cursor-underline.png differ diff --git a/demo/baselines/hyperlink.png b/demo/baselines/hyperlink.png new file mode 100644 index 0000000..8eb1170 Binary files /dev/null and b/demo/baselines/hyperlink.png differ diff --git a/demo/baselines/inverse-video.png b/demo/baselines/inverse-video.png new file mode 100644 index 0000000..eaa53cb Binary files /dev/null and b/demo/baselines/inverse-video.png differ diff --git a/demo/baselines/invisible-text.png b/demo/baselines/invisible-text.png new file mode 100644 index 0000000..d828fc6 Binary files /dev/null and b/demo/baselines/invisible-text.png differ diff --git a/demo/baselines/powerline-prompt.png b/demo/baselines/powerline-prompt.png new file mode 100644 index 0000000..f26b6e8 Binary files /dev/null and b/demo/baselines/powerline-prompt.png differ diff --git a/demo/baselines/rgb-colors.png b/demo/baselines/rgb-colors.png new file mode 100644 index 0000000..a2ab2be Binary files /dev/null and b/demo/baselines/rgb-colors.png differ diff --git a/demo/baselines/text-styles.png b/demo/baselines/text-styles.png new file mode 100644 index 0000000..0893fc7 Binary files /dev/null and b/demo/baselines/text-styles.png differ diff --git a/demo/baselines/wide-chars.png b/demo/baselines/wide-chars.png new file mode 100644 index 0000000..3ad6287 Binary files /dev/null and b/demo/baselines/wide-chars.png differ diff --git a/demo/bin/render-test.ts b/demo/bin/render-test.ts new file mode 100644 index 0000000..d755336 --- /dev/null +++ b/demo/bin/render-test.ts @@ -0,0 +1,314 @@ +#!/usr/bin/env bun +/** + * Headless visual regression test runner for the renderer. + * + * Usage: + * bun demo/bin/render-test.ts # Run tests against baselines + * bun demo/bin/render-test.ts --update # Update baselines from current renders + * + * Baselines are stored in demo/baselines/*.png + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { decode as decodePng } from 'fast-png'; + +// Get script directory +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const DEMO_DIR = dirname(__dirname); +const BASELINES_DIR = join(DEMO_DIR, 'baselines'); +const PROJECT_ROOT = dirname(DEMO_DIR); + +// Parse args +const args = process.argv.slice(2); +const updateMode = args.includes('--update') || args.includes('-u'); +const helpMode = args.includes('--help') || args.includes('-h'); + +if (helpMode) { + console.log(` +Visual Render Test Runner + +Usage: + bun demo/bin/render-test.ts [options] + +Options: + --update, -u Update baselines from current renders + --help, -h Show this help message + +Baselines are stored in demo/baselines/*.png +`); + process.exit(0); +} + +// Ensure baselines directory exists +if (!existsSync(BASELINES_DIR)) { + mkdirSync(BASELINES_DIR, { recursive: true }); +} + +interface TestResult { + id: string; + name: string; + status: 'pass' | 'fail' | 'new' | 'error'; + diffPercent?: number; + error?: string; +} + +async function main() { + console.log('๐Ÿงช Visual Render Test Runner\n'); + + // Dynamic import puppeteer (install if needed) + let puppeteer: typeof import('puppeteer'); + try { + puppeteer = await import('puppeteer'); + } catch { + console.log('๐Ÿ“ฆ Installing puppeteer...'); + const proc = Bun.spawn(['bun', 'add', '-d', 'puppeteer'], { + cwd: PROJECT_ROOT, + stdout: 'inherit', + stderr: 'inherit', + }); + await proc.exited; + puppeteer = await import('puppeteer'); + } + + // Start local server + console.log('๐ŸŒ Starting local server...'); + const server = Bun.serve({ + port: 0, // Let OS pick a free port + async fetch(req) { + const url = new URL(req.url); + let filePath = join(PROJECT_ROOT, url.pathname); + + // Default to index.html for directories + if (filePath.endsWith('/')) { + filePath += 'index.html'; + } + + try { + const file = Bun.file(filePath); + if (await file.exists()) { + // Set content type based on extension + const ext = filePath.split('.').pop() || ''; + const contentTypes: Record = { + html: 'text/html', + js: 'application/javascript', + css: 'text/css', + json: 'application/json', + wasm: 'application/wasm', + png: 'image/png', + ttf: 'font/ttf', + }; + return new Response(file, { + headers: { 'Content-Type': contentTypes[ext] || 'application/octet-stream' }, + }); + } + } catch { + // Fall through to 404 + } + return new Response('Not found', { status: 404 }); + }, + }); + + const serverUrl = `http://localhost:${server.port}`; + console.log(` Server running at ${serverUrl}`); + + // Launch browser + console.log('๐Ÿš€ Launching headless browser...'); + const browser = await puppeteer.default.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + + const page = await browser.newPage(); + + // Set viewport for consistent rendering + await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 1 }); + + try { + // Navigate to test page + console.log('๐Ÿ“„ Loading test page...\n'); + await page.goto(`${serverUrl}/demo/render-test.html`, { + waitUntil: 'networkidle0', + timeout: 30000, + }); + + // Wait for the page's runAllTests() to complete. + // render-test.html sets window.__testsComplete = true when done. + await page.waitForFunction('window.__testsComplete === true', { timeout: 60000 }); + + // Get test cases from the page + const testCases = await page.evaluate(() => { + // Access the module's test cases through the window exports + // We need to extract test info from the DOM since testCases is module-scoped + const cards = document.querySelectorAll('.test-case'); + return Array.from(cards).map((card) => { + const id = card.id.replace('test-', ''); + const name = card.querySelector('h3')?.textContent || id; + return { id, name }; + }); + }); + + if (testCases.length === 0) { + throw new Error('No test cases found. Make sure the page loaded correctly.'); + } + + console.log(`Found ${testCases.length} tests\n`); + + // Run tests and collect results + const results: TestResult[] = []; + let passed = 0; + let failed = 0; + let newTests = 0; + + for (const test of testCases) { + const baselinePath = join(BASELINES_DIR, `${test.id}.png`); + const hasBaseline = existsSync(baselinePath); + + // Get the canvas data URL from the page + const canvasDataUrl = await page.evaluate((testId: string) => { + const canvas = document.getElementById(`canvas-${testId}`) as HTMLCanvasElement; + return canvas?.toDataURL('image/png') || null; + }, test.id); + + if (!canvasDataUrl) { + results.push({ id: test.id, name: test.name, status: 'error', error: 'Canvas not found' }); + console.log(` โŒ ${test.name}: Canvas not found`); + failed++; + continue; + } + + // Convert data URL to buffer + const base64Data = canvasDataUrl.replace(/^data:image\/png;base64,/, ''); + const currentBuffer = Buffer.from(base64Data, 'base64'); + + if (updateMode) { + // Update mode: save current as baseline + writeFileSync(baselinePath, currentBuffer); + console.log(` ๐Ÿ“ธ ${test.name}: Baseline ${hasBaseline ? 'updated' : 'created'}`); + results.push({ id: test.id, name: test.name, status: 'new' }); + newTests++; + } else if (!hasBaseline) { + // No baseline exists + console.log(` ๐Ÿ†• ${test.name}: No baseline (run with --update to create)`); + results.push({ id: test.id, name: test.name, status: 'new' }); + newTests++; + } else { + // Compare with baseline + const baselineBuffer = readFileSync(baselinePath); + + // Simple byte comparison first + if (currentBuffer.equals(baselineBuffer)) { + console.log(` โœ… ${test.name}: Pass (identical)`); + results.push({ id: test.id, name: test.name, status: 'pass', diffPercent: 0 }); + passed++; + } else { + // Buffers differ - calculate difference percentage + const diffPercent = calculateDiffPercent(currentBuffer, baselineBuffer); + + if (diffPercent <= 0.1) { + // Within threshold + console.log(` โœ… ${test.name}: Pass (${diffPercent.toFixed(3)}% diff)`); + results.push({ id: test.id, name: test.name, status: 'pass', diffPercent }); + passed++; + } else { + console.log(` โŒ ${test.name}: Fail (${diffPercent.toFixed(3)}% diff)`); + results.push({ id: test.id, name: test.name, status: 'fail', diffPercent }); + failed++; + + // Save the current render for debugging + const failPath = join(BASELINES_DIR, `${test.id}.fail.png`); + writeFileSync(failPath, currentBuffer); + } + } + } + } + + // Summary + console.log('\n' + 'โ”€'.repeat(50)); + console.log(`\n๐Ÿ“Š Results: ${passed} passed, ${failed} failed, ${newTests} new\n`); + + if (updateMode) { + console.log(`โœจ Baselines ${newTests > 0 ? 'updated' : 'unchanged'} in demo/baselines/\n`); + } + + // Exit with appropriate code + await browser.close(); + server.stop(); + + if (failed > 0) { + process.exit(1); + } else if (newTests > 0 && !updateMode) { + console.log('โš ๏ธ New tests detected. Run with --update to create baselines.\n'); + process.exit(1); + } + } catch (error) { + console.error('Error:', error); + await browser.close(); + server.stop(); + process.exit(1); + } +} + +/** Per-channel tolerance for the pixel diff, in 8-bit units. Tuned to + * absorb the ~1-pixel anti-aliasing jitter we routinely see when the + * same canvas path is re-rasterized across different runs (different + * GPUs, headless-Chrome versions, OSes) while still catching real + * geometric regressions. */ +const PIXEL_DIFF_TOLERANCE = 2; + +/** + * Pixel-level visual diff between two PNG buffers. + * + * Both PNGs are decoded to raw RGBA via fast-png (already a project + * dependency โ€” no extra `pixelmatch`/`pngjs` install needed), then + * compared pixel-by-pixel. A pixel counts as different if any RGBA + * channel differs by more than PIXEL_DIFF_TOLERANCE. The percentage + * returned is `(differing pixels / total pixels) * 100`. + * + * The previous implementation compared raw PNG byte buffers โ€” that + * was flaky because PNG metadata, compression-level changes, and + * chunk reordering all produce different byte streams from visually + * identical images, and the byte-count delta has no relationship to + * the visual delta. + * + * A dimension OR channel-layout mismatch is reported as 100% diff: + * the baseline and the current render disagree on the fundamental + * shape of the data (canvas size, or RGB vs RGBA), which is by + * definition a regression. (Returning a partial-pixel score would + * just hide the real problem.) + */ +function calculateDiffPercent(buf1: Buffer, buf2: Buffer): number { + const a = decodePng(buf1); + const b = decodePng(buf2); + + // Treat any size or channel-layout disagreement as a full regression. + // Canvas2D `toDataURL('image/png')` always produces RGBA, so an RGB + // baseline against an RGBA current means the baseline was created + // outside the normal pipeline โ€” comparing only the shared channels + // would silently ignore alpha, and an opaque-vs-transparent pair + // would then falsely match. + if (a.width !== b.width || a.height !== b.height || a.channels !== b.channels) return 100; + + const channels = a.channels; + const totalPixels = a.width * a.height; + + let diffPixels = 0; + for (let i = 0; i < totalPixels; i++) { + const base = i * channels; + for (let c = 0; c < channels; c++) { + if (Math.abs(a.data[base + c] - b.data[base + c]) > PIXEL_DIFF_TOLERANCE) { + diffPixels++; + break; + } + } + } + + return (diffPixels / totalPixels) * 100; +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/demo/render-test.html b/demo/render-test.html new file mode 100644 index 0000000..ccd2540 --- /dev/null +++ b/demo/render-test.html @@ -0,0 +1,1004 @@ + + + + + + Visual Render Tests - Ghostty WASM + + + +

Visual Render Tests

+

Renderer regression tests comparing against baseline images

+ +
+ Usage: Run bun test:render:web then open + http://localhost:3000/demo/render-test
+ To update baselines: bun test:render:update +
+ +
+ +
+
+ 0 +
Passed
+
+
+ 0 +
Failed
+
+
+ 0 +
New
+
+
+
+ +
+ + + + diff --git a/lib/box-drawing.test.ts b/lib/box-drawing.test.ts index 9275f32..da0bc4b 100644 --- a/lib/box-drawing.test.ts +++ b/lib/box-drawing.test.ts @@ -14,123 +14,7 @@ import { describe, expect, test } from 'bun:test'; import { drawBoxOrBlock, isBoxOrBlock } from './box-drawing'; - -type Op = - | { kind: 'fillStyle'; v: string } - | { kind: 'strokeStyle'; v: string } - | { kind: 'lineWidth'; v: number } - | { kind: 'lineCap'; v: string } - | { kind: 'globalAlpha'; v: number } - | { kind: 'fillRect'; x: number; y: number; w: number; h: number } - | { kind: 'save' } - | { kind: 'restore' } - | { kind: 'beginPath' } - | { kind: 'moveTo'; x: number; y: number } - | { kind: 'lineTo'; x: number; y: number } - | { - kind: 'bezierCurveTo'; - cp1x: number; - cp1y: number; - cp2x: number; - cp2y: number; - x: number; - y: number; - } - | { kind: 'stroke' } - | { kind: 'translate'; x: number; y: number }; - -interface RecordingCtx { - ops: Op[]; - // Mirrored from CanvasRenderingContext2D for type compat. - fillStyle: string; - strokeStyle: string; - lineWidth: number; - lineCap: string; - globalAlpha: number; - fillRect(x: number, y: number, w: number, h: number): void; - save(): void; - restore(): void; - beginPath(): void; - moveTo(x: number, y: number): void; - lineTo(x: number, y: number): void; - bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void; - stroke(): void; - translate(x: number, y: number): void; -} - -function makeCtx(): RecordingCtx { - const ops: Op[] = []; - let fillStyleBacking = '#000'; - let strokeStyleBacking = '#000'; - let lineWidthBacking = 1; - let lineCapBacking = 'butt'; - let globalAlphaBacking = 1; - return { - ops, - get fillStyle() { - return fillStyleBacking; - }, - set fillStyle(v: string) { - fillStyleBacking = v; - ops.push({ kind: 'fillStyle', v }); - }, - get strokeStyle() { - return strokeStyleBacking; - }, - set strokeStyle(v: string) { - strokeStyleBacking = v; - ops.push({ kind: 'strokeStyle', v }); - }, - get lineWidth() { - return lineWidthBacking; - }, - set lineWidth(v: number) { - lineWidthBacking = v; - ops.push({ kind: 'lineWidth', v }); - }, - get lineCap() { - return lineCapBacking; - }, - set lineCap(v: string) { - lineCapBacking = v; - ops.push({ kind: 'lineCap', v }); - }, - get globalAlpha() { - return globalAlphaBacking; - }, - set globalAlpha(v: number) { - globalAlphaBacking = v; - ops.push({ kind: 'globalAlpha', v }); - }, - fillRect(x, y, w, h) { - ops.push({ kind: 'fillRect', x, y, w, h }); - }, - save() { - ops.push({ kind: 'save' }); - }, - restore() { - ops.push({ kind: 'restore' }); - }, - beginPath() { - ops.push({ kind: 'beginPath' }); - }, - moveTo(x, y) { - ops.push({ kind: 'moveTo', x, y }); - }, - lineTo(x, y) { - ops.push({ kind: 'lineTo', x, y }); - }, - bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) { - ops.push({ kind: 'bezierCurveTo', cp1x, cp1y, cp2x, cp2y, x, y }); - }, - stroke() { - ops.push({ kind: 'stroke' }); - }, - translate(x, y) { - ops.push({ kind: 'translate', x, y }); - }, - }; -} +import { type RecordingOp, makeRecordingCtx } from './canvas-recorder'; // Standard cell for tests: 10x20 with a 1px light stroke. const CW = 10; @@ -139,21 +23,12 @@ const LT = 1; const COLOR = '#fff'; function draw(cp: number, lightPx = LT) { - const ctx = makeCtx(); - const handled = drawBoxOrBlock( - ctx as unknown as CanvasRenderingContext2D, - cp, - 0, - 0, - CW, - CH, - COLOR, - lightPx - ); + const ctx = makeRecordingCtx(); + const handled = drawBoxOrBlock(ctx, cp, 0, 0, CW, CH, COLOR, lightPx); return { ctx, handled }; } -function rectsOnly(ops: Op[]): { x: number; y: number; w: number; h: number }[] { +function rectsOnly(ops: RecordingOp[]): { x: number; y: number; w: number; h: number }[] { return ops.flatMap((o) => (o.kind === 'fillRect' ? [{ x: o.x, y: o.y, w: o.w, h: o.h }] : [])); } @@ -486,9 +361,9 @@ describe('box-drawing', () => { // back to a LIGHT line regardless of dash weight (vlineMiddle/ // hlineMiddle take .light), so a heavy dash at a tiny cell size // shouldn't suddenly turn into a heavy bar. - const ctx = makeCtx(); + const ctx = makeRecordingCtx(); drawBoxOrBlock( - ctx as unknown as CanvasRenderingContext2D, + ctx, 0x2505, // โ”โ”โ” heavy triple dash 0, 0, diff --git a/lib/canvas-recorder.ts b/lib/canvas-recorder.ts new file mode 100644 index 0000000..9fcae91 --- /dev/null +++ b/lib/canvas-recorder.ts @@ -0,0 +1,186 @@ +/** + * Recording stub for `CanvasRenderingContext2D`, shared between the + * `lib/box-drawing.test.ts` and `lib/powerline.test.ts` suites. + * + * happy-dom's Canvas2D implementation doesn't actually rasterize, so + * we can't render-then-pixel-diff in unit tests. Instead the recorder + * captures every drawing call as a structured `RecordingOp` and tests + * make assertions against the op stream. This gives us: + * - coverage assertions (every supported codepoint must produce at + * least one fillRect / fill / stroke), + * - structural assertions on hand-picked glyphs (vertex coords, + * control points, line widths, mirror-envelope wrapping), and + * - regression-on-refactor stability (the op sequence is stable). + * + * The returned value is cast to `CanvasRenderingContext2D` here so + * test files don't need their own `as unknown as` jump โ€” they get a + * value typed as the real Canvas2D context plus an `ops` field they + * can inspect. + * + * Kept out of the published package on two fronts: + * - JS: this file isn't reachable from `lib/index.ts`, so Rollup + * tree-shakes it out of `dist/ghostty-web.js`. + * - Types: vite-plugin-dts's `exclude` in `vite.config.js` lists + * this file by name (the `*.test.ts` wildcard alone wouldn't + * catch it), so its declarations stay out of `ghostty-web.d.ts`. + */ + +/** + * Union of every Canvas2D operation either glyph renderer can emit. + * Order intentionally matches the order each kind first appears in + * box-drawing.ts / powerline.ts; if a new op gets added to one of + * those modules it goes here too. + */ +export type RecordingOp = + // Style property writes. + | { kind: 'fillStyle'; v: string } + | { kind: 'strokeStyle'; v: string } + | { kind: 'lineWidth'; v: number } + | { kind: 'lineCap'; v: CanvasLineCap } + | { kind: 'lineJoin'; v: CanvasLineJoin } + | { kind: 'globalAlpha'; v: number } + // Rectangles. + | { kind: 'fillRect'; x: number; y: number; w: number; h: number } + // State stack. + | { kind: 'save' } + | { kind: 'restore' } + // Path lifecycle. + | { kind: 'beginPath' } + | { kind: 'closePath' } + // Path construction. + | { kind: 'moveTo'; x: number; y: number } + | { kind: 'lineTo'; x: number; y: number } + | { + kind: 'bezierCurveTo'; + cp1x: number; + cp1y: number; + cp2x: number; + cp2y: number; + x: number; + y: number; + } + // Path consumption. + | { kind: 'fill' } + | { kind: 'stroke' } + | { kind: 'clip' } + // Transforms. + | { kind: 'translate'; x: number; y: number } + | { kind: 'scale'; x: number; y: number }; + +/** + * A recorder typed as a real `CanvasRenderingContext2D` so it can be + * passed straight to `drawBoxOrBlock`, `drawPowerline`, etc. The + * extra `ops` field is what tests assert against. + * + * Only the methods/properties the procedural glyph renderers actually + * use are implemented; touching anything else throws at runtime (the + * type cast hides it from the type system on purpose โ€” we want test + * failures, not silent no-ops, if a renderer starts calling a new + * Canvas2D method without us updating this recorder). + */ +export type RecordingCanvas = CanvasRenderingContext2D & { ops: RecordingOp[] }; + +export function makeRecordingCtx(): RecordingCanvas { + const ops: RecordingOp[] = []; + // Style properties are stored in backing variables so the getters + // can return what was last set. Without this, `ctx.fillStyle = x; + // ctx.fillStyle` would return undefined and break any renderer that + // reads back a style it just wrote (e.g. powerline.ts copies + // fillStyle to strokeStyle). + let fillStyleBacking = '#000'; + let strokeStyleBacking = '#000'; + let lineWidthBacking = 1; + let lineCapBacking: CanvasLineCap = 'butt'; + let lineJoinBacking: CanvasLineJoin = 'miter'; + let globalAlphaBacking = 1; + + const recorder = { + ops, + get fillStyle() { + return fillStyleBacking; + }, + set fillStyle(v: string) { + fillStyleBacking = v; + ops.push({ kind: 'fillStyle', v }); + }, + get strokeStyle() { + return strokeStyleBacking; + }, + set strokeStyle(v: string) { + strokeStyleBacking = v; + ops.push({ kind: 'strokeStyle', v }); + }, + get lineWidth() { + return lineWidthBacking; + }, + set lineWidth(v: number) { + lineWidthBacking = v; + ops.push({ kind: 'lineWidth', v }); + }, + get lineCap() { + return lineCapBacking; + }, + set lineCap(v: CanvasLineCap) { + lineCapBacking = v; + ops.push({ kind: 'lineCap', v }); + }, + get lineJoin() { + return lineJoinBacking; + }, + set lineJoin(v: CanvasLineJoin) { + lineJoinBacking = v; + ops.push({ kind: 'lineJoin', v }); + }, + get globalAlpha() { + return globalAlphaBacking; + }, + set globalAlpha(v: number) { + globalAlphaBacking = v; + ops.push({ kind: 'globalAlpha', v }); + }, + fillRect(x: number, y: number, w: number, h: number) { + ops.push({ kind: 'fillRect', x, y, w, h }); + }, + save() { + ops.push({ kind: 'save' }); + }, + restore() { + ops.push({ kind: 'restore' }); + }, + beginPath() { + ops.push({ kind: 'beginPath' }); + }, + closePath() { + ops.push({ kind: 'closePath' }); + }, + moveTo(x: number, y: number) { + ops.push({ kind: 'moveTo', x, y }); + }, + lineTo(x: number, y: number) { + ops.push({ kind: 'lineTo', x, y }); + }, + bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number) { + ops.push({ kind: 'bezierCurveTo', cp1x, cp1y, cp2x, cp2y, x, y }); + }, + fill() { + ops.push({ kind: 'fill' }); + }, + stroke() { + ops.push({ kind: 'stroke' }); + }, + clip() { + ops.push({ kind: 'clip' }); + }, + translate(x: number, y: number) { + ops.push({ kind: 'translate', x, y }); + }, + scale(x: number, y: number) { + ops.push({ kind: 'scale', x, y }); + }, + }; + + // Single cast site for the whole project โ€” see the doc on + // RecordingCanvas for why we don't try to fully implement the + // CanvasRenderingContext2D surface. + return recorder as unknown as RecordingCanvas; +} diff --git a/lib/powerline.test.ts b/lib/powerline.test.ts new file mode 100644 index 0000000..28dabd5 --- /dev/null +++ b/lib/powerline.test.ts @@ -0,0 +1,346 @@ +/** + * Tests for the Powerline glyph renderer. + * + * Same approach as box-drawing.test.ts: happy-dom's CanvasRenderingContext2D + * doesn't rasterize, so we test against a recording stub that captures + * every drawing call as a structured op. This catches: + * - coverage gaps (every codepoint isPowerline accepts should produce + * at least one fill or stroke), + * - dispatch correctness (codepoints outside the supported set must + * not be claimed), + * - structural correctness on a few hand-picked glyphs (chevron + * thickness, stadium curvature, mirror-pair symmetry, bisector + * gap width). + */ + +import { describe, expect, test } from 'bun:test'; +import { type RecordingOp, makeRecordingCtx } from './canvas-recorder'; +import { drawPowerline, isPowerline } from './powerline'; + +// Standard cell for tests: 10ร—20 (tall and narrow, typical of a +// terminal) with a 1px light stroke. +const CW = 10; +const CH = 20; +const LT = 1; +const COLOR = '#fff'; + +function draw(cp: number, w = CW, h = CH, lightPx = LT) { + const ctx = makeRecordingCtx(); + const handled = drawPowerline(ctx, cp, 0, 0, w, h, COLOR, lightPx); + return { ctx, handled }; +} + +// All Powerline glyphs we claim to render. +const SUPPORTED: number[] = [...Array.from({ length: 16 }, (_, i) => 0xe0b0 + i), 0xe0d2, 0xe0d4]; + +/** + * Locate the start of the horizontal-mirror envelope: a save followed + * immediately by translate + scale(-1, 1). Returns the index of the + * save, or -1 if no such envelope is present. drawPowerline sets + * fillStyle/strokeStyle before dispatching, so the envelope rarely + * sits at index 0. + */ +function mirrorIndex(ops: RecordingOp[]): number { + for (let i = 0; i < ops.length - 2; i++) { + const a = ops[i]; + const b = ops[i + 1]; + const c = ops[i + 2]; + if ( + a.kind === 'save' && + b.kind === 'translate' && + c.kind === 'scale' && + c.x === -1 && + c.y === 1 + ) { + return i; + } + } + return -1; +} + +// ---------------------------------------------------------------------------- +// isPowerline membership +// ---------------------------------------------------------------------------- + +describe('isPowerline', () => { + test('accepts the full Powerline Symbols block U+E0B0..U+E0BF', () => { + for (let cp = 0xe0b0; cp <= 0xe0bf; cp++) { + expect(isPowerline(cp)).toBe(true); + } + }); + + test('accepts the two Powerline-Extra bisector glyphs U+E0D2, U+E0D4', () => { + expect(isPowerline(0xe0d2)).toBe(true); + expect(isPowerline(0xe0d4)).toBe(true); + }); + + test('rejects neighbors and unrelated PUA codepoints', () => { + // Just outside the Powerline block. + expect(isPowerline(0xe0af)).toBe(false); + expect(isPowerline(0xe0c0)).toBe(false); + // Adjacent to the bisector pair. + expect(isPowerline(0xe0d1)).toBe(false); + expect(isPowerline(0xe0d3)).toBe(false); + expect(isPowerline(0xe0d5)).toBe(false); + // Plain ASCII and box-drawing must not be claimed. + expect(isPowerline(0x20)).toBe(false); + expect(isPowerline(0x2500)).toBe(false); + expect(isPowerline(0x2588)).toBe(false); + }); +}); + +// ---------------------------------------------------------------------------- +// Coverage: every supported codepoint must produce at least one +// fill or stroke op, and report `handled = true`. +// ---------------------------------------------------------------------------- + +describe('drawPowerline coverage', () => { + for (const cp of SUPPORTED) { + test(`U+${cp.toString(16).toUpperCase().padStart(4, '0')} renders`, () => { + const { ctx, handled } = draw(cp); + expect(handled).toBe(true); + const visibleOps = ctx.ops.filter((o) => o.kind === 'fill' || o.kind === 'stroke'); + expect(visibleOps.length).toBeGreaterThan(0); + }); + } + + test('unsupported codepoints in the powerline-ish range return false', () => { + // E0C0..E0D1 and E0D3 / E0D5 etc. are not handled by Ghostty. + // We mainly care that the function doesn't crash and reports + // `handled=false` so the caller falls back to the font. + for (const cp of [0xe0c0, 0xe0c5, 0xe0d1, 0xe0d3, 0xe0d5]) { + const { handled } = draw(cp); + expect(handled).toBe(false); + } + }); + + test('returns false for zero-area cells without drawing anything', () => { + { + const { ctx, handled } = draw(0xe0b0, 0, CH); + expect(handled).toBe(false); + expect(ctx.ops).toEqual([]); + } + { + const { ctx, handled } = draw(0xe0b0, CW, 0); + expect(handled).toBe(false); + expect(ctx.ops).toEqual([]); + } + }); +}); + +// ---------------------------------------------------------------------------- +// Structural checks on hand-picked glyphs. +// ---------------------------------------------------------------------------- + +describe('drawPowerline structure', () => { + test('E0B0 draws a single right-pointing filled triangle', () => { + const { ctx } = draw(0xe0b0); + // moveTo (0,0) -> lineTo (w, h/2) -> lineTo (0, h) -> closePath -> fill + const path = ctx.ops.filter( + (o) => + o.kind === 'moveTo' || o.kind === 'lineTo' || o.kind === 'closePath' || o.kind === 'fill' + ); + expect(path).toEqual([ + { kind: 'moveTo', x: 0, y: 0 }, + { kind: 'lineTo', x: CW, y: CH / 2 }, + { kind: 'lineTo', x: 0, y: CH }, + { kind: 'closePath' }, + { kind: 'fill' }, + ]); + // No stroke for a hard divider. + expect(ctx.ops.some((o) => o.kind === 'stroke')).toBe(false); + }); + + test('E0B1 strokes a thin chevron at lightPx', () => { + const { ctx } = draw(0xe0b1, CW, CH, 2); + // Path: moveTo, lineTo, lineTo, stroke (no closePath, no fill) + const path = ctx.ops.filter( + (o) => o.kind === 'moveTo' || o.kind === 'lineTo' || o.kind === 'stroke' + ); + expect(path).toEqual([ + { kind: 'moveTo', x: 0, y: 0 }, + { kind: 'lineTo', x: CW, y: CH / 2 }, + { kind: 'lineTo', x: 0, y: CH }, + { kind: 'stroke' }, + ]); + // lineWidth must be set to the requested 2 px. + const widths = ctx.ops.flatMap((o) => (o.kind === 'lineWidth' ? [o.v] : [])); + expect(widths[widths.length - 1]).toBe(2); + // Soft chevrons should not get filled. + expect(ctx.ops.some((o) => o.kind === 'fill')).toBe(false); + }); + + test('E0B3 is the horizontal mirror of E0B1 (translate + scale(-1, 1))', () => { + const { ctx } = draw(0xe0b3); + // The mirror envelope is the FIRST save (drawPowerline sets + // fillStyle/strokeStyle before the switch, so those come first). + const mi = mirrorIndex(ctx.ops); + expect(mi).toBeGreaterThanOrEqual(0); + // The transform applied: translate(0 + CW, 0) + scale(-1, 1). + expect(ctx.ops[mi + 1]).toEqual({ kind: 'translate', x: CW, y: 0 }); + expect(ctx.ops[mi + 2]).toEqual({ kind: 'scale', x: -1, y: 1 }); + // Outermost restore must be the last op. + expect(ctx.ops[ctx.ops.length - 1]).toEqual({ kind: 'restore' }); + // Inside the mirror, the path coordinates are the un-flipped E0B1 + // shape โ€” checking moveTo(0, 0) and lineTo(w, h/2) confirms it. + const path = ctx.ops.filter((o) => o.kind === 'moveTo' || o.kind === 'lineTo'); + expect(path[0]).toEqual({ kind: 'moveTo', x: 0, y: 0 }); + expect(path[1]).toEqual({ kind: 'lineTo', x: CW, y: CH / 2 }); + }); + + test('E0B4 (filled stadium) uses Bezier corners with the standard arc-K factor', () => { + const w = 10; + const h = 40; // h/2 = 20 > w, so r = min(w, h/2) = w = 10 + const { ctx } = draw(0xe0b4, w, h); + + const moves = ctx.ops.filter((o) => o.kind === 'moveTo'); + const beziers = ctx.ops.filter((o) => o.kind === 'bezierCurveTo'); + const lines = ctx.ops.filter((o) => o.kind === 'lineTo'); + + // Path shape: moveTo + 2 bezierCurveTo + 1 lineTo (the straight + // segment between the two rounded corners, present because h > 2w). + expect(moves.length).toBe(1); + expect(beziers.length).toBe(2); + expect(lines.length).toBe(1); + + // Start at top-left. + expect(moves[0]).toEqual({ kind: 'moveTo', x: 0, y: 0 }); + + // Top-right Bezier: ends at (r, r) = (10, 10) with control points + // (r*K, 0) and (r, r - r*K). K = (sqrt(2)-1) * 4/3. + const K = (Math.SQRT2 - 1) * (4 / 3); + const b0 = beziers[0] as Extract; + expect(b0.cp1x).toBeCloseTo(10 * K, 10); + expect(b0.cp1y).toBe(0); + expect(b0.cp2x).toBe(10); + expect(b0.cp2y).toBeCloseTo(10 - 10 * K, 10); + expect(b0.x).toBe(10); + expect(b0.y).toBe(10); + + // Straight segment down the right side from (r, r) to (r, h - r). + expect(lines[0]).toEqual({ kind: 'lineTo', x: 10, y: 30 }); + + // closePath + fill complete the shape. + expect(ctx.ops.some((o) => o.kind === 'closePath')).toBe(true); + expect(ctx.ops.some((o) => o.kind === 'fill')).toBe(true); + expect(ctx.ops.some((o) => o.kind === 'stroke')).toBe(false); + }); + + test('E0B5 (inner-stroke stadium) clips then strokes at 2ร— lightPx', () => { + const { ctx } = draw(0xe0b5, CW, CH, 2); + + // The implementation: + // - save + // - build stadium path + closePath + clip + // - re-issue stadium path (left OPEN โ€” see assertion below) + // - stroke at 2ร— lightPx + // - restore + // We don't need to pin the exact op sequence โ€” just the shape. + expect(ctx.ops.some((o) => o.kind === 'clip')).toBe(true); + expect(ctx.ops.some((o) => o.kind === 'stroke')).toBe(true); + // Outline-only: no fill. + expect(ctx.ops.some((o) => o.kind === 'fill')).toBe(false); + + // The doubled line width is 2 * 2 = 4. + const widths = ctx.ops.flatMap((o) => (o.kind === 'lineWidth' ? [o.v] : [])); + expect(widths[widths.length - 1]).toBe(4); + + // Properly paired save/restore. + const saves = ctx.ops.filter((o) => o.kind === 'save').length; + const restores = ctx.ops.filter((o) => o.kind === 'restore').length; + expect(saves).toBe(restores); + + // Stroke path must be OPEN. Ghostty's innerStrokePath builds a + // closed mask but strokes the original open path; we follow the + // same shape with exactly one closePath (the one used to seal + // the clip region) and no second close before the stroke. A + // second closePath would draw the missing left edge of the + // stadium โ€” visually wrong, but otherwise silent. + const closes = ctx.ops.filter((o) => o.kind === 'closePath').length; + expect(closes).toBe(1); + }); + + test('E0D2 fills two trapezoidal halves separated by a lightPx gap', () => { + const lightPx = 3; + const { ctx } = draw(0xe0d2, CW, CH, lightPx); + + // Two beginPath/closePath/fill cycles for the two halves. + const fills = ctx.ops.filter((o) => o.kind === 'fill'); + const closes = ctx.ops.filter((o) => o.kind === 'closePath'); + expect(fills.length).toBe(2); + expect(closes.length).toBe(2); + + // Each half's flat edge sits at h/2 ยฑ lightPx/2; check the + // y-coordinates that appear in the path. + const ys = ctx.ops.flatMap((o) => (o.kind === 'moveTo' || o.kind === 'lineTo' ? [o.y] : [])); + const midTop = CH / 2 - lightPx / 2; + const midBot = CH / 2 + lightPx / 2; + expect(ys).toContain(midTop); + expect(ys).toContain(midBot); + // The gap is exactly lightPx. + expect(midBot - midTop).toBeCloseTo(lightPx, 10); + }); + + test('E0D4 wraps E0D2 in a mirror block', () => { + const { ctx } = draw(0xe0d4); + expect(mirrorIndex(ctx.ops)).toBeGreaterThanOrEqual(0); + expect(ctx.ops[ctx.ops.length - 1]).toEqual({ kind: 'restore' }); + // Two fills, same as E0D2. + expect(ctx.ops.filter((o) => o.kind === 'fill').length).toBe(2); + }); + + test('E0B9 and E0BF delegate to U+2572 (โ•ฒ) via drawBoxOrBlock', () => { + // Without depending on box-drawing's exact op shape, we can + // confirm the call produced output and that both aliases produce + // the same op sequence. + const a = draw(0xe0b9); + const b = draw(0xe0bf); + expect(a.handled).toBe(true); + expect(b.handled).toBe(true); + // Op streams must match โ€” these glyphs are defined as identical. + expect(b.ctx.ops).toEqual(a.ctx.ops); + }); + + test('E0BB and E0BD delegate to U+2571 (โ•ฑ) via drawBoxOrBlock', () => { + const a = draw(0xe0bb); + const b = draw(0xe0bd); + expect(a.handled).toBe(true); + expect(b.handled).toBe(true); + expect(b.ctx.ops).toEqual(a.ctx.ops); + }); + + test('the / diagonals (E0BB/E0BD) differ from the \\ diagonals (E0B9/E0BF)', () => { + const slash = draw(0xe0bb); + const backslash = draw(0xe0b9); + expect(slash.ctx.ops).not.toEqual(backslash.ctx.ops); + }); +}); + +// ---------------------------------------------------------------------------- +// Mirror pairs: E0B6 mirrors E0B4, E0B7 mirrors E0B5. The mirrored +// version must produce the un-mirrored op sequence wrapped in +// save / translate / scale(-1,1) / ... / restore. +// ---------------------------------------------------------------------------- + +describe('mirror pairs', () => { + function stripMirrorEnvelope(ops: RecordingOp[]): RecordingOp[] { + const i = mirrorIndex(ops); + if (i < 0) return ops; + if (ops[ops.length - 1].kind !== 'restore') return ops; + // Strip the [save, translate, scale(-1,1)] triple AND the matching + // outermost restore at the tail. + return [...ops.slice(0, i), ...ops.slice(i + 3, ops.length - 1)]; + } + + test('E0B6 inner ops match E0B4 ops', () => { + const filled = draw(0xe0b4); + const mirrored = draw(0xe0b6); + expect(stripMirrorEnvelope(mirrored.ctx.ops)).toEqual(filled.ctx.ops); + }); + + test('E0B7 inner ops match E0B5 ops', () => { + const stroked = draw(0xe0b5); + const mirrored = draw(0xe0b7); + expect(stripMirrorEnvelope(mirrored.ctx.ops)).toEqual(stroked.ctx.ops); + }); +}); diff --git a/lib/powerline.ts b/lib/powerline.ts new file mode 100644 index 0000000..688baeb --- /dev/null +++ b/lib/powerline.ts @@ -0,0 +1,399 @@ +/** + * Powerline + Powerline-Extra glyph renderer. + * + * Covers U+E0B0..U+E0BF and the two bisector glyphs U+E0D2 / U+E0D4 + * from the Powerline Extra Symbols set: + * https://github.com/ryanoasis/powerline-extra-symbols + * + * Fonts that patch these in (Nerd Fonts, JetBrains Mono NF, etc.) are + * usually close to flush with the cell, but not exactly: the font's + * ascent/descent rarely matches the cell height we choose for + * descender safety, leaving visible seams in a column of separators + * or stair-stepping joins between adjacent shapes. We draw them as + * canvas paths sized to the cell instead, mirroring what every modern + * terminal does for the box-drawing range. + * + * Ports the structure of Ghostty's `src/font/sprite/draw/powerline.zig`: + * - filled triangles for the hard dividers and corner wedges + * (E0B0, E0B2, E0B8, E0BA, E0BC, E0BE), + * - stroked angle dividers for the soft, thin chevrons + * (E0B1, E0B3) at light box thickness, + * - "stadium"-shaped semicircle caps via cubic Beziers with the + * standard Kappa โ‰ˆ 0.5523 quarter-circle approximation + * (E0B4 filled, E0B5 inner-stroked, plus their horizontal + * mirrors E0B6, E0B7), + * - hourglass / vertical-bisector wedges with a `lightPx` gap at + * the midline (E0D2, E0D4), + * - diagonal line variants (E0B9, E0BB, E0BD, E0BF) that delegate + * to the box-drawing diagonals (U+2571 โ•ฑ, U+2572 โ•ฒ) so the + * glyph is identical to the box character that tiles with it. + * + * Two notes on the Canvas2D side: + * + * 1. "Inner stroke" (Ghostty's `innerStrokePath`) means the stroke + * lies inside the path rather than centered on it. Canvas2D has + * no built-in mode for this, so we clip to the filled region of + * the path before stroking. With the clip in place the half of + * the stroke that would lie outside gets cut, leaving a clean + * inside-only stroke at the natural line width. + * + * 2. Horizontal mirroring (Ghostty's `flipHorizontal`) is done by + * reflecting around the cell's vertical centerline with a + * save / translate / scale(-1, 1) / draw / restore block; this + * keeps every helper authored as if the cell sits at (0, 0). + */ + +import { drawBoxOrBlock } from './box-drawing'; + +// Kappa: cubic-Bezier control-point factor for approximating a +// quarter-circle. (sqrt(2) - 1) * 4/3 โ‰ˆ 0.5522847498. +const ARC_K = (Math.SQRT2 - 1) * (4 / 3); + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Returns true if the codepoint is a Powerline glyph that we render + * directly. Caller should skip the font path in that case. + * + * Covers the full Powerline Symbols block (U+E0B0..U+E0BF) plus the + * two vertical-bisector glyphs (U+E0D2, U+E0D4) from Powerline Extra + * Symbols. Other Private Use Area glyphs (Powerline Extras includes + * stylized variants like leaves and lightning bolts) fall through to + * the font. + */ +export function isPowerline(codepoint: number): boolean { + if (codepoint >= 0xe0b0 && codepoint <= 0xe0bf) return true; + return codepoint === 0xe0d2 || codepoint === 0xe0d4; +} + +/** + * Render a Powerline glyph into the cell at (x, y, w, h). + * + * - `color` is the css color string used for the foreground stroke + * and fill. Both the fill style and the stroke style of the 2D + * context are set to this value internally. + * - `lightPx` is the font-derived light box-stroke thickness in CSS + * pixels, the same value used by `drawBoxOrBlock`. Used for the + * soft chevrons (E0B1, E0B3), the semicircle outlines (E0B5, + * E0B7), and the bisector gap (E0D2, E0D4). Defensively rounded + * to the nearest integer โ‰ฅ 1 inside this function. + * + * Returns true if the glyph was handled; false if the caller should + * fall back to font rendering. Returns false (no draw) if `w` or `h` + * is non-positive. + */ +export function drawPowerline( + ctx: CanvasRenderingContext2D, + codepoint: number, + x: number, + y: number, + w: number, + h: number, + color: string, + lightPx: number +): boolean { + if (!(w > 0) || !(h > 0)) return false; + const px = Math.max(1, Math.round(lightPx)); + + ctx.fillStyle = color; + ctx.strokeStyle = color; + + switch (codepoint) { + // ----- Hard dividers (filled triangles, full cell height) --------------- + + case 0xe0b0: // right-pointing triangle, hard divider + fillTriangle(ctx, x, y, x + w, y + h / 2, x, y + h); + return true; + + case 0xe0b2: // left-pointing triangle, hard divider + fillTriangle(ctx, x + w, y, x, y + h / 2, x + w, y + h); + return true; + + // ----- Soft dividers (stroked angles at light box thickness) ------------ + + case 0xe0b1: // right-pointing angle, thin divider + strokeChevron(ctx, x, y, x + w, y + h / 2, x, y + h, px); + return true; + + case 0xe0b3: // left-pointing angle, thin divider (mirror of E0B1) + ctx.save(); + mirrorAroundCellCenter(ctx, x, w); + strokeChevron(ctx, x, y, x + w, y + h / 2, x, y + h, px); + ctx.restore(); + return true; + + // ----- Semicircle caps (stadium-shaped, not true ellipses) -------------- + // + // When the cell is taller than it is wide (the usual case) the + // shape is a rectangle with a rounded right (or left) side and + // straight top/bottom edges; only the corners are curved. + + case 0xe0b4: + fillStadium(ctx, x, y, w, h, /* mirror */ false); + return true; + + case 0xe0b5: + innerStrokeStadium(ctx, x, y, w, h, px, /* mirror */ false); + return true; + + case 0xe0b6: + fillStadium(ctx, x, y, w, h, /* mirror */ true); + return true; + + case 0xe0b7: + innerStrokeStadium(ctx, x, y, w, h, px, /* mirror */ true); + return true; + + // ----- Corner wedges (filled right triangles in each corner) ------------ + + case 0xe0b8: // bottom-left filled corner โ—ฃ + fillTriangle(ctx, x, y, x + w, y + h, x, y + h); + return true; + + case 0xe0ba: // bottom-right filled corner โ—ข + fillTriangle(ctx, x + w, y, x + w, y + h, x, y + h); + return true; + + case 0xe0bc: // top-left filled corner โ—ค + fillTriangle(ctx, x, y, x + w, y, x, y + h); + return true; + + case 0xe0be: // top-right filled corner โ—ฅ + fillTriangle(ctx, x, y, x + w, y, x + w, y + h); + return true; + + // ----- Diagonal-line variants ------------------------------------------- + // + // Ghostty defines these to be the same vector geometry as the + // box-drawing diagonals U+2571 (โ•ฑ) and U+2572 (โ•ฒ); routing + // through drawBoxOrBlock guarantees a powerline diagonal tiles + // perfectly against an adjacent box-drawing diagonal. + + case 0xe0b9: // โ•ฒ, upper-left to lower-right + case 0xe0bf: // โ•ฒ, upper-left to lower-right (same geometry as E0B9) + return drawBoxOrBlock(ctx, 0x2572, x, y, w, h, color, px); + + case 0xe0bb: // โ•ฑ, upper-right to lower-left + case 0xe0bd: // โ•ฑ, upper-right to lower-left (same geometry as E0BB) + return drawBoxOrBlock(ctx, 0x2571, x, y, w, h, color, px); + + // ----- Vertical bisectors (Powerline Extra) ----------------------------- + + case 0xe0d2: // โ—„ filled wedge pointing right at midline + fillBisector(ctx, x, y, w, h, px, /* mirror */ false); + return true; + + case 0xe0d4: // โ–บ filled wedge pointing left at midline + fillBisector(ctx, x, y, w, h, px, /* mirror */ true); + return true; + + default: + return false; + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function fillTriangle( + ctx: CanvasRenderingContext2D, + x0: number, + y0: number, + x1: number, + y1: number, + x2: number, + y2: number +): void { + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.closePath(); + ctx.fill(); +} + +function strokeChevron( + ctx: CanvasRenderingContext2D, + x0: number, + y0: number, + x1: number, + y1: number, + x2: number, + y2: number, + lineWidth: number +): void { + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.lineWidth = lineWidth; + ctx.lineCap = 'butt'; + ctx.lineJoin = 'miter'; + ctx.stroke(); +} + +/** + * Build the path of a "stadium" โ€” a rectangle whose right side is + * rounded into a half-stadium, with the rounded corners drawn as + * cubic Beziers approximating quarter-circles (Kappa โ‰ˆ 0.5523). + * + * The radius is `min(w, h/2)`: equal to the half-height when the + * cell is taller than wide (the rounded side is a full half-circle), + * or to the cell width when the cell is wider than tall (the + * "rounded" portion can't exceed the cell horizontally). Vertical + * straight segment between the two rounded corners exists when + * `h/2 > w`. + * + * Path is left open โ€” the caller fills (closing the shape via the + * implicit straight line back to the start) or strokes (leaving + * the open path). + */ +function stadiumPath( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number +): void { + const r = Math.min(w, h / 2); + const c = r * ARC_K; + + ctx.beginPath(); + ctx.moveTo(x, y); + // Top-right rounded corner: (x, y) -> (x + r, y + r) + ctx.bezierCurveTo(x + c, y, x + r, y + r - c, x + r, y + r); + // Straight segment down the right side. Zero-length if h <= 2*r. + ctx.lineTo(x + r, y + h - r); + // Bottom-right rounded corner: (x + r, y + h - r) -> (x, y + h) + ctx.bezierCurveTo(x + r, y + h - r + c, x + c, y + h, x, y + h); +} + +function fillStadium( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + mirror: boolean +): void { + if (mirror) { + ctx.save(); + mirrorAroundCellCenter(ctx, x, w); + } + stadiumPath(ctx, x, y, w, h); + ctx.closePath(); + ctx.fill(); + if (mirror) { + ctx.restore(); + } +} + +/** + * Stroke the stadium path with an "inner stroke": the stroke lies + * inside the filled region of the curve, not centered on it. Done + * by clipping to the filled shape and stroking at 2ร— the desired + * line width so the visible (un-clipped) inside half matches the + * requested width. + */ +function innerStrokeStadium( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + lineWidth: number, + mirror: boolean +): void { + if (mirror) { + ctx.save(); + mirrorAroundCellCenter(ctx, x, w); + } + ctx.save(); + // Build the filled-shape clip first. closePath implicitly draws + // the left edge back to the start. + stadiumPath(ctx, x, y, w, h); + ctx.closePath(); + ctx.clip(); + + // Re-issue the stadium outline (without the implicit close) and + // stroke double-wide; the half outside the clip is discarded. + stadiumPath(ctx, x, y, w, h); + ctx.lineWidth = lineWidth * 2; + ctx.lineCap = 'butt'; + ctx.lineJoin = 'miter'; + ctx.stroke(); + ctx.restore(); + if (mirror) { + ctx.restore(); + } +} + +/** + * Filled vertical-bisector wedge. Two trapezoidal halves separated + * by a `lightPx` horizontal gap at the midline; each half is a + * triangle whose apex points to the cell's vertical center on + * the right side. + * + * `mirror=true` flips horizontally for the right-facing E0D4 variant. + */ +function fillBisector( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + lightPx: number, + mirror: boolean +): void { + if (mirror) { + ctx.save(); + mirrorAroundCellCenter(ctx, x, w); + } + const halfGap = lightPx / 2; + const midTop = y + h / 2 - halfGap; + const midBot = y + h / 2 + halfGap; + + // Top piece: trapezoid (0,0) โ†’ (w,0) โ†’ (w/2, mid-) โ†’ (0, mid-) + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + w, y); + ctx.lineTo(x + w / 2, midTop); + ctx.lineTo(x, midTop); + ctx.closePath(); + ctx.fill(); + + // Bottom piece: trapezoid (0,h) โ†’ (w,h) โ†’ (w/2, mid+) โ†’ (0, mid+) + ctx.beginPath(); + ctx.moveTo(x, y + h); + ctx.lineTo(x + w, y + h); + ctx.lineTo(x + w / 2, midBot); + ctx.lineTo(x, midBot); + ctx.closePath(); + ctx.fill(); + + if (mirror) { + ctx.restore(); + } +} + +/** + * Reflect the canvas around the cell's vertical centerline (x + w/2). + * Caller is responsible for `ctx.save()` before calling and + * `ctx.restore()` after the drawing that should be mirrored โ€” keeping + * those out of this helper avoids allocating a closure for every + * drawn cell, which matters on the render hot path. + * + * After this transform a point `(px, py)` in user code lands at + * `(2*(x + w/2) - px, py) = (2x + w - px, py)` on screen. + * + * Used to derive E0B3 from E0B1, E0B6/E0B7 from E0B4/E0B5, and E0D4 + * from E0D2 โ€” the same pattern Ghostty's powerline.zig uses via + * `canvas.flipHorizontal()`. + */ +function mirrorAroundCellCenter(ctx: CanvasRenderingContext2D, x: number, w: number): void { + ctx.translate(2 * x + w, 0); + ctx.scale(-1, 1); +} diff --git a/lib/renderer.ts b/lib/renderer.ts index 1a7581e..b89c548 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -13,6 +13,7 @@ import { drawBoxOrBlock, isBoxOrBlock } from './box-drawing'; import type { ITheme } from './interfaces'; import { KITTY_PLACEHOLDER, diacriticToInt } from './kitty_diacritics'; +import { drawPowerline, isPowerline } from './powerline'; import type { SelectionManager } from './selection-manager'; import type { GhosttyCell, ILink, KittyImagePixels, KittyPlacementInfo } from './types'; import { CellFlags, KittyImageFormat } from './types'; @@ -20,7 +21,18 @@ import { CellFlags, KittyImageFormat } from './types'; // Interface for objects that can be rendered export interface IRenderable { getLine(y: number): GhosttyCell[] | null; - getCursor(): { x: number; y: number; visible: boolean }; + getCursor(): { + x: number; + y: number; + visible: boolean; + /** + * Optional per-cursor style override. When set, takes precedence over + * the renderer-level default. Mainly used by mock buffers in tests + * (e.g. demo/render-test.html) so a single test page can exercise + * all three cursor styles without mutating renderer state. + */ + style?: 'block' | 'underline' | 'bar'; + }; getDimensions(): { cols: number; rows: number }; isRowDirty(y: number): boolean; /** Returns true if a full redraw is needed (e.g., screen change) */ @@ -60,7 +72,15 @@ export interface IScrollbackProvider { export interface RendererOptions { fontSize?: number; // Default: 15 fontFamily?: string; // Default: 'monospace' - cursorStyle?: 'block' | 'underline' | 'bar'; // Default: 'block' + /** + * Default cursor shape. Matches Ghostty native's `cursor-style` config: + * a running program can override this for the rest of the session via + * `CSI Ps SP q` (DECSCUSR), and the renderer respects that. This + * option is the fallback used when the buffer's reported cursor + * style is absent (which happens for test fakes and any non-WASM + * IRenderable). Default: 'block'. + */ + cursorStyle?: 'block' | 'underline' | 'bar'; cursorBlink?: boolean; // Default: false theme?: ITheme; devicePixelRatio?: number; // Default: window.devicePixelRatio @@ -711,7 +731,7 @@ export class CanvasRenderer { // Render cursor (only if we're at the bottom, not scrolled) if (viewportY === 0 && cursor.visible && this.cursorVisible) { - this.renderCursor(cursor.x, cursor.y); + this.renderCursor(cursor.x, cursor.y, cursor.style); } // Render scrollbar if scrolled or scrollback exists (with opacity for fade effect) @@ -895,14 +915,14 @@ export class CanvasRenderer { // proportions the font designer gave U+2502 etc. // This is the standard approach in modern terminal renderers // (Alacritty, kitty, wezterm, Ghostty native). - const isSimpleBoxOrBlock = - cell.grapheme_len === 0 && cell.codepoint > 0 && isBoxOrBlock(cell.codepoint); + const isSimple = cell.grapheme_len === 0 && cell.codepoint > 0; // boxThickness is optional in the public FontMetrics type for // backward compat, but the built-in measureFont always sets it. // Fall back to a font-size-derived value as a safety net. const boxThickness = this.metrics.boxThickness ?? Math.max(1, Math.round(this.fontSize * 0.07)); if ( - isSimpleBoxOrBlock && + isSimple && + isBoxOrBlock(cell.codepoint) && drawBoxOrBlock( this.ctx, cell.codepoint, @@ -915,6 +935,21 @@ export class CanvasRenderer { ) ) { // Drawn directly; skip the font path. + } else if ( + isSimple && + isPowerline(cell.codepoint) && + drawPowerline( + this.ctx, + cell.codepoint, + cellX, + cellY, + cellWidth, + this.metrics.height, + this.ctx.fillStyle as string, + boxThickness + ) + ) { + // Drawn directly; skip the font path. } else { // Multi-codepoint grapheme (e.g. emoji, ZWJ sequence): look up // the full cluster from the buffer. Otherwise: a single @@ -1398,15 +1433,18 @@ export class CanvasRenderer { } /** - * Render cursor + * Render cursor. `styleOverride` lets a buffer pick a style per-frame + * (mainly for tests); when omitted, falls back to the renderer's + * configured `cursorStyle`. */ - private renderCursor(x: number, y: number): void { + private renderCursor(x: number, y: number, styleOverride?: 'block' | 'underline' | 'bar'): void { const cursorX = x * this.metrics.width; const cursorY = y * this.metrics.height; + const style = styleOverride ?? this.cursorStyle; this.ctx.fillStyle = this.theme.cursor; - switch (this.cursorStyle) { + switch (style) { case 'block': // Full cell block this.ctx.fillRect(cursorX, cursorY, this.metrics.width, this.metrics.height); diff --git a/package.json b/package.json index 42cbd7c..41ca09c 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,9 @@ "dev": "vite --port 8000", "demo": "node demo/bin/demo.js", "demo:dev": "node demo/bin/demo.js --dev", + "test:render": "bun demo/bin/render-test.ts", + "test:render:update": "bun demo/bin/render-test.ts --update", + "test:render:web": "bunx serve . -p 3000", "prebuild": "bun install", "build": "bun run clean && bun run build:wasm && bun run build:lib && bun run build:wasm-copy", "build:wasm": "./scripts/build-wasm.sh", @@ -72,6 +75,7 @@ "@xterm/xterm": "^5.5.0", "mitata": "^1.0.34", "prettier": "^3.6.2", + "puppeteer": "^24.37.5", "typescript": "^5.9.3", "vite": "^4.5.0", "vite-plugin-dts": "^4.5.4" diff --git a/vite.config.js b/vite.config.js index bdd3c48..43e3f13 100644 --- a/vite.config.js +++ b/vite.config.js @@ -9,7 +9,11 @@ export default defineConfig({ plugins: [ dts({ include: ['lib/**/*.ts'], - exclude: ['lib/**/*.test.ts'], + // Test files are caught by the *.test.ts glob; canvas-recorder.ts + // is a test-only helper that *.test.ts wildcards don't match, so + // exclude it explicitly to keep its internal types out of the + // rolled-up ghostty-web.d.ts shipped to consumers. + exclude: ['lib/**/*.test.ts', 'lib/canvas-recorder.ts'], rollupTypes: true, // Bundle all .d.ts into single file copyDtsFiles: false, // Don't copy individual .d.ts files }),