From f04f3a8ae9b52f5407d07eee3ce1ffa559edeb63 Mon Sep 17 00:00:00 2001 From: Andrew Hamilton Date: Mon, 20 Jul 2026 12:53:51 +1000 Subject: [PATCH] initial commit --- .gitignore | 6 + Cargo.lock | 2207 ++++++++++++++++++++++++++++++++ Cargo.toml | 18 + demo.py | 41 + demo_features.py | 350 +++++ demo_termstr.py | 52 + pyproject.toml | 17 + python/textgrid/__init__.py | 6 + python/textgrid/_pack_style.py | 46 + speed.py | 47 + src/atlas.rs | 221 ++++ src/color.rs | 16 + src/font.rs | 415 ++++++ src/lib.rs | 205 +++ src/renderer.rs | 660 ++++++++++ src/shader.wgsl | 68 + tests/test_color_packing.py | 163 +++ uv.lock | 39 + 18 files changed, 4577 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 demo.py create mode 100644 demo_features.py create mode 100644 demo_termstr.py create mode 100644 pyproject.toml create mode 100644 python/textgrid/__init__.py create mode 100644 python/textgrid/_pack_style.py create mode 100644 speed.py create mode 100644 src/atlas.rs create mode 100644 src/color.rs create mode 100644 src/font.rs create mode 100644 src/lib.rs create mode 100644 src/renderer.rs create mode 100644 src/shader.wgsl create mode 100644 tests/test_color_packing.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26ab164 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +target/ +__pycache__/ +.pytest_cache/ +.eris/ +*.so diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3cbab65 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2207 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glfw" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c38183b2df227d4b59fcabc647ccc65d04ed9a14420ea9c1520e5ed09b6a79" +dependencies = [ + "bitflags 1.3.2", + "glfw-sys", + "image", + "log", + "objc2 0.5.2", + "raw-window-handle", + "winapi", +] + +[[package]] +name = "glfw-sys" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b92fb706b28b5ccb8e93edb0b7fc8a153f626430abe8e354bc5e4ad29860b0" +dependencies = [ + "cmake", + "pkg-config", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.0", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd91265cc2454558f659b3b4b9640f0ddb8cc6521277f166b8a8c181c898079" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash", + "spirv", + "thiserror", + "unicode-ident", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.6.4", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "pyo3" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7778bffd85cf38175ac1f545509665d0b9b92a198ca7941f131f85f7a4f9a872" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f1b4c431c0bb1c8fb0a338709859eed0d030ff6daa34368d3b152a63dfdd8d" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc2201328f63c4710f68abdf653c89d8dbc2858b88c5d88b0ff38a75288a9da" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca6726ad0f3da9c9de093d6f116a93c1a38e417ed73bf138472cf4064f72028" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "swash" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0811b01ca2c4e8718760713911feaf4675c24f94e50530a015ec646cfb622f7c" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textgrid" +version = "0.1.0" +dependencies = [ + "bytemuck", + "glfw", + "png", + "pollster", + "pyo3", + "swash", + "ttf-parser", + "unicode-width", + "wgpu", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "ttf-parser" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb3feacc458f7bee8bc1737149b42b6c731aa461039a4264a67bb6681646b250" +dependencies = [ + "arrayvec", + "bitflags 2.13.0", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02da3ad1b568337f25513b317870960ef87073ea0945502e44b864b67a8c77b7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.0", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash", + "smallvec", + "thiserror", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e51b5447e144b3dbba4feb01f80f4fa21696fa0cd99afb2c3df1affd6fdb28" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3487cd6293a963bc5c0c0396f6a2192043c50003c07f4efdccbad3d90ec9d819" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb01076d0aa08b0ba9bd741e178b5cc440f5abe99d9581323a4c8b5d1a1916" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f8e1a9e7a8512f276f7c62e018c7fa8d60954303fed2e5750114332049193f" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.0", + "block2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "ndk-sys", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation", + "objc2-metal", + "objc2-quartz-core", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "thiserror", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c654c483f058800972c3645e95388a7eca31bf9fe1933bc20e036588a0be02" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9bcc31518a0e9735aefebedb5f7a9ef3ed1c42549c9f4c882fa9060ceaac639" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "js-sys", + "log", + "raw-window-handle", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f7978f8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "textgrid" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.23", features = ["extension-module"] } +wgpu = "29" +glfw = "0.62" +pollster = "0.4" +bytemuck = { version = "1", features = ["derive"] } +swash = "0.2" +unicode-width = "0.2" +ttf-parser = "0.24" +png = "0.18" diff --git a/demo.py b/demo.py new file mode 100644 index 0000000..77c08c8 --- /dev/null +++ b/demo.py @@ -0,0 +1,41 @@ +"""Animated Unicode pattern using the GPU-accelerated text grid extension.""" + +import math +import textgrid +import time + +ROWS = 24 +COLS = 80 +FONT_PATH = None # set to a .ttf path to override DejaVu Sans Mono + +# Unicode gradient blocks from light to dark +BLOCKS = " ░▒▓█" + +def main(): + tg = textgrid.TextGrid(ROWS, COLS, font_path=FONT_PATH) + time.sleep(0.3) + + t0 = time.monotonic() + frame = 0 + duration = 10 + + while time.monotonic() - t0 < duration: + rows = [] + for y in range(ROWS): + row_chars = [] + phase = frame * 0.08 + y * 0.4 + for x in range(COLS): + dx = x - COLS / 2 + dy = y - ROWS / 2 + v = math.sin(math.sqrt(dx * dx + dy * dy) * 0.25 - phase) + v = max(-1, min(1, v)) + idx = int((v + 1) * 0.5 * (len(BLOCKS) - 1)) + row_chars.append(BLOCKS[idx]) + rows.append("".join(row_chars)) + tg.update(rows) + frame += 1 + print(frame) + tg.close() + +if __name__ == "__main__": + main() diff --git a/demo_features.py b/demo_features.py new file mode 100644 index 0000000..b5af4bf --- /dev/null +++ b/demo_features.py @@ -0,0 +1,350 @@ +"""Feature demo: colors, bold/italic, CJK, emoji, background colors — cycles through 5 panels.""" + +import math +import time +import termstr +import textgrid +from cwcwidth import wcswidth + +ROWS = 24 +COLS = 80 +BLOCKS = " ░▒▓█" + + +def center(text): + """Center text in COLS columns.""" + w = wcswidth(text) + if w >= COLS: + return text + left = (COLS - w) // 2 + return " " * left + text + " " * (COLS - w - left) + + +def pad(text): + """Left-align and pad text to display width COLS.""" + w = wcswidth(text) + if w >= COLS: + return text + return text + " " * (COLS - w) + + +def rainbow(hue): + r = int(255 * (0.5 + 0.5 * math.cos(hue * math.tau))) + g = int(255 * (0.5 + 0.5 * math.cos(hue * math.tau - 2.094))) + b = int(255 * (0.5 + 0.5 * math.cos(hue * math.tau - 4.188))) + return r, g, b + + +# --------------------------------------------------------------------------- +# Panel 1 — Colors +# --------------------------------------------------------------------------- +def panel_colors(phase): + rows = [] + + # header + rows.append(termstr.TermStr( + center("Colors: RGB and xterm 256"), + termstr.CharStyle(fg_color=(120, 120, 120)), + )) + + rows.append(" " * COLS) + + # 2 rows of RGB rainbow + for _ in range(2): + s = termstr.CharStyle(fg_color=(0, 0, 0)) + ts = termstr.TermStr( + "".join(BLOCKS[int((math.sin(x / COLS * math.pi) + 1) * 2)] for x in range(COLS)), + s, + ) + rows.append(ts) + + # 2 rows of xterm 256 gradient + for offset in (16, 80): + text = "".join(BLOCKS[(offset + x) % 5] for x in range(COLS)) + styles = tuple( + termstr.CharStyle(fg_color=(offset + x) % 256) for x in range(COLS) + ) + rows.append(termstr.TermStr(text, styles)) + + rows.append(" " * COLS) + + # ripple (plain str to show default rendering still works) + for y in range(ROWS - len(rows)): + line = [] + for x in range(COLS): + dx = x - COLS / 2 + dy = (y - 2) - ROWS / 2 + 6 + v = math.sin(math.sqrt(dx * dx + dy * dy) * 0.3 - phase) + v = max(-1, min(1, v)) + idx = int((v + 1) * 0.5 * (len(BLOCKS) - 1)) + line.append(BLOCKS[idx]) + rows.append("".join(line)) + + return rows + + +# --------------------------------------------------------------------------- +# Panel 2 — Font Styles +# --------------------------------------------------------------------------- +def panel_font_styles(): + rows = [] + + rows.append(termstr.TermStr( + center("Font Styles: Bold · Italic · Bold+Italic"), + termstr.CharStyle(fg_color=(120, 120, 120)), + )) + + rows.append(" " * COLS) + + demos = [ + ("Regular", lambda s: s), + ("Bold", lambda s: s.bold()), + ("Italic", lambda s: s.italic()), + ("Underline", lambda s: s.underline()), + ("Bold+Italic", lambda s: s.bold().italic()), + ("Bold+Underline", lambda s: s.bold().underline()), + ("Italic+Underline", lambda s: s.italic().underline()), + ("Bold+Ita+Under", lambda s: s.bold().italic().underline()), + ] + for label, mod in demos: + text = f" {label:14s} The quick brown fox jumps over the lazy dog" + text = pad(text) + styles = tuple( + mod(termstr.CharStyle(fg_color=(200, 200, 200))) + for _ in range(COLS) + ) + rows.append(termstr.TermStr(text, styles)) + + rows.append(" " * COLS) + + # per-character alternation (no wide chars) + text = " MIXED: Bold | Italic | Regular | Bold+Italic | Repeat..." + text = pad(text) + styles = [] + for i in range(COLS): + s = termstr.CharStyle(fg_color=rainbow(i / COLS)) + if i % 4 == 0: + s = s.bold() + elif i % 4 == 1: + s = s.italic() + elif i % 4 == 3: + s = s.bold().italic() + styles.append(s) + rows.append(termstr.TermStr(text, tuple(styles))) + + while len(rows) < ROWS: + rows.append(" " * COLS) + return rows + + +# --------------------------------------------------------------------------- +# Panel 3 — Wide Characters +# --------------------------------------------------------------------------- +def panel_wide_chars(): + rows = [] + + rows.append(termstr.TermStr( + center("Wide Characters: CJK & Emoji (2 columns each)"), + termstr.CharStyle(fg_color=(120, 120, 120)), + )) + + rows.append(" " * COLS) + + # CJK rows — uniform colors, single CharStyle (broadcast) + for text, fg in [ + (" CJK: 你好世界 你好世界 你好世界 你好世界", (0, 200, 255)), + (" 你好世界 你好世界 你好世界 你好世界", (100, 220, 255)), + (" 你好世界 你好世界 你好世界 你好世界", (180, 240, 255)), + ]: + text = pad(text) + rows.append(termstr.TermStr(text, termstr.CharStyle(fg_color=fg))) + + rows.append(" " * COLS) + + # Emoji rows + for text, fg in [ + (" Emoji: 🍕🍔🌭🎉🔥⭐💎🚀❤️🌟🎵💡⚡❤️💎", (255, 200, 0)), + (" 🍕🍔🌭🎉🔥⭐💎🚀❤️🌟🎵💡⚡❤️💎", (255, 220, 80)), + (" 🍕🍔🌭🎉🔥⭐💎🚀❤️🌟🎵💡⚡❤️💎", (255, 240, 120)), + ]: + text = pad(text) + rows.append(termstr.TermStr(text, termstr.CharStyle(fg_color=fg))) + + rows.append(" " * COLS) + + # Mixed row — use plain str (default colors) + rows.append(pad("hello你好🍕world你好🍔!!!")) + + rows.append(" " * COLS) + rows.append(termstr.TermStr( + center("(Zero-width padding chars are invisible; wide chars occupy 2 cells)"), + termstr.CharStyle(fg_color=(100, 100, 100)), + )) + + while len(rows) < ROWS: + rows.append(" " * COLS) + return rows + + +# --------------------------------------------------------------------------- +# Panel 4 — Background Colors +# --------------------------------------------------------------------------- +def panel_bg_colors(): + rows = [] + + rows.append(termstr.TermStr( + center("Background Colors: text & emoji on colored backgrounds"), + termstr.CharStyle(fg_color=(120, 120, 120)), + )) + + rows.append(" " * COLS) + + # Highlighted text rows + labels = [" Highlight", " Selection", " Warning ", " Success "] + bgs = [(60, 60, 100), (50, 50, 150), (100, 30, 30), (30, 80, 30)] + for label, bg in zip(labels, bgs): + text = f"{label}: Lorem ipsum dolor sit amet, consectetur adipiscing elit " + text = pad(text) + rows.append(termstr.TermStr(text, termstr.CharStyle(bg_color=bg))) + + rows.append(" " * COLS) + + # Underline with background (full row) + rows.append(termstr.TermStr( + pad(" Underlined text on colored background — highlight + underline "), + termstr.CharStyle(bg_color=(50, 30, 60)).underline(), + )) + + rows.append(" " * COLS) + + # Emoji with background — tests bg + color emoji blending + rows.append(termstr.TermStr( + pad(" Emoji on colored backgrounds — 🍕🍔🌭🎉🔥⭐💎🚀❤️🌟🎵💡⚡ "), + termstr.CharStyle(bg_color=(40, 40, 80)), + )) + rows.append(termstr.TermStr( + pad(" Brighter bg — 🍕🍔🌭🎉🔥⭐💎🚀❤️🌟🎵💡⚡ "), + termstr.CharStyle(bg_color=(80, 60, 40)), + )) + rows.append(termstr.TermStr( + pad(" Green bg — 🍕🍔🌭🎉🔥⭐💎🚀❤️🌟🎵💡⚡ "), + termstr.CharStyle(bg_color=(20, 80, 40)), + )) + + rows.append(" " * COLS) + + # Alternating row highlights (like terminal selection) + rows.append(termstr.TermStr( + center("Alternating row backgrounds"), + termstr.CharStyle(fg_color=(100, 100, 100)), + )) + for i in range(6): + bg = (30, 35 + i * 8, 50 + i * 12) if i % 2 == 0 else (0, 0, 0) + icon = "🍕" if i % 2 == 0 else " " + text = f" Row {i+1}: {icon} Alternating background colors in a list view " + text = pad(text) + rows.append(termstr.TermStr(text, termstr.CharStyle(bg_color=bg))) + + while len(rows) < ROWS: + rows.append(" " * COLS) + return rows + + +# --------------------------------------------------------------------------- +# Panel 5 — Everything Together +# --------------------------------------------------------------------------- +def panel_mixed(phase): + rows = [] + + rows.append(termstr.TermStr( + center("All Features Combined"), + termstr.CharStyle(fg_color=(120, 120, 120)), + )) + + for y in range(ROWS - 1): + # Top half: per-char styles with ASCII chars only + if y < (ROWS - 1) // 2: + row_chars = [] + row_styles = [] + for x in range(COLS): + p = math.sin(x * 0.2 + y * 0.3 - phase) + ch = BLOCKS[int((p + 1) * 2)] + s = termstr.CharStyle(fg_color=rainbow(x / COLS + y / ROWS)) + if y % 2 == 0: + s = s.bold() + elif y % 3 == 0: + s = s.italic() + row_chars.append(ch) + row_styles.append(s) + rows.append(termstr.TermStr("".join(row_chars), tuple(row_styles))) + # Bottom half: three bands with mixed features + else: + band = y - (ROWS - 1) // 2 + if band % 3 == 0: + # xterm gradient + styles = tuple( + termstr.CharStyle(fg_color=(x + band * 10) % 256) + for x in range(COLS) + ) + text = "".join(BLOCKS[(x + band) % 5] for x in range(COLS)) + rows.append(termstr.TermStr(text, styles)) + elif band % 3 == 1: + text = "CJK: 你好世界你好世界你好世界你好世界" + text = pad(text) + rows.append(termstr.TermStr(text, termstr.CharStyle(fg_color=(0, 200, 255)))) + else: + text = "Emoji 🍕🍔🌭🎉🔥⭐💎🚀❤️🌟🎵💡⚡" + text = pad(text) + rows.append(termstr.TermStr(text, termstr.CharStyle(fg_color=(255, 200, 0)))) + + return rows + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +_closed = False + +def _on_close(): + global _closed + _closed = True + +def main(): + tg = textgrid.TextGrid( + ROWS, COLS, + "TextGrid Feature Demo", + font_path="/usr/local/share/fonts/truetype/sarasa/SarasaTermSC-Regular.ttf", + color_font_path="/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf", + on_close=_on_close, + ) + phase = 0.0 + try: + while not _closed: + tg.update(panel_colors(phase)) + if _closed: break + time.sleep(4) + if _closed: break + tg.update(panel_font_styles()) + if _closed: break + time.sleep(4) + if _closed: break + tg.update(panel_wide_chars()) + if _closed: break + time.sleep(4) + if _closed: break + tg.update(panel_bg_colors()) + if _closed: break + time.sleep(4) + if _closed: break + tg.update(panel_mixed(phase)) + if _closed: break + time.sleep(4) + phase += 0.25 + except KeyboardInterrupt: + pass + finally: + tg.close() + + +if __name__ == "__main__": + main() diff --git a/demo_termstr.py b/demo_termstr.py new file mode 100644 index 0000000..be36d48 --- /dev/null +++ b/demo_termstr.py @@ -0,0 +1,52 @@ +import math +import time + +import textgrid +import termstr + + +ROWS = 24 +COLS = 80 +BLOCKS = " ░▒▓█" + + +def rainbow(hue): + r = int(255 * (0.5 + 0.5 * math.cos(hue * math.tau))) + g = int(255 * (0.5 + 0.5 * math.cos(hue * math.tau - 2.094))) + b = int(255 * (0.5 + 0.5 * math.cos(hue * math.tau - 4.188))) + return r, g, b + + +def main(): + tg = textgrid.TextGrid(ROWS, COLS, "TermStr Demo") + phase = 0.0 + try: + while True: + rows = [] + for y in range(ROWS): + text_chars = [] + styles = [] + for x in range(COLS): + dx = x - COLS / 2 + dy = y - ROWS / 2 + v = math.sin( + math.sqrt(dx * dx + dy * dy) * 0.25 - phase + ) + v = max(-1, min(1, v)) + idx = int((v + 1) * 0.5 * (len(BLOCKS) - 1)) + text_chars.append(BLOCKS[idx]) + hue = (x / COLS + y / ROWS + phase * 0.25) % 1.0 + styles.append(termstr.CharStyle(fg_color=rainbow(hue))) + text = "".join(text_chars) + rows.append(termstr.TermStr(text, tuple(styles))) + tg.update(rows) + phase += 0.05 + time.sleep(0.03) + except KeyboardInterrupt: + pass + finally: + tg.close() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a763d9f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["maturin>=1.0,<2"] +build-backend = "maturin" + +[project] +name = "textgrid" +version = "0.1.0" +requires-python = ">=3.11" + +[tool.maturin] +python-source = "python" + +[dependency-groups] +dev = [ + "cwcwidth==0.1.5", + "termstr @ file:///home/ahamilton/code/terminal/termstr", +] diff --git a/python/textgrid/__init__.py b/python/textgrid/__init__.py new file mode 100644 index 0000000..2cdb77d --- /dev/null +++ b/python/textgrid/__init__.py @@ -0,0 +1,6 @@ +from .textgrid import * +from ._pack_style import pack_style_colors + +__doc__ = textgrid.__doc__ +if hasattr(textgrid, "__all__"): + __all__ = textgrid.__all__ diff --git a/python/textgrid/_pack_style.py b/python/textgrid/_pack_style.py new file mode 100644 index 0000000..b2d5f18 --- /dev/null +++ b/python/textgrid/_pack_style.py @@ -0,0 +1,46 @@ +from array import array + +_xterm = [ + (0, 0, 0), (205, 0, 0), (0, 205, 0), (205, 205, 0), + (0, 0, 238), (205, 0, 205), (0, 205, 205), (229, 229, 229), + (127, 127, 127), (255, 0, 0), (0, 255, 0), (255, 255, 0), + (92, 92, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255), +] +_grad = [0, 95, 135, 175, 215, 255] +for _i in range(216): + _xterm.append((_grad[_i // 36 % 6], _grad[_i // 6 % 6], _grad[_i % 6])) +for _i in range(24): + _v = 8 + _i * 10 + _xterm.append((_v, _v, _v)) + + +def _pack_one(s): + fg = s.fg_color + if isinstance(fg, tuple): + fv = (fg[0] << 24) | (fg[1] << 16) | (fg[2] << 8) + else: + r, g, b = _xterm[fg] + fv = (r << 24) | (g << 16) | (b << 8) + bg = s.bg_color + if isinstance(bg, tuple): + bv = (bg[0] << 24) | (bg[1] << 16) | (bg[2] << 8) + else: + r, g, b = _xterm[bg] + bv = (r << 24) | (g << 16) | (b << 8) + attr = (s.is_bold << 0) | (s.is_italic << 1) | (s.is_underlined << 2) + return fv, bv, attr + + +def pack_style_colors(style): + n = len(style) + first = style[0] + if n > 1 and all(s is first for s in style): + fv, bv, attr = _pack_one(first) + return array('I', [fv, bv, attr] * n) + out = array('I') + for s in style: + fv, bv, attr = _pack_one(s) + out.append(fv) + out.append(bv) + out.append(attr) + return out diff --git a/speed.py b/speed.py new file mode 100644 index 0000000..24ec4b1 --- /dev/null +++ b/speed.py @@ -0,0 +1,47 @@ +"""Benchmark update throughput: plain str vs TermStr (RGB & xterm).""" + +import textgrid +import termstr +import time + +ROWS = 40 +COLS = 100 +SECONDS = 5 + + +def main(): + tg = textgrid.TextGrid(ROWS, COLS) + time.sleep(0.3) + + plain = ["A" * COLS] * ROWS + + style_rgb = termstr.CharStyle(fg_color=(255, 80, 80)) + term_rgb = [termstr.TermStr("A" * COLS, style_rgb)] * ROWS + + style_xterm = termstr.CharStyle(fg_color=9) + term_xterm = [termstr.TermStr("A" * COLS, style_xterm)] * ROWS + + for label, data in [ + ("plain str ", plain), + ("TermStr RGB ", term_rgb), + ("TermStr xterm ", term_xterm), + ]: + for _ in range(10): + tg.update(data) + + n = 0 + t0 = time.monotonic() + while True: + tg.update(data) + n += 1 + elapsed = time.monotonic() - t0 + if elapsed >= SECONDS: + break + + print(f" {label} {n / elapsed:>8.0f} updates/s ({n} in {elapsed:.1f}s)") + + tg.close() + + +if __name__ == "__main__": + main() diff --git a/src/atlas.rs b/src/atlas.rs new file mode 100644 index 0000000..f427e64 --- /dev/null +++ b/src/atlas.rs @@ -0,0 +1,221 @@ +use std::collections::HashMap; + +use unicode_width::UnicodeWidthChar; + +use crate::font::{self, FontSet, ColorFont, GlyphRasterizer}; + +pub(crate) const ATLAS_SIZE: u32 = 2560; + +pub(crate) struct GlyphAtlas { + texture: wgpu::Texture, + texture_view: wgpu::TextureView, + font_set: FontSet, + color_font: Option, + scale: f32, + cell_width: u32, + cell_height: u32, + rasterizer: GlyphRasterizer, + next_slot: u32, + glyph_map: HashMap<(char, u8), u32>, +} + +impl GlyphAtlas { + pub fn new( + device: &wgpu::Device, + queue: &wgpu::Queue, + font_set: FontSet, + scale: f32, + cell_width: u32, + cell_height: u32, + color_font_path: Option<&str>, + ) -> Self { + let color_font = color_font_path.map(font::load_color_font); + + let size = wgpu::Extent3d { + width: ATLAS_SIZE, + height: ATLAS_SIZE, + depth_or_array_layers: 1, + }; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("glyph_atlas"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let zero_pixels = vec![0u8; (cell_width * cell_height * 4) as usize]; + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d { x: 0, y: 0, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + &zero_pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(cell_width * 4), + rows_per_image: Some(cell_height), + }, + wgpu::Extent3d { + width: cell_width, + height: cell_height, + depth_or_array_layers: 1, + }, + ); + + GlyphAtlas { + texture, + texture_view, + font_set, + color_font, + scale, + cell_width, + cell_height, + rasterizer: GlyphRasterizer::new(), + next_slot: 1, + glyph_map: HashMap::new(), + } + } + + pub fn view(&self) -> &wgpu::TextureView { + &self.texture_view + } + + pub fn cell_width(&self) -> u32 { + self.cell_width + } + + pub fn cell_height(&self) -> u32 { + self.cell_height + } + + pub fn scale(&self) -> f32 { + self.scale + } + + pub fn atlas_cols(&self) -> u32 { + ATLAS_SIZE / self.cell_width + } + + pub fn atlas_rows(&self) -> u32 { + ATLAS_SIZE / self.cell_height + } + + pub fn max_glyphs(&self) -> u32 { + self.atlas_cols() * self.atlas_rows() + } + + pub fn set_scale(&mut self, new_scale: f32, device: &wgpu::Device, queue: &wgpu::Queue) { + self.scale = new_scale; + let font_data = self.font_set.face_data(0); + let (w, h) = font::compute_cell_metrics(font_data, new_scale); + self.cell_width = w; + self.cell_height = h; + + let size = wgpu::Extent3d { + width: ATLAS_SIZE, + height: ATLAS_SIZE, + depth_or_array_layers: 1, + }; + self.texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("glyph_atlas"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.texture_view = self.texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let zero_pixels = vec![0u8; (self.cell_width * self.cell_height * 4) as usize]; + self.write_slot(0, &zero_pixels, queue); + + self.next_slot = 1; + self.glyph_map.clear(); + } + + fn write_slot(&self, slot: u32, pixels: &[u8], queue: &wgpu::Queue) { + let aw = self.atlas_cols(); + let col = slot % aw; + let row = slot / aw; + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &self.texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: col * self.cell_width, + y: row * self.cell_height, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(self.cell_width * 4), + rows_per_image: Some(self.cell_height), + }, + wgpu::Extent3d { + width: self.cell_width, + height: self.cell_height, + depth_or_array_layers: 1, + }, + ); + } + + pub fn ensure(&mut self, ch: char, style: u8, queue: &wgpu::Queue) -> u32 { + if let Some(&slot) = self.glyph_map.get(&(ch, style)) { + return slot; + } + + let is_wide = ch.width().unwrap_or(1) > 1; + let need_slots = if is_wide { 2 } else { 1 }; + + if self.next_slot + need_slots - 1 >= self.max_glyphs() { + return 0; + } + let slot = self.next_slot; + self.next_slot += need_slots; + + let (render_w, render_h) = if is_wide { + (self.cell_width * 2, self.cell_height) + } else { + (self.cell_width, self.cell_height) + }; + + let mut pixels = None; + if let Some(color_font) = &self.color_font { + pixels = font::render_color_glyph(ch, color_font, render_w, render_h); + } + let pixels = pixels.unwrap_or_else(|| { + let font_data = self.font_set.face_data(style); + self.rasterizer.render(font_data, ch, self.scale, render_w, render_h) + }); + + if is_wide { + let half_bytes = (self.cell_width * 4) as usize; + let row_bytes = (render_w * 4) as usize; + for half in 0..2 { + let mut half_pixels = Vec::with_capacity((self.cell_width * self.cell_height * 4) as usize); + for row in 0..self.cell_height { + let start = (row as usize * row_bytes) + (half as usize * half_bytes); + half_pixels.extend_from_slice(&pixels[start..start + half_bytes]); + } + self.write_slot(slot + half, &half_pixels, queue); + } + } else { + self.write_slot(slot, &pixels, queue); + } + + self.glyph_map.insert((ch, style), slot); + slot + } +} diff --git a/src/color.rs b/src/color.rs new file mode 100644 index 0000000..3bd845e --- /dev/null +++ b/src/color.rs @@ -0,0 +1,16 @@ +pub fn pack_rgb(r: u8, g: u8, b: u8) -> u32 { + (r as u32) << 24 | (g as u32) << 16 | (b as u32) << 8 +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_pack_rgb() { + assert_eq!(pack_rgb(255, 0, 0), 0xFF000000); + assert_eq!(pack_rgb(0, 255, 0), 0x00FF0000); + assert_eq!(pack_rgb(0, 0, 255), 0x0000FF00); + assert_eq!(pack_rgb(0, 0, 0), 0x00000000); + assert_eq!(pack_rgb(255, 255, 255), 0xFFFFFF00); + } +} diff --git a/src/font.rs b/src/font.rs new file mode 100644 index 0000000..7883ac4 --- /dev/null +++ b/src/font.rs @@ -0,0 +1,415 @@ +use std::io::Cursor; +use swash::scale::{ScaleContext, outline::Outline}; +use swash::{FontRef, TableProvider, tag_from_bytes}; +use ttf_parser::RasterImageFormat; + +pub struct FontSet { + pub regular: Vec, + pub bold: Vec, + pub italic: Vec, + pub bold_italic: Vec, +} + +impl FontSet { + pub fn face_data(&self, style: u8) -> &[u8] { + match style & 3 { + 0 => &self.regular, + 1 => &self.bold, + 2 => &self.italic, + 3 => &self.bold_italic, + _ => unreachable!(), + } + } +} + +pub struct ColorFont { + pub face: ttf_parser::Face<'static>, +} + +unsafe impl Send for ColorFont {} +unsafe impl Sync for ColorFont {} + +fn font_upem(font: &FontRef) -> f32 { + font.table_by_tag(tag_from_bytes(b"head")) + .and_then(|head| { + if head.len() >= 20 { + Some(u16::from_be_bytes([head[18], head[19]]) as f32) + } else { + None + } + }) + .unwrap_or(1000.0) +} + +fn glyph_advance(font: &FontRef, glyph_id: u16, upem: f32, scale: f32) -> f32 { + let num_hmetrics = font + .table_by_tag(tag_from_bytes(b"hhea")) + .and_then(|hhea| { + if hhea.len() >= 36 { + Some(u16::from_be_bytes([hhea[34], hhea[35]]) as usize) + } else { + None + } + }) + .unwrap_or(1); + let adv_units = font + .table_by_tag(tag_from_bytes(b"hmtx")) + .and_then(|hmtx| { + let idx = (glyph_id as usize).min(num_hmetrics.saturating_sub(1)); + let off = idx * 4; + if off + 2 <= hmtx.len() { + Some(u16::from_be_bytes([hmtx[off], hmtx[off + 1]]) as f32) + } else { + None + } + }) + .unwrap_or(500.0); + adv_units * scale / upem +} + +pub struct GlyphRasterizer { + ctx: ScaleContext, + scratch: swash::zeno::Scratch, +} + +impl GlyphRasterizer { + pub fn new() -> Self { + Self { + ctx: ScaleContext::new(), + scratch: swash::zeno::Scratch::new(), + } + } + + pub fn render( + &mut self, + font_data: &[u8], + ch: char, + scale: f32, + slot_w: u32, + slot_h: u32, + ) -> Vec { + let mut img = vec![0u8; (slot_w * slot_h * 4) as usize]; + + let font = match FontRef::from_index(font_data, 0) { + Some(f) => f, + None => return img, + }; + let glyph_id = font.charmap().map(ch); + + let upem = font_upem(&font); + let metrics = font.metrics(&[]); + let descent_px = metrics.descent * scale / upem; + + let advance = glyph_advance(&font, glyph_id, upem, scale); + let x_off = (slot_w as f32 - advance) / 2.0; + + let mut scaler = self.ctx.builder(font) + .hint(false) + .size(scale) + .build(); + + let mut outline = Outline::new(); + if scaler.scale_outline_into(glyph_id, &mut outline) { + let mut alpha = vec![0u8; (slot_w * slot_h) as usize]; + let _placement = swash::zeno::Mask::with_scratch(outline.path(), &mut self.scratch) + .format(swash::zeno::Format::Alpha) + .origin(swash::zeno::Origin::BottomLeft) + .size(slot_w, slot_h) + .offset(swash::zeno::Vector::ZERO) + .render_offset(swash::zeno::Vector { x: x_off, y: descent_px }) + .render_into(&mut alpha, None); + + for i in 0..(slot_w * slot_h) as usize { + let a = alpha[i]; + if a > 0 { + img[i * 4] = 255; + img[i * 4 + 1] = 255; + img[i * 4 + 2] = 255; + img[i * 4 + 3] = a; + } + } + } + + // Clamp top/bottom edge rows — eliminates 1-sub-pixel fringe at + // cell boundaries when the path exactly fills the fixed-size mask. + if slot_h > 1 { + for x in 0..slot_w as usize { + let a_top = x * 4 + 3; + if img[a_top] > 0 && img[slot_w as usize * 4 + 3 + x * 4] > 0 { + img[a_top] = 255; + } + let bot_row = (slot_h - 1) as usize; + let a_bot = (bot_row * slot_w as usize + x) * 4 + 3; + let a_bot_up = ((bot_row - 1) * slot_w as usize + x) * 4 + 3; + if img[a_bot] > 0 && img[a_bot_up] > 0 { + img[a_bot] = 255; + } + } + } + + img + } +} + +fn read_font_data(path: &str) -> Vec { + std::fs::read(path).expect("failed to read font file") +} + +fn variant_path(base: &str, suffix: &str) -> String { + let p = std::path::Path::new(base); + let dir = p.parent().unwrap_or(std::path::Path::new("")); + let stem = p.file_stem().unwrap().to_str().unwrap(); + let ext = p.extension().unwrap().to_str().unwrap(); + let stem = stem.strip_suffix("-Regular").unwrap_or(stem); + let dir_str = dir.to_str().unwrap_or(""); + if dir_str.is_empty() { + format!("{}-{}.{}", stem, suffix, ext) + } else { + format!("{}/{}-{}.{}", dir_str, stem, suffix, ext) + } +} + +pub fn compute_cell_metrics(font_data: &[u8], scale: f32) -> (u32, u32) { + let font = FontRef::from_index(font_data, 0).unwrap_or_else(|| panic!("failed to parse font")); + let upem = font_upem(&font); + let glyph_id = font.charmap().map('M'); + let advance = glyph_advance(&font, glyph_id, upem, scale); + let metrics = font.metrics(&[]); + let cell_w = (advance.ceil() as u32).max(1); + let cell_h = (((metrics.ascent + metrics.descent) * scale / upem).ceil() as u32).max(1); + (cell_w, cell_h) +} + +pub fn load_font(font_path: Option<&str>) -> (FontSet, f32) { + let scale = 32.0; + match font_path { + Some(path) => { + let regular = read_font_data(path); + let bold = read_font_data(&variant_path(path, "Bold")); + let italic = read_font_data(&variant_path(path, "Italic")); + let bold_italic = read_font_data(&variant_path(path, "BoldItalic")); + (FontSet { regular, bold, italic, bold_italic }, scale) + } + None => { + let regular = read_font_data("/usr/local/share/fonts/truetype/sarasa-mono/SarasaMonoSC-Regular.ttf"); + let bold = read_font_data("/usr/local/share/fonts/truetype/sarasa-mono/SarasaMonoSC-Bold.ttf"); + let italic = read_font_data("/usr/local/share/fonts/truetype/sarasa-mono/SarasaMonoSC-Italic.ttf"); + let bold_italic = read_font_data("/usr/local/share/fonts/truetype/sarasa-mono/SarasaMonoSC-BoldItalic.ttf"); + (FontSet { regular, bold, italic, bold_italic }, scale) + } + } +} + +pub fn load_color_font(path: &str) -> ColorFont { + let data = std::fs::read(path).expect("failed to read color font file"); + let leaked: &'static [u8] = Box::leak(data.into_boxed_slice()); + let face = ttf_parser::Face::parse(leaked, 0).expect("failed to parse color font"); + ColorFont { face } +} + +fn decode_png(data: &[u8]) -> Option<(u32, u32, Vec)> { + let mut decoder = png::Decoder::new(Cursor::new(data)); + decoder.set_transformations(png::Transformations::normalize_to_color8()); + let mut reader = decoder.read_info().ok()?; + let w = reader.info().width; + let h = reader.info().height; + let buf_size = reader.output_buffer_size()?; + let mut buf = vec![0u8; buf_size]; + reader.next_frame(&mut buf).ok()?; + + let (color_type, bit_depth) = reader.output_color_type(); + match (color_type, bit_depth) { + (png::ColorType::Rgba, png::BitDepth::Eight) => Some((w, h, buf)), + (png::ColorType::Rgb, png::BitDepth::Eight) => { + let mut rgba = vec![0u8; (w * h * 4) as usize]; + for y in 0..h { + for x in 0..w { + let si = (y * w + x) as usize * 3; + let di = (y * w + x) as usize * 4; + rgba[di] = buf[si]; + rgba[di + 1] = buf[si + 1]; + rgba[di + 2] = buf[si + 2]; + rgba[di + 3] = 255; + } + } + Some((w, h, rgba)) + } + (png::ColorType::GrayscaleAlpha, png::BitDepth::Eight) => { + let mut rgba = vec![0u8; (w * h * 4) as usize]; + for y in 0..h { + for x in 0..w { + let si = (y * w + x) as usize * 2; + let di = (y * w + x) as usize * 4; + let gray = buf[si]; + rgba[di] = gray; + rgba[di + 1] = gray; + rgba[di + 2] = gray; + rgba[di + 3] = buf[si + 1]; + } + } + Some((w, h, rgba)) + } + (png::ColorType::Grayscale, png::BitDepth::Eight) => { + let mut rgba = vec![0u8; (w * h * 4) as usize]; + for y in 0..h { + for x in 0..w { + let si = (y * w + x) as usize; + let di = (y * w + x) as usize * 4; + let gray = buf[si]; + rgba[di] = gray; + rgba[di + 1] = gray; + rgba[di + 2] = gray; + rgba[di + 3] = 255; + } + } + Some((w, h, rgba)) + } + _ => None, + } +} + +fn un_premultiply_bgra(data: &[u8], w: u32, h: u32) -> Vec { + let mut rgba = vec![0u8; (w * h * 4) as usize]; + for i in 0..(w * h) as usize { + let si = i * 4; + let di = i * 4; + let b = data[si]; + let g = data[si + 1]; + let r = data[si + 2]; + let a = data[si + 3]; + if a > 0 { + rgba[di] = ((r as u16 * 255) / a as u16).min(255) as u8; + rgba[di + 1] = ((g as u16 * 255) / a as u16).min(255) as u8; + rgba[di + 2] = ((b as u16 * 255) / a as u16).min(255) as u8; + } else { + rgba[di] = r; + rgba[di + 1] = g; + rgba[di + 2] = b; + } + rgba[di + 3] = a; + } + rgba +} + +fn resize_rgba(src: &[u8], sw: u32, sh: u32, slot_w: u32, slot_h: u32) -> Vec { + let mut img = vec![0u8; (slot_w * slot_h * 4) as usize]; + for dy in 0..slot_h { + for dx in 0..slot_w { + let sx = (dx * sw) / slot_w; + let sy = (dy * sh) / slot_h; + let si = (sy * sw + sx) as usize * 4; + let di = (dy * slot_w + dx) as usize * 4; + img[di] = src[si]; + img[di + 1] = src[si + 1]; + img[di + 2] = src[si + 2]; + img[di + 3] = src[si + 3]; + } + } + img +} + +pub fn render_color_glyph( + ch: char, + color_font: &ColorFont, + slot_w: u32, + slot_h: u32, +) -> Option> { + let glyph_id = color_font.face.glyph_index(ch)?; + let image = color_font.face.glyph_raster_image(glyph_id, slot_h as u16) + .or_else(|| color_font.face.glyph_raster_image(glyph_id, 128)) + .or_else(|| color_font.face.glyph_raster_image(glyph_id, 64))?; + if image.width == 0 || image.height == 0 { + return None; + } + let sw = image.width as u32; + let sh = image.height as u32; + let (img_w, img_h, rgba) = match image.format { + RasterImageFormat::PNG => decode_png(image.data)?, + RasterImageFormat::BitmapPremulBgra32 => { + let rgba = un_premultiply_bgra(image.data, sw, sh); + (sw, sh, rgba) + } + _ => return None, + }; + Some(resize_rgba(&rgba, img_w, img_h, slot_w, slot_h)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_color_glyph_decode() { + let cf = load_color_font("/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf"); + let pixels = render_color_glyph('🍕', &cf, 16, 40).expect("emoji should render"); + assert_eq!(pixels.len(), (16 * 40 * 4) as usize, "output should be 16x40 RGBA"); + let has_color = pixels.chunks_exact(4).any(|p| p[0] != 255 || p[1] != 255 || p[2] != 255); + assert!(has_color, "emoji should contain non-white color pixels"); + let has_alpha = pixels.chunks_exact(4).any(|p| p[3] != 0); + assert!(has_alpha, "emoji should have non-zero alpha pixels"); + } + + #[test] + fn test_ascii_falls_back() { + let cf = load_color_font("/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf"); + assert!(render_color_glyph('A', &cf, 16, 40).is_none(), "ASCII should return None from color font"); + } + + #[test] + fn test_box_draw_vertical_coverage() { + let paths = [ + ("SarasaTermSC", "/usr/local/share/fonts/truetype/sarasa/SarasaTermSC-Regular.ttf"), + ("SarasaMonoSC", "/usr/local/share/fonts/truetype/sarasa-mono/SarasaMonoSC-Regular.ttf"), + ("IosevkaTerm", "/usr/local/share/fonts/truetype/iosevka/IosevkaTerm-Regular.ttf"), + ("DejaVuSansMono", "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"), + ]; + let scale = 32.0; + let slot_w = 16u32; + let slot_h = 40u32; + + let mut rasterizer = GlyphRasterizer::new(); + for (name, path) in &paths { + println!("\n=== {} ===", name); + let font_data = std::fs::read(path).expect("failed to read font file"); + let chs = ['│', '┌', '┐', '└', '┘', '─']; + for &ch in &chs { + let pixels = rasterizer.render(&font_data, ch, scale, slot_w, slot_h); + let bar_x = (0..slot_w).find(|&x| { + (slot_h/4..3*slot_h/4).any(|y| { + let idx = ((y * slot_w + x) * 4 + 3) as usize; + pixels[idx] > 128 + }) + }); + let bar_x = match bar_x { + Some(x) => x, + None => { + println!(" {}: no vertical bar found", ch); + continue; + } + }; + let rows: Vec<(u32, u8)> = (0..slot_h) + .map(|y| { + let idx = ((y * slot_w + bar_x) * 4 + 3) as usize; + (y, pixels[idx]) + }) + .collect(); + let covered: Vec<(u32, u8)> = rows.iter().filter(|(_, a)| *a == 255).copied().collect(); + println!(" {} bar_x={} covered={}/{} rows first={:?} last={:?}", + ch, bar_x, covered.len(), slot_h, + covered.first().map(|(r, a)| (r, a)), + covered.last().map(|(r, a)| (r, a))); + println!(" coverage (top 5): {:?}", + &rows.iter().take(5).map(|(r, a)| (r, a)).collect::>()); + println!(" coverage (bottom 5): {:?}", + &rows.iter().rev().take(5).map(|(r, a)| (r, a)).collect::>()); + if ch == '│' && *name != "DejaVuSansMono" { + assert_eq!( + covered.len(), slot_h as usize, + "{} {}: expected all {} rows at alpha=255, got {}", + name, ch, slot_h, covered.len() + ); + } + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9d0812f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,205 @@ +mod atlas; +mod color; +mod font; +mod renderer; + +use pyo3::buffer::PyBuffer; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyString}; +use std::ffi::CString; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::OnceLock; +use std::sync::{Arc, Condvar, Mutex}; +use std::thread; +use unicode_width::UnicodeWidthChar; + +static PACK_HELPER: OnceLock> = OnceLock::new(); + +fn get_pack_helper(py: Python<'_>) -> PyResult<&Py> { + if let Some(f) = PACK_HELPER.get() { + return Ok(f); + } + let code = include_str!("../python/textgrid/_pack_style.py"); + let code_c = CString::new(code).expect("pack_style.py contains null byte"); + let file_c = CString::new("_pack_style.py").unwrap(); + let module_c = CString::new("textgrid._pack_style").unwrap(); + let module = PyModule::from_code(py, &code_c, &file_c, &module_c)?; + let func: Py = module.getattr("pack_style_colors")?.into(); + let _ = PACK_HELPER.set(func); + Ok(PACK_HELPER.get().unwrap()) +} + +#[derive(Clone)] +pub(crate) struct UpdateBatch { + pub(crate) cols: u32, + pub(crate) rows: Vec<(String, Vec<[u32; 3]>)>, +} + +#[pyclass] +pub struct TextGrid { + cols: Arc, + rows: Arc, + needs_render: Arc, + notify_pair: Arc<(Mutex, Condvar)>, + close_requested: Arc, + update_tx: std::sync::mpsc::Sender, + thread: Option>, +} + +#[pymethods] +impl TextGrid { + #[new] + #[pyo3(signature = (rows, cols, title=None, font_path=None, color_font_path=None, on_key=None, appearance_changed=None, event_loop=None, on_close=None))] + fn new( + rows: u32, + cols: u32, + title: Option, + font_path: Option, + color_font_path: Option, + on_key: Option, + appearance_changed: Option, + event_loop: Option, + on_close: Option, + ) -> PyResult { + let needs_render = Arc::new(AtomicBool::new(true)); + let notify_pair = Arc::new((Mutex::new(false), Condvar::new())); + let close_requested = Arc::new(AtomicBool::new(false)); + let cols_atomic = Arc::new(AtomicU32::new(cols)); + let rows_atomic = Arc::new(AtomicU32::new(rows)); + + let (init_tx, init_rx) = std::sync::mpsc::channel(); + let (update_tx, update_rx) = std::sync::mpsc::channel(); + + let nr = needs_render.clone(); + let np = notify_pair.clone(); + let cr = close_requested.clone(); + let ca = cols_atomic.clone(); + let ra = rows_atomic.clone(); + + let handle = thread::Builder::new() + .name("textgrid-render".into()) + .spawn(move || { + renderer::start( + rows, + cols, + nr, + np, + cr, + title.unwrap_or_else(|| "Text Grid".to_string()), + font_path, + color_font_path, + init_tx, + update_rx, + on_key, + appearance_changed, + ca, + ra, + event_loop, + on_close, + ); + }) + .map_err(|e| PyValueError::new_err(format!("failed to spawn render thread: {e}")))?; + + init_rx + .recv() + .map_err(|_| PyValueError::new_err("render thread failed during initialization"))?; + + Ok(TextGrid { + cols: cols_atomic, + rows: rows_atomic, + needs_render, + notify_pair, + close_requested, + update_tx, + thread: Some(handle), + }) + } + + fn update(&self, py: Python, rows: Vec>) -> PyResult<()> { + if self.close_requested.load(Ordering::Acquire) { + return Ok(()); + } + + let mut row_data: Vec<(String, Vec<[u32; 3]>)> = Vec::with_capacity(rows.len()); + let mut batch_cols: u32 = 0; + + for row_obj in rows.iter() { + let has_data = row_obj.getattr("data").ok(); + let (text, colors) = if let Some(data_attr) = has_data { + let text: String = data_attr.extract()?; + let display_width: u32 = + text.chars().map(|c| c.width().unwrap_or(1) as u32).sum(); + if batch_cols == 0 { + batch_cols = display_width; + } + let style_attr = row_obj.getattr("style")?; + let helper = get_pack_helper(py)?; + let result = helper.call1(py, (style_attr,))?; + + let buf = PyBuffer::::get(result.bind(py))?; + let cell_slice = buf.as_slice(py).ok_or_else(|| { + PyValueError::new_err("color buffer is not C-contiguous") + })?; + let n = cell_slice.len(); + let mut colors = Vec::with_capacity(n / 3); + let mut idx = 0; + while idx + 2 < n { + colors.push([ + cell_slice[idx].get(), + cell_slice[idx + 1].get(), + cell_slice[idx + 2].get(), + ]); + idx += 3; + } + (text, colors) + } else { + let py_str = row_obj.downcast::()?; + let text: String = py_str.to_cow()?.into_owned(); + let display_width: u32 = + text.chars().map(|c| c.width().unwrap_or(1) as u32).sum(); + if batch_cols == 0 { + batch_cols = display_width; + } + let default_fg = color::pack_rgb(255, 255, 255); + let default_bg = color::pack_rgb(0, 0, 0); + let colors = vec![[default_fg, default_bg, 0]; display_width as usize]; + (text, colors) + }; + row_data.push((text, colors)); + } + + self.update_tx.send(UpdateBatch { cols: batch_cols, rows: row_data }).map_err(|_| { + PyValueError::new_err("render thread disconnected") + })?; + + self.needs_render.store(true, Ordering::Release); + let (lock, cvar) = &*self.notify_pair; + *lock.lock().unwrap() = true; + cvar.notify_one(); + + Ok(()) + } + + fn get_dimensions(&self) -> (u32, u32) { + (self.cols.load(Ordering::Acquire), self.rows.load(Ordering::Acquire)) + } + + fn close(&mut self, py: Python) { + self.close_requested.store(true, Ordering::Release); + let (lock, cvar) = &*self.notify_pair; + *lock.lock().unwrap() = true; + cvar.notify_one(); + if let Some(handle) = self.thread.take() { + py.allow_threads(|| { + let _ = handle.join(); + }); + } + } +} + +#[pymodule] +fn textgrid(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} diff --git a/src/renderer.rs b/src/renderer.rs new file mode 100644 index 0000000..4d6fa6b --- /dev/null +++ b/src/renderer.rs @@ -0,0 +1,660 @@ +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::mpsc::Receiver; +use std::sync::{Arc, Condvar, Mutex}; + +use bytemuck::{Pod, Zeroable}; +use pyo3::prelude::*; +use unicode_width::UnicodeWidthChar; + +use crate::atlas::GlyphAtlas; +use crate::font; +use crate::UpdateBatch; + +#[derive(Copy, Clone, Pod, Zeroable)] +#[repr(C)] +struct GridParams { + cols: u32, + rows: u32, + cell_w: f32, + cell_h: f32, + window_w: f32, + window_h: f32, + atlas_cols: u32, + atlas_rows: u32, + atlas_size: f32, + _pad0: u32, + _pad1: u32, + _pad2: u32, +} + +const VERTICES: &[f32] = &[ + 0.0, 0.0, 0.0, 0.0, + 1.0, 0.0, 1.0, 0.0, + 0.0, 1.0, 0.0, 1.0, + 1.0, 1.0, 1.0, 1.0, +]; + +const INDICES: &[u32] = &[0, 1, 2, 1, 3, 2]; + +pub fn start( + mut rows: u32, + mut cols: u32, + needs_render: Arc, + notify_pair: Arc<(Mutex, Condvar)>, + close_requested: Arc, + title: String, + font_path: Option, + color_font_path: Option, + init_tx: std::sync::mpsc::Sender<()>, + update_rx: Receiver, + on_key: Option, + appearance_changed: Option, + cols_atomic: Arc, + rows_atomic: Arc, + event_loop: Option, + on_close: Option, +) { + // ----- Font loading & cell metrics ----- + let (font_set, scale) = font::load_font(font_path.as_deref()); + let font_data = font_set.face_data(0); + let (cell_w, cell_h) = font::compute_cell_metrics(font_data, scale); + + // ----- GLFW ----- + let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); + if desktop == "GNOME" { + glfw::init_hint(glfw::InitHint::Platform(glfw::Platform::X11)); + } + let mut glfw_ctx = glfw::init(glfw::fail_on_errors).unwrap(); + + let mut fb_w: u32; + let mut fb_h: u32; + + let (window, events_rx) = { + glfw_ctx.window_hint(glfw::WindowHint::ClientApi(glfw::ClientApiHint::NoApi)); + let (mut w, r) = glfw_ctx + .create_window(cols * cell_w, rows * cell_h, &title, glfw::WindowMode::Windowed) + .unwrap(); + w.set_size_polling(true); + w.set_close_polling(true); + w.set_key_polling(true); + w.set_char_polling(true); + let fb = w.get_framebuffer_size(); + fb_w = fb.0.max(1) as u32; + fb_h = fb.1.max(1) as u32; + (w, r) + }; + + // ----- wgpu ----- + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + flags: wgpu::InstanceFlags::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + backend_options: wgpu::BackendOptions::default(), + display: None, + }); + + let surface = instance.create_surface(&window).unwrap(); + + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: Some(&surface), + force_fallback_adapter: false, + })) + .expect("no suitable GPU adapter found"); + + let (device, queue) = pollster::block_on(adapter.request_device( + &wgpu::DeviceDescriptor { + label: None, + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + experimental_features: wgpu::ExperimentalFeatures::disabled(), + memory_hints: wgpu::MemoryHints::Performance, + trace: wgpu::Trace::Off, + }, + )) + .expect("failed to create GPU device"); + + let caps = surface.get_capabilities(&adapter); + let format = caps.formats[0]; + + let mut surface_config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: fb_w, + height: fb_h, + present_mode: wgpu::PresentMode::Fifo, + alpha_mode: wgpu::CompositeAlphaMode::Auto, + view_formats: vec![], + desired_maximum_frame_latency: 2, + }; + surface.configure(&device, &surface_config); + + // ----- Glyph atlas (owned by render thread) ----- + let mut atlas = GlyphAtlas::new( + &device, &queue, font_set, scale, cell_w, cell_h, + color_font_path.as_deref(), + ); + let mut atlas_view = atlas.view().clone(); + + // ----- Pipeline ----- + let (pipeline, bind_group_layout) = create_pipeline(&device, format); + + // ----- Vertex / index buffers ----- + let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("vertex"), + size: (VERTICES.len() * core::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&vertex_buffer, 0, bytemuck::cast_slice(VERTICES)); + + let index_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("index"), + size: (INDICES.len() * core::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&index_buffer, 0, bytemuck::cast_slice(INDICES)); + + // ----- Uniform buffer ----- + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("grid_params"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + fn write_uniform(queue: &wgpu::Queue, buf: &wgpu::Buffer, cols: u32, rows: u32, cell_w: f32, cell_h: f32, w: u32, h: u32, atlas_cols: u32, atlas_rows: u32, atlas_size: f32) { + let params = GridParams { + cols, + rows, + cell_w, + cell_h, + window_w: w as f32, + window_h: h as f32, + atlas_cols, + atlas_rows, + atlas_size, + _pad0: 0u32, + _pad1: 0u32, + _pad2: 0u32, + }; + queue.write_buffer(buf, 0, bytemuck::bytes_of(¶ms)); + } + + fn create_bind_group( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + atlas_view: &wgpu::TextureView, + sampler: &wgpu::Sampler, + uniform_buffer: &wgpu::Buffer, + char_buffer: &wgpu::Buffer, + char_buffer_size: u64, + ) -> wgpu::BindGroup { + let ub_size = Some(wgpu::BufferSize::new(std::mem::size_of::() as u64).unwrap()); + let cb_size = Some(wgpu::BufferSize::new(char_buffer_size).unwrap()); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("main"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(atlas_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: uniform_buffer, + offset: 0, + size: ub_size, + }), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: char_buffer, + offset: 0, + size: cb_size, + }), + }, + ], + }) + } + + write_uniform(&queue, &uniform_buffer, cols, rows, cell_w as f32, cell_h as f32, fb_w, fb_h, atlas.atlas_cols(), atlas.atlas_rows(), crate::atlas::ATLAS_SIZE as f32); + + // ----- Sampler ----- + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("font_sampler"), + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + // ----- Char storage buffer (vec4 per char: glyph, fg, bg, attr) ----- + let char_stride = 16u64; + let mut char_buffer_capacity = cols * rows; + let char_buffer_size = (char_buffer_capacity as u64) * char_stride; + let mut char_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("char_data"), + size: char_buffer_size, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let init_data: Vec = vec![0u32; (char_buffer_capacity * 4) as usize]; + queue.write_buffer(&char_buffer, 0, bytemuck::cast_slice(&init_data)); + + // ----- Bind group ----- + let mut bind_group = create_bind_group( + &device, &bind_group_layout, &atlas_view, &sampler, + &uniform_buffer, &char_buffer, char_buffer_size, + ); + + // Signal Python that the window and GPU are ready + let _ = init_tx.send(()); + + let waker = Python::with_gil(|py| -> Py { + let code = std::ffi::CString::new("lambda: None").unwrap(); + py.eval(&code, None, None).unwrap().into() + }); + + // ----- Render loop ----- + let (lock, cvar) = &*notify_pair; + let mut last_batch: Option = None; + + while !close_requested.load(Ordering::Acquire) { + glfw_ctx.poll_events(); + + for (_, event) in glfw::flush_messages(&events_rx) { + match event { + glfw::WindowEvent::Close => { + close_requested.store(true, Ordering::Release); + if let Some(ref cb) = on_close { + Python::with_gil(|py| { + let _ = cb.call0(py); + }); + } + } + glfw::WindowEvent::Char(codepoint) => { + if let Some(ref cb) = on_key { + let s: String = codepoint.into(); + Python::with_gil(|py| { + let _ = cb.call1(py, (s,)); + }); + } + } + glfw::WindowEvent::Key(key, _, glfw::Action::Press | glfw::Action::Repeat, mods) => { + if mods.contains(glfw::Modifiers::Control) + && (key == glfw::Key::Equal || key == glfw::Key::Minus) + { + let factor = if key == glfw::Key::Equal { 1.2 } else { 1.0 / 1.2 }; + let new_scale = (atlas.scale() * factor).clamp(4.0, 200.0); + atlas.set_scale(new_scale, &device, &queue); + atlas_view = atlas.view().clone(); + while update_rx.try_recv().is_ok() {} + let new_cols = fb_w / atlas.cell_width(); + let new_rows = fb_h / atlas.cell_height(); + cols_atomic.store(new_cols.max(1), Ordering::Release); + rows_atomic.store(new_rows.max(1), Ordering::Release); + if let Some(ref event) = appearance_changed { + Python::with_gil(|py| { + let _ = event.call_method0(py, "set"); + if let Some(ref loop_) = event_loop { + let _ = loop_.call_method1(py, + "call_soon_threadsafe", (waker.clone_ref(py),)); + } + }); + } + let needed = new_cols.max(1) * new_rows.max(1); + if needed > char_buffer_capacity { + char_buffer_capacity = needed; + let new_size = (char_buffer_capacity as u64) * 16; + char_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("char_data"), + size: new_size, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } + let cb_size = (char_buffer_capacity as u64) * 16; + bind_group = create_bind_group( + &device, &bind_group_layout, &atlas_view, &sampler, + &uniform_buffer, &char_buffer, cb_size, + ); + if let Some(ref batch) = last_batch { + let mut data = Vec::with_capacity((batch.rows.len() * batch.cols as usize) * 4); + for (text, colors) in &batch.rows { + for (ch, [fg, bg, attr]) in text.chars().zip(colors.iter()) { + let w = ch.width().unwrap_or(1); + if w == 0 { + continue; + } + let glyph = atlas.ensure(ch, (*attr & 3) as u8, &queue); + data.push(glyph); + data.push(*fg); + data.push(*bg); + data.push(*attr); + if w > 1 { + data.push(glyph + 1); + data.push(*fg); + data.push(*bg); + data.push(*attr); + } + } + } + queue.write_buffer(&char_buffer, 0, bytemuck::cast_slice(&data)); + cols = batch.cols; + rows = batch.rows.len() as u32; + } else { + let zero_data = vec![0u32; (cols * rows * 4) as usize]; + queue.write_buffer(&char_buffer, 0, bytemuck::cast_slice(&zero_data)); + } + let a_cols = atlas.atlas_cols(); + let a_rows = atlas.atlas_rows(); + write_uniform(&queue, &uniform_buffer, cols, rows, + atlas.cell_width() as f32, atlas.cell_height() as f32, + fb_w, fb_h, a_cols, a_rows, crate::atlas::ATLAS_SIZE as f32); + needs_render.store(true, Ordering::Release); + } else { + let s: Option = + if key == glfw::Key::V && mods.contains(glfw::Modifiers::Control) { + if mods.contains(glfw::Modifiers::Shift) { + window.get_clipboard_string() + } else { + Some(String::from('\x16')) + } + } else if mods.contains(glfw::Modifiers::Control) { + let code = key as i32; + if (65..=90).contains(&code) { + Some(String::from((code - 64) as u8 as char)) + } else { + None + } + } else { + match key { + glfw::Key::Enter => Some("\n".into()), + glfw::Key::Escape => Some("\x1b".into()), + glfw::Key::Backspace => Some("\x7f".into()), + glfw::Key::Tab => Some("\t".into()), + glfw::Key::Up => Some("\x1b[A".into()), + glfw::Key::Down => Some("\x1b[B".into()), + glfw::Key::Left => Some("\x1b[D".into()), + glfw::Key::Right => Some("\x1b[C".into()), + glfw::Key::Home => Some("\x1b[H".into()), + glfw::Key::End => Some("\x1b[F".into()), + glfw::Key::PageUp => Some("\x1b[5~".into()), + glfw::Key::PageDown => Some("\x1b[6~".into()), + _ => None, + } + }; + if let Some(s) = s { + if let Some(ref cb) = on_key { + Python::with_gil(|py| { + let _ = cb.call1(py, (s,)); + }); + } + } + } + } + glfw::WindowEvent::Size(_, _) => { + let fb = window.get_framebuffer_size(); + let fbw = fb.0.max(1) as u32; + let fbh = fb.1.max(1) as u32; + if fbw != fb_w || fbh != fb_h { + fb_w = fbw; + fb_h = fbh; + surface_config.width = fbw; + surface_config.height = fbh; + surface.configure(&device, &surface_config); + + let new_cols = fbw / atlas.cell_width(); + let new_rows = fbh / atlas.cell_height(); + cols_atomic.store(new_cols.max(1), Ordering::Release); + rows_atomic.store(new_rows.max(1), Ordering::Release); + if let Some(ref event) = appearance_changed { + Python::with_gil(|py| { + let _ = event.call_method0(py, "set"); + if let Some(ref loop_) = event_loop { + let _ = loop_.call_method1(py, + "call_soon_threadsafe", (waker.clone_ref(py),)); + } + }); + } + let needed = new_cols.max(1) * new_rows.max(1); + if needed > char_buffer_capacity { + char_buffer_capacity = needed; + let new_size = (char_buffer_capacity as u64) * 16; + char_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("char_data"), + size: new_size, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let cb_size = (char_buffer_capacity as u64) * 16; + bind_group = create_bind_group( + &device, &bind_group_layout, &atlas_view, &sampler, + &uniform_buffer, &char_buffer, cb_size, + ); + } + needs_render.store(true, Ordering::Release); + let a_cols = atlas.atlas_cols(); + let a_rows = atlas.atlas_rows(); + write_uniform(&queue, &uniform_buffer, cols, rows, + atlas.cell_width() as f32, atlas.cell_height() as f32, + fbw, fbh, a_cols, a_rows, crate::atlas::ATLAS_SIZE as f32); + } + } + _ => {} + } + } + + if close_requested.load(Ordering::Acquire) { + break; + } + + // Drain and process incoming update data + let mut batch_cols = 0u32; + let mut batch_rows = 0u32; + while let Ok(batch) = update_rx.try_recv() { + last_batch = Some(batch.clone()); + batch_cols = batch.cols; + batch_rows = batch.rows.len() as u32; + let mut data = Vec::with_capacity((batch.rows.len() * batch.cols as usize) * 4); + for (text, colors) in &batch.rows { + for (ch, [fg, bg, attr]) in text.chars().zip(colors.iter()) { + let w = ch.width().unwrap_or(1); + if w == 0 { + continue; + } + let glyph = atlas.ensure(ch, (*attr & 3) as u8, &queue); + data.push(glyph); + data.push(*fg); + data.push(*bg); + data.push(*attr); + if w > 1 { + data.push(glyph + 1); + data.push(*fg); + data.push(*bg); + data.push(*attr); + } + } + } + queue.write_buffer(&char_buffer, 0, bytemuck::cast_slice(&data)); + needs_render.store(true, Ordering::Release); + } + if batch_cols > 0 { + cols = batch_cols; + rows = batch_rows; + let a_cols = atlas.atlas_cols(); + let a_rows = atlas.atlas_rows(); + write_uniform(&queue, &uniform_buffer, cols, rows, + atlas.cell_width() as f32, atlas.cell_height() as f32, + fb_w, fb_h, a_cols, a_rows, crate::atlas::ATLAS_SIZE as f32); + } + + if needs_render.load(Ordering::Acquire) { + let st = match surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(st) => st, + wgpu::CurrentSurfaceTexture::Suboptimal(st) => st, + _ => continue, + }; + let view = st.texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("render"), + }); + + { + let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: None, + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.06, + g: 0.06, + b: 0.1, + a: 1.0, + }), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + rp.set_pipeline(&pipeline); + rp.set_vertex_buffer(0, vertex_buffer.slice(..)); + rp.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32); + rp.set_bind_group(0, Some(&bind_group), &[]); + rp.draw_indexed(0..6, 0, 0..(rows * cols)); + } + + queue.submit([encoder.finish()]); + st.present(); + needs_render.store(false, Ordering::Release); + } + + let guard = lock.lock().unwrap(); + let _ = cvar.wait_timeout(guard, std::time::Duration::from_millis(16)); + } +} + +fn create_pipeline( + device: &wgpu::Device, + format: wgpu::TextureFormat, +) -> (wgpu::RenderPipeline, wgpu::BindGroupLayout) { + let shader_code = include_str!("shader.wgsl"); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("main"), + source: wgpu::ShaderSource::Wgsl(shader_code.into()), + }); + + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("main"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: Some( + std::num::NonZeroU64::new(std::mem::size_of::() as u64) + .unwrap(), + ), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new(16), + }, + count: None, + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("main"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + + let vertex_buffers = [wgpu::VertexBufferLayout { + array_stride: 16, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 0, + shader_location: 0, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 8, + shader_location: 1, + }, + ], + }]; + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("main"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &vertex_buffers, + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + (pipeline, bind_group_layout) +} diff --git a/src/shader.wgsl b/src/shader.wgsl new file mode 100644 index 0000000..8b8b3d5 --- /dev/null +++ b/src/shader.wgsl @@ -0,0 +1,68 @@ +struct GridParams { + cols: u32, + rows: u32, + cell_w: f32, + cell_h: f32, + window_w: f32, + window_h: f32, + atlas_cols: u32, + atlas_rows: u32, + atlas_size: f32, + _pad0: u32, + _pad1: u32, + _pad2: u32, +} + +@group(0) @binding(0) var font_atlas: texture_2d; +@group(0) @binding(1) var font_sampler: sampler; +@group(0) @binding(2) var grid: GridParams; +@group(0) @binding(3) var char_data: array>; + +struct VIn { @location(0) pos: vec2f, @location(1) uv: vec2f, }; +struct VOut { @builtin(position) position: vec4f, @location(0) uv: vec2f, @location(1) fg: vec4f, @location(2) bg: vec4f, @location(3) attr: u32, @location(4) cell_uv: vec2f, }; + +fn unpack_color(packed: u32) -> vec4f { + return vec4f( + f32((packed >> 24u) & 0xFFu) / 255.0, + f32((packed >> 16u) & 0xFFu) / 255.0, + f32((packed >> 8u) & 0xFFu) / 255.0, + 1.0, + ); +} + +@vertex +fn vs_main(input: VIn, @builtin(instance_index) iid: u32) -> VOut { + let col = iid % grid.cols; + let row = iid / grid.cols; + let x = f32(col) * grid.cell_w + input.pos.x * grid.cell_w; + let y = f32(row) * grid.cell_h + input.pos.y * grid.cell_h; + let ndc_x = x / grid.window_w * 2.0 - 1.0; + let ndc_y = 1.0 - y / grid.window_h * 2.0; + var out: VOut; + out.position = vec4f(ndc_x, ndc_y, 0.0, 1.0); + let entry = char_data[iid]; + out.fg = unpack_color(entry.y); + out.bg = unpack_color(entry.z); + out.attr = entry.w; + let slot = entry.x; + let ac = slot % grid.atlas_cols; + let ar = slot / grid.atlas_cols; + let uvx = (f32(ac) + input.uv.x) * grid.cell_w / grid.atlas_size; + let uvy = (f32(ar) + input.uv.y) * grid.cell_h / grid.atlas_size; + out.uv = vec2f(uvx, uvy); + out.cell_uv = input.uv; + return out; +} + +@fragment +fn fs_main(@location(0) uv: vec2f, @location(1) fg: vec4f, @location(2) bg: vec4f, @location(3) attr: u32, @location(4) cell_uv: vec2f) -> @location(0) vec4f { + let texel = textureSample(font_atlas, font_sampler, uv); + let glyph_alpha = texel.a; + let is_color = texel.r != 1.0 || texel.g != 1.0 || texel.b != 1.0; + let glyph_color = select(fg.rgb * texel.rgb, texel.rgb, is_color); + var color = vec4f(mix(bg.rgb, glyph_color, glyph_alpha), 1.0); + if (attr & 4u) != 0u && cell_uv.y >= 0.79 && cell_uv.y <= 0.85 { + color = vec4f(fg.rgb, 1.0); + } + return color; +} \ No newline at end of file diff --git a/tests/test_color_packing.py b/tests/test_color_packing.py new file mode 100644 index 0000000..d167c96 --- /dev/null +++ b/tests/test_color_packing.py @@ -0,0 +1,163 @@ +"""Tests for textgrid color packing (no GPU required).""" + +from textgrid._pack_style import pack_style_colors, _pack_one, _xterm + + +def test_pack_red(): + """(255, 0, 0) → 0xFF000000 (R in bits 31-24)""" + from termstr import CharStyle + result = pack_style_colors((CharStyle(fg_color=(255, 0, 0)),)) + assert result[0] == 0xFF000000 + + +def test_pack_green(): + """(0, 255, 0) → 0x00FF0000 (G in bits 23-16)""" + from termstr import CharStyle + result = pack_style_colors((CharStyle(fg_color=(0, 255, 0)),)) + assert result[0] == 0x00FF0000 + + +def test_pack_blue(): + """(0, 0, 255) → 0x0000FF00 (B in bits 15-8)""" + from termstr import CharStyle + result = pack_style_colors((CharStyle(fg_color=(0, 0, 255)),)) + assert result[0] == 0x0000FF00 + + +def test_pack_black(): + """(0, 0, 0) → 0x00000000""" + from termstr import CharStyle + result = pack_style_colors((CharStyle(fg_color=(0, 0, 0)),)) + assert result[0] == 0x00000000 + + +def test_pack_white(): + """(255, 255, 255) → 0xFFFFFF00""" + from termstr import CharStyle + result = pack_style_colors((CharStyle(fg_color=(255, 255, 255)),)) + assert result[0] == 0xFFFFFF00 + + +def test_pack_fg_and_bg(): + """fg and bg are interleaved in the output array""" + from termstr import CharStyle + result = pack_style_colors((CharStyle(fg_color=(255, 0, 0), bg_color=(0, 0, 255)),)) + assert result[0] == 0xFF000000, "fg should be first" + assert result[1] == 0x0000FF00, "bg should be second" + assert result[2] == 0, "attr should be 0 for default style" + + +def test_pack_xterm_index_9(): + """xterm index 9 is bright red = (255, 0, 0)""" + from termstr import CharStyle + result = pack_style_colors((CharStyle(fg_color=9),)) + expected = pack_style_colors((CharStyle(fg_color=(255, 0, 0)),)) + assert result[0] == expected[0], "index 9 should match (255, 0, 0)" + + +def test_xterm_table_index_0(): + """xterm index 0 is (0, 0, 0)""" + assert _xterm[0] == (0, 0, 0) + + +def test_xterm_table_index_1(): + """xterm index 1 is (205, 0, 0)""" + assert _xterm[1] == (205, 0, 0) + + +def test_xterm_table_index_15(): + """xterm index 15 is (255, 255, 255)""" + assert _xterm[15] == (255, 255, 255) + + +def test_xterm_table_index_16(): + """xterm index 16 is (0, 0, 0) — start of color cube""" + assert _xterm[16] == (0, 0, 0) + + +def test_xterm_table_index_231(): + """xterm index 231 is (255, 255, 255) — end of color cube""" + assert _xterm[231] == (255, 255, 255) + + +def test_xterm_table_index_232(): + """xterm index 232 is (8, 8, 8) — start of gray ramp""" + assert _xterm[232] == (8, 8, 8) + + +def test_xterm_table_index_255(): + """xterm index 255 is (238, 238, 238) — end of gray ramp""" + assert _xterm[255] == (238, 238, 238) + + +def test_identity_fast_path_matches_slow_path(): + """all(s is first) fast path produces same output as per-element iteration""" + from termstr import CharStyle + style = CharStyle(fg_color=(255, 80, 80), bg_color=(20, 40, 60)) + styles = tuple([style] * 100) + # The identity check will trigger for n > 1 + result = pack_style_colors(styles) + # Length: 100 chars * 3 values (fg, bg, attr) = 300 + assert len(result) == 300 + # Every triplet should be identical + for i in range(0, 300, 3): + assert result[i] == result[0], f"fg differs at char {i//3}" + assert result[i + 1] == result[1], f"bg differs at char {i//3}" + assert result[i + 2] == result[2], f"attr differs at char {i//3}" + + +def test_mixed_styles_are_all_different(): + """Each char has different colors — no identity optimization triggered""" + from termstr import CharStyle + styles = tuple([ + CharStyle(fg_color=(255, 0, 0)), + CharStyle(fg_color=(0, 255, 0)), + CharStyle(fg_color=(0, 0, 255)), + ]) + result = pack_style_colors(styles) + assert len(result) == 9 + assert result[0] != result[3], "red != green" + assert result[0] != result[6], "red != blue" + assert result[3] != result[6], "green != blue" + + +def test_output_length(): + """N chars produces 3N output values""" + from termstr import CharStyle + for n in [1, 5, 100]: + styles = tuple([CharStyle(fg_color=(255, 0, 0))] * n) + result = pack_style_colors(styles) + assert len(result) == 3 * n, f"expected {3*n}, got {len(result)}" + + +def test_xterm_pack_matches_rgb(): + """Packing xterm index should produce same u32 as direct RGB""" + from termstr import CharStyle + for idx in [1, 9, 16, 100, 200, 255]: + r, g, b = _xterm[idx] + xterm_result = pack_style_colors((CharStyle(fg_color=idx),)) + rgb_result = pack_style_colors((CharStyle(fg_color=(r, g, b)),)) + assert xterm_result[0] == rgb_result[0], \ + f"index {idx} = ({r},{g},{b}) didn't match direct RGB" + + +def test_attr_encoding(): + """bold/italic flags encoded in attr byte""" + from termstr import CharStyle + r = pack_style_colors((CharStyle(fg_color=(255, 0, 0)),)) + assert r[2] == 0, "default: no bold, no italic" + + r = pack_style_colors((CharStyle(fg_color=(255, 0, 0)).bold(),)) + assert r[2] & 1, "bold bit set" + + r = pack_style_colors((CharStyle(fg_color=(255, 0, 0)).italic(),)) + assert r[2] & 2, "italic bit set" + + r = pack_style_colors((CharStyle(fg_color=(255, 0, 0)).bold().italic(),)) + assert (r[2] & 3) == 3, "both bold and italic bits set" + + r = pack_style_colors((CharStyle(fg_color=(255, 0, 0)).underline(),)) + assert r[2] & 4, "underline bit set" + + r = pack_style_colors((CharStyle(fg_color=(255, 0, 0)).bold().italic().underline(),)) + assert (r[2] & 7) == 7, "bold, italic, and underline bits set" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bbefa71 --- /dev/null +++ b/uv.lock @@ -0,0 +1,39 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "cwcwidth" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/17/aadd0c6190dca91aa27c4d7e84d69d30fdf4966e7764247cdc395f8fe7d9/cwcwidth-0.1.5.tar.gz", hash = "sha256:2c840e7d85f6de45c45986b416d79312c91882e1121b78d4c347e49c4238c09d", size = 11071, upload-time = "2021-10-10T16:46:56.825Z" } + +[[package]] +name = "termstr" +version = "2025.6.21" +source = { directory = "../termstr" } +dependencies = [ + { name = "cwcwidth" }, +] + +[package.metadata] +requires-dist = [{ name = "cwcwidth", specifier = "==0.1.5" }] + +[[package]] +name = "textgrid" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "cwcwidth" }, + { name = "termstr" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "cwcwidth", specifier = "==0.1.5" }, + { name = "termstr", directory = "../termstr" }, +]