commit 5d47177fbf70623d3e7a9dd72820313da60e253b Author: Zacharias-Brohn Date: Fri Dec 12 22:11:20 2025 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..df99c69 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1 @@ +max_width = 80 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9bab6b7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2637 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +dependencies = [ + "android-properties", + "bitflags 2.10.0", + "cc", + "cesu8", + "jni", + "jni-sys", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[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 = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[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 = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +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 = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.10.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +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 = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fontdue" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b" +dependencies = [ + "hashbrown 0.15.5", + "ttf-parser 0.21.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.2", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[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 = "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 = "glow" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51fa363f025f5c111e03f13eda21162faeacb6911fe8caa0c0349f9cf0c4483" +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-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.10.0", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.10.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.10.0", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jiff" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "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 = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[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.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall 0.5.18", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.10.0", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "naga" +version = "23.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "364f94bc34f61332abebe8cad6f6cd82a5b65cff22c828d05d0968911462ca4f" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.10.0", + "cfg_aliases 0.1.1", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[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-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2", + "objc2-contacts", + "objc2-foundation", +] + +[[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.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.10.0", + "block2", + "dispatch", + "libc", + "objc2", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "247ad146e19b9437f8604c21f8652423595cf710ad108af40e77d3ae6e96b827" +dependencies = [ + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser 0.25.1", +] + +[[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 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[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.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +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 = "range-alloc" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.17", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "itoa", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser 0.25.1", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.10.0", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" + +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vte" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a0b683b20ef64071ff03745b14391751f6beab06a54347885459b77a3f2caa5" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.2", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +dependencies = [ + "bitflags 2.10.0", + "rustix 1.1.2", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.10.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" +dependencies = [ + "rustix 1.1.2", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80f70000db37c469ea9d67defdc13024ddf9a5f1b89cb2941b812ad7cde1735a" +dependencies = [ + "arrayvec", + "cfg_aliases 0.1.1", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot", + "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 = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d63c3c478de8e7e01786479919c8769f62a22eec16788d8c2ac77ce2c132778a" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.10.0", + "cfg_aliases 0.1.1", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash", + "smallvec", + "thiserror 1.0.69", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89364b8a0b211adc7b16aeaf1bd5ad4a919c1154b44c9ce27838213ba05fd821" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.10.0", + "block", + "bytemuck", + "cfg_aliases 0.1.1", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-types" +version = "23.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610f6ff27778148c31093f3b03abc4840f9636d58d597ca2f5977433acfe0068" +dependencies = [ + "bitflags 2.10.0", + "js-sys", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +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-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.10.0", + "block2", + "bytemuck", + "calloop", + "cfg_aliases 0.2.1", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.2", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.10.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zterm" +version = "0.1.0" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "dirs", + "env_logger", + "fontdue", + "libc", + "log", + "polling", + "pollster", + "rustix 0.38.44", + "rustybuzz", + "serde", + "serde_json", + "thiserror 2.0.17", + "ttf-parser 0.25.1", + "vte", + "wgpu", + "winit", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d1b0fe7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "zterm" +version = "0.1.0" +edition = "2024" + +[lib] +name = "zterm" +path = "src/lib.rs" + +[[bin]] +name = "zterm" +path = "src/main.rs" + +[[bin]] +name = "ztermd" +path = "src/bin/ztermd.rs" + +[dependencies] +# Window and rendering +winit = { version = "0.30", features = ["wayland", "x11"] } +wgpu = "23" +pollster = "0.4" + +# Terminal emulation +vte = "0.13" + +# PTY handling +rustix = { version = "0.38", features = ["termios", "pty", "process", "fs"] } + +# Async I/O +polling = "3" + +# Error handling +thiserror = "2" + +# Logging +log = "0.4" +env_logger = "0.11" + +# Utilities +bytemuck = { version = "1", features = ["derive"] } +libc = "0.2" +bitflags = "2" + +# Font rasterization and shaping +fontdue = "0.9" +rustybuzz = "0.20" +ttf-parser = "0.25" + +# Configuration +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "6" diff --git a/src/bin/ztermd.rs b/src/bin/ztermd.rs new file mode 100644 index 0000000..bbd7776 --- /dev/null +++ b/src/bin/ztermd.rs @@ -0,0 +1,25 @@ +//! ZTerm Daemon - Background process that manages terminal sessions. + +use zterm::daemon::Daemon; + +fn main() { + // Initialize logging + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + log::info!("ZTerm daemon starting..."); + + match Daemon::new() { + Ok(mut daemon) => { + if let Err(e) = daemon.run() { + log::error!("Daemon error: {}", e); + std::process::exit(1); + } + } + Err(e) => { + log::error!("Failed to start daemon: {}", e); + std::process::exit(1); + } + } + + log::info!("ZTerm daemon exiting"); +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..5af4612 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,303 @@ +//! ZTerm client - connects to daemon and handles rendering. + +use crate::daemon::{is_running, socket_path, start_daemon}; +use crate::protocol::{ClientMessage, DaemonMessage, Direction, PaneSnapshot, SplitDirection, WindowState}; +use std::io::{self, Read, Write}; +use std::os::unix::net::UnixStream; +use std::time::Duration; + +/// Client connection to the daemon. +pub struct DaemonClient { + stream: UnixStream, + /// Current window state from daemon. + pub window: Option, + /// Current pane snapshots. + pub panes: Vec, +} + +impl DaemonClient { + /// Connects to the daemon, starting it if necessary. + pub fn connect() -> io::Result { + // Start daemon if not running + if !is_running() { + log::info!("Starting daemon..."); + start_daemon()?; + + // Wait for daemon to start + for _ in 0..50 { + if is_running() { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + + if !is_running() { + return Err(io::Error::new( + io::ErrorKind::ConnectionRefused, + "failed to start daemon", + )); + } + } + + let socket = socket_path(); + log::info!("Connecting to daemon at {:?}", socket); + + let stream = UnixStream::connect(&socket)?; + // Keep blocking mode for initial handshake + stream.set_nonblocking(false)?; + + Ok(Self { + stream, + window: None, + panes: Vec::new(), + }) + } + + /// Sends hello message with initial window size. + pub fn hello(&mut self, cols: usize, rows: usize) -> io::Result<()> { + self.send(&ClientMessage::Hello { cols, rows }) + } + + /// Sets the socket to non-blocking mode for use after initial handshake. + pub fn set_nonblocking(&mut self) -> io::Result<()> { + self.stream.set_nonblocking(true) + } + + /// Sends keyboard input. + pub fn send_input(&mut self, data: Vec) -> io::Result<()> { + self.send(&ClientMessage::Input { data }) + } + + /// Sends resize notification. + pub fn send_resize(&mut self, cols: usize, rows: usize) -> io::Result<()> { + self.send(&ClientMessage::Resize { cols, rows }) + } + + /// Requests creation of a new tab. + pub fn create_tab(&mut self) -> io::Result<()> { + self.send(&ClientMessage::CreateTab) + } + + /// Requests closing the current tab. + pub fn close_tab(&mut self, tab_id: u32) -> io::Result<()> { + self.send(&ClientMessage::CloseTab { tab_id }) + } + + /// Requests switching to the next tab. + pub fn next_tab(&mut self) -> io::Result<()> { + self.send(&ClientMessage::NextTab) + } + + /// Requests switching to the previous tab. + pub fn prev_tab(&mut self) -> io::Result<()> { + self.send(&ClientMessage::PrevTab) + } + + /// Requests switching to a tab by index (0-based). + pub fn switch_tab_index(&mut self, index: usize) -> io::Result<()> { + self.send(&ClientMessage::SwitchTabIndex { index }) + } + + /// Requests splitting the current pane horizontally (new pane below). + pub fn split_horizontal(&mut self) -> io::Result<()> { + self.send(&ClientMessage::SplitPane { direction: SplitDirection::Horizontal }) + } + + /// Requests splitting the current pane vertically (new pane to the right). + pub fn split_vertical(&mut self) -> io::Result<()> { + self.send(&ClientMessage::SplitPane { direction: SplitDirection::Vertical }) + } + + /// Requests closing the current pane (closes tab if last pane). + pub fn close_pane(&mut self) -> io::Result<()> { + self.send(&ClientMessage::ClosePane) + } + + /// Requests focusing a pane in the given direction. + pub fn focus_pane(&mut self, direction: Direction) -> io::Result<()> { + self.send(&ClientMessage::FocusPane { direction }) + } + + /// Sends a message to the daemon. + pub fn send(&mut self, msg: &ClientMessage) -> io::Result<()> { + let json = serde_json::to_vec(msg) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + let len = json.len() as u32; + self.stream.write_all(&len.to_le_bytes())?; + self.stream.write_all(&json)?; + self.stream.flush()?; + Ok(()) + } + + /// Tries to receive a message (non-blocking). + /// Socket must be set to non-blocking mode first. + pub fn try_recv(&mut self) -> io::Result> { + // Try to read the length prefix (non-blocking) + let mut len_buf = [0u8; 4]; + match self.stream.read(&mut len_buf) { + Ok(0) => { + // EOF - daemon disconnected + return Err(io::Error::new(io::ErrorKind::ConnectionReset, "daemon disconnected")); + } + Ok(n) if n < 4 => { + // Partial read - shouldn't happen often with 4-byte prefix + // For now, treat as no data + return Ok(None); + } + Ok(_) => {} // Got all 4 bytes + Err(e) if e.kind() == io::ErrorKind::WouldBlock => { + return Ok(None); + } + Err(e) => return Err(e), + } + + let len = u32::from_le_bytes(len_buf) as usize; + + // Sanity check + if len > 64 * 1024 * 1024 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large")); + } + + // For the message body, temporarily use blocking mode with timeout + // since we know the data should be available + self.stream.set_nonblocking(false)?; + self.stream.set_read_timeout(Some(Duration::from_secs(5)))?; + + let mut buf = vec![0u8; len]; + let result = self.stream.read_exact(&mut buf); + + // Restore non-blocking mode + self.stream.set_nonblocking(true)?; + + result?; + + let msg: DaemonMessage = serde_json::from_slice(&buf) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + self.handle_message(&msg); + Ok(Some(msg)) + } + + /// Receives a message (blocking). + pub fn recv(&mut self) -> io::Result { + // Use a long timeout for blocking reads + self.stream.set_read_timeout(Some(Duration::from_secs(30)))?; + + // Read length prefix + let mut len_buf = [0u8; 4]; + self.stream.read_exact(&mut len_buf)?; + let len = u32::from_le_bytes(len_buf) as usize; + + // Sanity check + if len > 64 * 1024 * 1024 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large")); + } + + // Read message body + let mut buf = vec![0u8; len]; + self.stream.read_exact(&mut buf)?; + + let msg: DaemonMessage = serde_json::from_slice(&buf) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + self.handle_message(&msg); + Ok(msg) + } + + /// Handles a received message by updating local state. + fn handle_message(&mut self, msg: &DaemonMessage) { + match msg { + DaemonMessage::FullState { window, panes } => { + self.window = Some(window.clone()); + self.panes = panes.clone(); + } + DaemonMessage::PaneUpdate { pane_id, cells, cursor } => { + // Update existing pane or add new + if let Some(pane) = self.panes.iter_mut().find(|p| p.pane_id == *pane_id) { + pane.cells = cells.clone(); + pane.cursor = cursor.clone(); + } else { + self.panes.push(PaneSnapshot { + pane_id: *pane_id, + cells: cells.clone(), + cursor: cursor.clone(), + scroll_offset: 0, + scrollback_len: 0, + }); + } + } + DaemonMessage::TabChanged { active_tab } => { + if let Some(ref mut window) = self.window { + window.active_tab = *active_tab; + } + } + DaemonMessage::TabCreated { tab } => { + if let Some(ref mut window) = self.window { + window.tabs.push(tab.clone()); + } + } + DaemonMessage::TabClosed { tab_id } => { + if let Some(ref mut window) = self.window { + window.tabs.retain(|t| t.id != *tab_id); + } + } + DaemonMessage::PaneCreated { tab_id, pane } => { + if let Some(ref mut window) = self.window { + if let Some(tab) = window.tabs.iter_mut().find(|t| t.id == *tab_id) { + tab.panes.push(pane.clone()); + } + } + } + DaemonMessage::PaneClosed { tab_id, pane_id } => { + if let Some(ref mut window) = self.window { + if let Some(tab) = window.tabs.iter_mut().find(|t| t.id == *tab_id) { + tab.panes.retain(|p| p.id != *pane_id); + } + } + // Also remove from pane snapshots + self.panes.retain(|p| p.pane_id != *pane_id); + } + DaemonMessage::PaneFocused { tab_id, active_pane } => { + if let Some(ref mut window) = self.window { + if let Some(tab) = window.tabs.iter_mut().find(|t| t.id == *tab_id) { + tab.active_pane = *active_pane; + } + } + } + DaemonMessage::Shutdown => { + log::info!("Daemon shutting down"); + } + } + } + + /// Gets the active pane snapshot. + pub fn active_pane(&self) -> Option<&PaneSnapshot> { + let window = self.window.as_ref()?; + let tab = window.tabs.get(window.active_tab)?; + let pane_info = tab.panes.get(tab.active_pane)?; + self.panes.iter().find(|p| p.pane_id == pane_info.id) + } + + /// Returns the file descriptor for polling. + pub fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { + use std::os::fd::AsFd; + self.stream.as_fd() + } + + /// Returns the raw file descriptor for initial poll registration. + pub fn as_raw_fd(&self) -> std::os::fd::RawFd { + use std::os::fd::AsRawFd; + self.stream.as_raw_fd() + } + + /// Sends goodbye message before disconnecting. + pub fn goodbye(&mut self) -> io::Result<()> { + self.send(&ClientMessage::Goodbye) + } +} + +impl Drop for DaemonClient { + fn drop(&mut self) { + let _ = self.goodbye(); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..ed93dba --- /dev/null +++ b/src/config.rs @@ -0,0 +1,425 @@ +//! Configuration management for ZTerm. +//! +//! Loads configuration from `~/.config/zterm/config.json`. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +/// Position of the tab bar. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum TabBarPosition { + /// Tab bar at the top of the window. + #[default] + Top, + /// Tab bar at the bottom of the window. + Bottom, + /// Tab bar is hidden. + Hidden, +} + +/// A keybinding specification. +/// Format: "modifier+modifier+key" where modifiers are: ctrl, alt, shift, super +/// Examples: "ctrl+shift+t", "ctrl+w", "alt+1" +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(transparent)] +pub struct Keybind(pub String); + +impl Keybind { + /// Parses the keybind into modifiers and key. + /// Returns (ctrl, alt, shift, super_key, key_char_or_name) + /// + /// Supports special syntax for symbol keys: + /// - "ctrl+alt+plus" or "ctrl+alt++" for the + key + /// - "ctrl+minus" or "ctrl+-" for the - key + /// - Symbol names: plus, minus, equal, bracket_left, bracket_right, etc. + pub fn parse(&self) -> Option<(bool, bool, bool, bool, String)> { + let lowercase = self.0.to_lowercase(); + + // Handle the special case where the key is "+" at the end + // e.g., "ctrl+alt++" should parse as ctrl+alt with key "+" + let (modifier_part, key) = if lowercase.ends_with("++") { + // Last char is the key "+", everything before the final "++" is modifiers + let prefix = &lowercase[..lowercase.len() - 2]; + (prefix, "+".to_string()) + } else if lowercase == "+" { + // Just the plus key alone + ("", "+".to_string()) + } else if let Some(last_plus) = lowercase.rfind('+') { + // Normal case: split at last + + let key_part = &lowercase[last_plus + 1..]; + let mod_part = &lowercase[..last_plus]; + // Normalize symbol names to actual characters + let key = Self::normalize_key_name(key_part); + (mod_part, key) + } else { + // No modifiers, just a key + let key = Self::normalize_key_name(&lowercase); + ("", key) + }; + + if key.is_empty() { + return None; + } + + let mut ctrl = false; + let mut alt = false; + let mut shift = false; + let mut super_key = false; + + // Parse modifiers from the modifier part + for part in modifier_part.split('+') { + match part { + "ctrl" | "control" => ctrl = true, + "alt" => alt = true, + "shift" => shift = true, + "super" | "meta" | "cmd" => super_key = true, + "" => {} // Empty parts from splitting + _ => {} // Unknown modifiers ignored + } + } + + Some((ctrl, alt, shift, super_key, key)) + } + + /// Normalizes key names to their canonical form. + /// Supports both symbol names ("plus", "minus") and literal symbols ("+", "-"). + fn normalize_key_name(name: &str) -> String { + match name { + // Arrow keys + "left" | "arrowleft" | "arrow_left" => "left".to_string(), + "right" | "arrowright" | "arrow_right" => "right".to_string(), + "up" | "arrowup" | "arrow_up" => "up".to_string(), + "down" | "arrowdown" | "arrow_down" => "down".to_string(), + + // Other special keys + "enter" | "return" => "enter".to_string(), + "tab" => "tab".to_string(), + "escape" | "esc" => "escape".to_string(), + "backspace" | "back" => "backspace".to_string(), + "delete" | "del" => "delete".to_string(), + "insert" | "ins" => "insert".to_string(), + "home" => "home".to_string(), + "end" => "end".to_string(), + "pageup" | "page_up" | "pgup" => "pageup".to_string(), + "pagedown" | "page_down" | "pgdn" => "pagedown".to_string(), + + // Function keys + "f1" => "f1".to_string(), + "f2" => "f2".to_string(), + "f3" => "f3".to_string(), + "f4" => "f4".to_string(), + "f5" => "f5".to_string(), + "f6" => "f6".to_string(), + "f7" => "f7".to_string(), + "f8" => "f8".to_string(), + "f9" => "f9".to_string(), + "f10" => "f10".to_string(), + "f11" => "f11".to_string(), + "f12" => "f12".to_string(), + + // Symbol name aliases + "plus" => "+".to_string(), + "minus" => "-".to_string(), + "equal" | "equals" => "=".to_string(), + "bracket_left" | "bracketleft" | "lbracket" => "[".to_string(), + "bracket_right" | "bracketright" | "rbracket" => "]".to_string(), + "brace_left" | "braceleft" | "lbrace" => "{".to_string(), + "brace_right" | "braceright" | "rbrace" => "}".to_string(), + "semicolon" => ";".to_string(), + "colon" => ":".to_string(), + "apostrophe" | "quote" => "'".to_string(), + "quotedbl" | "doublequote" => "\"".to_string(), + "comma" => ",".to_string(), + "period" | "dot" => ".".to_string(), + "slash" => "/".to_string(), + "backslash" => "\\".to_string(), + "grave" | "backtick" => "`".to_string(), + "tilde" => "~".to_string(), + "at" => "@".to_string(), + "hash" | "pound" => "#".to_string(), + "dollar" => "$".to_string(), + "percent" => "%".to_string(), + "caret" => "^".to_string(), + "ampersand" => "&".to_string(), + "asterisk" | "star" => "*".to_string(), + "paren_left" | "parenleft" | "lparen" => "(".to_string(), + "paren_right" | "parenright" | "rparen" => ")".to_string(), + "underscore" => "_".to_string(), + "pipe" | "bar" => "|".to_string(), + "question" => "?".to_string(), + "exclam" | "exclamation" | "bang" => "!".to_string(), + "less" | "lessthan" => "<".to_string(), + "greater" | "greaterthan" => ">".to_string(), + "space" => " ".to_string(), + // Pass through everything else as-is + _ => name.to_string(), + } + } +} + +/// Terminal actions that can be bound to keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Action { + /// Create a new tab. + NewTab, + /// Switch to the next tab. + NextTab, + /// Switch to the previous tab. + PrevTab, + /// Switch to tab by index (1-9). + Tab1, + Tab2, + Tab3, + Tab4, + Tab5, + Tab6, + Tab7, + Tab8, + Tab9, + /// Split pane horizontally (new pane below). + SplitHorizontal, + /// Split pane vertically (new pane to the right). + SplitVertical, + /// Close the current pane (closes tab if last pane). + ClosePane, + /// Focus the pane above the current one. + FocusPaneUp, + /// Focus the pane below the current one. + FocusPaneDown, + /// Focus the pane to the left of the current one. + FocusPaneLeft, + /// Focus the pane to the right of the current one. + FocusPaneRight, +} + +/// Keybinding configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Keybindings { + /// Create new tab. + pub new_tab: Keybind, + /// Switch to next tab. + pub next_tab: Keybind, + /// Switch to previous tab. + pub prev_tab: Keybind, + /// Switch to tab 1. + pub tab_1: Keybind, + /// Switch to tab 2. + pub tab_2: Keybind, + /// Switch to tab 3. + pub tab_3: Keybind, + /// Switch to tab 4. + pub tab_4: Keybind, + /// Switch to tab 5. + pub tab_5: Keybind, + /// Switch to tab 6. + pub tab_6: Keybind, + /// Switch to tab 7. + pub tab_7: Keybind, + /// Switch to tab 8. + pub tab_8: Keybind, + /// Switch to tab 9. + pub tab_9: Keybind, + /// Split pane horizontally (new pane below). + pub split_horizontal: Keybind, + /// Split pane vertically (new pane to the right). + pub split_vertical: Keybind, + /// Close current pane (closes tab if last pane). + pub close_pane: Keybind, + /// Focus pane above. + pub focus_pane_up: Keybind, + /// Focus pane below. + pub focus_pane_down: Keybind, + /// Focus pane to the left. + pub focus_pane_left: Keybind, + /// Focus pane to the right. + pub focus_pane_right: Keybind, +} + +impl Default for Keybindings { + fn default() -> Self { + Self { + new_tab: Keybind("ctrl+shift+t".to_string()), + next_tab: Keybind("ctrl+tab".to_string()), + prev_tab: Keybind("ctrl+shift+tab".to_string()), + tab_1: Keybind("alt+1".to_string()), + tab_2: Keybind("alt+2".to_string()), + tab_3: Keybind("alt+3".to_string()), + tab_4: Keybind("alt+4".to_string()), + tab_5: Keybind("alt+5".to_string()), + tab_6: Keybind("alt+6".to_string()), + tab_7: Keybind("alt+7".to_string()), + tab_8: Keybind("alt+8".to_string()), + tab_9: Keybind("alt+9".to_string()), + split_horizontal: Keybind("ctrl+shift+h".to_string()), + split_vertical: Keybind("ctrl+shift+v".to_string()), + close_pane: Keybind("ctrl+shift+w".to_string()), + focus_pane_up: Keybind("ctrl+shift+up".to_string()), + focus_pane_down: Keybind("ctrl+shift+down".to_string()), + focus_pane_left: Keybind("ctrl+shift+left".to_string()), + focus_pane_right: Keybind("ctrl+shift+right".to_string()), + } + } +} + +impl Keybindings { + /// Builds a lookup map from parsed keybinds to actions. + pub fn build_action_map(&self) -> HashMap<(bool, bool, bool, bool, String), Action> { + let mut map = HashMap::new(); + + if let Some(parsed) = self.new_tab.parse() { + map.insert(parsed, Action::NewTab); + } + if let Some(parsed) = self.next_tab.parse() { + map.insert(parsed, Action::NextTab); + } + if let Some(parsed) = self.prev_tab.parse() { + map.insert(parsed, Action::PrevTab); + } + if let Some(parsed) = self.tab_1.parse() { + map.insert(parsed, Action::Tab1); + } + if let Some(parsed) = self.tab_2.parse() { + map.insert(parsed, Action::Tab2); + } + if let Some(parsed) = self.tab_3.parse() { + map.insert(parsed, Action::Tab3); + } + if let Some(parsed) = self.tab_4.parse() { + map.insert(parsed, Action::Tab4); + } + if let Some(parsed) = self.tab_5.parse() { + map.insert(parsed, Action::Tab5); + } + if let Some(parsed) = self.tab_6.parse() { + map.insert(parsed, Action::Tab6); + } + if let Some(parsed) = self.tab_7.parse() { + map.insert(parsed, Action::Tab7); + } + if let Some(parsed) = self.tab_8.parse() { + map.insert(parsed, Action::Tab8); + } + if let Some(parsed) = self.tab_9.parse() { + map.insert(parsed, Action::Tab9); + } + if let Some(parsed) = self.split_horizontal.parse() { + map.insert(parsed, Action::SplitHorizontal); + } + if let Some(parsed) = self.split_vertical.parse() { + map.insert(parsed, Action::SplitVertical); + } + if let Some(parsed) = self.close_pane.parse() { + map.insert(parsed, Action::ClosePane); + } + if let Some(parsed) = self.focus_pane_up.parse() { + map.insert(parsed, Action::FocusPaneUp); + } + if let Some(parsed) = self.focus_pane_down.parse() { + map.insert(parsed, Action::FocusPaneDown); + } + if let Some(parsed) = self.focus_pane_left.parse() { + map.insert(parsed, Action::FocusPaneLeft); + } + if let Some(parsed) = self.focus_pane_right.parse() { + map.insert(parsed, Action::FocusPaneRight); + } + + map + } +} + +/// Main configuration struct for ZTerm. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Config { + /// Font size in points. + pub font_size: f32, + /// Position of the tab bar: "top", "bottom", or "hidden". + pub tab_bar_position: TabBarPosition, + /// Background opacity (0.0 = fully transparent, 1.0 = fully opaque). + /// Requires compositor support for transparency. + pub background_opacity: f32, + /// Keybindings. + pub keybindings: Keybindings, +} + +impl Default for Config { + fn default() -> Self { + Self { + font_size: 16.0, + tab_bar_position: TabBarPosition::Top, + background_opacity: 1.0, + keybindings: Keybindings::default(), + } + } +} + +impl Config { + /// Returns the path to the config file. + pub fn config_path() -> Option { + dirs::config_dir().map(|p| p.join("zterm").join("config.json")) + } + + /// Loads configuration from the default config file. + /// If the file doesn't exist, writes the default config to that location. + /// Returns default config if file can't be parsed. + pub fn load() -> Self { + let Some(config_path) = Self::config_path() else { + log::warn!("Could not determine config directory, using defaults"); + return Self::default(); + }; + + if !config_path.exists() { + log::info!("No config file found at {:?}, creating with defaults", config_path); + let default_config = Self::default(); + if let Err(e) = default_config.save() { + log::warn!("Failed to write default config: {}", e); + } + return default_config; + } + + match fs::read_to_string(&config_path) { + Ok(contents) => match serde_json::from_str(&contents) { + Ok(config) => { + log::info!("Loaded config from {:?}", config_path); + config + } + Err(e) => { + log::error!("Failed to parse config file: {}", e); + Self::default() + } + }, + Err(e) => { + log::error!("Failed to read config file: {}", e); + Self::default() + } + } + } + + /// Saves the current configuration to the default config file. + pub fn save(&self) -> Result<(), std::io::Error> { + let Some(config_path) = Self::config_path() else { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Could not determine config directory", + )); + }; + + // Create parent directories if they don't exist + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent)?; + } + + let json = serde_json::to_string_pretty(self) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + + fs::write(&config_path, json)?; + log::info!("Saved config to {:?}", config_path); + Ok(()) + } +} diff --git a/src/daemon.rs b/src/daemon.rs new file mode 100644 index 0000000..17d33c2 --- /dev/null +++ b/src/daemon.rs @@ -0,0 +1,605 @@ +//! ZTerm daemon - manages terminal sessions and communicates with clients. + +use crate::protocol::{ClientMessage, DaemonMessage, PaneSnapshot}; +use crate::window_state::WindowStateManager; +use polling::{Event, Events, Poller}; +use std::collections::HashMap; +use std::io::{self, Read, Write}; +use std::os::fd::AsRawFd; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::PathBuf; +use std::time::Duration; + +/// Get the socket path for the daemon. +pub fn socket_path() -> PathBuf { + let runtime_dir = std::env::var("XDG_RUNTIME_DIR") + .unwrap_or_else(|_| format!("/tmp/zterm-{}", unsafe { libc::getuid() })); + PathBuf::from(runtime_dir).join("zterm.sock") +} + +/// Event keys for the poller. +const LISTENER_KEY: usize = 0; +const CLIENT_KEY_BASE: usize = 1000; +const SESSION_KEY_BASE: usize = 2000; + +/// A connected client. +struct Client { + stream: UnixStream, +} + +impl Client { + fn new(stream: UnixStream) -> io::Result { + // Set to non-blocking mode for use with poller + stream.set_nonblocking(true)?; + Ok(Self { stream }) + } + + fn send(&mut self, msg: &DaemonMessage) -> io::Result<()> { + let json = serde_json::to_vec(msg) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + let len = json.len() as u32; + + // Temporarily set blocking mode for sends to ensure complete writes + self.stream.set_nonblocking(false)?; + self.stream.set_write_timeout(Some(Duration::from_secs(5)))?; + + let result = (|| { + self.stream.write_all(&len.to_le_bytes())?; + self.stream.write_all(&json)?; + self.stream.flush() + })(); + + // Restore non-blocking mode + self.stream.set_nonblocking(true)?; + + result + } + + /// Tries to receive a message. Returns: + /// - Ok(Some(msg)) if a message was received + /// - Ok(None) if no data available (would block) + /// - Err with ConnectionReset if client disconnected + /// - Err for other errors + fn try_recv(&mut self) -> io::Result> { + // Try to read length prefix (non-blocking) + let mut len_buf = [0u8; 4]; + match self.stream.read(&mut len_buf) { + Ok(0) => { + // EOF - client disconnected + return Err(io::Error::new(io::ErrorKind::ConnectionReset, "client disconnected")); + } + Ok(n) if n < 4 => { + // Partial read - need to read more (shouldn't happen often with small prefix) + // For now, treat as would-block and let next poll handle it + return Ok(None); + } + Ok(_) => {} // Got all 4 bytes + Err(e) if e.kind() == io::ErrorKind::WouldBlock => { + return Ok(None); + } + Err(e) => return Err(e), + } + + let len = u32::from_le_bytes(len_buf) as usize; + + // Sanity check + if len > 64 * 1024 * 1024 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large")); + } + + // Read the message body - since we're non-blocking, we need to handle partial reads + // For simplicity, temporarily set blocking with timeout for the body + self.stream.set_nonblocking(false)?; + self.stream.set_read_timeout(Some(Duration::from_secs(5)))?; + + let mut buf = vec![0u8; len]; + let result = self.stream.read_exact(&mut buf); + + // Restore non-blocking mode + self.stream.set_nonblocking(true)?; + + result?; + + let msg: ClientMessage = serde_json::from_slice(&buf) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + Ok(Some(msg)) + } +} + +/// The daemon server. +pub struct Daemon { + listener: UnixListener, + poller: Poller, + state: WindowStateManager, + clients: HashMap, + next_client_id: usize, + read_buffer: Vec, +} + +impl Daemon { + /// Creates and starts a new daemon. + pub fn new() -> io::Result { + let socket = socket_path(); + + // Remove old socket if it exists + let _ = std::fs::remove_file(&socket); + + // Create parent directory if needed + if let Some(parent) = socket.parent() { + std::fs::create_dir_all(parent)?; + } + + let listener = UnixListener::bind(&socket)?; + listener.set_nonblocking(true)?; + + log::info!("Daemon listening on {:?}", socket); + + let poller = Poller::new()?; + + // Register listener for new connections + unsafe { + poller.add(listener.as_raw_fd(), Event::readable(LISTENER_KEY))?; + } + + // Create initial window state (will create session when client connects with size) + let state = WindowStateManager::new(80, 24); // Default size, will be updated + + Ok(Self { + listener, + poller, + state, + clients: HashMap::new(), + next_client_id: 0, + read_buffer: vec![0u8; 65536], + }) + } + + /// Runs the daemon main loop. + pub fn run(&mut self) -> io::Result<()> { + let mut events = Events::new(); + + loop { + events.clear(); + + // Poll with a longer timeout - we'll wake up when there's actual I/O + // Use None for infinite wait, or a reasonable timeout for periodic checks + self.poller.wait(&mut events, Some(Duration::from_millis(100)))?; + + // Track which sessions had output so we only read from those + let mut sessions_with_output: Vec = Vec::new(); + + for event in events.iter() { + match event.key { + LISTENER_KEY => { + self.accept_client()?; + } + key if key >= CLIENT_KEY_BASE && key < SESSION_KEY_BASE => { + let client_id = key - CLIENT_KEY_BASE; + if let Err(e) = self.handle_client(client_id) { + log::warn!("Client {} error: {}", client_id, e); + self.remove_client(client_id); + } + } + key if key >= SESSION_KEY_BASE => { + let session_id = (key - SESSION_KEY_BASE) as u32; + sessions_with_output.push(session_id); + // Don't re-register here - we'll do it AFTER reading from the session + } + _ => {} + } + } + + // Read from sessions that have data, THEN re-register for polling + // This order is critical: we must fully drain the buffer before re-registering + // to avoid busy-looping with level-triggered polling + for session_id in sessions_with_output { + if let Some(session) = self.state.sessions.get_mut(&session_id) { + // First, drain all available data from the PTY + let _ = session.poll(&mut self.read_buffer); + + // Now re-register for polling (after buffer is drained) + let _ = self.poller.modify( + session.fd(), + Event::readable(SESSION_KEY_BASE + session_id as usize), + ); + } + } + + // Send updates to clients if any session is dirty + if self.state.any_dirty() { + self.broadcast_updates()?; + self.state.mark_all_clean(); + } + + // Re-register listener + self.poller.modify(&self.listener, Event::readable(LISTENER_KEY))?; + + // If no tabs exist, create one automatically + if self.state.tabs.is_empty() { + log::info!("No tabs open, creating new tab"); + if let Err(e) = self.state.create_initial_tab() { + log::error!("Failed to create tab: {}", e); + } else { + // Register session FD for polling + for session in self.state.sessions.values() { + unsafe { + let _ = self.poller.add( + session.fd().as_raw_fd(), + Event::readable(SESSION_KEY_BASE + session.id as usize), + ); + } + } + } + } + } + + // Notify clients of shutdown + for client in self.clients.values_mut() { + let _ = client.send(&DaemonMessage::Shutdown); + } + + // Clean up socket + let _ = std::fs::remove_file(socket_path()); + + Ok(()) + } + + fn accept_client(&mut self) -> io::Result<()> { + match self.listener.accept() { + Ok((stream, _addr)) => { + let client_id = self.next_client_id; + self.next_client_id += 1; + + log::info!("Client {} connected", client_id); + + let client = Client::new(stream)?; + + // Register client socket for reading + unsafe { + self.poller.add( + client.stream.as_raw_fd(), + Event::readable(CLIENT_KEY_BASE + client_id), + )?; + } + + self.clients.insert(client_id, client); + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => {} + Err(e) => return Err(e), + } + Ok(()) + } + + fn handle_client(&mut self, client_id: usize) -> io::Result<()> { + // First, collect all messages from this client + // We read all available messages BEFORE re-registering to avoid busy-looping + let messages: Vec = { + let client = self.clients.get_mut(&client_id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "client not found"))?; + + let mut msgs = Vec::new(); + loop { + match client.try_recv() { + Ok(Some(msg)) => msgs.push(msg), + Ok(None) => break, + Err(e) => return Err(e), + } + } + + // Re-register for more events AFTER draining the socket + self.poller.modify(&client.stream, Event::readable(CLIENT_KEY_BASE + client_id))?; + + msgs + }; + + // Now process messages without holding client borrow + for msg in messages { + match msg { + ClientMessage::Hello { cols, rows } => { + log::info!("Client {} says hello with size {}x{}", client_id, cols, rows); + + // Update dimensions + self.state.resize(cols, rows); + + // Create initial tab if none exists + if self.state.tabs.is_empty() { + self.state.create_initial_tab() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + + // Register session FD for polling + for session in self.state.sessions.values() { + unsafe { + self.poller.add( + session.fd().as_raw_fd(), + Event::readable(SESSION_KEY_BASE + session.id as usize), + )?; + } + } + } + + // Send full state to client + self.send_full_state(client_id)?; + } + + ClientMessage::Input { data } => { + if let Some(session) = self.state.focused_session_mut() { + let _ = session.write(&data); + + // Send any terminal responses back to PTY + if let Some(response) = session.take_response() { + let _ = session.write(&response); + } + } + } + + ClientMessage::Resize { cols, rows } => { + log::debug!("Client {} resize to {}x{}", client_id, cols, rows); + self.state.resize(cols, rows); + + // Send updated state + self.broadcast_updates()?; + } + + ClientMessage::CreateTab => { + match self.state.create_tab() { + Ok(tab_id) => { + log::info!("Created tab {}", tab_id); + + // Register new session for polling + if let Some(tab) = self.state.tabs.iter().find(|t| t.id == tab_id) { + if let Some(pane) = tab.panes.first() { + if let Some(session) = self.state.sessions.get(&pane.session_id) { + unsafe { + let _ = self.poller.add( + session.fd().as_raw_fd(), + Event::readable(SESSION_KEY_BASE + session.id as usize), + ); + } + } + } + } + + // Broadcast full state update + self.broadcast_full_state()?; + } + Err(e) => { + log::error!("Failed to create tab: {}", e); + } + } + } + + ClientMessage::CloseTab { tab_id } => { + if self.state.close_tab(tab_id) { + log::info!("Closed tab {}", tab_id); + self.broadcast_full_state()?; + } + } + + ClientMessage::SwitchTab { tab_id } => { + if self.state.switch_tab(tab_id) { + log::debug!("Switched to tab {}", tab_id); + + // Send tab changed message + let active_tab = self.state.active_tab; + for client in self.clients.values_mut() { + let _ = client.send(&DaemonMessage::TabChanged { active_tab }); + } + + // Send current pane content + self.broadcast_updates()?; + } + } + + ClientMessage::NextTab => { + if self.state.next_tab() { + log::debug!("Switched to next tab"); + let active_tab = self.state.active_tab; + for client in self.clients.values_mut() { + let _ = client.send(&DaemonMessage::TabChanged { active_tab }); + } + self.broadcast_updates()?; + } + } + + ClientMessage::PrevTab => { + if self.state.prev_tab() { + log::debug!("Switched to previous tab"); + let active_tab = self.state.active_tab; + for client in self.clients.values_mut() { + let _ = client.send(&DaemonMessage::TabChanged { active_tab }); + } + self.broadcast_updates()?; + } + } + + ClientMessage::SwitchTabIndex { index } => { + if self.state.switch_tab_index(index) { + log::debug!("Switched to tab index {}", index); + let active_tab = self.state.active_tab; + for client in self.clients.values_mut() { + let _ = client.send(&DaemonMessage::TabChanged { active_tab }); + } + self.broadcast_updates()?; + } + } + + ClientMessage::SplitPane { direction } => { + match self.state.split_pane(direction) { + Ok((tab_id, pane_info)) => { + log::info!("Split pane in tab {}, new pane {}", tab_id, pane_info.id); + + // Register new session for polling + if let Some(session) = self.state.sessions.get(&pane_info.session_id) { + unsafe { + let _ = self.poller.add( + session.fd().as_raw_fd(), + Event::readable(SESSION_KEY_BASE + session.id as usize), + ); + } + } + + // Broadcast full state update + self.broadcast_full_state()?; + } + Err(e) => { + log::error!("Failed to split pane: {}", e); + } + } + } + + ClientMessage::ClosePane => { + if let Some((tab_id, pane_id, tab_closed)) = self.state.close_pane() { + if tab_closed { + log::info!("Closed pane {} (last pane, closed tab {})", pane_id, tab_id); + } else { + log::info!("Closed pane {} in tab {}", pane_id, tab_id); + } + self.broadcast_full_state()?; + } + } + + ClientMessage::FocusPane { direction } => { + if let Some((tab_id, active_pane)) = self.state.focus_pane_direction(direction) { + log::debug!("Focused pane in direction {:?}", direction); + for client in self.clients.values_mut() { + let _ = client.send(&DaemonMessage::PaneFocused { tab_id, active_pane }); + } + self.broadcast_updates()?; + } + } + + ClientMessage::Scroll { pane_id, delta } => { + // Find the session ID for this pane + let session_id = self.state.active_tab() + .and_then(|tab| tab.panes.iter().find(|p| p.id == pane_id)) + .map(|pane| pane.session_id); + + // Now adjust scroll on the session + if let Some(session_id) = session_id { + if let Some(session) = self.state.sessions.get_mut(&session_id) { + if delta > 0 { + session.terminal.scroll_viewport_up(delta as usize); + } else if delta < 0 { + session.terminal.scroll_viewport_down((-delta) as usize); + } + // Mark session dirty to trigger update + session.dirty = true; + } + } + self.broadcast_updates()?; + } + + ClientMessage::Goodbye => { + log::info!("Client {} disconnecting", client_id); + return Err(io::Error::new(io::ErrorKind::ConnectionReset, "goodbye")); + } + } + } + + Ok(()) + } + + fn remove_client(&mut self, client_id: usize) { + if let Some(client) = self.clients.remove(&client_id) { + let _ = self.poller.delete(&client.stream); + log::info!("Client {} removed", client_id); + } + } + + + fn send_full_state(&mut self, client_id: usize) -> io::Result<()> { + let window = self.state.to_protocol(); + + // Collect snapshots for all visible panes in the active tab + let panes: Vec = if let Some(tab) = self.state.active_tab() { + tab.panes.iter().filter_map(|pane| { + self.state.sessions.get(&pane.session_id) + .map(|session| session.snapshot(pane.id)) + }).collect() + } else { + Vec::new() + }; + + let msg = DaemonMessage::FullState { window, panes }; + + if let Some(client) = self.clients.get_mut(&client_id) { + client.send(&msg)?; + } + + Ok(()) + } + + fn broadcast_full_state(&mut self) -> io::Result<()> { + let window = self.state.to_protocol(); + + let panes: Vec = if let Some(tab) = self.state.active_tab() { + tab.panes.iter().filter_map(|pane| { + self.state.sessions.get(&pane.session_id) + .map(|session| session.snapshot(pane.id)) + }).collect() + } else { + Vec::new() + }; + + let msg = DaemonMessage::FullState { window, panes }; + + for client in self.clients.values_mut() { + let _ = client.send(&msg); + } + + Ok(()) + } + + fn broadcast_updates(&mut self) -> io::Result<()> { + // For now, send full state on updates + // TODO: Send incremental PaneUpdate messages instead + self.broadcast_full_state() + } +} + +/// Check if the daemon is running. +pub fn is_running() -> bool { + let socket = socket_path(); + if !socket.exists() { + return false; + } + + // Try to connect + match UnixStream::connect(&socket) { + Ok(_) => true, + Err(_) => { + // Socket exists but can't connect - stale socket + let _ = std::fs::remove_file(&socket); + false + } + } +} + +/// Start the daemon in the background. +pub fn start_daemon() -> io::Result<()> { + use std::process::Command; + + // Get path to current executable + let exe = std::env::current_exe()?; + let daemon_exe = exe.with_file_name("ztermd"); + + if !daemon_exe.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("daemon executable not found: {:?}", daemon_exe), + )); + } + + // Spawn daemon in background + Command::new(&daemon_exe) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn()?; + + // Wait a bit for it to start + std::thread::sleep(Duration::from_millis(100)); + + Ok(()) +} diff --git a/src/glyph_shader.wgsl b/src/glyph_shader.wgsl new file mode 100644 index 0000000..a7758f5 --- /dev/null +++ b/src/glyph_shader.wgsl @@ -0,0 +1,48 @@ +// Glyph rendering shader for terminal emulator + +struct VertexInput { + @location(0) position: vec2, + @location(1) uv: vec2, + @location(2) color: vec4, + @location(3) bg_color: vec4, +} + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) uv: vec2, + @location(1) color: vec4, + @location(2) bg_color: vec4, +} + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; + out.clip_position = vec4(in.position, 0.0, 1.0); + out.uv = in.uv; + out.color = in.color; + out.bg_color = in.bg_color; + return out; +} + +@group(0) @binding(0) +var atlas_texture: texture_2d; +@group(0) @binding(1) +var atlas_sampler: sampler; + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + // If UV is at origin (0,0), this is a background-only quad + let is_background_only = in.uv.x == 0.0 && in.uv.y == 0.0; + + if is_background_only { + // Just render the background color (fully opaque) + return in.bg_color; + } + + // Sample the glyph alpha from the atlas + let glyph_alpha = textureSample(atlas_texture, atlas_sampler, in.uv).r; + + // Output foreground color with glyph alpha for blending + // The background was already rendered, so we just blend the glyph on top + return vec4(in.color.rgb, in.color.a * glyph_alpha); +} diff --git a/src/keyboard.rs b/src/keyboard.rs new file mode 100644 index 0000000..8198c14 --- /dev/null +++ b/src/keyboard.rs @@ -0,0 +1,559 @@ +//! Kitty keyboard protocol implementation. +//! +//! This module implements the progressive keyboard enhancement protocol +//! as specified at: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + +use bitflags::bitflags; + +bitflags! { + /// Keyboard enhancement flags for the Kitty keyboard protocol. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] + pub struct KeyboardFlags: u8 { + /// Disambiguate escape codes (report Esc, alt+key, ctrl+key using CSI u). + const DISAMBIGUATE = 0b00001; + /// Report key repeat and release events. + const REPORT_EVENTS = 0b00010; + /// Report alternate keys (shifted key, base layout key). + const REPORT_ALTERNATES = 0b00100; + /// Report all keys as escape codes (including text-generating keys). + const REPORT_ALL_KEYS = 0b01000; + /// Report associated text with key events. + const REPORT_TEXT = 0b10000; + } +} + +/// Key event types for the keyboard protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyEventType { + Press = 1, + Repeat = 2, + Release = 3, +} + +/// Modifier flags for key events. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Modifiers { + pub shift: bool, + pub alt: bool, + pub ctrl: bool, + pub super_key: bool, + pub hyper: bool, + pub meta: bool, + pub caps_lock: bool, + pub num_lock: bool, +} + +impl Modifiers { + /// Encodes modifiers as a decimal number (1 + bitfield). + /// Returns None if no modifiers are active. + pub fn encode(&self) -> Option { + let mut bits: u8 = 0; + if self.shift { + bits |= 1; + } + if self.alt { + bits |= 2; + } + if self.ctrl { + bits |= 4; + } + if self.super_key { + bits |= 8; + } + if self.hyper { + bits |= 16; + } + if self.meta { + bits |= 32; + } + if self.caps_lock { + bits |= 64; + } + if self.num_lock { + bits |= 128; + } + + if bits == 0 { + None + } else { + Some(1 + bits) + } + } + + /// Returns true if any modifier is active. + pub fn any(&self) -> bool { + self.shift + || self.alt + || self.ctrl + || self.super_key + || self.hyper + || self.meta + || self.caps_lock + || self.num_lock + } +} + +/// Functional key codes from the Kitty keyboard protocol. +/// These are Unicode Private Use Area codepoints (57344 - 63743). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +pub enum FunctionalKey { + Escape = 27, + Enter = 13, + Tab = 9, + Backspace = 127, + Insert = 57348, + Delete = 57349, + Left = 57350, + Right = 57351, + Up = 57352, + Down = 57353, + PageUp = 57354, + PageDown = 57355, + Home = 57356, + End = 57357, + CapsLock = 57358, + ScrollLock = 57359, + NumLock = 57360, + PrintScreen = 57361, + Pause = 57362, + Menu = 57363, + F1 = 57364, + F2 = 57365, + F3 = 57366, + F4 = 57367, + F5 = 57368, + F6 = 57369, + F7 = 57370, + F8 = 57371, + F9 = 57372, + F10 = 57373, + F11 = 57374, + F12 = 57375, + F13 = 57376, + F14 = 57377, + F15 = 57378, + F16 = 57379, + F17 = 57380, + F18 = 57381, + F19 = 57382, + F20 = 57383, + F21 = 57384, + F22 = 57385, + F23 = 57386, + F24 = 57387, + F25 = 57388, + // Keypad keys + KpDecimal = 57409, + KpDivide = 57410, + KpMultiply = 57411, + KpSubtract = 57412, + KpAdd = 57413, + KpEnter = 57414, + KpEqual = 57415, + KpSeparator = 57416, + KpLeft = 57417, + KpRight = 57418, + KpUp = 57419, + KpDown = 57420, + KpPageUp = 57421, + KpPageDown = 57422, + KpHome = 57423, + KpEnd = 57424, + KpInsert = 57425, + KpDelete = 57426, + KpBegin = 57427, + // Media keys + MediaPlay = 57428, + MediaPause = 57429, + MediaPlayPause = 57430, + MediaReverse = 57431, + MediaStop = 57432, + MediaFastForward = 57433, + MediaRewind = 57434, + MediaTrackNext = 57435, + MediaTrackPrevious = 57436, + MediaRecord = 57437, + LowerVolume = 57438, + RaiseVolume = 57439, + MuteVolume = 57440, + // Modifier keys + LeftShift = 57441, + LeftControl = 57442, + LeftAlt = 57443, + LeftSuper = 57444, + LeftHyper = 57445, + LeftMeta = 57446, + RightShift = 57447, + RightControl = 57448, + RightAlt = 57449, + RightSuper = 57450, + RightHyper = 57451, + RightMeta = 57452, + IsoLevel3Shift = 57453, + IsoLevel5Shift = 57454, +} + +/// Keyboard protocol state. +#[derive(Debug, Clone)] +pub struct KeyboardState { + /// Current enhancement flags. + flags: KeyboardFlags, + /// Stack of pushed flag states (for push/pop). + stack: Vec, +} + +impl Default for KeyboardState { + fn default() -> Self { + Self::new() + } +} + +impl KeyboardState { + /// Maximum stack size to prevent DoS. + const MAX_STACK_SIZE: usize = 16; + + pub fn new() -> Self { + Self { + flags: KeyboardFlags::empty(), + stack: Vec::new(), + } + } + + /// Gets the current keyboard enhancement flags. + pub fn flags(&self) -> KeyboardFlags { + self.flags + } + + /// Sets keyboard flags using the specified mode. + /// mode 1: set all flags to the given value + /// mode 2: set bits that are set in flags, leave others unchanged + /// mode 3: reset bits that are set in flags, leave others unchanged + pub fn set_flags(&mut self, flags: u8, mode: u8) { + let new_flags = KeyboardFlags::from_bits_truncate(flags); + match mode { + 1 => self.flags = new_flags, + 2 => self.flags |= new_flags, + 3 => self.flags &= !new_flags, + _ => self.flags = new_flags, // Default to mode 1 + } + } + + /// Pushes current flags onto the stack and optionally sets new flags. + pub fn push(&mut self, flags: Option) { + // Evict oldest entry if stack is full + if self.stack.len() >= Self::MAX_STACK_SIZE { + self.stack.remove(0); + } + self.stack.push(self.flags); + if let Some(f) = flags { + self.flags = KeyboardFlags::from_bits_truncate(f); + } + } + + /// Pops entries from the stack. + pub fn pop(&mut self, count: usize) { + let count = count.max(1); + for _ in 0..count { + if let Some(flags) = self.stack.pop() { + self.flags = flags; + } else { + // Stack is empty, reset all flags + self.flags = KeyboardFlags::empty(); + break; + } + } + } + + /// Returns whether the DISAMBIGUATE flag is set. + pub fn disambiguate(&self) -> bool { + self.flags.contains(KeyboardFlags::DISAMBIGUATE) + } + + /// Returns whether the REPORT_EVENTS flag is set. + pub fn report_events(&self) -> bool { + self.flags.contains(KeyboardFlags::REPORT_EVENTS) + } + + /// Returns whether the REPORT_ALTERNATES flag is set. + pub fn report_alternates(&self) -> bool { + self.flags.contains(KeyboardFlags::REPORT_ALTERNATES) + } + + /// Returns whether the REPORT_ALL_KEYS flag is set. + pub fn report_all_keys(&self) -> bool { + self.flags.contains(KeyboardFlags::REPORT_ALL_KEYS) + } + + /// Returns whether the REPORT_TEXT flag is set. + pub fn report_text(&self) -> bool { + self.flags.contains(KeyboardFlags::REPORT_TEXT) + } +} + +/// Encodes a key event according to the Kitty keyboard protocol. +pub struct KeyEncoder<'a> { + state: &'a KeyboardState, +} + +impl<'a> KeyEncoder<'a> { + pub fn new(state: &'a KeyboardState) -> Self { + Self { state } + } + + /// Encodes a functional key press to bytes. + pub fn encode_functional( + &self, + key: FunctionalKey, + modifiers: Modifiers, + event_type: KeyEventType, + ) -> Vec { + let key_code = key as u32; + + // Special handling for legacy keys in legacy mode + if self.state.flags().is_empty() { + return self.encode_legacy_functional(key, modifiers); + } + + self.encode_csi_u(key_code, modifiers, event_type, None) + } + + /// Encodes a Unicode character key press. + pub fn encode_char( + &self, + c: char, + modifiers: Modifiers, + event_type: KeyEventType, + ) -> Vec { + let key_code = c as u32; + + // In legacy mode without REPORT_ALL_KEYS, just send the character + // (with legacy ctrl/alt handling) + if !self.state.report_all_keys() { + return self.encode_legacy_text(c, modifiers); + } + + // With REPORT_ALL_KEYS, encode as CSI u + let text = if self.state.report_text() { + Some(c) + } else { + None + }; + + self.encode_csi_u(key_code, modifiers, event_type, text) + } + + /// Encodes a key event as CSI u format. + fn encode_csi_u( + &self, + key_code: u32, + modifiers: Modifiers, + event_type: KeyEventType, + text: Option, + ) -> Vec { + let mut result = Vec::with_capacity(16); + result.extend_from_slice(b"\x1b["); + result.extend_from_slice(key_code.to_string().as_bytes()); + + let mod_value = modifiers.encode(); + let has_event_type = + self.state.report_events() && event_type != KeyEventType::Press; + + if mod_value.is_some() || has_event_type || text.is_some() { + result.push(b';'); + if let Some(m) = mod_value { + result.extend_from_slice(m.to_string().as_bytes()); + } else if has_event_type { + result.push(b'1'); // Default modifier value + } + + if has_event_type { + result.push(b':'); + result.extend_from_slice((event_type as u8).to_string().as_bytes()); + } + } + + if let Some(text_char) = text { + result.push(b';'); + result.extend_from_slice((text_char as u32).to_string().as_bytes()); + } + + result.push(b'u'); + result + } + + /// Encodes functional keys in legacy mode. + fn encode_legacy_functional(&self, key: FunctionalKey, modifiers: Modifiers) -> Vec { + let mod_param = modifiers.encode(); + + match key { + FunctionalKey::Escape => { + if modifiers.alt { + vec![0x1b, 0x1b] + } else { + vec![0x1b] + } + } + FunctionalKey::Enter => { + if modifiers.alt { + vec![0x1b, 0x0d] + } else { + vec![0x0d] + } + } + FunctionalKey::Tab => { + if modifiers.shift && !modifiers.alt && !modifiers.ctrl { + // Shift+Tab -> CSI Z + vec![0x1b, b'[', b'Z'] + } else if modifiers.alt { + vec![0x1b, 0x09] + } else { + vec![0x09] + } + } + FunctionalKey::Backspace => { + if modifiers.ctrl { + if modifiers.alt { + vec![0x1b, 0x08] + } else { + vec![0x08] + } + } else if modifiers.alt { + vec![0x1b, 0x7f] + } else { + vec![0x7f] + } + } + // Arrow keys + FunctionalKey::Up => self.encode_arrow(b'A', mod_param), + FunctionalKey::Down => self.encode_arrow(b'B', mod_param), + FunctionalKey::Right => self.encode_arrow(b'C', mod_param), + FunctionalKey::Left => self.encode_arrow(b'D', mod_param), + FunctionalKey::Home => self.encode_arrow(b'H', mod_param), + FunctionalKey::End => self.encode_arrow(b'F', mod_param), + // Function keys F1-F4 (SS3 in legacy mode without modifiers) + FunctionalKey::F1 => self.encode_f1_f4(b'P', mod_param), + FunctionalKey::F2 => self.encode_f1_f4(b'Q', mod_param), + FunctionalKey::F3 => self.encode_f1_f4(b'R', mod_param), + FunctionalKey::F4 => self.encode_f1_f4(b'S', mod_param), + // Function keys F5-F12 (CSI number ~) + FunctionalKey::F5 => self.encode_tilde(15, mod_param), + FunctionalKey::F6 => self.encode_tilde(17, mod_param), + FunctionalKey::F7 => self.encode_tilde(18, mod_param), + FunctionalKey::F8 => self.encode_tilde(19, mod_param), + FunctionalKey::F9 => self.encode_tilde(20, mod_param), + FunctionalKey::F10 => self.encode_tilde(21, mod_param), + FunctionalKey::F11 => self.encode_tilde(23, mod_param), + FunctionalKey::F12 => self.encode_tilde(24, mod_param), + // Navigation keys + FunctionalKey::Insert => self.encode_tilde(2, mod_param), + FunctionalKey::Delete => self.encode_tilde(3, mod_param), + FunctionalKey::PageUp => self.encode_tilde(5, mod_param), + FunctionalKey::PageDown => self.encode_tilde(6, mod_param), + // Other functional keys - encode as CSI u + _ => { + let key_code = key as u32; + self.encode_csi_u(key_code, modifiers, KeyEventType::Press, None) + } + } + } + + /// Encodes arrow/home/end keys: CSI 1;mod X or SS3 X (no modifiers). + fn encode_arrow(&self, letter: u8, mod_param: Option) -> Vec { + if let Some(m) = mod_param { + vec![0x1b, b'[', b'1', b';', b'0' + (m / 10), b'0' + (m % 10), letter] + .into_iter() + .filter(|&b| b != b'0' || m >= 10) + .collect::>() + .into_iter() + .take(6 + if m >= 10 { 1 } else { 0 }) + .collect() + } else { + // SS3 letter (cursor key mode) or CSI letter + vec![0x1b, b'[', letter] + } + } + + /// Encodes F1-F4: SS3 letter (no mods) or CSI 1;mod letter (with mods). + fn encode_f1_f4(&self, letter: u8, mod_param: Option) -> Vec { + if let Some(m) = mod_param { + let mut result = vec![0x1b, b'[', b'1', b';']; + result.extend_from_slice(m.to_string().as_bytes()); + result.push(letter); + result + } else { + vec![0x1b, b'O', letter] + } + } + + /// Encodes CSI number ; modifier ~ format. + fn encode_tilde(&self, number: u8, mod_param: Option) -> Vec { + let mut result = vec![0x1b, b'[']; + result.extend_from_slice(number.to_string().as_bytes()); + if let Some(m) = mod_param { + result.push(b';'); + result.extend_from_slice(m.to_string().as_bytes()); + } + result.push(b'~'); + result + } + + /// Encodes text keys in legacy mode. + fn encode_legacy_text(&self, c: char, modifiers: Modifiers) -> Vec { + // For plain text without modifiers, just send UTF-8 + if !modifiers.any() { + let mut buf = [0u8; 4]; + let s = c.encode_utf8(&mut buf); + return s.as_bytes().to_vec(); + } + + // Handle ctrl modifier for ASCII keys + if modifiers.ctrl && !modifiers.shift && c.is_ascii_lowercase() { + let ctrl_code = (c as u8) - b'a' + 1; + if modifiers.alt { + return vec![0x1b, ctrl_code]; + } else { + return vec![ctrl_code]; + } + } + + // Handle ctrl+space + if modifiers.ctrl && c == ' ' { + if modifiers.alt { + return vec![0x1b, 0x00]; + } else { + return vec![0x00]; + } + } + + // Handle alt modifier alone + if modifiers.alt && !modifiers.ctrl { + let mut buf = [0u8; 4]; + let s = c.encode_utf8(&mut buf); + let mut result = vec![0x1b]; + result.extend_from_slice(s.as_bytes()); + return result; + } + + // Handle shift (just send the shifted character) + if modifiers.shift && !modifiers.ctrl && !modifiers.alt { + let shifted = c.to_uppercase().next().unwrap_or(c); + let mut buf = [0u8; 4]; + let s = shifted.encode_utf8(&mut buf); + return s.as_bytes().to_vec(); + } + + // For complex modifier combinations, use CSI u encoding even in "legacy" mode + // This provides better compatibility than dropping the key + let key_code = c as u32; + self.encode_csi_u(key_code, modifiers, KeyEventType::Press, None) + } +} + +/// Generates the response for a keyboard mode query (CSI ? u). +pub fn query_response(flags: KeyboardFlags) -> Vec { + let mut result = vec![0x1b, b'[', b'?']; + result.extend_from_slice(flags.bits().to_string().as_bytes()); + result.push(b'u'); + result +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..67a7637 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,14 @@ +//! ZTerm - A GPU-accelerated terminal emulator for Wayland. +//! +//! This library provides shared functionality between the daemon and client. + +pub mod client; +pub mod config; +pub mod daemon; +pub mod keyboard; +pub mod protocol; +pub mod pty; +pub mod renderer; +pub mod session; +pub mod terminal; +pub mod window_state; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..dc350c1 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,798 @@ +//! ZTerm Client - GPU-accelerated terminal emulator that connects to the daemon. + +use zterm::client::DaemonClient; +use zterm::config::{Action, Config}; +use zterm::keyboard::{FunctionalKey, KeyEncoder, KeyEventType, KeyboardState, Modifiers}; +use zterm::protocol::{ClientMessage, DaemonMessage, Direction, PaneId, PaneInfo, PaneSnapshot, WindowState}; +use zterm::renderer::Renderer; + +use polling::{Event, Events, Poller}; +use std::collections::HashMap; +use std::os::fd::{AsRawFd, BorrowedFd}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use winit::application::ApplicationHandler; +use winit::dpi::PhysicalSize; +use winit::event::{ElementState, KeyEvent, Modifiers as WinitModifiers, MouseScrollDelta, WindowEvent}; +use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy}; +use winit::keyboard::{Key, NamedKey}; +use winit::platform::wayland::EventLoopBuilderExtWayland; +use winit::window::{Window, WindowId}; + +/// Main application state. +struct App { + window: Option>, + renderer: Option, + daemon_client: Option, + /// Current window state (tabs info) from daemon. + window_state: Option, + /// All pane snapshots from daemon. + panes: Vec, + /// Whether we need to redraw. + dirty: bool, + /// Current modifier state. + modifiers: WinitModifiers, + /// Keyboard state for encoding (tracks protocol mode from daemon). + keyboard_state: KeyboardState, + /// Application configuration. + config: Config, + /// Keybinding action map. + action_map: HashMap<(bool, bool, bool, bool, String), Action>, + /// Event loop proxy for waking from daemon poll thread. + event_loop_proxy: Option>, + /// Shutdown signal for daemon poll thread. + shutdown: Arc, +} + +const DAEMON_SOCKET_KEY: usize = 1; + +impl App { + fn new() -> Self { + let config = Config::load(); + log::info!("Config: font_size={}", config.font_size); + + // Build action map from keybindings + let action_map = config.keybindings.build_action_map(); + + Self { + window: None, + renderer: None, + daemon_client: None, + window_state: None, + panes: Vec::new(), + dirty: true, + modifiers: WinitModifiers::default(), + keyboard_state: KeyboardState::new(), + config, + action_map, + event_loop_proxy: None, + shutdown: Arc::new(AtomicBool::new(false)), + } + } + + fn set_event_loop_proxy(&mut self, proxy: EventLoopProxy<()>) { + self.event_loop_proxy = Some(proxy); + } + + fn initialize(&mut self, event_loop: &ActiveEventLoop) { + let init_start = std::time::Instant::now(); + + // Create window first so it appears immediately + let mut window_attributes = Window::default_attributes() + .with_title("ZTerm") + .with_inner_size(PhysicalSize::new(800, 600)); + + // Enable transparency if background opacity is less than 1.0 + if self.config.background_opacity < 1.0 { + window_attributes = window_attributes.with_transparent(true); + } + + let window = Arc::new( + event_loop + .create_window(window_attributes) + .expect("Failed to create window"), + ); + log::debug!("Window created in {:?}", init_start.elapsed()); + + // Start daemon connection in parallel with renderer initialization + let daemon_start = std::time::Instant::now(); + let mut daemon_client = match DaemonClient::connect() { + Ok(client) => client, + Err(e) => { + log::error!("Failed to connect to daemon: {}", e); + event_loop.exit(); + return; + } + }; + log::debug!("Daemon connected in {:?}", daemon_start.elapsed()); + + // Create renderer (this is the slow part - GPU initialization) + let renderer_start = std::time::Instant::now(); + let renderer = pollster::block_on(Renderer::new(window.clone(), &self.config)); + log::debug!("Renderer created in {:?}", renderer_start.elapsed()); + + // Calculate terminal size based on window size + let (cols, rows) = renderer.terminal_size(); + + // Send hello with our size + if let Err(e) = daemon_client.hello(cols, rows) { + log::error!("Failed to send hello: {}", e); + event_loop.exit(); + return; + } + + // Wait for initial state + match daemon_client.recv() { + Ok(DaemonMessage::FullState { window: win_state, panes }) => { + log::debug!("Received initial state with {} tabs, {} panes", + win_state.tabs.len(), panes.len()); + self.window_state = Some(win_state); + self.panes = panes; + } + Ok(msg) => { + log::warn!("Unexpected initial message: {:?}", msg); + } + Err(e) => { + log::error!("Failed to receive initial state: {}", e); + event_loop.exit(); + return; + } + } + + // Switch to non-blocking mode for the event loop + if let Err(e) = daemon_client.set_nonblocking() { + log::error!("Failed to set non-blocking mode: {}", e); + event_loop.exit(); + return; + } + + // Set up polling for daemon socket in a background thread + // This thread will wake the event loop when data is available + if let Some(proxy) = self.event_loop_proxy.clone() { + let daemon_fd = daemon_client.as_raw_fd(); + let shutdown = self.shutdown.clone(); + + std::thread::spawn(move || { + let poller = match Poller::new() { + Ok(p) => p, + Err(e) => { + log::error!("Failed to create poller: {}", e); + return; + } + }; + + // SAFETY: daemon_fd is valid for the lifetime of the daemon_client, + // and we signal shutdown before dropping daemon_client + unsafe { + if let Err(e) = poller.add(daemon_fd, Event::readable(DAEMON_SOCKET_KEY)) { + log::error!("Failed to add daemon socket to poller: {}", e); + return; + } + } + + let mut events = Events::new(); + + while !shutdown.load(Ordering::Relaxed) { + events.clear(); + + // Wait for data with a timeout so we can check shutdown + match poller.wait(&mut events, Some(Duration::from_millis(100))) { + Ok(_) if !events.is_empty() => { + // Wake the event loop by sending an empty event + let _ = proxy.send_event(()); + + // Re-register for more events + // SAFETY: daemon_fd is still valid + unsafe { + let _ = poller.modify( + std::os::fd::BorrowedFd::borrow_raw(daemon_fd), + Event::readable(DAEMON_SOCKET_KEY) + ); + } + } + Ok(_) => {} // Timeout, no events + Err(e) => { + log::error!("Poller error: {}", e); + break; + } + } + } + + log::debug!("Daemon poll thread exiting"); + }); + } + + self.window = Some(window); + self.renderer = Some(renderer); + self.daemon_client = Some(daemon_client); + + log::info!("Client initialized in {:?}: {}x{} cells", init_start.elapsed(), cols, rows); + } + + /// Gets all pane snapshots with their layout info for the active tab. + /// Returns (panes_with_info, active_pane_id) with cloned/owned data. + fn active_tab_panes(&self) -> (Vec<(PaneSnapshot, PaneInfo)>, PaneId) { + let Some(win) = self.window_state.as_ref() else { + return (Vec::new(), 0); + }; + let Some(tab) = win.tabs.get(win.active_tab) else { + return (Vec::new(), 0); + }; + + let active_pane_id = tab.panes.get(tab.active_pane) + .map(|p| p.id) + .unwrap_or(0); + + let panes_with_info: Vec<(PaneSnapshot, PaneInfo)> = tab.panes.iter() + .filter_map(|pane_info| { + self.panes.iter() + .find(|snap| snap.pane_id == pane_info.id) + .map(|snap| (snap.clone(), pane_info.clone())) + }) + .collect(); + + (panes_with_info, active_pane_id) + } + + /// Gets the active pane ID and its snapshot. + fn get_active_pane(&self) -> Option<(PaneId, &PaneSnapshot)> { + let win = self.window_state.as_ref()?; + let tab = win.tabs.get(win.active_tab)?; + let pane_info = tab.panes.get(tab.active_pane)?; + let snapshot = self.panes.iter().find(|s| s.pane_id == pane_info.id)?; + Some((pane_info.id, snapshot)) + } + + fn poll_daemon(&mut self) { + let Some(client) = &mut self.daemon_client else { return }; + + // Read all available messages (non-blocking) + // The background thread wakes us when data is available + let mut messages = Vec::new(); + loop { + match client.try_recv() { + Ok(Some(msg)) => { + messages.push(msg); + } + Ok(None) => break, + Err(e) => { + log::error!("Daemon connection error: {}", e); + // Daemon disconnected - we'll handle this after the loop + messages.push(DaemonMessage::Shutdown); + break; + } + } + } + + // Now process messages without holding the client borrow + for msg in messages { + self.handle_daemon_message(msg); + } + } + + fn handle_daemon_message(&mut self, msg: DaemonMessage) { + match msg { + DaemonMessage::FullState { window, panes } => { + log::debug!("Received full state: {} tabs, {} panes", + window.tabs.len(), panes.len()); + self.window_state = Some(window); + self.panes = panes; + self.dirty = true; + } + DaemonMessage::PaneUpdate { pane_id, cells, cursor } => { + log::debug!("Received pane update for pane {}", pane_id); + if let Some(pane) = self.panes.iter_mut().find(|p| p.pane_id == pane_id) { + pane.cells = cells; + pane.cursor = cursor; + } else { + // New pane + self.panes.push(PaneSnapshot { + pane_id, + cells, + cursor, + scroll_offset: 0, + scrollback_len: 0, + }); + } + self.dirty = true; + } + DaemonMessage::TabChanged { active_tab } => { + log::debug!("Tab changed to {}", active_tab); + if let Some(ref mut win) = self.window_state { + win.active_tab = active_tab; + } + self.dirty = true; + } + DaemonMessage::TabCreated { tab } => { + log::debug!("Tab created: {:?}", tab); + if let Some(ref mut win) = self.window_state { + win.tabs.push(tab); + } + self.dirty = true; + } + DaemonMessage::TabClosed { tab_id } => { + log::debug!("Tab closed: {}", tab_id); + if let Some(ref mut win) = self.window_state { + win.tabs.retain(|t| t.id != tab_id); + // Adjust active tab if needed + if win.active_tab >= win.tabs.len() && !win.tabs.is_empty() { + win.active_tab = win.tabs.len() - 1; + } + } + // Remove panes for closed tab + // Note: daemon should send updated panes, but we clean up just in case + self.dirty = true; + } + DaemonMessage::PaneCreated { tab_id, pane } => { + log::debug!("Pane created in tab {}: {:?}", tab_id, pane); + if let Some(ref mut win) = self.window_state { + if let Some(tab) = win.tabs.iter_mut().find(|t| t.id == tab_id) { + tab.panes.push(pane); + } + } + self.dirty = true; + } + DaemonMessage::PaneClosed { tab_id, pane_id } => { + log::debug!("Pane {} closed in tab {}", pane_id, tab_id); + if let Some(ref mut win) = self.window_state { + if let Some(tab) = win.tabs.iter_mut().find(|t| t.id == tab_id) { + tab.panes.retain(|p| p.id != pane_id); + } + } + // Also remove from pane snapshots + self.panes.retain(|p| p.pane_id != pane_id); + self.dirty = true; + } + DaemonMessage::PaneFocused { tab_id, active_pane } => { + log::debug!("Pane focus changed in tab {}: pane {}", tab_id, active_pane); + if let Some(ref mut win) = self.window_state { + if let Some(tab) = win.tabs.iter_mut().find(|t| t.id == tab_id) { + tab.active_pane = active_pane; + } + } + self.dirty = true; + } + DaemonMessage::Shutdown => { + log::info!("Daemon shutting down"); + self.daemon_client = None; + } + } + } + + /// Checks if the key event matches a keybinding and executes the action. + /// Returns true if the key was consumed by a keybinding. + fn check_keybinding(&mut self, event: &KeyEvent) -> bool { + // Only process key presses, not releases or repeats + if event.state != ElementState::Pressed || event.repeat { + return false; + } + + let mod_state = self.modifiers.state(); + let ctrl = mod_state.control_key(); + let alt = mod_state.alt_key(); + let shift = mod_state.shift_key(); + let super_key = mod_state.super_key(); + + // Get the key name + let key_name = match &event.logical_key { + Key::Named(named) => { + match named { + NamedKey::Tab => "tab".to_string(), + NamedKey::Enter => "enter".to_string(), + NamedKey::Escape => "escape".to_string(), + NamedKey::Backspace => "backspace".to_string(), + NamedKey::Delete => "delete".to_string(), + NamedKey::Insert => "insert".to_string(), + NamedKey::Home => "home".to_string(), + NamedKey::End => "end".to_string(), + NamedKey::PageUp => "pageup".to_string(), + NamedKey::PageDown => "pagedown".to_string(), + NamedKey::ArrowUp => "up".to_string(), + NamedKey::ArrowDown => "down".to_string(), + NamedKey::ArrowLeft => "left".to_string(), + NamedKey::ArrowRight => "right".to_string(), + NamedKey::Space => " ".to_string(), + NamedKey::F1 => "f1".to_string(), + NamedKey::F2 => "f2".to_string(), + NamedKey::F3 => "f3".to_string(), + NamedKey::F4 => "f4".to_string(), + NamedKey::F5 => "f5".to_string(), + NamedKey::F6 => "f6".to_string(), + NamedKey::F7 => "f7".to_string(), + NamedKey::F8 => "f8".to_string(), + NamedKey::F9 => "f9".to_string(), + NamedKey::F10 => "f10".to_string(), + NamedKey::F11 => "f11".to_string(), + NamedKey::F12 => "f12".to_string(), + _ => return false, + } + } + Key::Character(c) => c.to_lowercase(), + _ => return false, + }; + + // Look up the action + let lookup = (ctrl, alt, shift, super_key, key_name); + let Some(action) = self.action_map.get(&lookup).copied() else { + return false; + }; + + // Execute the action + self.execute_action(action); + true + } + + fn execute_action(&mut self, action: Action) { + let Some(client) = &mut self.daemon_client else { return }; + + match action { + Action::NewTab => { + log::debug!("Action: NewTab"); + let _ = client.create_tab(); + } + Action::NextTab => { + log::debug!("Action: NextTab"); + let _ = client.next_tab(); + } + Action::PrevTab => { + log::debug!("Action: PrevTab"); + let _ = client.prev_tab(); + } + Action::Tab1 => { let _ = client.switch_tab_index(0); } + Action::Tab2 => { let _ = client.switch_tab_index(1); } + Action::Tab3 => { let _ = client.switch_tab_index(2); } + Action::Tab4 => { let _ = client.switch_tab_index(3); } + Action::Tab5 => { let _ = client.switch_tab_index(4); } + Action::Tab6 => { let _ = client.switch_tab_index(5); } + Action::Tab7 => { let _ = client.switch_tab_index(6); } + Action::Tab8 => { let _ = client.switch_tab_index(7); } + Action::Tab9 => { let _ = client.switch_tab_index(8); } + Action::SplitHorizontal => { + log::debug!("Action: SplitHorizontal"); + let _ = client.split_horizontal(); + } + Action::SplitVertical => { + log::debug!("Action: SplitVertical"); + let _ = client.split_vertical(); + } + Action::ClosePane => { + log::debug!("Action: ClosePane"); + let _ = client.close_pane(); + } + Action::FocusPaneUp => { + log::debug!("Action: FocusPaneUp"); + let _ = client.focus_pane(Direction::Up); + } + Action::FocusPaneDown => { + log::debug!("Action: FocusPaneDown"); + let _ = client.focus_pane(Direction::Down); + } + Action::FocusPaneLeft => { + log::debug!("Action: FocusPaneLeft"); + let _ = client.focus_pane(Direction::Left); + } + Action::FocusPaneRight => { + log::debug!("Action: FocusPaneRight"); + let _ = client.focus_pane(Direction::Right); + } + } + } + + fn handle_keyboard_input(&mut self, event: KeyEvent) { + // First check if this is a keybinding + if self.check_keybinding(&event) { + return; + } + + // Determine event type + let event_type = match event.state { + ElementState::Pressed => { + if event.repeat { + KeyEventType::Repeat + } else { + KeyEventType::Press + } + } + ElementState::Released => KeyEventType::Release, + }; + + // In legacy mode, ignore release events + if event_type == KeyEventType::Release && !self.keyboard_state.report_events() { + return; + } + + // Build modifiers from the tracked state + let mod_state = self.modifiers.state(); + let modifiers = Modifiers { + shift: mod_state.shift_key(), + alt: mod_state.alt_key(), + ctrl: mod_state.control_key(), + super_key: mod_state.super_key(), + hyper: false, + meta: false, + caps_lock: false, + num_lock: false, + }; + + let encoder = KeyEncoder::new(&self.keyboard_state); + + let bytes: Option> = match &event.logical_key { + Key::Named(named) => { + let func_key = match named { + NamedKey::Enter => Some(FunctionalKey::Enter), + NamedKey::Backspace => Some(FunctionalKey::Backspace), + NamedKey::Tab => Some(FunctionalKey::Tab), + NamedKey::Escape => Some(FunctionalKey::Escape), + NamedKey::Space => None, + NamedKey::ArrowUp => Some(FunctionalKey::Up), + NamedKey::ArrowDown => Some(FunctionalKey::Down), + NamedKey::ArrowRight => Some(FunctionalKey::Right), + NamedKey::ArrowLeft => Some(FunctionalKey::Left), + NamedKey::Home => Some(FunctionalKey::Home), + NamedKey::End => Some(FunctionalKey::End), + NamedKey::PageUp => Some(FunctionalKey::PageUp), + NamedKey::PageDown => Some(FunctionalKey::PageDown), + NamedKey::Insert => Some(FunctionalKey::Insert), + NamedKey::Delete => Some(FunctionalKey::Delete), + NamedKey::F1 => Some(FunctionalKey::F1), + NamedKey::F2 => Some(FunctionalKey::F2), + NamedKey::F3 => Some(FunctionalKey::F3), + NamedKey::F4 => Some(FunctionalKey::F4), + NamedKey::F5 => Some(FunctionalKey::F5), + NamedKey::F6 => Some(FunctionalKey::F6), + NamedKey::F7 => Some(FunctionalKey::F7), + NamedKey::F8 => Some(FunctionalKey::F8), + NamedKey::F9 => Some(FunctionalKey::F9), + NamedKey::F10 => Some(FunctionalKey::F10), + NamedKey::F11 => Some(FunctionalKey::F11), + NamedKey::F12 => Some(FunctionalKey::F12), + NamedKey::CapsLock => Some(FunctionalKey::CapsLock), + NamedKey::ScrollLock => Some(FunctionalKey::ScrollLock), + NamedKey::NumLock => Some(FunctionalKey::NumLock), + NamedKey::PrintScreen => Some(FunctionalKey::PrintScreen), + NamedKey::Pause => Some(FunctionalKey::Pause), + NamedKey::ContextMenu => Some(FunctionalKey::Menu), + _ => None, + }; + + if let Some(key) = func_key { + Some(encoder.encode_functional(key, modifiers, event_type)) + } else if *named == NamedKey::Space { + Some(encoder.encode_char(' ', modifiers, event_type)) + } else { + None + } + } + Key::Character(c) => { + if let Some(ch) = c.chars().next() { + let key_char = ch.to_lowercase().next().unwrap_or(ch); + Some(encoder.encode_char(key_char, modifiers, event_type)) + } else { + None + } + } + _ => None, + }; + + if let Some(bytes) = bytes { + // Check scroll offset before borrowing client mutably + let scroll_reset = self.get_active_pane() + .filter(|(_, snapshot)| snapshot.scroll_offset > 0) + .map(|(pane_id, snapshot)| (pane_id, snapshot.scroll_offset)); + + // Now borrow client mutably + if let Some(client) = &mut self.daemon_client { + let _ = client.send_input(bytes); + + // Reset scroll position when typing (go back to live terminal) + if let Some((active_pane_id, scroll_offset)) = scroll_reset { + let _ = client.send(&ClientMessage::Scroll { + pane_id: active_pane_id, + delta: -(scroll_offset as i32) + }); + } + } + } + } + + fn resize(&mut self, new_size: PhysicalSize) { + if new_size.width == 0 || new_size.height == 0 { + return; + } + + if let Some(renderer) = &mut self.renderer { + renderer.resize(new_size.width, new_size.height); + + let (cols, rows) = renderer.terminal_size(); + + if let Some(client) = &mut self.daemon_client { + let _ = client.send_resize(cols, rows); + } + + log::debug!("Resized to {}x{} cells", cols, rows); + self.dirty = true; + } + } +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_none() { + self.initialize(event_loop); + } + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + match event { + WindowEvent::CloseRequested => { + log::info!("Window close requested"); + event_loop.exit(); + } + + WindowEvent::Resized(new_size) => { + self.resize(new_size); + } + + WindowEvent::ScaleFactorChanged { scale_factor, .. } => { + log::info!("Scale factor changed to {}", scale_factor); + if let Some(renderer) = &mut self.renderer { + if renderer.set_scale_factor(scale_factor) { + let (cols, rows) = renderer.terminal_size(); + + if let Some(client) = &mut self.daemon_client { + let _ = client.send_resize(cols, rows); + } + + log::info!("Terminal resized to {}x{} cells after scale change", cols, rows); + } + + if let Some(window) = &self.window { + window.request_redraw(); + } + } + } + + WindowEvent::ModifiersChanged(new_modifiers) => { + self.modifiers = new_modifiers; + } + + WindowEvent::MouseWheel { delta, .. } => { + // Handle mouse wheel for scrollback + let lines = match delta { + MouseScrollDelta::LineDelta(_, y) => { + // y > 0 means scrolling up (into history), y < 0 means down + (y * 3.0) as i32 // 3 lines per scroll notch + } + MouseScrollDelta::PixelDelta(pos) => { + // Convert pixels to lines (rough approximation) + (pos.y / 20.0) as i32 + } + }; + + if lines != 0 { + // Get the active pane ID to scroll + if let Some((active_pane_id, _)) = self.get_active_pane() { + if let Some(client) = &mut self.daemon_client { + let _ = client.send(&ClientMessage::Scroll { + pane_id: active_pane_id, + delta: lines + }); + } + } + if let Some(window) = &self.window { + window.request_redraw(); + } + } + } + + WindowEvent::KeyboardInput { event, .. } => { + self.handle_keyboard_input(event); + if let Some(window) = &self.window { + window.request_redraw(); + } + } + + WindowEvent::RedrawRequested => { + // Gather all panes for the active tab with their layout info (cloned to avoid borrow conflict) + let (panes_with_info, active_pane_id) = self.active_tab_panes(); + let tabs = self.window_state.as_ref().map(|w| w.tabs.clone()); + let active_tab = self.window_state.as_ref().map(|w| w.active_tab).unwrap_or(0); + + if let Some(renderer) = &mut self.renderer { + if !panes_with_info.is_empty() { + let tabs = tabs.unwrap_or_default(); + // Convert owned data to references for the renderer + let pane_refs: Vec<(&PaneSnapshot, &PaneInfo)> = panes_with_info.iter() + .map(|(snap, info)| (snap, info)) + .collect(); + match renderer.render_with_tabs(&pane_refs, active_pane_id, &tabs, active_tab) { + Ok(_) => {} + Err(wgpu::SurfaceError::Lost) => { + renderer.resize(renderer.width, renderer.height); + } + Err(wgpu::SurfaceError::OutOfMemory) => { + log::error!("Out of GPU memory!"); + event_loop.exit(); + } + Err(e) => { + log::error!("Render error: {:?}", e); + } + } + self.dirty = false; + } + } + } + + _ => {} + } + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + // Check if daemon is still connected + if self.daemon_client.is_none() { + log::info!("Lost connection to daemon, exiting"); + event_loop.exit(); + return; + } + + // Poll daemon for updates + self.poll_daemon(); + + // Request redraw if we have new content + if self.dirty { + if let Some(window) = &self.window { + window.request_redraw(); + } + } + + // Use WaitUntil to wake up periodically and check for daemon messages + // This is more compatible than relying on send_event across threads + event_loop.set_control_flow(ControlFlow::WaitUntil( + std::time::Instant::now() + Duration::from_millis(16) + )); + } + + fn user_event(&mut self, _event_loop: &ActiveEventLoop, _event: ()) { + // Daemon poll thread woke us up - poll for messages + self.poll_daemon(); + + // Request redraw if we have new content + if self.dirty { + if let Some(window) = &self.window { + window.request_redraw(); + } + } + } +} + +impl Drop for App { + fn drop(&mut self) { + // Signal the daemon poll thread to exit + self.shutdown.store(true, Ordering::Relaxed); + } +} + +fn main() { + // Initialize logging + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + log::info!("Starting ZTerm client"); + + // Create event loop with Wayland preference + let event_loop = EventLoop::builder() + .with_any_thread(true) + .build() + .expect("Failed to create event loop"); + + // Use Wait instead of Poll to avoid busy-looping + // The daemon poll thread will wake us when data is available + event_loop.set_control_flow(ControlFlow::Wait); + + let mut app = App::new(); + + // Give the app a proxy to wake the event loop from the daemon poll thread + app.set_event_loop_proxy(event_loop.create_proxy()); + + event_loop.run_app(&mut app).expect("Event loop error"); +} diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..488aede --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,254 @@ +//! Protocol messages for daemon/client communication. +//! +//! The daemon owns all terminal state (sessions, tabs, panes). +//! The client is a thin rendering layer that receives cell data and sends input. + +use serde::{Deserialize, Serialize}; + +/// Unique identifier for a session (owns a PTY + terminal state). +pub type SessionId = u32; + +/// Unique identifier for a pane within a tab. +pub type PaneId = u32; + +/// Unique identifier for a tab. +pub type TabId = u32; + +/// Direction for splitting a pane. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SplitDirection { + /// Split horizontally (new pane below). + Horizontal, + /// Split vertically (new pane to the right). + Vertical, +} + +/// Direction for pane navigation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Direction { + /// Navigate up. + Up, + /// Navigate down. + Down, + /// Navigate left. + Left, + /// Navigate right. + Right, +} + +/// Cursor shape styles. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CursorStyle { + /// Block cursor (like normal mode in vim). + #[default] + Block, + /// Underline cursor. + Underline, + /// Bar/beam cursor (like insert mode in vim). + Bar, +} + +/// A single cell to be rendered by the client. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RenderCell { + pub character: char, + pub fg_color: CellColor, + pub bg_color: CellColor, + pub bold: bool, + pub italic: bool, + pub underline: bool, +} + +/// Color representation for protocol messages. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] +pub enum CellColor { + /// Default foreground or background. + Default, + /// RGB color. + Rgb(u8, u8, u8), + /// Indexed color (0-255). + Indexed(u8), +} + +/// A pane's layout within a tab. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PaneInfo { + pub id: PaneId, + pub session_id: SessionId, + /// Position and size in cells (for future splits). + /// For now, always (0, 0, cols, rows). + pub x: usize, + pub y: usize, + pub cols: usize, + pub rows: usize, +} + +/// A tab containing one or more panes. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TabInfo { + pub id: TabId, + /// Index of the active/focused pane within this tab. + pub active_pane: usize, + pub panes: Vec, +} + +/// Cursor information for a pane. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CursorInfo { + pub col: usize, + pub row: usize, + pub visible: bool, + pub style: CursorStyle, +} + +/// Full window state sent to client on connect. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WindowState { + /// All tabs. + pub tabs: Vec, + /// Index of the active tab. + pub active_tab: usize, + /// Terminal dimensions in cells. + pub cols: usize, + pub rows: usize, +} + +/// Messages sent from client to daemon. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum ClientMessage { + /// Client is connecting and requests full state. + /// Includes the client's window size. + Hello { cols: usize, rows: usize }, + + /// Keyboard input to send to the focused session. + Input { data: Vec }, + + /// Window was resized. + Resize { cols: usize, rows: usize }, + + /// Request to create a new tab. + CreateTab, + + /// Request to close the current tab. + CloseTab { tab_id: TabId }, + + /// Switch to a different tab by ID. + SwitchTab { tab_id: TabId }, + + /// Switch to next tab. + NextTab, + + /// Switch to previous tab. + PrevTab, + + /// Switch to tab by index (0-based). + SwitchTabIndex { index: usize }, + + /// Split the current pane. + SplitPane { direction: SplitDirection }, + + /// Close the current pane (closes tab if last pane). + ClosePane, + + /// Focus a pane in the given direction. + FocusPane { direction: Direction }, + + /// Scroll the viewport (for scrollback viewing). + /// Positive delta scrolls up (into history), negative scrolls down (toward live). + Scroll { pane_id: PaneId, delta: i32 }, + + /// Client is disconnecting gracefully. + Goodbye, +} + +/// Messages sent from daemon to client. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum DaemonMessage { + /// Full state snapshot (sent on connect and major changes). + FullState { + window: WindowState, + /// Cell data for all visible panes, keyed by pane ID. + /// Each pane has rows x cols cells. + panes: Vec, + }, + + /// Incremental update for a single pane. + PaneUpdate { + pane_id: PaneId, + cells: Vec>, + cursor: CursorInfo, + }, + + /// Active tab changed. + TabChanged { active_tab: usize }, + + /// A tab was created. + TabCreated { tab: TabInfo }, + + /// A tab was closed. + TabClosed { tab_id: TabId }, + + /// A pane was created (split). + PaneCreated { tab_id: TabId, pane: PaneInfo }, + + /// A pane was closed. + PaneClosed { tab_id: TabId, pane_id: PaneId }, + + /// Active pane changed within a tab. + PaneFocused { tab_id: TabId, active_pane: usize }, + + /// Daemon is shutting down. + Shutdown, +} + +/// Snapshot of a pane's content. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PaneSnapshot { + pub pane_id: PaneId, + pub cells: Vec>, + pub cursor: CursorInfo, + /// Current scroll offset (0 = live terminal, >0 = viewing scrollback). + pub scroll_offset: usize, + /// Total lines in scrollback buffer. + pub scrollback_len: usize, +} + +/// Wire format for messages: length-prefixed JSON. +/// Format: [4 bytes little-endian length][JSON payload] +pub mod wire { + use super::*; + use std::io::{self, Read, Write}; + + /// Write a message to a writer with length prefix. + pub fn write_message(writer: &mut W, msg: &M) -> io::Result<()> { + let json = serde_json::to_vec(msg).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + let len = json.len() as u32; + writer.write_all(&len.to_le_bytes())?; + writer.write_all(&json)?; + writer.flush()?; + Ok(()) + } + + /// Read a message from a reader with length prefix. + pub fn read_message Deserialize<'de>>(reader: &mut R) -> io::Result { + let mut len_buf = [0u8; 4]; + reader.read_exact(&mut len_buf)?; + let len = u32::from_le_bytes(len_buf) as usize; + + // Sanity check to prevent huge allocations + if len > 64 * 1024 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "message too large", + )); + } + + let mut buf = vec![0u8; len]; + reader.read_exact(&mut buf)?; + + serde_json::from_slice(&buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) + } +} diff --git a/src/pty.rs b/src/pty.rs new file mode 100644 index 0000000..f3ae79a --- /dev/null +++ b/src/pty.rs @@ -0,0 +1,175 @@ +//! PTY (pseudo-terminal) handling for shell communication. + +use rustix::fs::{fcntl_setfl, OFlags}; +use rustix::io::{read, write, Errno}; +use rustix::pty::{grantpt, openpt, ptsname, unlockpt, OpenptFlags}; +use std::ffi::CString; +use std::os::fd::{AsFd, BorrowedFd, OwnedFd}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum PtyError { + #[error("Failed to open PTY master: {0}")] + OpenMaster(#[source] rustix::io::Errno), + #[error("Failed to grant PTY: {0}")] + Grant(#[source] rustix::io::Errno), + #[error("Failed to unlock PTY: {0}")] + Unlock(#[source] rustix::io::Errno), + #[error("Failed to get PTS name: {0}")] + PtsName(#[source] rustix::io::Errno), + #[error("Failed to open PTS: {0}")] + OpenSlave(#[source] rustix::io::Errno), + #[error("Failed to fork: {0}")] + Fork(#[source] std::io::Error), + #[error("Failed to execute shell: {0}")] + Exec(#[source] std::io::Error), + #[error("I/O error: {0}")] + Io(#[source] std::io::Error), +} + +/// Represents the master side of a PTY pair. +pub struct Pty { + master: OwnedFd, + child_pid: rustix::process::Pid, +} + +impl Pty { + /// Creates a new PTY and spawns a shell process. + pub fn spawn(shell: Option<&str>) -> Result { + // Open the PTY master + let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC) + .map_err(PtyError::OpenMaster)?; + + // Set non-blocking mode on master + fcntl_setfl(&master, OFlags::NONBLOCK).map_err(|e| PtyError::Io(e.into()))?; + + // Grant and unlock the PTY + grantpt(&master).map_err(PtyError::Grant)?; + unlockpt(&master).map_err(PtyError::Unlock)?; + + // Get the slave name + let slave_name = ptsname(&master, Vec::new()).map_err(PtyError::PtsName)?; + + // Fork the process + // SAFETY: We're careful to only use async-signal-safe functions in the child + let fork_result = unsafe { libc::fork() }; + + match fork_result { + -1 => Err(PtyError::Fork(std::io::Error::last_os_error())), + 0 => { + // Child process + Self::setup_child(&slave_name, shell); + } + pid => { + // Parent process + let child_pid = unsafe { rustix::process::Pid::from_raw_unchecked(pid) }; + Ok(Self { master, child_pid }) + } + } + } + + /// Sets up the child process (runs in forked child). + fn setup_child(slave_name: &CString, shell: Option<&str>) -> ! { + // Create a new session + unsafe { libc::setsid() }; + + // Open the slave PTY using libc for async-signal-safety + let slave_fd = unsafe { libc::open(slave_name.as_ptr(), libc::O_RDWR) }; + if slave_fd < 0 { + unsafe { libc::_exit(1) }; + } + + // Set as controlling terminal + unsafe { libc::ioctl(slave_fd, libc::TIOCSCTTY, 0) }; + + // Duplicate slave to stdin/stdout/stderr + unsafe { + libc::dup2(slave_fd, 0); + libc::dup2(slave_fd, 1); + libc::dup2(slave_fd, 2); + } + + // Close the original slave fd if it's not 0, 1, or 2 + if slave_fd > 2 { + unsafe { libc::close(slave_fd) }; + } + + // Determine which shell to use + let shell_path = shell + .map(String::from) + .or_else(|| std::env::var("SHELL").ok()) + .unwrap_or_else(|| "/bin/sh".to_string()); + + let shell_cstr = CString::new(shell_path.clone()).expect("Invalid shell path"); + let shell_name = std::path::Path::new(&shell_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("sh"); + + // Login shell (prepend with -) + let login_shell = CString::new(format!("-{}", shell_name)).expect("Invalid shell name"); + + // Execute the shell + let args = [login_shell.as_ptr(), std::ptr::null()]; + + unsafe { + libc::execvp(shell_cstr.as_ptr(), args.as_ptr()); + } + + // If exec fails, exit + std::process::exit(1); + } + + /// Returns a reference to the master file descriptor. + pub fn master_fd(&self) -> BorrowedFd<'_> { + self.master.as_fd() + } + + /// Reads data from the PTY master. + /// Returns Ok(0) if no data is available (non-blocking). + pub fn read(&self, buf: &mut [u8]) -> Result { + match read(&self.master, buf) { + Ok(n) => Ok(n), + Err(Errno::AGAIN) => Ok(0), // WOULDBLOCK is same as AGAIN on Linux + Err(e) => Err(PtyError::Io(e.into())), + } + } + + /// Writes data to the PTY master. + pub fn write(&self, buf: &[u8]) -> Result { + write(&self.master, buf).map_err(|e| PtyError::Io(e.into())) + } + + /// Resizes the PTY window. + pub fn resize(&self, cols: u16, rows: u16) -> Result<(), PtyError> { + let winsize = libc::winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + + let fd = std::os::fd::AsRawFd::as_raw_fd(&self.master); + let result = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &winsize) }; + + if result == -1 { + Err(PtyError::Io(std::io::Error::last_os_error())) + } else { + Ok(()) + } + } + + /// Returns the child process ID. + pub fn child_pid(&self) -> rustix::process::Pid { + self.child_pid + } +} + +impl Drop for Pty { + fn drop(&mut self) { + // Send SIGHUP to the child process + unsafe { + libc::kill(self.child_pid.as_raw_nonzero().get(), libc::SIGHUP); + } + } +} diff --git a/src/renderer.rs b/src/renderer.rs new file mode 100644 index 0000000..03ed2b0 --- /dev/null +++ b/src/renderer.rs @@ -0,0 +1,3666 @@ +//! GPU-accelerated terminal rendering using wgpu with a glyph atlas. +//! Uses rustybuzz (HarfBuzz port) for text shaping to support font features. + +use crate::config::TabBarPosition; +use crate::protocol::{CellColor, CursorStyle, PaneId, PaneInfo, PaneSnapshot, TabInfo}; +use crate::terminal::{Color, ColorPalette, CursorShape, Terminal}; +use fontdue::Font as FontdueFont; +use rustybuzz::UnicodeBuffer; +use std::collections::HashMap; +use std::sync::Arc; + +/// Size of the glyph atlas texture. +const ATLAS_SIZE: u32 = 1024; + +/// Cached glyph information. +#[derive(Clone, Copy, Debug)] +struct GlyphInfo { + /// UV coordinates in the atlas (left, top, width, height) normalized 0-1. + uv: [f32; 4], + /// Offset from cell origin to glyph origin. + offset: [f32; 2], + /// Size of the glyph in pixels. + size: [f32; 2], + /// Advance width (how much to move cursor after this glyph). + advance: f32, +} + +/// Wrapper to hold the rustybuzz Face with a 'static lifetime. +/// This is safe because we keep font_data alive for the lifetime of the Renderer. +struct ShapingContext { + face: rustybuzz::Face<'static>, +} + +/// Result of shaping a text sequence. +#[derive(Clone, Debug)] +struct ShapedGlyphs { + /// Glyph IDs and their advances. + glyphs: Vec<(u16, f32)>, +} + +/// Vertex for rendering textured quads. +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +struct GlyphVertex { + position: [f32; 2], + uv: [f32; 2], + color: [f32; 4], + bg_color: [f32; 4], +} + +impl GlyphVertex { + const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![ + 0 => Float32x2, // position + 1 => Float32x2, // uv + 2 => Float32x4, // color (fg) + 3 => Float32x4, // bg_color + ]; + + fn desc() -> wgpu::VertexBufferLayout<'static> { + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &Self::ATTRIBS, + } + } +} + +/// The terminal renderer. +pub struct Renderer { + surface: wgpu::Surface<'static>, + device: wgpu::Device, + queue: wgpu::Queue, + surface_config: wgpu::SurfaceConfiguration, + + // Glyph rendering pipeline + glyph_pipeline: wgpu::RenderPipeline, + glyph_bind_group: wgpu::BindGroup, + + // Atlas texture + atlas_texture: wgpu::Texture, + atlas_data: Vec, + atlas_dirty: bool, + + // Font and shaping + font_data: Box<[u8]>, + fontdue_font: FontdueFont, + fallback_fonts: Vec, + fallback_font_paths: Vec<&'static str>, // Paths for lazy loading + shaping_ctx: ShapingContext, + char_cache: HashMap, // cache char -> rendered glyph + ligature_cache: HashMap, // cache multi-char -> shaped glyphs + glyph_cache: HashMap<(usize, u16), GlyphInfo>, // keyed by (font_index, glyph ID) + atlas_cursor_x: u32, + atlas_cursor_y: u32, + atlas_row_height: u32, + + // Dynamic vertex/index buffers + vertex_buffer: wgpu::Buffer, + index_buffer: wgpu::Buffer, + vertex_capacity: usize, + index_capacity: usize, + + /// Base font size in points (from config). + base_font_size: f32, + /// Current scale factor. + pub scale_factor: f64, + /// Effective font size in pixels (base_font_size * scale_factor). + pub font_size: f32, + /// Cell dimensions in pixels. + pub cell_width: f32, + pub cell_height: f32, + /// Window dimensions. + pub width: u32, + pub height: u32, + /// Color palette for rendering. + palette: ColorPalette, + /// Tab bar position. + tab_bar_position: TabBarPosition, + /// Background opacity (0.0 = transparent, 1.0 = opaque). + background_opacity: f32, +} + +use crate::config::Config; + +impl Renderer { + /// Creates a new renderer for the given window. + pub async fn new(window: Arc, config: &Config) -> Self { + let size = window.inner_size(); + let scale_factor = window.scale_factor(); + + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::PRIMARY, + ..Default::default() + }); + + let surface = instance.create_surface(window).unwrap(); + + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: Some(&surface), + force_fallback_adapter: false, + }) + .await + .expect("Failed to find a suitable GPU adapter"); + + let (device, queue) = adapter + .request_device( + &wgpu::DeviceDescriptor { + label: Some("Terminal Device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::Performance, + }, + None, + ) + .await + .expect("Failed to create device"); + + let surface_caps = surface.get_capabilities(&adapter); + let surface_format = surface_caps + .formats + .iter() + .find(|f| f.is_srgb()) + .copied() + .unwrap_or(surface_caps.formats[0]); + + // Select alpha mode for transparency support + // Prefer PreMultiplied for proper transparency blending, fall back to others + let alpha_mode = if config.background_opacity < 1.0 { + if surface_caps.alpha_modes.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { + wgpu::CompositeAlphaMode::PreMultiplied + } else if surface_caps.alpha_modes.contains(&wgpu::CompositeAlphaMode::PostMultiplied) { + wgpu::CompositeAlphaMode::PostMultiplied + } else { + log::warn!("Transparency requested but compositor doesn't support alpha blending"); + surface_caps.alpha_modes[0] + } + } else { + surface_caps.alpha_modes[0] + }; + + let surface_config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: surface_format, + width: size.width.max(1), + height: size.height.max(1), + present_mode: wgpu::PresentMode::Mailbox, + alpha_mode, + view_formats: vec![], + desired_maximum_frame_latency: 2, + }; + surface.configure(&device, &surface_config); + + // Load primary font + let font_data: Box<[u8]> = std::fs::read("/usr/share/fonts/TTF/0xProtoNerdFont-Regular.ttf") + .or_else(|_| std::fs::read("/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Regular.ttf")) + .or_else(|_| std::fs::read("/usr/share/fonts/TTF/JetBrainsMono-Regular.ttf")) + .or_else(|_| std::fs::read("/usr/share/fonts/noto/NotoSansMono-Regular.ttf")) + .expect("Failed to load any monospace font") + .into_boxed_slice(); + + let fontdue_font = FontdueFont::from_bytes( + &font_data[..], + fontdue::FontSettings::default(), + ) + .expect("Failed to parse font with fontdue"); + + // Store fallback font paths for lazy loading (loaded on first cache miss) + let fallback_font_paths: Vec<&'static str> = vec![ + // Nerd Font symbols + "/usr/share/fonts/TTF/SymbolsNerdFont-Regular.ttf", + "/usr/share/fonts/TTF/SymbolsNerdFontMono-Regular.ttf", + // Noto fonts for broad Unicode coverage + "/usr/share/fonts/noto/NotoSansMono-Regular.ttf", + "/usr/share/fonts/noto/NotoSansSymbols-Regular.ttf", + "/usr/share/fonts/noto/NotoSansSymbols2-Regular.ttf", + "/usr/share/fonts/noto/NotoEmoji-Regular.ttf", + // DejaVu has good symbol coverage + "/usr/share/fonts/TTF/DejaVuSansMono.ttf", + "/usr/share/fonts/dejavu/DejaVuSansMono.ttf", + ]; + // Start with empty fallback fonts - will be loaded lazily + let fallback_fonts: Vec = Vec::new(); + + // Create rustybuzz Face for text shaping (ligatures). + // SAFETY: We transmute to 'static because font_data lives as long as Renderer. + // The Face only borrows the data, so this is safe as long as we don't drop font_data + // before dropping the Face, which is guaranteed by struct drop order. + let face: rustybuzz::Face<'static> = { + let face = rustybuzz::Face::from_slice(&font_data, 0) + .expect("Failed to parse font for shaping"); + unsafe { std::mem::transmute(face) } + }; + let shaping_ctx = ShapingContext { face }; + + // Calculate cell dimensions from font metrics + // Scale font size by the display scale factor for crisp rendering + let base_font_size = config.font_size; + let font_size = base_font_size * scale_factor as f32; + let metrics = fontdue_font.metrics('M', font_size); + let cell_width = metrics.advance_width.ceil(); + + // Use actual font line metrics for cell height (matching Kitty's approach) + // Kitty uses the font's "height" metric which is: ascent - descent + line_gap + // In fontdue, this is provided as "new_line_size" + let cell_height = if let Some(line_metrics) = fontdue_font.horizontal_line_metrics(font_size) { + line_metrics.new_line_size + } else { + // Fallback if no line metrics available + font_size * 1.2 + }; + + log::info!("Scale factor: {}, font size: {}pt -> {}px, cell: {}x{}", + scale_factor, base_font_size, font_size, cell_width, cell_height); + + // Create atlas texture + let atlas_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Glyph Atlas"), + size: wgpu::Extent3d { + width: ATLAS_SIZE, + height: ATLAS_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + // Create bind group layout + let glyph_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Glyph Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + ], + }); + + let glyph_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Glyph Bind Group"), + layout: &glyph_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&atlas_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&atlas_sampler), + }, + ], + }); + + // Create shader + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Glyph Shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("glyph_shader.wgsl").into()), + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Glyph Pipeline Layout"), + bind_group_layouts: &[&glyph_bind_group_layout], + push_constant_ranges: &[], + }); + + let glyph_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Glyph Pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[GlyphVertex::desc()], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: surface_config.format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + // Create initial buffers with some capacity + let initial_vertex_capacity = 4096; + let initial_index_capacity = 6144; + + let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Glyph Vertex Buffer"), + size: (initial_vertex_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let index_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Glyph Index Buffer"), + size: (initial_index_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + Self { + surface, + device, + queue, + surface_config, + glyph_pipeline, + glyph_bind_group, + atlas_texture, + atlas_data: vec![0u8; (ATLAS_SIZE * ATLAS_SIZE) as usize], + atlas_dirty: false, + font_data, + fontdue_font, + fallback_fonts, + fallback_font_paths, + shaping_ctx, + char_cache: HashMap::new(), + ligature_cache: HashMap::new(), + glyph_cache: HashMap::new(), + atlas_cursor_x: 0, + atlas_cursor_y: 0, + atlas_row_height: 0, + vertex_buffer, + index_buffer, + vertex_capacity: initial_vertex_capacity, + index_capacity: initial_index_capacity, + base_font_size, + scale_factor, + font_size, + cell_width, + cell_height, + width: size.width, + height: size.height, + palette: ColorPalette::default(), + tab_bar_position: config.tab_bar_position, + background_opacity: config.background_opacity.clamp(0.0, 1.0), + } + } + + /// Returns the height of the tab bar in pixels (one cell height, or 0 if hidden). + pub fn tab_bar_height(&self) -> f32 { + match self.tab_bar_position { + TabBarPosition::Hidden => 0.0, + _ => self.cell_height, + } + } + + /// Returns the Y offset where the terminal content starts. + pub fn terminal_y_offset(&self) -> f32 { + match self.tab_bar_position { + TabBarPosition::Top => self.tab_bar_height(), + _ => 0.0, + } + } + + /// Resizes the rendering surface. + pub fn resize(&mut self, new_width: u32, new_height: u32) { + if new_width > 0 && new_height > 0 { + self.width = new_width; + self.height = new_height; + self.surface_config.width = new_width; + self.surface_config.height = new_height; + self.surface.configure(&self.device, &self.surface_config); + } + } + + /// Calculates terminal dimensions in cells, accounting for tab bar. + pub fn terminal_size(&self) -> (usize, usize) { + let available_height = self.height as f32 - self.tab_bar_height(); + let cols = (self.width as f32 / self.cell_width).floor() as usize; + let rows = (available_height / self.cell_height).floor() as usize; + (cols.max(1), rows.max(1)) + } + + /// Updates the scale factor and recalculates font/cell dimensions. + /// Returns true if the cell dimensions changed (terminal needs resize). + pub fn set_scale_factor(&mut self, new_scale: f64) -> bool { + if (self.scale_factor - new_scale).abs() < 0.001 { + return false; + } + + let old_cell_width = self.cell_width; + let old_cell_height = self.cell_height; + + self.scale_factor = new_scale; + self.font_size = self.base_font_size * new_scale as f32; + + // Recalculate cell dimensions + let metrics = self.fontdue_font.metrics('M', self.font_size); + self.cell_width = metrics.advance_width.ceil(); + self.cell_height = if let Some(line_metrics) = self.fontdue_font.horizontal_line_metrics(self.font_size) { + line_metrics.new_line_size + } else { + self.font_size * 1.2 + }; + + log::info!( + "Scale factor changed to {}: font {}pt -> {}px, cell: {}x{}", + new_scale, self.base_font_size, self.font_size, self.cell_width, self.cell_height + ); + + // Clear all glyph caches - they were rendered at the old size + self.char_cache.clear(); + self.ligature_cache.clear(); + self.glyph_cache.clear(); + + // Reset atlas + self.atlas_cursor_x = 0; + self.atlas_cursor_y = 0; + self.atlas_row_height = 0; + self.atlas_data.fill(0); + self.atlas_dirty = true; + + // Return true if cell dimensions changed + (self.cell_width - old_cell_width).abs() > 0.01 + || (self.cell_height - old_cell_height).abs() > 0.01 + } + + /// Check if a character is a box-drawing character that should be rendered procedurally. + fn is_box_drawing(c: char) -> bool { + let cp = c as u32; + // Box Drawing: U+2500-U+257F + // Block Elements: U+2580-U+259F + (0x2500..=0x257F).contains(&cp) || (0x2580..=0x259F).contains(&cp) + } + + /// Render a box-drawing character procedurally to a bitmap. + /// Returns (bitmap, width, height) where the bitmap fills the entire cell. + fn render_box_char(&self, c: char) -> Option> { + let w = self.cell_width.ceil() as usize; + let h = self.cell_height.ceil() as usize; + let mut bitmap = vec![0u8; w * h]; + + let mid_x = w / 2; + let mid_y = h / 2; + let light = 2.max((self.font_size / 8.0).round() as usize); // 2px minimum, scales with font + let heavy = light * 2; // 4px minimum + + // For double lines + let double_gap = light + 2; + let double_off = double_gap / 2; + + // Helper: draw horizontal line + let hline = |buf: &mut [u8], x1: usize, x2: usize, y: usize, t: usize| { + let y_start = y.saturating_sub(t / 2); + let y_end = (y_start + t).min(h); + for py in y_start..y_end { + for px in x1..x2.min(w) { + buf[py * w + px] = 255; + } + } + }; + + // Helper: draw vertical line + let vline = |buf: &mut [u8], y1: usize, y2: usize, x: usize, t: usize| { + let x_start = x.saturating_sub(t / 2); + let x_end = (x_start + t).min(w); + for py in y1..y2.min(h) { + for px in x_start..x_end { + buf[py * w + px] = 255; + } + } + }; + + // Helper: fill rectangle + let fill_rect = |buf: &mut [u8], x1: usize, y1: usize, x2: usize, y2: usize| { + for py in y1..y2.min(h) { + for px in x1..x2.min(w) { + buf[py * w + px] = 255; + } + } + }; + + match c { + // ═══════════════════════════════════════════════════════════════ + // LIGHT BOX DRAWING (single lines) + // ═══════════════════════════════════════════════════════════════ + + // Horizontal and vertical lines + '─' => hline(&mut bitmap, 0, w, mid_y, light), + '│' => vline(&mut bitmap, 0, h, mid_x, light), + + // Light corners + '┌' => { + hline(&mut bitmap, mid_x, w, mid_y, light); + vline(&mut bitmap, mid_y, h, mid_x, light); + } + '┐' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + vline(&mut bitmap, mid_y, h, mid_x, light); + } + '└' => { + hline(&mut bitmap, mid_x, w, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + } + '┘' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + } + + // Light T-junctions + '├' => { + vline(&mut bitmap, 0, h, mid_x, light); + hline(&mut bitmap, mid_x, w, mid_y, light); + } + '┤' => { + vline(&mut bitmap, 0, h, mid_x, light); + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + } + '┬' => { + hline(&mut bitmap, 0, w, mid_y, light); + vline(&mut bitmap, mid_y, h, mid_x, light); + } + '┴' => { + hline(&mut bitmap, 0, w, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + } + + // Light cross + '┼' => { + hline(&mut bitmap, 0, w, mid_y, light); + vline(&mut bitmap, 0, h, mid_x, light); + } + + // ═══════════════════════════════════════════════════════════════ + // HEAVY BOX DRAWING (bold lines) + // ═══════════════════════════════════════════════════════════════ + + '━' => hline(&mut bitmap, 0, w, mid_y, heavy), + '┃' => vline(&mut bitmap, 0, h, mid_x, heavy), + + // Heavy corners + '┏' => { + hline(&mut bitmap, mid_x, w, mid_y, heavy); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + } + '┓' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + } + '┗' => { + hline(&mut bitmap, mid_x, w, mid_y, heavy); + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + } + '┛' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + } + + // Heavy T-junctions + '┣' => { + vline(&mut bitmap, 0, h, mid_x, heavy); + hline(&mut bitmap, mid_x, w, mid_y, heavy); + } + '┫' => { + vline(&mut bitmap, 0, h, mid_x, heavy); + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + } + '┳' => { + hline(&mut bitmap, 0, w, mid_y, heavy); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + } + '┻' => { + hline(&mut bitmap, 0, w, mid_y, heavy); + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + } + + // Heavy cross + '╋' => { + hline(&mut bitmap, 0, w, mid_y, heavy); + vline(&mut bitmap, 0, h, mid_x, heavy); + } + + // ═══════════════════════════════════════════════════════════════ + // MIXED LIGHT/HEAVY + // ═══════════════════════════════════════════════════════════════ + + // Light horizontal, heavy vertical corners + '┎' => { + hline(&mut bitmap, mid_x, w, mid_y, light); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + } + '┒' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + } + '┖' => { + hline(&mut bitmap, mid_x, w, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + } + '┚' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + } + + // Heavy horizontal, light vertical corners + '┍' => { + hline(&mut bitmap, mid_x, w, mid_y, heavy); + vline(&mut bitmap, mid_y, h, mid_x, light); + } + '┑' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + vline(&mut bitmap, mid_y, h, mid_x, light); + } + '┕' => { + hline(&mut bitmap, mid_x, w, mid_y, heavy); + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + } + '┙' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + } + + // Mixed T-junctions (vertical heavy, horizontal light) + '┠' => { + vline(&mut bitmap, 0, h, mid_x, heavy); + hline(&mut bitmap, mid_x, w, mid_y, light); + } + '┨' => { + vline(&mut bitmap, 0, h, mid_x, heavy); + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + } + '┰' => { + hline(&mut bitmap, 0, w, mid_y, light); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + } + '┸' => { + hline(&mut bitmap, 0, w, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + } + + // Mixed T-junctions (vertical light, horizontal heavy) + '┝' => { + vline(&mut bitmap, 0, h, mid_x, light); + hline(&mut bitmap, mid_x, w, mid_y, heavy); + } + '┥' => { + vline(&mut bitmap, 0, h, mid_x, light); + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + } + '┯' => { + hline(&mut bitmap, 0, w, mid_y, heavy); + vline(&mut bitmap, mid_y, h, mid_x, light); + } + '┷' => { + hline(&mut bitmap, 0, w, mid_y, heavy); + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + } + + // More mixed T-junctions + '┞' => { + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + hline(&mut bitmap, mid_x, w, mid_y, light); + } + '┟' => { + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + vline(&mut bitmap, mid_y, h, mid_x, light); + hline(&mut bitmap, mid_x, w, mid_y, light); + } + '┡' => { + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + hline(&mut bitmap, mid_x, w, mid_y, heavy); + } + '┢' => { + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + vline(&mut bitmap, mid_y, h, mid_x, light); + hline(&mut bitmap, mid_x, w, mid_y, heavy); + } + '┦' => { + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + } + '┧' => { + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + vline(&mut bitmap, mid_y, h, mid_x, light); + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + } + '┩' => { + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + } + '┪' => { + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + vline(&mut bitmap, mid_y, h, mid_x, light); + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + } + '┭' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + hline(&mut bitmap, mid_x, w, mid_y, heavy); + vline(&mut bitmap, mid_y, h, mid_x, light); + } + '┮' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + hline(&mut bitmap, mid_x, w, mid_y, light); + vline(&mut bitmap, mid_y, h, mid_x, light); + } + '┱' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + hline(&mut bitmap, mid_x, w, mid_y, heavy); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + } + '┲' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + hline(&mut bitmap, mid_x, w, mid_y, light); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + } + '┵' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + hline(&mut bitmap, mid_x, w, mid_y, heavy); + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + } + '┶' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + hline(&mut bitmap, mid_x, w, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + } + '┹' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + hline(&mut bitmap, mid_x, w, mid_y, heavy); + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + } + '┺' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + hline(&mut bitmap, mid_x, w, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + } + + // Mixed crosses + '╀' => { + hline(&mut bitmap, 0, w, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + vline(&mut bitmap, mid_y, h, mid_x, light); + } + '╁' => { + hline(&mut bitmap, 0, w, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + } + '╂' => { + hline(&mut bitmap, 0, w, mid_y, light); + vline(&mut bitmap, 0, h, mid_x, heavy); + } + '╃' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + hline(&mut bitmap, mid_x, w, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + vline(&mut bitmap, mid_y, h, mid_x, light); + } + '╄' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + hline(&mut bitmap, mid_x, w, mid_y, heavy); + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + vline(&mut bitmap, mid_y, h, mid_x, light); + } + '╅' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + hline(&mut bitmap, mid_x, w, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + } + '╆' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + hline(&mut bitmap, mid_x, w, mid_y, heavy); + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + } + '╇' => { + hline(&mut bitmap, 0, w, mid_y, heavy); + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + } + '╈' => { + hline(&mut bitmap, 0, w, mid_y, heavy); + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + vline(&mut bitmap, mid_y, h, mid_x, light); + } + '╉' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + hline(&mut bitmap, mid_x, w, mid_y, heavy); + vline(&mut bitmap, 0, h, mid_x, heavy); + } + '╊' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + hline(&mut bitmap, mid_x, w, mid_y, light); + vline(&mut bitmap, 0, h, mid_x, heavy); + } + + // ═══════════════════════════════════════════════════════════════ + // DOUBLE LINES + // ═══════════════════════════════════════════════════════════════ + + '═' => { + hline(&mut bitmap, 0, w, mid_y.saturating_sub(double_off), light); + hline(&mut bitmap, 0, w, mid_y + double_off, light); + } + '║' => { + vline(&mut bitmap, 0, h, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, 0, h, mid_x + double_off, light); + } + + // Double corners + '╔' => { + hline(&mut bitmap, mid_x, w, mid_y.saturating_sub(double_off), light); + hline(&mut bitmap, mid_x + double_off, w, mid_y + double_off, light); + vline(&mut bitmap, mid_y, h, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, mid_y.saturating_sub(double_off), h, mid_x + double_off, light); + } + '╗' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y.saturating_sub(double_off), light); + hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light); + vline(&mut bitmap, mid_y, h, mid_x + double_off, light); + vline(&mut bitmap, mid_y.saturating_sub(double_off), h, mid_x.saturating_sub(double_off), light); + } + '╚' => { + hline(&mut bitmap, mid_x, w, mid_y + double_off, light); + hline(&mut bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light); + vline(&mut bitmap, 0, mid_y + 1, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, 0, mid_y + double_off + 1, mid_x + double_off, light); + } + '╝' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y + double_off, light); + hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light); + vline(&mut bitmap, 0, mid_y + 1, mid_x + double_off, light); + vline(&mut bitmap, 0, mid_y + double_off + 1, mid_x.saturating_sub(double_off), light); + } + + // Double T-junctions + '╠' => { + vline(&mut bitmap, 0, h, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x + double_off, light); + vline(&mut bitmap, mid_y + double_off, h, mid_x + double_off, light); + hline(&mut bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light); + hline(&mut bitmap, mid_x + double_off, w, mid_y + double_off, light); + } + '╣' => { + vline(&mut bitmap, 0, h, mid_x + double_off, light); + vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, mid_y + double_off, h, mid_x.saturating_sub(double_off), light); + hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light); + hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light); + } + '╦' => { + hline(&mut bitmap, 0, w, mid_y.saturating_sub(double_off), light); + hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light); + hline(&mut bitmap, mid_x + double_off, w, mid_y + double_off, light); + vline(&mut bitmap, mid_y + double_off, h, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, mid_y + double_off, h, mid_x + double_off, light); + } + '╩' => { + hline(&mut bitmap, 0, w, mid_y + double_off, light); + hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light); + hline(&mut bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light); + vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x + double_off, light); + } + + // Double cross + '╬' => { + vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x + double_off, light); + vline(&mut bitmap, mid_y + double_off, h, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, mid_y + double_off, h, mid_x + double_off, light); + hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light); + hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light); + hline(&mut bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light); + hline(&mut bitmap, mid_x + double_off, w, mid_y + double_off, light); + } + + // ═══════════════════════════════════════════════════════════════ + // SINGLE/DOUBLE MIXED + // ═══════════════════════════════════════════════════════════════ + + // Single horizontal, double vertical corners + '╒' => { + hline(&mut bitmap, mid_x + double_off, w, mid_y, light); + vline(&mut bitmap, mid_y, h, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, mid_y, h, mid_x + double_off, light); + } + '╓' => { + hline(&mut bitmap, mid_x, w, mid_y, light); + vline(&mut bitmap, mid_y, h, mid_x, light); + } + '╕' => { + hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light); + vline(&mut bitmap, mid_y, h, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, mid_y, h, mid_x + double_off, light); + } + '╖' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + vline(&mut bitmap, mid_y, h, mid_x, light); + } + '╘' => { + hline(&mut bitmap, mid_x + double_off, w, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, 0, mid_y + 1, mid_x + double_off, light); + } + '╙' => { + hline(&mut bitmap, mid_x, w, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + } + '╛' => { + hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, 0, mid_y + 1, mid_x + double_off, light); + } + '╜' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + } + + // Mixed T-junctions + '╞' => { + vline(&mut bitmap, 0, h, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, 0, h, mid_x + double_off, light); + hline(&mut bitmap, mid_x + double_off, w, mid_y, light); + } + '╟' => { + vline(&mut bitmap, 0, h, mid_x, light); + hline(&mut bitmap, mid_x, w, mid_y.saturating_sub(double_off), light); + hline(&mut bitmap, mid_x, w, mid_y + double_off, light); + } + '╡' => { + vline(&mut bitmap, 0, h, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, 0, h, mid_x + double_off, light); + hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light); + } + '╢' => { + vline(&mut bitmap, 0, h, mid_x, light); + hline(&mut bitmap, 0, mid_x + 1, mid_y.saturating_sub(double_off), light); + hline(&mut bitmap, 0, mid_x + 1, mid_y + double_off, light); + } + '╤' => { + hline(&mut bitmap, 0, w, mid_y.saturating_sub(double_off), light); + hline(&mut bitmap, 0, w, mid_y + double_off, light); + vline(&mut bitmap, mid_y + double_off, h, mid_x, light); + } + '╥' => { + hline(&mut bitmap, 0, w, mid_y, light); + vline(&mut bitmap, mid_y, h, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, mid_y, h, mid_x + double_off, light); + } + '╧' => { + hline(&mut bitmap, 0, w, mid_y.saturating_sub(double_off), light); + hline(&mut bitmap, 0, w, mid_y + double_off, light); + vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x, light); + } + '╨' => { + hline(&mut bitmap, 0, w, mid_y, light); + vline(&mut bitmap, 0, mid_y + 1, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, 0, mid_y + 1, mid_x + double_off, light); + } + + // Mixed crosses + '╪' => { + hline(&mut bitmap, 0, w, mid_y.saturating_sub(double_off), light); + hline(&mut bitmap, 0, w, mid_y + double_off, light); + vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x, light); + vline(&mut bitmap, mid_y + double_off, h, mid_x, light); + } + '╫' => { + hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light); + hline(&mut bitmap, mid_x + double_off, w, mid_y, light); + vline(&mut bitmap, 0, h, mid_x.saturating_sub(double_off), light); + vline(&mut bitmap, 0, h, mid_x + double_off, light); + } + + // ═══════════════════════════════════════════════════════════════ + // ROUNDED CORNERS (using SDF like Kitty, with anti-aliasing) + // ═══════════════════════════════════════════════════════════════ + + '╭' | '╮' | '╯' | '╰' => { + // Kitty-style rounded corner using signed distance field + // Translated directly from kitty/decorations.c rounded_corner() + + // hline_limits: for a horizontal line at y with thickness t, + // returns range [y - t/2, y - t/2 + t] + let hori_line_start = mid_y.saturating_sub(light / 2); + let hori_line_end = (hori_line_start + light).min(h); + let hori_line_height = hori_line_end - hori_line_start; + + // vline_limits: for a vertical line at x with thickness t, + // returns range [x - t/2, x - t/2 + t] + let vert_line_start = mid_x.saturating_sub(light / 2); + let vert_line_end = (vert_line_start + light).min(w); + let vert_line_width = vert_line_end - vert_line_start; + + // adjusted_Hx/Hy: center of the line in each direction + let adjusted_hx = vert_line_start as f64 + vert_line_width as f64 / 2.0; + let adjusted_hy = hori_line_start as f64 + hori_line_height as f64 / 2.0; + + let stroke = (hori_line_height.max(vert_line_width)) as f64; + let corner_radius = adjusted_hx.min(adjusted_hy); + let bx = adjusted_hx - corner_radius; + let by = adjusted_hy - corner_radius; + + let aa_corner = 0.5; // anti-aliasing amount (kitty uses supersample_factor * 0.5) + let half_stroke = 0.5 * stroke; + + // Determine shifts based on corner type (matching Kitty's Edge flags) + // RIGHT_EDGE = 4, TOP_EDGE = 2 + // ╭ = TOP_LEFT (top-left corner, line goes right and down) + // ╮ = TOP_RIGHT (top-right corner, line goes left and down) + // ╰ = BOTTOM_LEFT (bottom-left corner, line goes right and up) + // ╯ = BOTTOM_RIGHT (bottom-right corner, line goes left and up) + let (is_right, is_top) = match c { + '╭' => (false, true), // TOP_LEFT + '╮' => (true, true), // TOP_RIGHT + '╰' => (false, false), // BOTTOM_LEFT + '╯' => (true, false), // BOTTOM_RIGHT + _ => unreachable!(), + }; + + let x_shift = if is_right { adjusted_hx } else { -adjusted_hx }; + let y_shift = if is_top { -adjusted_hy } else { adjusted_hy }; + + // Smoothstep for anti-aliasing + let smoothstep = |edge0: f64, edge1: f64, x: f64| -> f64 { + if edge0 == edge1 { + return if x < edge0 { 0.0 } else { 1.0 }; + } + let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) + }; + + for py in 0..h { + let sample_y = py as f64 + y_shift + 0.5; + let pos_y = sample_y - adjusted_hy; + + for px in 0..w { + let sample_x = px as f64 + x_shift + 0.5; + let pos_x = sample_x - adjusted_hx; + + let qx = pos_x.abs() - bx; + let qy = pos_y.abs() - by; + let dx = if qx > 0.0 { qx } else { 0.0 }; + let dy = if qy > 0.0 { qy } else { 0.0 }; + let dist = (dx * dx + dy * dy).sqrt() + qx.max(qy).min(0.0) - corner_radius; + + let aa = if qx > 1e-7 && qy > 1e-7 { aa_corner } else { 0.0 }; + let outer = half_stroke - dist; + let inner = -half_stroke - dist; + let alpha = smoothstep(-aa, aa, outer) - smoothstep(-aa, aa, inner); + + if alpha <= 0.0 { + continue; + } + let value = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8; + let idx = py * w + px; + if value > bitmap[idx] { + bitmap[idx] = value; + } + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // DASHED/DOTTED LINES + // ═══════════════════════════════════════════════════════════════ + + '┄' => { + let seg = w / 8; + for i in 0..4 { + let x1 = i * 2 * seg; + let x2 = (x1 + seg).min(w); + hline(&mut bitmap, x1, x2, mid_y, light); + } + } + '┅' => { + let seg = w / 8; + for i in 0..4 { + let x1 = i * 2 * seg; + let x2 = (x1 + seg).min(w); + hline(&mut bitmap, x1, x2, mid_y, heavy); + } + } + '┆' => { + let seg = h / 8; + for i in 0..4 { + let y1 = i * 2 * seg; + let y2 = (y1 + seg).min(h); + vline(&mut bitmap, y1, y2, mid_x, light); + } + } + '┇' => { + let seg = h / 8; + for i in 0..4 { + let y1 = i * 2 * seg; + let y2 = (y1 + seg).min(h); + vline(&mut bitmap, y1, y2, mid_x, heavy); + } + } + '┈' => { + let seg = w / 12; + for i in 0..6 { + let x1 = i * 2 * seg; + let x2 = (x1 + seg).min(w); + hline(&mut bitmap, x1, x2, mid_y, light); + } + } + '┉' => { + let seg = w / 12; + for i in 0..6 { + let x1 = i * 2 * seg; + let x2 = (x1 + seg).min(w); + hline(&mut bitmap, x1, x2, mid_y, heavy); + } + } + '┊' => { + let seg = h / 12; + for i in 0..6 { + let y1 = i * 2 * seg; + let y2 = (y1 + seg).min(h); + vline(&mut bitmap, y1, y2, mid_x, light); + } + } + '┋' => { + let seg = h / 12; + for i in 0..6 { + let y1 = i * 2 * seg; + let y2 = (y1 + seg).min(h); + vline(&mut bitmap, y1, y2, mid_x, heavy); + } + } + + // Double dashed + '╌' => { + let seg = w / 4; + hline(&mut bitmap, 0, seg, mid_y, light); + hline(&mut bitmap, seg * 2, seg * 3, mid_y, light); + } + '╍' => { + let seg = w / 4; + hline(&mut bitmap, 0, seg, mid_y, heavy); + hline(&mut bitmap, seg * 2, seg * 3, mid_y, heavy); + } + '╎' => { + let seg = h / 4; + vline(&mut bitmap, 0, seg, mid_x, light); + vline(&mut bitmap, seg * 2, seg * 3, mid_x, light); + } + '╏' => { + let seg = h / 4; + vline(&mut bitmap, 0, seg, mid_x, heavy); + vline(&mut bitmap, seg * 2, seg * 3, mid_x, heavy); + } + + // ═══════════════════════════════════════════════════════════════ + // HALF LINES (line to edge) + // ═══════════════════════════════════════════════════════════════ + + '╴' => hline(&mut bitmap, 0, mid_x + 1, mid_y, light), + '╵' => vline(&mut bitmap, 0, mid_y + 1, mid_x, light), + '╶' => hline(&mut bitmap, mid_x, w, mid_y, light), + '╷' => vline(&mut bitmap, mid_y, h, mid_x, light), + '╸' => hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy), + '╹' => vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy), + '╺' => hline(&mut bitmap, mid_x, w, mid_y, heavy), + '╻' => vline(&mut bitmap, mid_y, h, mid_x, heavy), + + // Mixed half lines + '╼' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, light); + hline(&mut bitmap, mid_x, w, mid_y, heavy); + } + '╽' => { + vline(&mut bitmap, 0, mid_y + 1, mid_x, light); + vline(&mut bitmap, mid_y, h, mid_x, heavy); + } + '╾' => { + hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); + hline(&mut bitmap, mid_x, w, mid_y, light); + } + '╿' => { + vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); + vline(&mut bitmap, mid_y, h, mid_x, light); + } + + // ═══════════════════════════════════════════════════════════════ + // DIAGONAL LINES + // ═══════════════════════════════════════════════════════════════ + + '╱' => { + for i in 0..w.max(h) { + let x = w.saturating_sub(1).saturating_sub(i * w / h.max(1)); + let y = i * h / w.max(1); + if x < w && y < h { + for t in 0..light { + if x + t < w { bitmap[y * w + x + t] = 255; } + } + } + } + } + '╲' => { + for i in 0..w.max(h) { + let x = i * w / h.max(1); + let y = i * h / w.max(1); + if x < w && y < h { + for t in 0..light { + if x + t < w { bitmap[y * w + x + t] = 255; } + } + } + } + } + '╳' => { + // Draw both diagonals + for i in 0..w.max(h) { + let x1 = w.saturating_sub(1).saturating_sub(i * w / h.max(1)); + let x2 = i * w / h.max(1); + let y = i * h / w.max(1); + if y < h { + for t in 0..light { + if x1 + t < w { bitmap[y * w + x1 + t] = 255; } + if x2 + t < w { bitmap[y * w + x2 + t] = 255; } + } + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // BLOCK ELEMENTS (U+2580-U+259F) + // ═══════════════════════════════════════════════════════════════ + + '▀' => fill_rect(&mut bitmap, 0, 0, w, h / 2), + '▁' => fill_rect(&mut bitmap, 0, h * 7 / 8, w, h), + '▂' => fill_rect(&mut bitmap, 0, h * 3 / 4, w, h), + '▃' => fill_rect(&mut bitmap, 0, h * 5 / 8, w, h), + '▄' => fill_rect(&mut bitmap, 0, h / 2, w, h), + '▅' => fill_rect(&mut bitmap, 0, h * 3 / 8, w, h), + '▆' => fill_rect(&mut bitmap, 0, h / 4, w, h), + '▇' => fill_rect(&mut bitmap, 0, h / 8, w, h), + '█' => fill_rect(&mut bitmap, 0, 0, w, h), + '▉' => fill_rect(&mut bitmap, 0, 0, w * 7 / 8, h), + '▊' => fill_rect(&mut bitmap, 0, 0, w * 3 / 4, h), + '▋' => fill_rect(&mut bitmap, 0, 0, w * 5 / 8, h), + '▌' => fill_rect(&mut bitmap, 0, 0, w / 2, h), + '▍' => fill_rect(&mut bitmap, 0, 0, w * 3 / 8, h), + '▎' => fill_rect(&mut bitmap, 0, 0, w / 4, h), + '▏' => fill_rect(&mut bitmap, 0, 0, w / 8, h), + '▐' => fill_rect(&mut bitmap, w / 2, 0, w, h), + + // Shades + '░' => { + for y in 0..h { + for x in 0..w { + if (x + y) % 4 == 0 { bitmap[y * w + x] = 255; } + } + } + } + '▒' => { + for y in 0..h { + for x in 0..w { + if (x + y) % 2 == 0 { bitmap[y * w + x] = 255; } + } + } + } + '▓' => { + for y in 0..h { + for x in 0..w { + if (x + y) % 4 != 0 { bitmap[y * w + x] = 255; } + } + } + } + + // Right half blocks + '▕' => fill_rect(&mut bitmap, w * 7 / 8, 0, w, h), + + // Quadrants + '▖' => fill_rect(&mut bitmap, 0, h / 2, w / 2, h), + '▗' => fill_rect(&mut bitmap, w / 2, h / 2, w, h), + '▘' => fill_rect(&mut bitmap, 0, 0, w / 2, h / 2), + '▙' => { + fill_rect(&mut bitmap, 0, 0, w / 2, h); + fill_rect(&mut bitmap, w / 2, h / 2, w, h); + } + '▚' => { + fill_rect(&mut bitmap, 0, 0, w / 2, h / 2); + fill_rect(&mut bitmap, w / 2, h / 2, w, h); + } + '▛' => { + fill_rect(&mut bitmap, 0, 0, w, h / 2); + fill_rect(&mut bitmap, 0, h / 2, w / 2, h); + } + '▜' => { + fill_rect(&mut bitmap, 0, 0, w, h / 2); + fill_rect(&mut bitmap, w / 2, h / 2, w, h); + } + '▝' => fill_rect(&mut bitmap, w / 2, 0, w, h / 2), + '▞' => { + fill_rect(&mut bitmap, w / 2, 0, w, h / 2); + fill_rect(&mut bitmap, 0, h / 2, w / 2, h); + } + '▟' => { + fill_rect(&mut bitmap, w / 2, 0, w, h); + fill_rect(&mut bitmap, 0, h / 2, w / 2, h); + } + + _ => return None, + } + + Some(bitmap) + } + + /// Get or rasterize a glyph by character, with font fallback. + /// Returns the GlyphInfo for the character. + fn rasterize_char(&mut self, c: char) -> GlyphInfo { + // Check cache first + if let Some(info) = self.char_cache.get(&c) { + return *info; + } + + // Check if this is a box-drawing character - render procedurally + if Self::is_box_drawing(c) { + if let Some(bitmap) = self.render_box_char(c) { + let glyph_width = self.cell_width.ceil() as u32; + let glyph_height = self.cell_height.ceil() as u32; + + // Check if we need to move to next row + if self.atlas_cursor_x + glyph_width > ATLAS_SIZE { + self.atlas_cursor_x = 0; + self.atlas_cursor_y += self.atlas_row_height + 1; + self.atlas_row_height = 0; + } + + // Check if atlas is full + if self.atlas_cursor_y + glyph_height > ATLAS_SIZE { + log::warn!("Glyph atlas is full!"); + let info = GlyphInfo { + uv: [0.0, 0.0, 0.0, 0.0], + offset: [0.0, 0.0], + size: [0.0, 0.0], + advance: self.cell_width, + }; + self.char_cache.insert(c, info); + return info; + } + + // Copy bitmap to atlas + for y in 0..glyph_height as usize { + for x in 0..glyph_width as usize { + let src_idx = y * glyph_width as usize + x; + let dst_x = self.atlas_cursor_x + x as u32; + let dst_y = self.atlas_cursor_y + y as u32; + let dst_idx = (dst_y * ATLAS_SIZE + dst_x) as usize; + self.atlas_data[dst_idx] = bitmap[src_idx]; + } + } + self.atlas_dirty = true; + + // Calculate UV coordinates + let uv_x = self.atlas_cursor_x as f32 / ATLAS_SIZE as f32; + let uv_y = self.atlas_cursor_y as f32 / ATLAS_SIZE as f32; + let uv_w = glyph_width as f32 / ATLAS_SIZE as f32; + let uv_h = glyph_height as f32 / ATLAS_SIZE as f32; + + // Box-drawing chars fill the entire cell, positioned at origin + let info = GlyphInfo { + uv: [uv_x, uv_y, uv_w, uv_h], + offset: [0.0, 0.0], + size: [glyph_width as f32, glyph_height as f32], + advance: self.cell_width, + }; + + // Update atlas cursor + self.atlas_cursor_x += glyph_width + 1; + self.atlas_row_height = self.atlas_row_height.max(glyph_height); + + self.char_cache.insert(c, info); + return info; + } + } + + // Try primary font first, then fallbacks + let (metrics, bitmap) = { + // Check if primary font has this glyph + let glyph_idx = self.fontdue_font.lookup_glyph_index(c); + if glyph_idx != 0 { + self.fontdue_font.rasterize(c, self.font_size) + } else { + // Lazy load fallback fonts on first cache miss if not yet loaded + if self.fallback_fonts.is_empty() && !self.fallback_font_paths.is_empty() { + log::debug!("Loading fallback fonts lazily..."); + let paths = std::mem::take(&mut self.fallback_font_paths); + for path in paths { + if let Ok(data) = std::fs::read(path) { + if let Ok(font) = FontdueFont::from_bytes(data.as_slice(), fontdue::FontSettings::default()) { + log::debug!("Loaded fallback font: {}", path); + self.fallback_fonts.push(font); + } + } + } + log::debug!("Loaded {} fallback fonts", self.fallback_fonts.len()); + } + + // Try fallback fonts + let mut result = None; + for fallback in &self.fallback_fonts { + let fb_glyph_idx = fallback.lookup_glyph_index(c); + if fb_glyph_idx != 0 { + result = Some(fallback.rasterize(c, self.font_size)); + break; + } + } + // Use primary font's .notdef if no fallback has the glyph + result.unwrap_or_else(|| self.fontdue_font.rasterize(c, self.font_size)) + } + }; + + if bitmap.is_empty() || metrics.width == 0 || metrics.height == 0 { + // Empty glyph (e.g., space) + let info = GlyphInfo { + uv: [0.0, 0.0, 0.0, 0.0], + offset: [0.0, 0.0], + size: [0.0, 0.0], + advance: metrics.advance_width, + }; + self.char_cache.insert(c, info); + return info; + } + + let glyph_width = metrics.width as u32; + let glyph_height = metrics.height as u32; + + // Check if we need to move to next row + if self.atlas_cursor_x + glyph_width > ATLAS_SIZE { + self.atlas_cursor_x = 0; + self.atlas_cursor_y += self.atlas_row_height + 1; + self.atlas_row_height = 0; + } + + // Check if atlas is full + if self.atlas_cursor_y + glyph_height > ATLAS_SIZE { + log::warn!("Glyph atlas is full!"); + let info = GlyphInfo { + uv: [0.0, 0.0, 0.0, 0.0], + offset: [0.0, 0.0], + size: [0.0, 0.0], + advance: metrics.advance_width, + }; + self.char_cache.insert(c, info); + return info; + } + + // Copy bitmap to atlas + for y in 0..metrics.height { + for x in 0..metrics.width { + let src_idx = y * metrics.width + x; + let dst_x = self.atlas_cursor_x + x as u32; + let dst_y = self.atlas_cursor_y + y as u32; + let dst_idx = (dst_y * ATLAS_SIZE + dst_x) as usize; + self.atlas_data[dst_idx] = bitmap[src_idx]; + } + } + self.atlas_dirty = true; + + // Calculate UV coordinates + let uv_x = self.atlas_cursor_x as f32 / ATLAS_SIZE as f32; + let uv_y = self.atlas_cursor_y as f32 / ATLAS_SIZE as f32; + let uv_w = glyph_width as f32 / ATLAS_SIZE as f32; + let uv_h = glyph_height as f32 / ATLAS_SIZE as f32; + + let info = GlyphInfo { + uv: [uv_x, uv_y, uv_w, uv_h], + offset: [metrics.xmin as f32, metrics.ymin as f32], + size: [glyph_width as f32, glyph_height as f32], + advance: metrics.advance_width, + }; + + // Update atlas cursor + self.atlas_cursor_x += glyph_width + 1; + self.atlas_row_height = self.atlas_row_height.max(glyph_height); + + self.char_cache.insert(c, info); + info + } + + /// Get or rasterize a glyph by its glyph ID from the primary font. + /// Used for ligatures where we have the glyph ID from rustybuzz. + fn get_glyph_by_id(&mut self, glyph_id: u16) -> GlyphInfo { + let cache_key = (0usize, glyph_id); // font index 0 = primary font + if let Some(info) = self.glyph_cache.get(&cache_key) { + return *info; + } + + // Rasterize the glyph by ID from primary font + let (metrics, bitmap) = self.fontdue_font.rasterize_indexed(glyph_id, self.font_size); + + if bitmap.is_empty() || metrics.width == 0 || metrics.height == 0 { + // Empty glyph (e.g., space) + let info = GlyphInfo { + uv: [0.0, 0.0, 0.0, 0.0], + offset: [0.0, 0.0], + size: [0.0, 0.0], + advance: metrics.advance_width, + }; + self.glyph_cache.insert(cache_key, info); + return info; + } + + let glyph_width = metrics.width as u32; + let glyph_height = metrics.height as u32; + + // Check if we need to move to next row + if self.atlas_cursor_x + glyph_width > ATLAS_SIZE { + self.atlas_cursor_x = 0; + self.atlas_cursor_y += self.atlas_row_height + 1; + self.atlas_row_height = 0; + } + + // Check if atlas is full + if self.atlas_cursor_y + glyph_height > ATLAS_SIZE { + log::warn!("Glyph atlas is full!"); + let info = GlyphInfo { + uv: [0.0, 0.0, 0.0, 0.0], + offset: [0.0, 0.0], + size: [0.0, 0.0], + advance: metrics.advance_width, + }; + self.glyph_cache.insert(cache_key, info); + return info; + } + + // Copy bitmap to atlas + for y in 0..metrics.height { + for x in 0..metrics.width { + let src_idx = y * metrics.width + x; + let dst_x = self.atlas_cursor_x + x as u32; + let dst_y = self.atlas_cursor_y + y as u32; + let dst_idx = (dst_y * ATLAS_SIZE + dst_x) as usize; + self.atlas_data[dst_idx] = bitmap[src_idx]; + } + } + self.atlas_dirty = true; + + // Calculate UV coordinates + let uv_x = self.atlas_cursor_x as f32 / ATLAS_SIZE as f32; + let uv_y = self.atlas_cursor_y as f32 / ATLAS_SIZE as f32; + let uv_w = glyph_width as f32 / ATLAS_SIZE as f32; + let uv_h = glyph_height as f32 / ATLAS_SIZE as f32; + + let info = GlyphInfo { + uv: [uv_x, uv_y, uv_w, uv_h], + offset: [metrics.xmin as f32, metrics.ymin as f32], + size: [glyph_width as f32, glyph_height as f32], + advance: metrics.advance_width, + }; + + // Update atlas cursor + self.atlas_cursor_x += glyph_width + 1; + self.atlas_row_height = self.atlas_row_height.max(glyph_height); + + self.glyph_cache.insert(cache_key, info); + info + } + + /// Shape a multi-character text string (for ligatures). + /// Returns the shaped glyphs. If the font produces a ligature, + /// there will be fewer glyphs than input characters. + fn shape_text(&mut self, text: &str) -> ShapedGlyphs { + // Check cache first + if let Some(cached) = self.ligature_cache.get(text) { + return cached.clone(); + } + + let mut buffer = UnicodeBuffer::new(); + buffer.push_str(text); + + let glyph_buffer = rustybuzz::shape(&self.shaping_ctx.face, &[], buffer); + let glyph_infos = glyph_buffer.glyph_infos(); + let glyph_positions = glyph_buffer.glyph_positions(); + + let glyphs: Vec<(u16, f32)> = glyph_infos + .iter() + .zip(glyph_positions.iter()) + .map(|(info, pos)| { + let glyph_id = info.glyph_id as u16; + // Ensure glyph is rasterized + self.get_glyph_by_id(glyph_id); + // Convert advance from font units to pixels + // rustybuzz uses 26.6 fixed point, so divide by 64 + let advance = pos.x_advance as f32 / 64.0; + (glyph_id, advance) + }) + .collect(); + + let shaped = ShapedGlyphs { glyphs }; + self.ligature_cache.insert(text.to_string(), shaped.clone()); + shaped + } + + /// Convert sRGB component (0.0-1.0) to linear RGB. + /// This is needed because we're rendering to an sRGB surface. + #[inline] + fn srgb_to_linear(c: f32) -> f32 { + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + } + + /// Converts a terminal Color to RGBA in linear color space using the palette. + /// Default backgrounds are fully transparent to let the window clear color show through. + /// Explicit background colors remain fully opaque. + fn color_to_rgba(color: &Color, is_foreground: bool, palette: &crate::terminal::ColorPalette) -> [f32; 4] { + // For default background: fully transparent so clear color shows through + if !is_foreground && *color == Color::Default { + return [0.0, 0.0, 0.0, 0.0]; + } + + let srgb = if is_foreground { + palette.to_rgba(color) + } else { + palette.to_rgba_bg(color) + }; + // Convert sRGB to linear for the GPU (which will convert back to sRGB for display) + [ + Self::srgb_to_linear(srgb[0]), + Self::srgb_to_linear(srgb[1]), + Self::srgb_to_linear(srgb[2]), + srgb[3], // Alpha stays linear (1.0 for explicit colors) + ] + } + + /// Converts a protocol CellColor to RGBA in linear color space. + /// Default backgrounds are fully transparent to let the window clear color show through. + /// Explicit background colors remain fully opaque. + fn cell_color_to_rgba(&self, color: &CellColor, is_foreground: bool) -> [f32; 4] { + // For default background: fully transparent so clear color shows through + if !is_foreground && *color == CellColor::Default { + return [0.0, 0.0, 0.0, 0.0]; + } + + let srgb = match color { + CellColor::Default => { + // Only foreground gets here (background returns early above) + let [r, g, b] = self.palette.default_fg; + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] + } + CellColor::Rgb(r, g, b) => [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0], + CellColor::Indexed(idx) => { + let [r, g, b] = self.palette.colors[*idx as usize]; + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] + } + }; + [ + Self::srgb_to_linear(srgb[0]), + Self::srgb_to_linear(srgb[1]), + Self::srgb_to_linear(srgb[2]), + srgb[3], + ] + } + + /// Convert pixel X coordinate to NDC, snapped to pixel boundaries. + #[inline] + fn pixel_to_ndc_x(pixel: f32, screen_width: f32) -> f32 { + let snapped = pixel.round(); + (snapped / screen_width) * 2.0 - 1.0 + } + + /// Convert pixel Y coordinate to NDC (inverted), snapped to pixel boundaries. + #[inline] + fn pixel_to_ndc_y(pixel: f32, screen_height: f32) -> f32 { + let snapped = pixel.round(); + 1.0 - (snapped / screen_height) * 2.0 + } + + /// Renders the terminal. + pub fn render(&mut self, terminal: &mut Terminal) -> Result<(), wgpu::SurfaceError> { + let output = self.surface.get_current_texture()?; + let view = output + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + + // Use two separate lists: backgrounds first, then glyphs + // This ensures wide glyphs (like Nerd Font icons) can extend beyond their cell + // without being covered by adjacent cell backgrounds + let mut bg_vertices: Vec = Vec::with_capacity(terminal.cols * terminal.rows * 4); + let mut bg_indices: Vec = Vec::with_capacity(terminal.cols * terminal.rows * 6); + let mut glyph_vertices: Vec = Vec::with_capacity(terminal.cols * terminal.rows * 4); + let mut glyph_indices: Vec = Vec::with_capacity(terminal.cols * terminal.rows * 6); + + let width = self.width as f32; + let height = self.height as f32; + + // Common programming ligatures to check (longest first for greedy matching) + const LIGATURE_PATTERNS: &[&str] = &[ + // 3-char + "===", "!==", ">>>", "<<<", "||=", "&&=", "??=", "...", "-->", "<--", "<->", + // 2-char + "=>", "->", "<-", ">=", "<=", "==", "!=", "::", "&&", "||", "??", "..", "++", + "--", "<<", ">>", "|>", "<|", "/*", "*/", "//", "##", ":=", "~=", "<>", + ]; + + for (row_idx, row) in terminal.grid.iter().enumerate() { + let mut col_idx = 0; + while col_idx < row.len() { + let cell = &row[col_idx]; + let cell_x = col_idx as f32 * self.cell_width; + let cell_y = row_idx as f32 * self.cell_height; + + let fg_color = Self::color_to_rgba(&cell.fg_color, true, &terminal.palette); + let bg_color = Self::color_to_rgba(&cell.bg_color, false, &terminal.palette); + + // Check for ligatures by looking ahead + let mut ligature_len = 0; + let mut ligature_glyph: Option = None; + + for pattern in LIGATURE_PATTERNS { + let pat_len = pattern.len(); + if col_idx + pat_len <= row.len() { + // Build the candidate string from consecutive cells + let candidate: String = row[col_idx..col_idx + pat_len] + .iter() + .map(|c| c.character) + .collect(); + + if candidate == *pattern { + // Check if font actually produces a ligature + let shaped = self.shape_text(&candidate); + if shaped.glyphs.len() == 1 { + // It's a ligature! + let glyph_id = shaped.glyphs[0].0; + ligature_glyph = Some(self.get_glyph_by_id(glyph_id)); + ligature_len = pat_len; + break; + } + } + } + } + + if let Some(glyph) = ligature_glyph { + // Render ligature spanning multiple cells + let span_width = ligature_len as f32 * self.cell_width; + + // Add background for all cells in the ligature + for i in 0..ligature_len { + let bg_cell_x = (col_idx + i) as f32 * self.cell_width; + let cell_left = Self::pixel_to_ndc_x(bg_cell_x, width); + let cell_right = Self::pixel_to_ndc_x(bg_cell_x + self.cell_width, width); + let cell_top = Self::pixel_to_ndc_y(cell_y, height); + let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); + + let base_idx = bg_vertices.len() as u32; + bg_vertices.push(GlyphVertex { + position: [cell_left, cell_top], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_right, cell_top], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_right, cell_bottom], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_left, cell_bottom], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + + // Add the ligature glyph centered over the span + if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { + let baseline_y = (cell_y + self.cell_height * 0.8).round(); + // Center the ligature horizontally over the span + let glyph_x = (cell_x + (span_width - glyph.size[0]) / 2.0 + glyph.offset[0]).round(); + let glyph_y = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); + + let left = Self::pixel_to_ndc_x(glyph_x, width); + let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); + let top = Self::pixel_to_ndc_y(glyph_y, height); + let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); + + let base_idx = glyph_vertices.len() as u32; + glyph_vertices.push(GlyphVertex { + position: [left, top], + uv: [glyph.uv[0], glyph.uv[1]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [right, top], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [right, bottom], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [left, bottom], + uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + + col_idx += ligature_len; + } else { + // Regular single-character rendering with font fallback + let glyph = self.rasterize_char(cell.character); + + // Cell bounds (pixel-aligned) + let cell_left = Self::pixel_to_ndc_x(cell_x, width); + let cell_right = Self::pixel_to_ndc_x(cell_x + self.cell_width, width); + let cell_top = Self::pixel_to_ndc_y(cell_y, height); + let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); + + // Add background quad + let base_idx = bg_vertices.len() as u32; + bg_vertices.push(GlyphVertex { + position: [cell_left, cell_top], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_right, cell_top], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_right, cell_bottom], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_left, cell_bottom], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + + // Add glyph quad if it has content + if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { + // Box-drawing characters fill the entire cell + let (glyph_x, glyph_y) = if Self::is_box_drawing(cell.character) { + (cell_x, cell_y) + } else { + // Calculate glyph position with pixel alignment + let baseline_y = (cell_y + self.cell_height * 0.8).round(); + let gx = (cell_x + glyph.offset[0]).round(); + let gy = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); + (gx, gy) + }; + + // Glyph quad (pixel-aligned) - no clipping, allow overflow + let left = Self::pixel_to_ndc_x(glyph_x, width); + let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); + let top = Self::pixel_to_ndc_y(glyph_y, height); + let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); + + let base_idx = glyph_vertices.len() as u32; + glyph_vertices.push(GlyphVertex { + position: [left, top], + uv: [glyph.uv[0], glyph.uv[1]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [right, top], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [right, bottom], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [left, bottom], + uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + + col_idx += 1; + } + } + } + + // Add cursor (rendered on top of everything) + let cursor_x = terminal.cursor_col as f32 * self.cell_width; + let cursor_y = terminal.cursor_row as f32 * self.cell_height; + + // Get the cell under the cursor to determine colors + let cursor_cell = terminal.grid + .get(terminal.cursor_row) + .and_then(|row| row.get(terminal.cursor_col)); + + // Get fg and bg colors from the cell under cursor + let (cell_fg, cell_bg, cell_char) = if let Some(cell) = cursor_cell { + let fg = Self::color_to_rgba(&cell.fg_color, true, &terminal.palette); + let bg = Self::color_to_rgba(&cell.bg_color, false, &terminal.palette); + (fg, bg, cell.character) + } else { + // Default colors if cell doesn't exist + let fg = Self::color_to_rgba(&Color::Default, true, &terminal.palette); + let bg = [0.0, 0.0, 0.0, 0.0]; + (fg, bg, ' ') + }; + + let has_character = cell_char != ' ' && cell_char != '\0'; + + // Cursor color: invert the background, or use fg if there's a character + let cursor_bg_color = if has_character { + // Character present: cursor takes fg color as background + [cell_fg[0], cell_fg[1], cell_fg[2], 1.0] + } else { + // Empty cell: invert the background color + if cell_bg[3] < 0.01 { + // Transparent background -> white cursor + let white = Self::srgb_to_linear(0.9); + [white, white, white, 1.0] + } else { + // Invert the background color + [1.0 - cell_bg[0], 1.0 - cell_bg[1], 1.0 - cell_bg[2], 1.0] + } + }; + + // Determine cursor bounds based on shape + let (left, right, top, bottom) = match terminal.cursor_shape { + CursorShape::BlinkingBlock | CursorShape::SteadyBlock => ( + cursor_x, + cursor_x + self.cell_width, + cursor_y, + cursor_y + self.cell_height, + ), + CursorShape::BlinkingUnderline | CursorShape::SteadyUnderline => { + let underline_height = 2.0_f32.max(self.cell_height * 0.1); + ( + cursor_x, + cursor_x + self.cell_width, + cursor_y + self.cell_height - underline_height, + cursor_y + self.cell_height, + ) + } + CursorShape::BlinkingBar | CursorShape::SteadyBar => { + let bar_width = 2.0_f32.max(self.cell_width * 0.1); + ( + cursor_x, + cursor_x + bar_width, + cursor_y, + cursor_y + self.cell_height, + ) + } + }; + + let cursor_left = Self::pixel_to_ndc_x(left, width); + let cursor_right = Self::pixel_to_ndc_x(right, width); + let cursor_top = Self::pixel_to_ndc_y(top, height); + let cursor_bottom = Self::pixel_to_ndc_y(bottom, height); + + let base_idx = glyph_vertices.len() as u32; + + glyph_vertices.push(GlyphVertex { + position: [cursor_left, cursor_top], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + glyph_vertices.push(GlyphVertex { + position: [cursor_right, cursor_top], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + glyph_vertices.push(GlyphVertex { + position: [cursor_right, cursor_bottom], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + glyph_vertices.push(GlyphVertex { + position: [cursor_left, cursor_bottom], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + + glyph_indices.extend_from_slice(&[ + base_idx, + base_idx + 1, + base_idx + 2, + base_idx, + base_idx + 2, + base_idx + 3, + ]); + + // If block cursor and there's a character, re-render it with inverted color + let is_block_cursor = matches!( + terminal.cursor_shape, + CursorShape::BlinkingBlock | CursorShape::SteadyBlock + ); + if is_block_cursor && has_character { + // Character color: use bg color (inverted from normal) + let char_color = if cell_bg[3] < 0.01 { + // If bg was transparent, use black for the character + [0.0, 0.0, 0.0, 1.0] + } else { + [cell_bg[0], cell_bg[1], cell_bg[2], 1.0] + }; + + let glyph = self.rasterize_char(cell_char); + if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { + let cell_x = cursor_x; + let cell_y = cursor_y; + let (glyph_x, glyph_y) = if Self::is_box_drawing(cell_char) { + (cell_x, cell_y) + } else { + let baseline_y = (cell_y + self.cell_height * 0.8).round(); + let gx = (cell_x + glyph.offset[0]).round(); + let gy = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); + (gx, gy) + }; + + let g_left = Self::pixel_to_ndc_x(glyph_x, width); + let g_right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); + let g_top = Self::pixel_to_ndc_y(glyph_y, height); + let g_bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); + + let base_idx = glyph_vertices.len() as u32; + glyph_vertices.push(GlyphVertex { + position: [g_left, g_top], + uv: [glyph.uv[0], glyph.uv[1]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [g_right, g_top], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [g_right, g_bottom], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [g_left, g_bottom], + uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + } + + // Combine: backgrounds first, then glyphs (with adjusted indices) + let mut vertices = bg_vertices; + let mut indices = bg_indices; + + let glyph_vertex_offset = vertices.len() as u32; + vertices.extend(glyph_vertices); + indices.extend(glyph_indices.iter().map(|i| i + glyph_vertex_offset)); + + // Resize buffers if needed + if vertices.len() > self.vertex_capacity { + self.vertex_capacity = vertices.len() * 2; + self.vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Glyph Vertex Buffer"), + size: (self.vertex_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } + + if indices.len() > self.index_capacity { + self.index_capacity = indices.len() * 2; + self.index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Glyph Index Buffer"), + size: (self.index_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } + + // Upload vertex and index data + self.queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&vertices)); + self.queue.write_buffer(&self.index_buffer, 0, bytemuck::cast_slice(&indices)); + + // Upload atlas if dirty + if self.atlas_dirty { + self.queue.write_texture( + wgpu::ImageCopyTexture { + texture: &self.atlas_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &self.atlas_data, + wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(ATLAS_SIZE), + rows_per_image: Some(ATLAS_SIZE), + }, + wgpu::Extent3d { + width: ATLAS_SIZE, + height: ATLAS_SIZE, + depth_or_array_layers: 1, + }, + ); + self.atlas_dirty = false; + } + + // Create command encoder and render + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Render Encoder"), + }); + + { + // Clear color from palette, converted to linear space + let [bg_r, bg_g, bg_b] = terminal.palette.default_bg; + let bg_r_linear = Self::srgb_to_linear(bg_r as f32 / 255.0) as f64; + let bg_g_linear = Self::srgb_to_linear(bg_g as f32 / 255.0) as f64; + let bg_b_linear = Self::srgb_to_linear(bg_b as f32 / 255.0) as f64; + let bg_alpha = self.background_opacity as f64; + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Render Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: bg_r_linear, + g: bg_g_linear, + b: bg_b_linear, + a: bg_alpha, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + }); + + render_pass.set_pipeline(&self.glyph_pipeline); + render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); + render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); + render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32); + render_pass.draw_indexed(0..indices.len() as u32, 0, 0..1); + } + + self.queue.submit(std::iter::once(encoder.finish())); + output.present(); + + terminal.dirty = false; + + Ok(()) + } + + /// Renders a pane from protocol data (used by client). + pub fn render_pane(&mut self, pane: &PaneSnapshot) -> Result<(), wgpu::SurfaceError> { + let output = self.surface.get_current_texture()?; + let view = output + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + + let rows = pane.cells.len(); + let cols = if rows > 0 { pane.cells[0].len() } else { 0 }; + + let mut bg_vertices: Vec = Vec::with_capacity(cols * rows * 4); + let mut bg_indices: Vec = Vec::with_capacity(cols * rows * 6); + let mut glyph_vertices: Vec = Vec::with_capacity(cols * rows * 4); + let mut glyph_indices: Vec = Vec::with_capacity(cols * rows * 6); + + let width = self.width as f32; + let height = self.height as f32; + + // Common programming ligatures to check (longest first for greedy matching) + const LIGATURE_PATTERNS: &[&str] = &[ + // 3-char + "===", "!==", ">>>", "<<<", "||=", "&&=", "??=", "...", "-->", "<--", "<->", + // 2-char + "=>", "->", "<-", ">=", "<=", "==", "!=", "::", "&&", "||", "??", "..", "++", + "--", "<<", ">>", "|>", "<|", "/*", "*/", "//", "##", ":=", "~=", "<>", + ]; + + for (row_idx, row) in pane.cells.iter().enumerate() { + let mut col_idx = 0; + while col_idx < row.len() { + let cell = &row[col_idx]; + let cell_x = col_idx as f32 * self.cell_width; + let cell_y = row_idx as f32 * self.cell_height; + + let fg_color = self.cell_color_to_rgba(&cell.fg_color, true); + let bg_color = self.cell_color_to_rgba(&cell.bg_color, false); + + // Check for ligatures by looking ahead + let mut ligature_len = 0; + let mut ligature_glyph: Option = None; + + for pattern in LIGATURE_PATTERNS { + let pat_len = pattern.len(); + if col_idx + pat_len <= row.len() { + let candidate: String = row[col_idx..col_idx + pat_len] + .iter() + .map(|c| c.character) + .collect(); + + if candidate == *pattern { + let shaped = self.shape_text(&candidate); + if shaped.glyphs.len() == 1 { + let glyph_id = shaped.glyphs[0].0; + ligature_glyph = Some(self.get_glyph_by_id(glyph_id)); + ligature_len = pat_len; + break; + } + } + } + } + + if let Some(glyph) = ligature_glyph { + let span_width = ligature_len as f32 * self.cell_width; + + for i in 0..ligature_len { + let bg_cell_x = (col_idx + i) as f32 * self.cell_width; + let cell_left = Self::pixel_to_ndc_x(bg_cell_x, width); + let cell_right = Self::pixel_to_ndc_x(bg_cell_x + self.cell_width, width); + let cell_top = Self::pixel_to_ndc_y(cell_y, height); + let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); + + let base_idx = bg_vertices.len() as u32; + bg_vertices.push(GlyphVertex { + position: [cell_left, cell_top], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_right, cell_top], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_right, cell_bottom], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_left, cell_bottom], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + + if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { + let baseline_y = (cell_y + self.cell_height * 0.8).round(); + let glyph_x = (cell_x + (span_width - glyph.size[0]) / 2.0 + glyph.offset[0]).round(); + let glyph_y = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); + + let left = Self::pixel_to_ndc_x(glyph_x, width); + let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); + let top = Self::pixel_to_ndc_y(glyph_y, height); + let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); + + let base_idx = glyph_vertices.len() as u32; + glyph_vertices.push(GlyphVertex { + position: [left, top], + uv: [glyph.uv[0], glyph.uv[1]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [right, top], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [right, bottom], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [left, bottom], + uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + + col_idx += ligature_len; + } else { + // Single character rendering + let cell_left = Self::pixel_to_ndc_x(cell_x, width); + let cell_right = Self::pixel_to_ndc_x(cell_x + self.cell_width, width); + let cell_top = Self::pixel_to_ndc_y(cell_y, height); + let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); + + let base_idx = bg_vertices.len() as u32; + bg_vertices.push(GlyphVertex { + position: [cell_left, cell_top], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_right, cell_top], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_right, cell_bottom], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_left, cell_bottom], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + + let c = cell.character; + if c != ' ' && c != '\0' { + let glyph = self.rasterize_char(c); + if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { + // Box-drawing characters fill the entire cell + let (glyph_x, glyph_y) = if Self::is_box_drawing(c) { + (cell_x, cell_y) + } else { + // Calculate glyph position with baseline alignment + let baseline_y = (cell_y + self.cell_height * 0.8).round(); + let gx = (cell_x + glyph.offset[0]).round(); + let gy = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); + (gx, gy) + }; + + let left = Self::pixel_to_ndc_x(glyph_x, width); + let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); + let top = Self::pixel_to_ndc_y(glyph_y, height); + let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); + + let base_idx = glyph_vertices.len() as u32; + glyph_vertices.push(GlyphVertex { + position: [left, top], + uv: [glyph.uv[0], glyph.uv[1]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [right, top], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [right, bottom], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [left, bottom], + uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + } + + col_idx += 1; + } + } + } + + // Add cursor + if pane.cursor.visible { + let cursor_x = pane.cursor.col as f32 * self.cell_width; + let cursor_y = pane.cursor.row as f32 * self.cell_height; + + // Get the cell under the cursor to determine colors + let cursor_cell = pane.cells + .get(pane.cursor.row) + .and_then(|row| row.get(pane.cursor.col)); + + // Get fg and bg colors from the cell under cursor + let (cell_fg, cell_bg, cell_char) = if let Some(cell) = cursor_cell { + let fg = self.cell_color_to_rgba(&cell.fg_color, true); + let bg = self.cell_color_to_rgba(&cell.bg_color, false); + (fg, bg, cell.character) + } else { + // Default colors if cell doesn't exist + let fg = self.cell_color_to_rgba(&CellColor::Default, true); + let bg = [0.0, 0.0, 0.0, 0.0]; + (fg, bg, ' ') + }; + + let has_character = cell_char != ' ' && cell_char != '\0'; + + // Cursor color: invert the background, or use fg if there's a character + let cursor_bg_color = if has_character { + // Character present: cursor takes fg color as background + [cell_fg[0], cell_fg[1], cell_fg[2], 1.0] + } else { + // Empty cell: invert the background color + if cell_bg[3] < 0.01 { + // Transparent background -> white cursor + let white = Self::srgb_to_linear(0.9); + [white, white, white, 1.0] + } else { + // Invert the background color + [1.0 - cell_bg[0], 1.0 - cell_bg[1], 1.0 - cell_bg[2], 1.0] + } + }; + + // Determine cursor bounds based on style + let (left, right, top, bottom) = match pane.cursor.style { + CursorStyle::Block => ( + cursor_x, + cursor_x + self.cell_width, + cursor_y, + cursor_y + self.cell_height, + ), + CursorStyle::Underline => { + let underline_height = 2.0_f32.max(self.cell_height * 0.1); + ( + cursor_x, + cursor_x + self.cell_width, + cursor_y + self.cell_height - underline_height, + cursor_y + self.cell_height, + ) + } + CursorStyle::Bar => { + let bar_width = 2.0_f32.max(self.cell_width * 0.1); + ( + cursor_x, + cursor_x + bar_width, + cursor_y, + cursor_y + self.cell_height, + ) + } + }; + + let cursor_left = Self::pixel_to_ndc_x(left, width); + let cursor_right = Self::pixel_to_ndc_x(right, width); + let cursor_top = Self::pixel_to_ndc_y(top, height); + let cursor_bottom = Self::pixel_to_ndc_y(bottom, height); + + let base_idx = glyph_vertices.len() as u32; + + glyph_vertices.push(GlyphVertex { + position: [cursor_left, cursor_top], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + glyph_vertices.push(GlyphVertex { + position: [cursor_right, cursor_top], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + glyph_vertices.push(GlyphVertex { + position: [cursor_right, cursor_bottom], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + glyph_vertices.push(GlyphVertex { + position: [cursor_left, cursor_bottom], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + + glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + + // If block cursor and there's a character, re-render it with inverted color + if matches!(pane.cursor.style, CursorStyle::Block) && has_character { + // Character color: use bg color (inverted from normal) + let char_color = if cell_bg[3] < 0.01 { + // If bg was transparent, use black for the character + [0.0, 0.0, 0.0, 1.0] + } else { + [cell_bg[0], cell_bg[1], cell_bg[2], 1.0] + }; + + let glyph = self.rasterize_char(cell_char); + if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { + let cell_x = cursor_x; + let cell_y = cursor_y; + let (glyph_x, glyph_y) = if Self::is_box_drawing(cell_char) { + (cell_x, cell_y) + } else { + let baseline_y = (cell_y + self.cell_height * 0.8).round(); + let gx = (cell_x + glyph.offset[0]).round(); + let gy = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); + (gx, gy) + }; + + let g_left = Self::pixel_to_ndc_x(glyph_x, width); + let g_right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); + let g_top = Self::pixel_to_ndc_y(glyph_y, height); + let g_bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); + + let base_idx = glyph_vertices.len() as u32; + glyph_vertices.push(GlyphVertex { + position: [g_left, g_top], + uv: [glyph.uv[0], glyph.uv[1]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [g_right, g_top], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [g_right, g_bottom], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [g_left, g_bottom], + uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + } + } + + // Combine vertices + let mut vertices = bg_vertices; + let mut indices = bg_indices; + + let glyph_vertex_offset = vertices.len() as u32; + vertices.extend(glyph_vertices); + indices.extend(glyph_indices.iter().map(|i| i + glyph_vertex_offset)); + + // Resize buffers if needed + if vertices.len() > self.vertex_capacity { + self.vertex_capacity = vertices.len() * 2; + self.vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Glyph Vertex Buffer"), + size: (self.vertex_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } + + if indices.len() > self.index_capacity { + self.index_capacity = indices.len() * 2; + self.index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Glyph Index Buffer"), + size: (self.index_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } + + // Upload data + self.queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&vertices)); + self.queue.write_buffer(&self.index_buffer, 0, bytemuck::cast_slice(&indices)); + + if self.atlas_dirty { + self.queue.write_texture( + wgpu::ImageCopyTexture { + texture: &self.atlas_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &self.atlas_data, + wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(ATLAS_SIZE), + rows_per_image: Some(ATLAS_SIZE), + }, + wgpu::Extent3d { + width: ATLAS_SIZE, + height: ATLAS_SIZE, + depth_or_array_layers: 1, + }, + ); + self.atlas_dirty = false; + } + + // Create command encoder and render + let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Render Encoder"), + }); + + { + let [bg_r, bg_g, bg_b] = self.palette.default_bg; + let bg_r_linear = Self::srgb_to_linear(bg_r as f32 / 255.0) as f64; + let bg_g_linear = Self::srgb_to_linear(bg_g as f32 / 255.0) as f64; + let bg_b_linear = Self::srgb_to_linear(bg_b as f32 / 255.0) as f64; + let bg_alpha = self.background_opacity as f64; + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Render Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: bg_r_linear, + g: bg_g_linear, + b: bg_b_linear, + a: bg_alpha, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + }); + + render_pass.set_pipeline(&self.glyph_pipeline); + render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); + render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); + render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32); + render_pass.draw_indexed(0..indices.len() as u32, 0, 0..1); + } + + self.queue.submit(std::iter::once(encoder.finish())); + output.present(); + + Ok(()) + } + + /// Renders multiple panes with tab bar from protocol data (used by client). + /// The tab bar is rendered outside the terminal grid at the configured position. + /// + /// Arguments: + /// - `panes`: All pane snapshots with their layout info for the active tab + /// - `active_pane_id`: The ID of the focused pane (for cursor rendering) + /// - `tabs`: Tab information for the tab bar + /// - `active_tab`: Index of the active tab + pub fn render_with_tabs( + &mut self, + panes: &[(&PaneSnapshot, &PaneInfo)], + active_pane_id: PaneId, + tabs: &[TabInfo], + active_tab: usize, + ) -> Result<(), wgpu::SurfaceError> { + let output = self.surface.get_current_texture()?; + let view = output + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + + // Estimate total cells across all panes for buffer capacity + let total_cells: usize = panes.iter() + .map(|(snap, _)| snap.cells.len() * snap.cells.first().map_or(0, |r| r.len())) + .sum(); + + let mut bg_vertices: Vec = Vec::with_capacity(total_cells * 4 + 64); + let mut bg_indices: Vec = Vec::with_capacity(total_cells * 6 + 96); + let mut glyph_vertices: Vec = Vec::with_capacity(total_cells * 4 + 256); + let mut glyph_indices: Vec = Vec::with_capacity(total_cells * 6 + 384); + + let width = self.width as f32; + let height = self.height as f32; + let tab_bar_height = self.tab_bar_height(); + let terminal_y_offset = self.terminal_y_offset(); + + // ═══════════════════════════════════════════════════════════════════ + // RENDER TAB BAR (outside terminal grid) + // ═══════════════════════════════════════════════════════════════════ + if self.tab_bar_position != TabBarPosition::Hidden && !tabs.is_empty() { + let tab_bar_y = match self.tab_bar_position { + TabBarPosition::Top => 0.0, + TabBarPosition::Bottom => height - tab_bar_height, + TabBarPosition::Hidden => unreachable!(), + }; + + // Tab bar background - slightly different from terminal background + let tab_bar_bg = { + let [r, g, b] = self.palette.default_bg; + // Darken/lighten slightly for visual separation + let factor = 0.85_f32; + [ + Self::srgb_to_linear((r as f32 / 255.0) * factor), + Self::srgb_to_linear((g as f32 / 255.0) * factor), + Self::srgb_to_linear((b as f32 / 255.0) * factor), + 1.0, + ] + }; + + // Draw tab bar background + let bar_left = Self::pixel_to_ndc_x(0.0, width); + let bar_right = Self::pixel_to_ndc_x(width, width); + let bar_top = Self::pixel_to_ndc_y(tab_bar_y, height); + let bar_bottom = Self::pixel_to_ndc_y(tab_bar_y + tab_bar_height, height); + + let base_idx = bg_vertices.len() as u32; + bg_vertices.push(GlyphVertex { + position: [bar_left, bar_top], + uv: [0.0, 0.0], + color: tab_bar_bg, + bg_color: tab_bar_bg, + }); + bg_vertices.push(GlyphVertex { + position: [bar_right, bar_top], + uv: [0.0, 0.0], + color: tab_bar_bg, + bg_color: tab_bar_bg, + }); + bg_vertices.push(GlyphVertex { + position: [bar_right, bar_bottom], + uv: [0.0, 0.0], + color: tab_bar_bg, + bg_color: tab_bar_bg, + }); + bg_vertices.push(GlyphVertex { + position: [bar_left, bar_bottom], + uv: [0.0, 0.0], + color: tab_bar_bg, + bg_color: tab_bar_bg, + }); + bg_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + + // Render each tab + let mut tab_x = 4.0_f32; // Start with small padding + let tab_padding = 8.0_f32; + let min_tab_width = self.cell_width * 8.0; // Minimum width for tab + + for (idx, _tab) in tabs.iter().enumerate() { + let is_active = idx == active_tab; + + // Generate tab title (for now, just "Tab N" or use first pane info) + let title = format!(" {} ", idx + 1); + let title_width = title.chars().count() as f32 * self.cell_width; + let tab_width = title_width.max(min_tab_width); + + // Tab background + let tab_bg = if is_active { + // Active tab uses terminal background + let [r, g, b] = self.palette.default_bg; + [ + Self::srgb_to_linear(r as f32 / 255.0), + Self::srgb_to_linear(g as f32 / 255.0), + Self::srgb_to_linear(b as f32 / 255.0), + 1.0, + ] + } else { + // Inactive tabs are slightly darker + tab_bar_bg + }; + + let tab_fg = { + let [r, g, b] = self.palette.default_fg; + let alpha = if is_active { 1.0 } else { 0.6 }; + [ + Self::srgb_to_linear(r as f32 / 255.0), + Self::srgb_to_linear(g as f32 / 255.0), + Self::srgb_to_linear(b as f32 / 255.0), + alpha, + ] + }; + + // Tab background rect + let tab_left = Self::pixel_to_ndc_x(tab_x, width); + let tab_right = Self::pixel_to_ndc_x(tab_x + tab_width, width); + + // For top tab bar: active tab extends to touch terminal content + // For bottom tab bar: active tab extends upward + let (tab_top, tab_bottom) = if is_active { + match self.tab_bar_position { + TabBarPosition::Top => ( + Self::pixel_to_ndc_y(tab_bar_y + 2.0, height), + Self::pixel_to_ndc_y(tab_bar_y + tab_bar_height, height), + ), + TabBarPosition::Bottom => ( + Self::pixel_to_ndc_y(tab_bar_y, height), + Self::pixel_to_ndc_y(tab_bar_y + tab_bar_height - 2.0, height), + ), + TabBarPosition::Hidden => unreachable!(), + } + } else { + ( + Self::pixel_to_ndc_y(tab_bar_y + 4.0, height), + Self::pixel_to_ndc_y(tab_bar_y + tab_bar_height - 2.0, height), + ) + }; + + let base_idx = bg_vertices.len() as u32; + bg_vertices.push(GlyphVertex { + position: [tab_left, tab_top], + uv: [0.0, 0.0], + color: tab_bg, + bg_color: tab_bg, + }); + bg_vertices.push(GlyphVertex { + position: [tab_right, tab_top], + uv: [0.0, 0.0], + color: tab_bg, + bg_color: tab_bg, + }); + bg_vertices.push(GlyphVertex { + position: [tab_right, tab_bottom], + uv: [0.0, 0.0], + color: tab_bg, + bg_color: tab_bg, + }); + bg_vertices.push(GlyphVertex { + position: [tab_left, tab_bottom], + uv: [0.0, 0.0], + color: tab_bg, + bg_color: tab_bg, + }); + bg_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + + // Render tab title text + let text_y = tab_bar_y + (tab_bar_height - self.cell_height) / 2.0; + let text_x = tab_x + (tab_width - title_width) / 2.0; + + for (char_idx, c) in title.chars().enumerate() { + if c == ' ' { + continue; + } + let glyph = self.rasterize_char(c); + if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { + let char_x = text_x + char_idx as f32 * self.cell_width; + let baseline_y = (text_y + self.cell_height * 0.8).round(); + let glyph_x = (char_x + glyph.offset[0]).round(); + let glyph_y = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); + + let left = Self::pixel_to_ndc_x(glyph_x, width); + let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); + let top = Self::pixel_to_ndc_y(glyph_y, height); + let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); + + let base_idx = glyph_vertices.len() as u32; + glyph_vertices.push(GlyphVertex { + position: [left, top], + uv: [glyph.uv[0], glyph.uv[1]], + color: tab_fg, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [right, top], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], + color: tab_fg, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [right, bottom], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], + color: tab_fg, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [left, bottom], + uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], + color: tab_fg, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + } + + tab_x += tab_width + tab_padding; + } + } + + // ═══════════════════════════════════════════════════════════════════ + // RENDER TERMINAL CONTENT (all panes, offset by tab bar) + // ═══════════════════════════════════════════════════════════════════ + const LIGATURE_PATTERNS: &[&str] = &[ + "===", "!==", ">>>", "<<<", "||=", "&&=", "??=", "...", "-->", "<--", "<->", + "=>", "->", "<-", ">=", "<=", "==", "!=", "::", "&&", "||", "??", "..", "++", + "--", "<<", ">>", "|>", "<|", "/*", "*/", "//", "##", ":=", "~=", "<>", + ]; + + // Separator line color (slightly brighter than background) + let separator_color = { + let [r, g, b] = self.palette.default_bg; + let factor = 1.5_f32; + [ + Self::srgb_to_linear(((r as f32 / 255.0) * factor).min(1.0)), + Self::srgb_to_linear(((g as f32 / 255.0) * factor).min(1.0)), + Self::srgb_to_linear(((b as f32 / 255.0) * factor).min(1.0)), + 1.0, + ] + }; + + // Render each pane + for (pane, pane_info) in panes.iter() { + let is_active_pane = pane.pane_id == active_pane_id; + + // Calculate pane position in pixels + let pane_x_offset = pane_info.x as f32 * self.cell_width; + let pane_y_offset = terminal_y_offset + pane_info.y as f32 * self.cell_height; + + for (row_idx, row) in pane.cells.iter().enumerate() { + // Skip rows outside the pane bounds + if row_idx >= pane_info.rows { + break; + } + + let mut col_idx = 0; + while col_idx < row.len() && col_idx < pane_info.cols { + let cell = &row[col_idx]; + let cell_x = pane_x_offset + col_idx as f32 * self.cell_width; + let cell_y = pane_y_offset + row_idx as f32 * self.cell_height; + + let fg_color = self.cell_color_to_rgba(&cell.fg_color, true); + let bg_color = self.cell_color_to_rgba(&cell.bg_color, false); + + // Check for ligatures + let mut ligature_len = 0; + let mut ligature_glyph: Option = None; + + for pattern in LIGATURE_PATTERNS { + let pat_len = pattern.len(); + if col_idx + pat_len <= row.len() && col_idx + pat_len <= pane_info.cols { + let candidate: String = row[col_idx..col_idx + pat_len] + .iter() + .map(|c| c.character) + .collect(); + + if candidate == *pattern { + let shaped = self.shape_text(&candidate); + if shaped.glyphs.len() == 1 { + let glyph_id = shaped.glyphs[0].0; + ligature_glyph = Some(self.get_glyph_by_id(glyph_id)); + ligature_len = pat_len; + break; + } + } + } + } + + if let Some(glyph) = ligature_glyph { + let span_width = ligature_len as f32 * self.cell_width; + + for i in 0..ligature_len { + let bg_cell_x = pane_x_offset + (col_idx + i) as f32 * self.cell_width; + let cell_left = Self::pixel_to_ndc_x(bg_cell_x, width); + let cell_right = Self::pixel_to_ndc_x(bg_cell_x + self.cell_width, width); + let cell_top = Self::pixel_to_ndc_y(cell_y, height); + let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); + + let base_idx = bg_vertices.len() as u32; + bg_vertices.push(GlyphVertex { + position: [cell_left, cell_top], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_right, cell_top], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_right, cell_bottom], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_left, cell_bottom], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + + if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { + let baseline_y = (cell_y + self.cell_height * 0.8).round(); + let glyph_x = (cell_x + (span_width - glyph.size[0]) / 2.0 + glyph.offset[0]).round(); + let glyph_y = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); + + let left = Self::pixel_to_ndc_x(glyph_x, width); + let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); + let top = Self::pixel_to_ndc_y(glyph_y, height); + let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); + + let base_idx = glyph_vertices.len() as u32; + glyph_vertices.push(GlyphVertex { + position: [left, top], + uv: [glyph.uv[0], glyph.uv[1]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [right, top], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [right, bottom], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [left, bottom], + uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + + col_idx += ligature_len; + } else { + // Single character rendering + let cell_left = Self::pixel_to_ndc_x(cell_x, width); + let cell_right = Self::pixel_to_ndc_x(cell_x + self.cell_width, width); + let cell_top = Self::pixel_to_ndc_y(cell_y, height); + let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); + + let base_idx = bg_vertices.len() as u32; + bg_vertices.push(GlyphVertex { + position: [cell_left, cell_top], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_right, cell_top], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_right, cell_bottom], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_vertices.push(GlyphVertex { + position: [cell_left, cell_bottom], + uv: [0.0, 0.0], + color: fg_color, + bg_color, + }); + bg_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + + let c = cell.character; + if c != ' ' && c != '\0' { + let glyph = self.rasterize_char(c); + if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { + let (glyph_x, glyph_y) = if Self::is_box_drawing(c) { + (cell_x, cell_y) + } else { + let baseline_y = (cell_y + self.cell_height * 0.8).round(); + let gx = (cell_x + glyph.offset[0]).round(); + let gy = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); + (gx, gy) + }; + + let left = Self::pixel_to_ndc_x(glyph_x, width); + let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); + let top = Self::pixel_to_ndc_y(glyph_y, height); + let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); + + let base_idx = glyph_vertices.len() as u32; + glyph_vertices.push(GlyphVertex { + position: [left, top], + uv: [glyph.uv[0], glyph.uv[1]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [right, top], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [right, bottom], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [left, bottom], + uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + } + + col_idx += 1; + } + } + } + + // Draw cursor only in the active pane + if is_active_pane && pane.cursor.visible { + let cursor_x = pane_x_offset + pane.cursor.col as f32 * self.cell_width; + let cursor_y = pane_y_offset + pane.cursor.row as f32 * self.cell_height; + + // Get the cell under the cursor to determine colors + let cursor_cell = pane.cells + .get(pane.cursor.row) + .and_then(|row| row.get(pane.cursor.col)); + + // Get fg and bg colors from the cell under cursor + let (cell_fg, cell_bg, cell_char) = if let Some(cell) = cursor_cell { + let fg = self.cell_color_to_rgba(&cell.fg_color, true); + let bg = self.cell_color_to_rgba(&cell.bg_color, false); + (fg, bg, cell.character) + } else { + // Default colors if cell doesn't exist + let fg = self.cell_color_to_rgba(&CellColor::Default, true); + let bg = [0.0, 0.0, 0.0, 0.0]; + (fg, bg, ' ') + }; + + let has_character = cell_char != ' ' && cell_char != '\0'; + + // Cursor color: invert the background, or use fg if there's a character + let cursor_bg_color = if has_character { + // Character present: cursor takes fg color as background + [cell_fg[0], cell_fg[1], cell_fg[2], 1.0] + } else { + // Empty cell: invert the background color + // If bg is transparent/default, invert to white; otherwise invert RGB + if cell_bg[3] < 0.01 { + // Transparent background -> white cursor + let white = Self::srgb_to_linear(0.9); + [white, white, white, 1.0] + } else { + // Invert the background color + [1.0 - cell_bg[0], 1.0 - cell_bg[1], 1.0 - cell_bg[2], 1.0] + } + }; + + // Determine cursor bounds based on style + let (left, right, top, bottom) = match pane.cursor.style { + CursorStyle::Block => ( + cursor_x, + cursor_x + self.cell_width, + cursor_y, + cursor_y + self.cell_height, + ), + CursorStyle::Underline => { + let underline_height = 2.0_f32.max(self.cell_height * 0.1); + ( + cursor_x, + cursor_x + self.cell_width, + cursor_y + self.cell_height - underline_height, + cursor_y + self.cell_height, + ) + } + CursorStyle::Bar => { + let bar_width = 2.0_f32.max(self.cell_width * 0.1); + ( + cursor_x, + cursor_x + bar_width, + cursor_y, + cursor_y + self.cell_height, + ) + } + }; + + let cursor_left = Self::pixel_to_ndc_x(left, width); + let cursor_right = Self::pixel_to_ndc_x(right, width); + let cursor_top = Self::pixel_to_ndc_y(top, height); + let cursor_bottom = Self::pixel_to_ndc_y(bottom, height); + + let base_idx = glyph_vertices.len() as u32; + + glyph_vertices.push(GlyphVertex { + position: [cursor_left, cursor_top], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + glyph_vertices.push(GlyphVertex { + position: [cursor_right, cursor_top], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + glyph_vertices.push(GlyphVertex { + position: [cursor_right, cursor_bottom], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + glyph_vertices.push(GlyphVertex { + position: [cursor_left, cursor_bottom], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + + glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + + // If block cursor and there's a character, re-render it with inverted color + if matches!(pane.cursor.style, CursorStyle::Block) && has_character { + // Character color: use bg color (inverted from normal) + let char_color = if cell_bg[3] < 0.01 { + // If bg was transparent, use black for the character + [0.0, 0.0, 0.0, 1.0] + } else { + [cell_bg[0], cell_bg[1], cell_bg[2], 1.0] + }; + + let glyph = self.rasterize_char(cell_char); + if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { + let cell_x = cursor_x; + let cell_y = cursor_y; + let (glyph_x, glyph_y) = if Self::is_box_drawing(cell_char) { + (cell_x, cell_y) + } else { + let baseline_y = (cell_y + self.cell_height * 0.8).round(); + let gx = (cell_x + glyph.offset[0]).round(); + let gy = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); + (gx, gy) + }; + + let g_left = Self::pixel_to_ndc_x(glyph_x, width); + let g_right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); + let g_top = Self::pixel_to_ndc_y(glyph_y, height); + let g_bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); + + let base_idx = glyph_vertices.len() as u32; + glyph_vertices.push(GlyphVertex { + position: [g_left, g_top], + uv: [glyph.uv[0], glyph.uv[1]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [g_right, g_top], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [g_right, g_bottom], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_vertices.push(GlyphVertex { + position: [g_left, g_bottom], + uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + } + } + + } + + // ═══════════════════════════════════════════════════════════════════ + // DRAW PANE SEPARATORS (only between adjacent panes, sized to the pane bounds) + // ═══════════════════════════════════════════════════════════════════ + if panes.len() > 1 { + let separator_thickness = 1.0_f32; + + for (_, pane_info) in panes.iter() { + let pane_left_x = pane_info.x as f32 * self.cell_width; + let pane_right_x = pane_left_x + pane_info.cols as f32 * self.cell_width; + let pane_top_y = terminal_y_offset + pane_info.y as f32 * self.cell_height; + let pane_bottom_y = pane_top_y + pane_info.rows as f32 * self.cell_height; + + // Check if there's a pane directly to the right (draw vertical separator on right edge) + let has_pane_right = panes.iter().any(|(_, other)| { + let other_left_x = other.x as f32 * self.cell_width; + let other_top_y = terminal_y_offset + other.y as f32 * self.cell_height; + let other_bottom_y = other_top_y + other.rows as f32 * self.cell_height; + // Other pane starts at our right edge and overlaps vertically + (other_left_x - pane_right_x).abs() < 2.0 + && other_top_y < pane_bottom_y + && other_bottom_y > pane_top_y + }); + + if has_pane_right { + // Draw vertical separator at right edge, spanning this pane's height + // Extend to window edges if this pane is at top/bottom + let sep_top = if pane_info.y == 0 { terminal_y_offset } else { pane_top_y }; + let terminal_bottom = height - if matches!(self.tab_bar_position, TabBarPosition::Bottom) { tab_bar_height } else { 0.0 }; + let max_pane_bottom: f32 = panes.iter() + .map(|(_, p)| terminal_y_offset + p.y as f32 * self.cell_height + p.rows as f32 * self.cell_height) + .fold(0.0_f32, |a, b| a.max(b)); + let sep_bottom = if (pane_bottom_y - max_pane_bottom).abs() < 2.0 { terminal_bottom } else { pane_bottom_y }; + + let sep_left_ndc = Self::pixel_to_ndc_x(pane_right_x, width); + let sep_right_ndc = Self::pixel_to_ndc_x(pane_right_x + separator_thickness, width); + let sep_top_ndc = Self::pixel_to_ndc_y(sep_top, height); + let sep_bottom_ndc = Self::pixel_to_ndc_y(sep_bottom, height); + + let base_idx = glyph_vertices.len() as u32; + glyph_vertices.push(GlyphVertex { + position: [sep_left_ndc, sep_top_ndc], + uv: [0.0, 0.0], + color: separator_color, + bg_color: separator_color, + }); + glyph_vertices.push(GlyphVertex { + position: [sep_right_ndc, sep_top_ndc], + uv: [0.0, 0.0], + color: separator_color, + bg_color: separator_color, + }); + glyph_vertices.push(GlyphVertex { + position: [sep_right_ndc, sep_bottom_ndc], + uv: [0.0, 0.0], + color: separator_color, + bg_color: separator_color, + }); + glyph_vertices.push(GlyphVertex { + position: [sep_left_ndc, sep_bottom_ndc], + uv: [0.0, 0.0], + color: separator_color, + bg_color: separator_color, + }); + glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + + // Check if there's a pane directly below (draw horizontal separator on bottom edge) + let has_pane_below = panes.iter().any(|(_, other)| { + let other_left_x = other.x as f32 * self.cell_width; + let other_right_x = other_left_x + other.cols as f32 * self.cell_width; + let other_top_y = terminal_y_offset + other.y as f32 * self.cell_height; + // Other pane starts at our bottom edge and overlaps horizontally + (other_top_y - pane_bottom_y).abs() < 2.0 + && other_left_x < pane_right_x + && other_right_x > pane_left_x + }); + + if has_pane_below { + // Draw horizontal separator at bottom edge, spanning this pane's width + // Extend to window edges if this pane is at left/right + let sep_left = if pane_info.x == 0 { 0.0 } else { pane_left_x }; + let max_pane_right: f32 = panes.iter() + .map(|(_, p)| p.x as f32 * self.cell_width + p.cols as f32 * self.cell_width) + .fold(0.0_f32, |a, b| a.max(b)); + let sep_right = if (pane_right_x - max_pane_right).abs() < 2.0 { width } else { pane_right_x }; + + let sep_left_ndc = Self::pixel_to_ndc_x(sep_left, width); + let sep_right_ndc = Self::pixel_to_ndc_x(sep_right, width); + let sep_top_ndc = Self::pixel_to_ndc_y(pane_bottom_y, height); + let sep_bottom_ndc = Self::pixel_to_ndc_y(pane_bottom_y + separator_thickness, height); + + let base_idx = glyph_vertices.len() as u32; + glyph_vertices.push(GlyphVertex { + position: [sep_left_ndc, sep_top_ndc], + uv: [0.0, 0.0], + color: separator_color, + bg_color: separator_color, + }); + glyph_vertices.push(GlyphVertex { + position: [sep_right_ndc, sep_top_ndc], + uv: [0.0, 0.0], + color: separator_color, + bg_color: separator_color, + }); + glyph_vertices.push(GlyphVertex { + position: [sep_right_ndc, sep_bottom_ndc], + uv: [0.0, 0.0], + color: separator_color, + bg_color: separator_color, + }); + glyph_vertices.push(GlyphVertex { + position: [sep_left_ndc, sep_bottom_ndc], + uv: [0.0, 0.0], + color: separator_color, + bg_color: separator_color, + }); + glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + } + } + + // Combine vertices + let mut vertices = bg_vertices; + let mut indices = bg_indices; + + let glyph_vertex_offset = vertices.len() as u32; + vertices.extend(glyph_vertices); + indices.extend(glyph_indices.iter().map(|i| i + glyph_vertex_offset)); + + // Resize buffers if needed + if vertices.len() > self.vertex_capacity { + self.vertex_capacity = vertices.len() * 2; + self.vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Glyph Vertex Buffer"), + size: (self.vertex_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } + + if indices.len() > self.index_capacity { + self.index_capacity = indices.len() * 2; + self.index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Glyph Index Buffer"), + size: (self.index_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } + + // Upload data + self.queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&vertices)); + self.queue.write_buffer(&self.index_buffer, 0, bytemuck::cast_slice(&indices)); + + if self.atlas_dirty { + self.queue.write_texture( + wgpu::ImageCopyTexture { + texture: &self.atlas_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &self.atlas_data, + wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(ATLAS_SIZE), + rows_per_image: Some(ATLAS_SIZE), + }, + wgpu::Extent3d { + width: ATLAS_SIZE, + height: ATLAS_SIZE, + depth_or_array_layers: 1, + }, + ); + self.atlas_dirty = false; + } + + // Create command encoder and render + let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Render Encoder"), + }); + + { + let [bg_r, bg_g, bg_b] = self.palette.default_bg; + let bg_r_linear = Self::srgb_to_linear(bg_r as f32 / 255.0) as f64; + let bg_g_linear = Self::srgb_to_linear(bg_g as f32 / 255.0) as f64; + let bg_b_linear = Self::srgb_to_linear(bg_b as f32 / 255.0) as f64; + let bg_alpha = self.background_opacity as f64; + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Render Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: bg_r_linear, + g: bg_g_linear, + b: bg_b_linear, + a: bg_alpha, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + }); + + render_pass.set_pipeline(&self.glyph_pipeline); + render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); + render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); + render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32); + render_pass.draw_indexed(0..indices.len() as u32, 0, 0..1); + } + + self.queue.submit(std::iter::once(encoder.finish())); + output.present(); + + Ok(()) + } +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..b497a6a --- /dev/null +++ b/src/session.rs @@ -0,0 +1,160 @@ +//! Terminal session management. +//! +//! A Session owns a PTY and its associated terminal state. + +use crate::protocol::{CellColor, CursorInfo, CursorStyle, PaneSnapshot, RenderCell, SessionId}; +use crate::pty::Pty; +use crate::terminal::{Cell, Color, ColorPalette, CursorShape, Terminal}; +use vte::Parser; + +/// A terminal session with its PTY and state. +pub struct Session { + /// Unique session identifier. + pub id: SessionId, + /// The PTY connected to the shell. + pub pty: Pty, + /// Terminal state (grid, cursor, colors, etc.). + pub terminal: Terminal, + /// VTE parser for this session. + parser: Parser, + /// Whether the session has new output to send. + pub dirty: bool, +} + +impl Session { + /// Creates a new session with the given dimensions. + pub fn new(id: SessionId, cols: usize, rows: usize) -> Result { + let pty = Pty::spawn(None)?; + pty.resize(cols as u16, rows as u16)?; + + let terminal = Terminal::new(cols, rows); + + Ok(Self { + id, + pty, + terminal, + parser: Parser::new(), + dirty: true, + }) + } + + /// Reads available data from the PTY and processes it. + /// Returns the number of bytes read. + pub fn poll(&mut self, buffer: &mut [u8]) -> Result { + let mut total = 0; + + loop { + match self.pty.read(buffer) { + Ok(0) => break, // WOULDBLOCK or no data + Ok(n) => { + self.terminal.process(&buffer[..n], &mut self.parser); + total += n; + self.dirty = true; + } + Err(e) => return Err(e), + } + } + + Ok(total) + } + + /// Writes data to the PTY (keyboard input). + pub fn write(&self, data: &[u8]) -> Result { + self.pty.write(data) + } + + /// Resizes the session. + pub fn resize(&mut self, cols: usize, rows: usize) { + self.terminal.resize(cols, rows); + let _ = self.pty.resize(cols as u16, rows as u16); + self.dirty = true; + } + + /// Gets any pending response from the terminal (e.g., query responses). + pub fn take_response(&mut self) -> Option> { + self.terminal.take_response() + } + + /// Converts internal Color to protocol CellColor, resolving indexed colors using the palette. + fn convert_color(color: &Color, palette: &ColorPalette) -> CellColor { + match color { + Color::Default => CellColor::Default, + Color::Rgb(r, g, b) => CellColor::Rgb(*r, *g, *b), + Color::Indexed(i) => { + // Resolve indexed colors to RGB using the palette + let [r, g, b] = palette.colors[*i as usize]; + CellColor::Rgb(r, g, b) + } + } + } + + /// Converts internal Cell to protocol RenderCell. + fn convert_cell(cell: &Cell, palette: &ColorPalette) -> RenderCell { + RenderCell { + character: cell.character, + fg_color: Self::convert_color(&cell.fg_color, palette), + bg_color: Self::convert_color(&cell.bg_color, palette), + bold: cell.bold, + italic: cell.italic, + underline: cell.underline, + } + } + + /// Creates a snapshot of the terminal state for sending to client. + pub fn snapshot(&self, pane_id: u32) -> PaneSnapshot { + let palette = &self.terminal.palette; + + // Use visible_rows() which accounts for scroll offset + let cells: Vec> = self.terminal.visible_rows() + .iter() + .map(|row| row.iter().map(|cell| Self::convert_cell(cell, palette)).collect()) + .collect(); + + // Convert terminal cursor shape to protocol cursor style + let cursor_style = match self.terminal.cursor_shape { + CursorShape::BlinkingBlock | CursorShape::SteadyBlock => CursorStyle::Block, + CursorShape::BlinkingUnderline | CursorShape::SteadyUnderline => CursorStyle::Underline, + CursorShape::BlinkingBar | CursorShape::SteadyBar => CursorStyle::Bar, + }; + + // When scrolled back, adjust cursor row to account for offset + // and hide cursor if it's not visible in the viewport + let (cursor_row, cursor_visible) = if self.terminal.scroll_offset > 0 { + // Cursor is at the "live" position, but we're viewing history + // The cursor should appear scroll_offset rows lower, or be hidden + let adjusted_row = self.terminal.cursor_row + self.terminal.scroll_offset; + if adjusted_row >= self.terminal.rows { + // Cursor is not in visible area + (0, false) + } else { + (adjusted_row, self.terminal.cursor_visible) + } + } else { + (self.terminal.cursor_row, self.terminal.cursor_visible) + }; + + PaneSnapshot { + pane_id, + cells, + cursor: CursorInfo { + col: self.terminal.cursor_col, + row: cursor_row, + visible: cursor_visible, + style: cursor_style, + }, + scroll_offset: self.terminal.scroll_offset, + scrollback_len: self.terminal.scrollback.len(), + } + } + + /// Returns the raw file descriptor for polling. + pub fn fd(&self) -> std::os::fd::BorrowedFd<'_> { + self.pty.master_fd() + } + + /// Marks the session as clean (updates sent). + pub fn mark_clean(&mut self) { + self.dirty = false; + self.terminal.dirty = false; + } +} diff --git a/src/shader.wgsl b/src/shader.wgsl new file mode 100644 index 0000000..6682c47 --- /dev/null +++ b/src/shader.wgsl @@ -0,0 +1,26 @@ +// Vertex shader + +struct VertexInput { + @location(0) position: vec2, + @location(1) color: vec4, +} + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) color: vec4, +} + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; + out.clip_position = vec4(in.position, 0.0, 1.0); + out.color = in.color; + return out; +} + +// Fragment shader + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + return in.color; +} diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..48216cd --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,982 @@ +//! Terminal state management and escape sequence handling. + +use crate::keyboard::{query_response, KeyboardState}; +use vte::{Params, Parser, Perform}; + +/// A single cell in the terminal grid. +#[derive(Clone, Debug)] +pub struct Cell { + pub character: char, + pub fg_color: Color, + pub bg_color: Color, + pub bold: bool, + pub italic: bool, + pub underline: bool, +} + +impl Default for Cell { + fn default() -> Self { + Self { + character: ' ', + fg_color: Color::Default, + bg_color: Color::Default, + bold: false, + italic: false, + underline: false, + } + } +} + +/// Terminal colors. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Color { + Default, + Rgb(u8, u8, u8), + Indexed(u8), +} + +/// Cursor shape styles (DECSCUSR). +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum CursorShape { + /// Blinking block (and default) + #[default] + BlinkingBlock, + /// Steady block + SteadyBlock, + /// Blinking underline + BlinkingUnderline, + /// Steady underline + SteadyUnderline, + /// Blinking bar (beam) + BlinkingBar, + /// Steady bar (beam) + SteadyBar, +} + +/// Color palette with 256 colors + default fg/bg. +pub struct ColorPalette { + /// 256 indexed colors (ANSI 0-15 + 216 color cube + 24 grayscale). + pub colors: [[u8; 3]; 256], + /// Default foreground color. + pub default_fg: [u8; 3], + /// Default background color. + pub default_bg: [u8; 3], +} + +impl Default for ColorPalette { + fn default() -> Self { + let mut colors = [[0u8; 3]; 256]; + + // Standard ANSI colors (0-7) + colors[0] = [0, 0, 0]; // Black + colors[1] = [204, 0, 0]; // Red + colors[2] = [0, 204, 0]; // Green + colors[3] = [204, 204, 0]; // Yellow + colors[4] = [0, 0, 204]; // Blue + colors[5] = [204, 0, 204]; // Magenta + colors[6] = [0, 204, 204]; // Cyan + colors[7] = [204, 204, 204]; // White + + // Bright ANSI colors (8-15) + colors[8] = [102, 102, 102]; // Bright Black (Gray) + colors[9] = [255, 0, 0]; // Bright Red + colors[10] = [0, 255, 0]; // Bright Green + colors[11] = [255, 255, 0]; // Bright Yellow + colors[12] = [0, 0, 255]; // Bright Blue + colors[13] = [255, 0, 255]; // Bright Magenta + colors[14] = [0, 255, 255]; // Bright Cyan + colors[15] = [255, 255, 255]; // Bright White + + // 216 color cube (16-231) + for r in 0..6 { + for g in 0..6 { + for b in 0..6 { + let idx = 16 + r * 36 + g * 6 + b; + let to_val = |c: usize| if c == 0 { 0 } else { (55 + c * 40) as u8 }; + colors[idx] = [to_val(r), to_val(g), to_val(b)]; + } + } + } + + // 24 grayscale colors (232-255) + for i in 0..24 { + let gray = (8 + i * 10) as u8; + colors[232 + i] = [gray, gray, gray]; + } + + Self { + colors, + default_fg: [230, 230, 230], // Light gray + default_bg: [26, 26, 26], // Dark gray + } + } +} + +impl ColorPalette { + /// Parse a color specification like "#RRGGBB" or "rgb:RR/GG/BB". + pub fn parse_color_spec(spec: &str) -> Option<[u8; 3]> { + let spec = spec.trim(); + + if let Some(hex) = spec.strip_prefix('#') { + // #RRGGBB format + if hex.len() == 6 { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + return Some([r, g, b]); + } + } else if let Some(rgb) = spec.strip_prefix("rgb:") { + // rgb:RR/GG/BB or rgb:RRRR/GGGG/BBBB format + let parts: Vec<&str> = rgb.split('/').collect(); + if parts.len() == 3 { + let parse_component = |s: &str| -> Option { + let val = u16::from_str_radix(s, 16).ok()?; + // Scale to 8-bit if it's a 16-bit value + Some(if s.len() > 2 { (val >> 8) as u8 } else { val as u8 }) + }; + let r = parse_component(parts[0])?; + let g = parse_component(parts[1])?; + let b = parse_component(parts[2])?; + return Some([r, g, b]); + } + } + + None + } + + /// Get RGBA for a color, using the palette for indexed colors. + pub fn to_rgba(&self, color: &Color) -> [f32; 4] { + match color { + Color::Default => { + let [r, g, b] = self.default_fg; + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] + } + Color::Rgb(r, g, b) => [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0], + Color::Indexed(idx) => { + let [r, g, b] = self.colors[*idx as usize]; + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] + } + } + } + + /// Get RGBA for background, using palette default_bg for Color::Default. + pub fn to_rgba_bg(&self, color: &Color) -> [f32; 4] { + match color { + Color::Default => { + let [r, g, b] = self.default_bg; + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] + } + Color::Rgb(r, g, b) => [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0], + Color::Indexed(idx) => { + let [r, g, b] = self.colors[*idx as usize]; + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] + } + } + } +} + +/// The terminal grid state. +pub struct Terminal { + /// Grid of cells (row-major order). + pub grid: Vec>, + /// Number of columns. + pub cols: usize, + /// Number of rows. + pub rows: usize, + /// Current cursor column (0-indexed). + pub cursor_col: usize, + /// Current cursor row (0-indexed). + pub cursor_row: usize, + /// Cursor visibility. + pub cursor_visible: bool, + /// Cursor shape (block, underline, bar). + pub cursor_shape: CursorShape, + /// Current foreground color for new text. + pub current_fg: Color, + /// Current background color for new text. + pub current_bg: Color, + /// Current bold state. + pub current_bold: bool, + /// Current italic state. + pub current_italic: bool, + /// Current underline state. + pub current_underline: bool, + /// Whether the terminal content has changed. + pub dirty: bool, + /// Scroll region top (0-indexed, inclusive). + scroll_top: usize, + /// Scroll region bottom (0-indexed, inclusive). + scroll_bottom: usize, + /// Kitty keyboard protocol state. + pub keyboard: KeyboardState, + /// Response queue (bytes to send back to PTY). + response_queue: Vec, + /// Color palette (can be modified by OSC sequences). + pub palette: ColorPalette, + /// Scrollback buffer (lines that scrolled off the top). + pub scrollback: Vec>, + /// Maximum number of lines to keep in scrollback. + pub scrollback_limit: usize, + /// Current scroll offset (0 = viewing live terminal, >0 = viewing history). + pub scroll_offset: usize, +} + +impl Terminal { + /// Default scrollback limit (10,000 lines). + const DEFAULT_SCROLLBACK_LIMIT: usize = 10_000; + + /// Creates a new terminal with the given dimensions. + pub fn new(cols: usize, rows: usize) -> Self { + let grid = vec![vec![Cell::default(); cols]; rows]; + + Self { + grid, + cols, + rows, + cursor_col: 0, + cursor_row: 0, + cursor_visible: true, + cursor_shape: CursorShape::default(), + current_fg: Color::Default, + current_bg: Color::Default, + current_bold: false, + current_italic: false, + current_underline: false, + dirty: true, + scroll_top: 0, + scroll_bottom: rows.saturating_sub(1), + keyboard: KeyboardState::new(), + response_queue: Vec::new(), + palette: ColorPalette::default(), + scrollback: Vec::new(), + scrollback_limit: Self::DEFAULT_SCROLLBACK_LIMIT, + scroll_offset: 0, + } + } + + /// Takes any pending response bytes to send to the PTY. + pub fn take_response(&mut self) -> Option> { + if self.response_queue.is_empty() { + None + } else { + Some(std::mem::take(&mut self.response_queue)) + } + } + + /// Processes raw bytes from the PTY using the provided parser. + pub fn process(&mut self, bytes: &[u8], parser: &mut Parser) { + for byte in bytes { + parser.advance(self, *byte); + } + self.dirty = true; + } + + /// Resizes the terminal grid. + pub fn resize(&mut self, cols: usize, rows: usize) { + if cols == self.cols && rows == self.rows { + return; + } + + // Create new grid + let mut new_grid = vec![vec![Cell::default(); cols]; rows]; + + // Copy existing content + for row in 0..rows.min(self.rows) { + for col in 0..cols.min(self.cols) { + new_grid[row][col] = self.grid[row][col].clone(); + } + } + + self.grid = new_grid; + self.cols = cols; + self.rows = rows; + + // Reset scroll region to full screen + self.scroll_top = 0; + self.scroll_bottom = rows.saturating_sub(1); + + // Adjust cursor position + self.cursor_col = self.cursor_col.min(cols.saturating_sub(1)); + self.cursor_row = self.cursor_row.min(rows.saturating_sub(1)); + self.dirty = true; + } + + /// Scrolls the scroll region up by n lines. + fn scroll_up(&mut self, n: usize) { + let n = n.min(self.scroll_bottom - self.scroll_top + 1); + for _ in 0..n { + // Remove the top line of the scroll region + let removed_line = self.grid.remove(self.scroll_top); + + // Save to scrollback only if scrolling from the very top of the screen + if self.scroll_top == 0 { + self.scrollback.push(removed_line); + // Trim scrollback if it exceeds the limit + if self.scrollback.len() > self.scrollback_limit { + self.scrollback.remove(0); + } + } + + // Insert a new blank line at the bottom of the scroll region + self.grid + .insert(self.scroll_bottom, vec![Cell::default(); self.cols]); + } + } + + /// Scrolls the scroll region down by n lines. + fn scroll_down(&mut self, n: usize) { + let n = n.min(self.scroll_bottom - self.scroll_top + 1); + for _ in 0..n { + // Remove the bottom line of the scroll region + self.grid.remove(self.scroll_bottom); + // Insert a new blank line at the top of the scroll region + self.grid + .insert(self.scroll_top, vec![Cell::default(); self.cols]); + } + } + + /// Scrolls the viewport up (into scrollback history) by n lines. + /// Returns the new scroll offset. + pub fn scroll_viewport_up(&mut self, n: usize) -> usize { + let max_offset = self.scrollback.len(); + self.scroll_offset = (self.scroll_offset + n).min(max_offset); + self.dirty = true; + self.scroll_offset + } + + /// Scrolls the viewport down (toward live terminal) by n lines. + /// Returns the new scroll offset. + pub fn scroll_viewport_down(&mut self, n: usize) -> usize { + self.scroll_offset = self.scroll_offset.saturating_sub(n); + self.dirty = true; + self.scroll_offset + } + + /// Resets viewport to show live terminal (scroll_offset = 0). + pub fn reset_scroll(&mut self) { + if self.scroll_offset != 0 { + self.scroll_offset = 0; + self.dirty = true; + } + } + + /// Returns the visible rows accounting for scroll offset. + /// This combines scrollback lines with the current grid. + pub fn visible_rows(&self) -> Vec<&Vec> { + let mut rows = Vec::with_capacity(self.rows); + + if self.scroll_offset == 0 { + // No scrollback viewing, just return the grid + for row in &self.grid { + rows.push(row); + } + } else { + // We're viewing scrollback + // scroll_offset = how many lines back we're looking + let scrollback_len = self.scrollback.len(); + + for i in 0..self.rows { + // Calculate which line to show + // If scroll_offset = 5, we want to show 5 lines from scrollback at the top + let lines_from_scrollback = self.scroll_offset.min(self.rows); + + if i < lines_from_scrollback { + // This row comes from scrollback + let scrollback_idx = scrollback_len - self.scroll_offset + i; + if scrollback_idx < scrollback_len { + rows.push(&self.scrollback[scrollback_idx]); + } else { + // Shouldn't happen, but fall back to grid + rows.push(&self.grid[i]); + } + } else { + // This row comes from the grid + let grid_idx = i - lines_from_scrollback; + if grid_idx < self.grid.len() { + rows.push(&self.grid[grid_idx]); + } + } + } + } + + rows + } + + /// Inserts n blank lines at the cursor position, scrolling lines below down. + fn insert_lines(&mut self, n: usize) { + if self.cursor_row < self.scroll_top || self.cursor_row > self.scroll_bottom { + return; + } + let n = n.min(self.scroll_bottom - self.cursor_row + 1); + for _ in 0..n { + // Remove the bottom line of the scroll region + self.grid.remove(self.scroll_bottom); + // Insert a new blank line at the cursor row + self.grid + .insert(self.cursor_row, vec![Cell::default(); self.cols]); + } + } + + /// Deletes n lines at the cursor position, scrolling lines below up. + fn delete_lines(&mut self, n: usize) { + if self.cursor_row < self.scroll_top || self.cursor_row > self.scroll_bottom { + return; + } + let n = n.min(self.scroll_bottom - self.cursor_row + 1); + for _ in 0..n { + // Remove the line at cursor + self.grid.remove(self.cursor_row); + // Insert a new blank line at the bottom of the scroll region + self.grid + .insert(self.scroll_bottom, vec![Cell::default(); self.cols]); + } + } + + /// Inserts n blank characters at the cursor, shifting existing chars right. + fn insert_characters(&mut self, n: usize) { + let row = &mut self.grid[self.cursor_row]; + let n = n.min(self.cols - self.cursor_col); + // Remove n characters from the end + for _ in 0..n { + row.pop(); + } + // Insert n blank characters at cursor position + for _ in 0..n { + row.insert(self.cursor_col, Cell::default()); + } + } + + /// Deletes n characters at the cursor, shifting remaining chars left. + fn delete_characters(&mut self, n: usize) { + let row = &mut self.grid[self.cursor_row]; + let n = n.min(self.cols - self.cursor_col); + // Remove n characters at cursor position + for _ in 0..n { + if self.cursor_col < row.len() { + row.remove(self.cursor_col); + } + } + // Pad with blank characters at the end + while row.len() < self.cols { + row.push(Cell::default()); + } + } + + /// Erases n characters at the cursor (replaces with spaces, doesn't shift). + fn erase_characters(&mut self, n: usize) { + let n = n.min(self.cols - self.cursor_col); + for i in 0..n { + if self.cursor_col + i < self.cols { + self.grid[self.cursor_row][self.cursor_col + i] = Cell::default(); + } + } + } + + /// Clears the current line from cursor to end. + fn clear_line_from_cursor(&mut self) { + for col in self.cursor_col..self.cols { + self.grid[self.cursor_row][col] = Cell::default(); + } + } + + /// Clears the entire screen. + fn clear_screen(&mut self) { + for row in &mut self.grid { + for cell in row { + *cell = Cell::default(); + } + } + self.cursor_col = 0; + self.cursor_row = 0; + } + + /// Handles Kitty keyboard protocol escape sequences. + fn handle_keyboard_protocol(&mut self, params: &[u16], intermediates: &[u8]) { + match intermediates { + // CSI ? u - Query current keyboard flags + [b'?'] => { + let response = query_response(self.keyboard.flags()); + self.response_queue.extend(response); + } + // CSI = flags ; mode u - Set keyboard flags + [b'='] => { + let flags = params.first().copied().unwrap_or(0) as u8; + let mode = params.get(1).copied().unwrap_or(1) as u8; + self.keyboard.set_flags(flags, mode); + log::debug!( + "Keyboard flags set to {:?} (mode {})", + self.keyboard.flags(), + mode + ); + } + // CSI > flags u - Push keyboard flags onto stack + [b'>'] => { + let flags = if params.is_empty() { + None + } else { + Some(params[0] as u8) + }; + self.keyboard.push(flags); + log::debug!("Keyboard flags pushed: {:?}", self.keyboard.flags()); + } + // CSI < number u - Pop keyboard flags from stack + [b'<'] => { + let count = params.first().copied().unwrap_or(1) as usize; + self.keyboard.pop(count); + log::debug!("Keyboard flags popped: {:?}", self.keyboard.flags()); + } + _ => { + // Unknown intermediate, ignore + } + } + } +} + +impl Perform for Terminal { + fn print(&mut self, c: char) { + if self.cursor_col >= self.cols { + self.cursor_col = 0; + self.cursor_row += 1; + if self.cursor_row > self.scroll_bottom { + self.scroll_up(1); + self.cursor_row = self.scroll_bottom; + } + } + + self.grid[self.cursor_row][self.cursor_col] = Cell { + character: c, + fg_color: self.current_fg, + bg_color: self.current_bg, + bold: self.current_bold, + italic: self.current_italic, + underline: self.current_underline, + }; + + self.cursor_col += 1; + } + + fn execute(&mut self, byte: u8) { + match byte { + // Backspace + 0x08 => { + if self.cursor_col > 0 { + self.cursor_col -= 1; + } + } + // Tab + 0x09 => { + let next_tab = (self.cursor_col / 8 + 1) * 8; + self.cursor_col = next_tab.min(self.cols - 1); + } + // Line feed + 0x0A => { + self.cursor_row += 1; + if self.cursor_row > self.scroll_bottom { + self.scroll_up(1); + self.cursor_row = self.scroll_bottom; + } + } + // Carriage return + 0x0D => { + self.cursor_col = 0; + } + _ => {} + } + } + + fn hook(&mut self, _params: &Params, _intermediates: &[u8], _ignore: bool, _action: char) {} + + fn put(&mut self, _byte: u8) {} + + fn unhook(&mut self) {} + + fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) { + // Handle OSC sequences + if params.is_empty() { + return; + } + + // First param is the OSC number + let osc_num = match std::str::from_utf8(params[0]) { + Ok(s) => s.parse::().unwrap_or(u32::MAX), + Err(_) => return, + }; + + match osc_num { + // OSC 4 - Set/query indexed color + 4 => { + // Format: OSC 4 ; index ; color ST + // params[0] = "4", params[1] = "index", params[2] = "color" + if params.len() >= 3 { + if let Ok(index_str) = std::str::from_utf8(params[1]) { + if let Ok(index) = index_str.parse::() { + if let Ok(color_spec) = std::str::from_utf8(params[2]) { + if let Some(rgb) = ColorPalette::parse_color_spec(color_spec) { + self.palette.colors[index as usize] = rgb; + log::debug!("OSC 4: Set color {} to {:?}", index, rgb); + } + } + } + } + } + } + // OSC 10 - Set/query default foreground color + 10 => { + if params.len() >= 2 { + if let Ok(color_spec) = std::str::from_utf8(params[1]) { + if let Some(rgb) = ColorPalette::parse_color_spec(color_spec) { + self.palette.default_fg = rgb; + log::debug!("OSC 10: Set default foreground to {:?}", rgb); + } + } + } + } + // OSC 11 - Set/query default background color + 11 => { + if params.len() >= 2 { + if let Ok(color_spec) = std::str::from_utf8(params[1]) { + if let Some(rgb) = ColorPalette::parse_color_spec(color_spec) { + self.palette.default_bg = rgb; + log::debug!("OSC 11: Set default background to {:?}", rgb); + } + } + } + } + // OSC 0, 1, 2 - Set window title (ignore for now) + 0 | 1 | 2 => {} + _ => { + log::debug!("Unhandled OSC {}", osc_num); + } + } + } + + fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) { + // For most commands, we just need the first value of each parameter group + let flat_params: Vec = params.iter().map(|p| p[0]).collect(); + + match action { + // Cursor Up + 'A' => { + let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; + self.cursor_row = self.cursor_row.saturating_sub(n); + } + // Cursor Down + 'B' => { + let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; + self.cursor_row = (self.cursor_row + n).min(self.rows - 1); + } + // Cursor Forward + 'C' => { + let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; + self.cursor_col = (self.cursor_col + n).min(self.cols - 1); + } + // Cursor Back + 'D' => { + let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; + self.cursor_col = self.cursor_col.saturating_sub(n); + } + // Cursor Position + 'H' | 'f' => { + let row = flat_params.first().copied().unwrap_or(1).max(1) as usize; + let col = flat_params.get(1).copied().unwrap_or(1).max(1) as usize; + self.cursor_row = (row - 1).min(self.rows - 1); + self.cursor_col = (col - 1).min(self.cols - 1); + } + // Erase in Display + 'J' => { + let mode = flat_params.first().copied().unwrap_or(0); + match mode { + 0 => { + // Clear from cursor to end of screen + self.clear_line_from_cursor(); + for row in (self.cursor_row + 1)..self.rows { + for cell in &mut self.grid[row] { + *cell = Cell::default(); + } + } + } + 1 => { + // Clear from start to cursor + for row in 0..self.cursor_row { + for cell in &mut self.grid[row] { + *cell = Cell::default(); + } + } + for col in 0..=self.cursor_col { + self.grid[self.cursor_row][col] = Cell::default(); + } + } + 2 | 3 => { + // Clear entire screen + self.clear_screen(); + } + _ => {} + } + } + // Erase in Line + 'K' => { + let mode = flat_params.first().copied().unwrap_or(0); + match mode { + 0 => self.clear_line_from_cursor(), + 1 => { + for col in 0..=self.cursor_col { + self.grid[self.cursor_row][col] = Cell::default(); + } + } + 2 => { + for cell in &mut self.grid[self.cursor_row] { + *cell = Cell::default(); + } + } + _ => {} + } + } + // SGR (Select Graphic Rendition) + 'm' => { + // Handle SGR with proper sub-parameter support + // VTE gives us parameter groups - each group can have sub-params (colon-separated) + // We need to handle both: + // - Legacy: ESC[38;5;196m -> groups: [38], [5], [196] + // - Modern: ESC[38:5:196m -> groups: [38, 5, 196] + // - Modern: ESC[38:2:r:g:bm -> groups: [38, 2, r, g, b] + + let param_groups: Vec> = params.iter() + .map(|subparams| subparams.iter().copied().collect()) + .collect(); + + log::debug!("SGR param_groups: {:?}", param_groups); + + if param_groups.is_empty() { + self.current_fg = Color::Default; + self.current_bg = Color::Default; + self.current_bold = false; + self.current_italic = false; + self.current_underline = false; + return; + } + + let mut i = 0; + while i < param_groups.len() { + let group = ¶m_groups[i]; + let code = group.first().copied().unwrap_or(0); + + match code { + 0 => { + self.current_fg = Color::Default; + self.current_bg = Color::Default; + self.current_bold = false; + self.current_italic = false; + self.current_underline = false; + } + 1 => self.current_bold = true, + 3 => self.current_italic = true, + 4 => self.current_underline = true, + 7 => { + // Reverse video - swap fg and bg + std::mem::swap(&mut self.current_fg, &mut self.current_bg); + } + 22 => self.current_bold = false, + 23 => self.current_italic = false, + 24 => self.current_underline = false, + 27 => { + // Reverse video off - swap back (simplified) + std::mem::swap(&mut self.current_fg, &mut self.current_bg); + } + 30..=37 => self.current_fg = Color::Indexed((code - 30) as u8), + 38 => { + // Foreground color - check for sub-parameters first (colon format) + if group.len() >= 3 && group[1] == 5 { + // Colon format: 38:5:index + self.current_fg = Color::Indexed(group[2] as u8); + } else if group.len() >= 5 && group[1] == 2 { + // Colon format: 38:2:r:g:b or 38:2:colorspace:r:g:b + // Check if we have colorspace indicator + if group.len() >= 6 { + // 38:2:colorspace:r:g:b + self.current_fg = Color::Rgb( + group[3] as u8, + group[4] as u8, + group[5] as u8, + ); + } else { + // 38:2:r:g:b + self.current_fg = Color::Rgb( + group[2] as u8, + group[3] as u8, + group[4] as u8, + ); + } + } else if i + 2 < param_groups.len() { + // Semicolon format: check next groups + let mode = param_groups[i + 1].first().copied().unwrap_or(0); + if mode == 5 { + // 38;5;index + let idx = param_groups[i + 2].first().copied().unwrap_or(0); + self.current_fg = Color::Indexed(idx as u8); + i += 2; + } else if mode == 2 && i + 4 < param_groups.len() { + // 38;2;r;g;b + let r = param_groups[i + 2].first().copied().unwrap_or(0); + let g = param_groups[i + 3].first().copied().unwrap_or(0); + let b = param_groups[i + 4].first().copied().unwrap_or(0); + self.current_fg = Color::Rgb(r as u8, g as u8, b as u8); + i += 4; + } + } + } + 39 => self.current_fg = Color::Default, + 40..=47 => self.current_bg = Color::Indexed((code - 40) as u8), + 48 => { + // Background color - check for sub-parameters first (colon format) + if group.len() >= 3 && group[1] == 5 { + // Colon format: 48:5:index + self.current_bg = Color::Indexed(group[2] as u8); + } else if group.len() >= 5 && group[1] == 2 { + // Colon format: 48:2:r:g:b or 48:2:colorspace:r:g:b + if group.len() >= 6 { + // 48:2:colorspace:r:g:b + self.current_bg = Color::Rgb( + group[3] as u8, + group[4] as u8, + group[5] as u8, + ); + } else { + // 48:2:r:g:b + self.current_bg = Color::Rgb( + group[2] as u8, + group[3] as u8, + group[4] as u8, + ); + } + } else if i + 2 < param_groups.len() { + // Semicolon format: check next groups + let mode = param_groups[i + 1].first().copied().unwrap_or(0); + if mode == 5 { + // 48;5;index + let idx = param_groups[i + 2].first().copied().unwrap_or(0); + self.current_bg = Color::Indexed(idx as u8); + i += 2; + } else if mode == 2 && i + 4 < param_groups.len() { + // 48;2;r;g;b + let r = param_groups[i + 2].first().copied().unwrap_or(0); + let g = param_groups[i + 3].first().copied().unwrap_or(0); + let b = param_groups[i + 4].first().copied().unwrap_or(0); + self.current_bg = Color::Rgb(r as u8, g as u8, b as u8); + i += 4; + } + } + } + 49 => self.current_bg = Color::Default, + 90..=97 => { + self.current_fg = Color::Indexed((code - 90 + 8) as u8) + } + 100..=107 => { + self.current_bg = Color::Indexed((code - 100 + 8) as u8) + } + _ => {} + } + i += 1; + } + } + // Set Scrolling Region (DECSTBM) + 'r' => { + let top = flat_params.first().copied().unwrap_or(1).max(1) as usize; + let bottom = flat_params.get(1).copied().unwrap_or(self.rows as u16).max(1) as usize; + self.scroll_top = (top - 1).min(self.rows - 1); + self.scroll_bottom = (bottom - 1).min(self.rows - 1); + if self.scroll_top > self.scroll_bottom { + std::mem::swap(&mut self.scroll_top, &mut self.scroll_bottom); + } + // Move cursor to home position + self.cursor_row = 0; + self.cursor_col = 0; + } + // Scroll Up (SU) + 'S' => { + let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; + self.scroll_up(n); + } + // Scroll Down (SD) + 'T' => { + let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; + self.scroll_down(n); + } + // Insert Lines (IL) + 'L' => { + let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; + self.insert_lines(n); + } + // Delete Lines (DL) + 'M' => { + let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; + self.delete_lines(n); + } + // Insert Characters (ICH) + '@' => { + let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; + self.insert_characters(n); + } + // Delete Characters (DCH) + 'P' => { + let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; + self.delete_characters(n); + } + // Erase Characters (ECH) + 'X' => { + let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; + self.erase_characters(n); + } + // Kitty keyboard protocol + 'u' => { + self.handle_keyboard_protocol(&flat_params, intermediates); + } + // DECSCUSR - Set Cursor Style (CSI Ps SP q) + 'q' if intermediates == [b' '] => { + let style = flat_params.first().copied().unwrap_or(0); + self.cursor_shape = match style { + 0 | 1 => CursorShape::BlinkingBlock, // 0 = default (blinking block), 1 = blinking block + 2 => CursorShape::SteadyBlock, + 3 => CursorShape::BlinkingUnderline, + 4 => CursorShape::SteadyUnderline, + 5 => CursorShape::BlinkingBar, + 6 => CursorShape::SteadyBar, + _ => CursorShape::BlinkingBlock, + }; + log::debug!("DECSCUSR: cursor shape set to {:?}", self.cursor_shape); + } + // DEC Private Mode Set (CSI ? Ps h) + 'h' if intermediates == [b'?'] => { + for ¶m in &flat_params { + match param { + 25 => { + // DECTCEM - Show cursor + self.cursor_visible = true; + log::debug!("DECTCEM: cursor visible"); + } + _ => { + log::debug!("Unhandled DEC private mode set: {}", param); + } + } + } + } + // DEC Private Mode Reset (CSI ? Ps l) + 'l' if intermediates == [b'?'] => { + for ¶m in &flat_params { + match param { + 25 => { + // DECTCEM - Hide cursor + self.cursor_visible = false; + log::debug!("DECTCEM: cursor hidden"); + } + _ => { + log::debug!("Unhandled DEC private mode reset: {}", param); + } + } + } + } + _ => {} + } + } + + fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {} +} diff --git a/src/window_state.rs b/src/window_state.rs new file mode 100644 index 0000000..6821473 --- /dev/null +++ b/src/window_state.rs @@ -0,0 +1,963 @@ +//! Window state management for tabs and panes. +//! +//! The daemon maintains the full UI state including tabs, panes, and which is active. + +use crate::protocol::{Direction, PaneId, PaneInfo, SessionId, SplitDirection, TabId, TabInfo, WindowState as ProtocolWindowState}; +use crate::session::Session; +use std::collections::HashMap; + +/// Check if two ranges overlap. +fn ranges_overlap(a_start: usize, a_end: usize, b_start: usize, b_end: usize) -> bool { + a_start < b_end && b_start < a_end +} + +/// A pane within a tab. +pub struct Pane { + pub id: PaneId, + pub session_id: SessionId, + /// Position in cells (for future splits). + pub x: usize, + pub y: usize, + /// Size in cells. + pub cols: usize, + pub rows: usize, +} + +impl Pane { + pub fn new(id: PaneId, session_id: SessionId, cols: usize, rows: usize) -> Self { + Self { + id, + session_id, + x: 0, + y: 0, + cols, + rows, + } + } + + pub fn new_at(id: PaneId, session_id: SessionId, x: usize, y: usize, cols: usize, rows: usize) -> Self { + Self { + id, + session_id, + x, + y, + cols, + rows, + } + } + + pub fn to_info(&self) -> PaneInfo { + PaneInfo { + id: self.id, + session_id: self.session_id, + x: self.x, + y: self.y, + cols: self.cols, + rows: self.rows, + } + } +} + +/// A tab containing one or more panes. +pub struct Tab { + pub id: TabId, + pub panes: Vec, + pub active_pane: usize, +} + +impl Tab { + pub fn new(id: TabId, pane: Pane) -> Self { + Self { + id, + panes: vec![pane], + active_pane: 0, + } + } + + pub fn active_pane(&self) -> Option<&Pane> { + self.panes.get(self.active_pane) + } + + pub fn to_info(&self) -> TabInfo { + TabInfo { + id: self.id, + active_pane: self.active_pane, + panes: self.panes.iter().map(|p| p.to_info()).collect(), + } + } +} + +/// Manages all window state: tabs, panes, sessions. +pub struct WindowStateManager { + /// All sessions, keyed by session ID. + pub sessions: HashMap, + /// All tabs in order. + pub tabs: Vec, + /// Index of the active tab. + pub active_tab: usize, + /// Terminal dimensions in cells. + pub cols: usize, + pub rows: usize, + /// Next session ID to assign. + next_session_id: SessionId, + /// Next pane ID to assign. + next_pane_id: PaneId, + /// Next tab ID to assign. + next_tab_id: TabId, +} + +impl WindowStateManager { + /// Creates a new window state manager with initial dimensions. + pub fn new(cols: usize, rows: usize) -> Self { + Self { + sessions: HashMap::new(), + tabs: Vec::new(), + active_tab: 0, + cols, + rows, + next_session_id: 0, + next_pane_id: 0, + next_tab_id: 0, + } + } + + /// Creates the initial tab with a single session. + pub fn create_initial_tab(&mut self) -> Result<(), crate::pty::PtyError> { + let session_id = self.next_session_id; + self.next_session_id += 1; + + let session = Session::new(session_id, self.cols, self.rows)?; + self.sessions.insert(session_id, session); + + let pane_id = self.next_pane_id; + self.next_pane_id += 1; + + let pane = Pane::new(pane_id, session_id, self.cols, self.rows); + + let tab_id = self.next_tab_id; + self.next_tab_id += 1; + + let tab = Tab::new(tab_id, pane); + self.tabs.push(tab); + + Ok(()) + } + + /// Creates a new tab with a new session. + pub fn create_tab(&mut self) -> Result { + let session_id = self.next_session_id; + self.next_session_id += 1; + + let session = Session::new(session_id, self.cols, self.rows)?; + self.sessions.insert(session_id, session); + + let pane_id = self.next_pane_id; + self.next_pane_id += 1; + + let pane = Pane::new(pane_id, session_id, self.cols, self.rows); + + let tab_id = self.next_tab_id; + self.next_tab_id += 1; + + let tab = Tab::new(tab_id, pane); + self.tabs.push(tab); + + // Switch to the new tab + self.active_tab = self.tabs.len() - 1; + + Ok(tab_id) + } + + /// Closes a tab and its sessions. + pub fn close_tab(&mut self, tab_id: TabId) -> bool { + if let Some(idx) = self.tabs.iter().position(|t| t.id == tab_id) { + let tab = self.tabs.remove(idx); + + // Remove all sessions owned by this tab's panes + for pane in &tab.panes { + self.sessions.remove(&pane.session_id); + } + + // Adjust active tab index + if self.tabs.is_empty() { + self.active_tab = 0; + } else if self.active_tab >= self.tabs.len() { + self.active_tab = self.tabs.len() - 1; + } + + true + } else { + false + } + } + + /// Switches to a tab by ID. + pub fn switch_tab(&mut self, tab_id: TabId) -> bool { + if let Some(idx) = self.tabs.iter().position(|t| t.id == tab_id) { + self.active_tab = idx; + true + } else { + false + } + } + + /// Switches to the next tab (wrapping around). + pub fn next_tab(&mut self) -> bool { + if self.tabs.is_empty() { + return false; + } + self.active_tab = (self.active_tab + 1) % self.tabs.len(); + true + } + + /// Switches to the previous tab (wrapping around). + pub fn prev_tab(&mut self) -> bool { + if self.tabs.is_empty() { + return false; + } + if self.active_tab == 0 { + self.active_tab = self.tabs.len() - 1; + } else { + self.active_tab -= 1; + } + true + } + + /// Switches to a tab by index (0-based). + pub fn switch_tab_index(&mut self, index: usize) -> bool { + if index < self.tabs.len() { + self.active_tab = index; + true + } else { + false + } + } + + /// Splits the active pane in the active tab. + /// Returns (tab_id, new_pane_info) on success. + pub fn split_pane(&mut self, direction: SplitDirection) -> Result<(TabId, PaneInfo), crate::pty::PtyError> { + let tab = self.tabs.get_mut(self.active_tab) + .ok_or_else(|| crate::pty::PtyError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "No active tab")))?; + + let tab_id = tab.id; + let active_pane = tab.panes.get_mut(tab.active_pane) + .ok_or_else(|| crate::pty::PtyError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "No active pane")))?; + + // Calculate new dimensions + let (new_x, new_y, new_cols, new_rows, orig_cols, orig_rows) = match direction { + SplitDirection::Horizontal => { + // Split top/bottom: new pane goes below + let half_rows = active_pane.rows / 2; + let new_rows = active_pane.rows - half_rows; + let new_y = active_pane.y + half_rows; + ( + active_pane.x, + new_y, + active_pane.cols, + new_rows, + active_pane.cols, + half_rows, + ) + } + SplitDirection::Vertical => { + // Split left/right: new pane goes to the right + let half_cols = active_pane.cols / 2; + let new_cols = active_pane.cols - half_cols; + let new_x = active_pane.x + half_cols; + ( + new_x, + active_pane.y, + new_cols, + active_pane.rows, + half_cols, + active_pane.rows, + ) + } + }; + + // Update original pane dimensions + let orig_session_id = active_pane.session_id; + active_pane.cols = orig_cols; + active_pane.rows = orig_rows; + + // Resize the original session + if let Some(session) = self.sessions.get_mut(&orig_session_id) { + session.resize(orig_cols, orig_rows); + } + + // Create new session for the new pane + let session_id = self.next_session_id; + self.next_session_id += 1; + + let session = Session::new(session_id, new_cols, new_rows)?; + self.sessions.insert(session_id, session); + + // Create new pane + let pane_id = self.next_pane_id; + self.next_pane_id += 1; + + let new_pane = Pane::new_at(pane_id, session_id, new_x, new_y, new_cols, new_rows); + let pane_info = new_pane.to_info(); + + // Add pane to tab and focus it + let tab = self.tabs.get_mut(self.active_tab).unwrap(); + tab.panes.push(new_pane); + tab.active_pane = tab.panes.len() - 1; + + Ok((tab_id, pane_info)) + } + + /// Closes the active pane in the active tab. + /// Returns Some((tab_id, pane_id, tab_closed)) on success. + /// If tab_closed is true, the tab was removed because it was the last pane. + pub fn close_pane(&mut self) -> Option<(TabId, PaneId, bool)> { + let tab = self.tabs.get_mut(self.active_tab)?; + let tab_id = tab.id; + + if tab.panes.is_empty() { + return None; + } + + // Capture closed pane's geometry before removing + let closed_pane = tab.panes.remove(tab.active_pane); + let pane_id = closed_pane.id; + let closed_x = closed_pane.x; + let closed_y = closed_pane.y; + let closed_cols = closed_pane.cols; + let closed_rows = closed_pane.rows; + + // Remove the session + self.sessions.remove(&closed_pane.session_id); + + // If this was the last pane, close the tab + if tab.panes.is_empty() { + self.tabs.remove(self.active_tab); + + // Adjust active tab index + if !self.tabs.is_empty() && self.active_tab >= self.tabs.len() { + self.active_tab = self.tabs.len() - 1; + } + + return Some((tab_id, pane_id, true)); + } + + // Adjust active pane index + if tab.active_pane >= tab.panes.len() { + tab.active_pane = tab.panes.len() - 1; + } + + // Recalculate pane layouts after closing - pass closed pane geometry + self.recalculate_pane_layout_after_close( + self.active_tab, + closed_x, + closed_y, + closed_cols, + closed_rows, + ); + + Some((tab_id, pane_id, false)) + } + + /// Focuses a pane in the given direction from the current pane. + /// Returns (tab_id, new_active_pane_index) on success. + pub fn focus_pane_direction(&mut self, direction: Direction) -> Option<(TabId, usize)> { + let tab = self.tabs.get_mut(self.active_tab)?; + let tab_id = tab.id; + + if tab.panes.len() <= 1 { + return None; + } + + let current_pane = tab.panes.get(tab.active_pane)?; + + // Current pane's bounding box and center + let curr_x = current_pane.x; + let curr_y = current_pane.y; + let curr_right = curr_x + current_pane.cols; + let curr_bottom = curr_y + current_pane.rows; + let curr_center_x = curr_x + current_pane.cols / 2; + let curr_center_y = curr_y + current_pane.rows / 2; + + let mut best_idx: Option = None; + let mut best_score: Option<(i64, i64)> = None; // (negative_overlap, distance) - lower is better + + for (idx, pane) in tab.panes.iter().enumerate() { + if idx == tab.active_pane { + continue; + } + + let pane_x = pane.x; + let pane_y = pane.y; + let pane_right = pane_x + pane.cols; + let pane_bottom = pane_y + pane.rows; + + // Check if pane is in the correct direction + let in_direction = match direction { + Direction::Up => pane_bottom <= curr_y, + Direction::Down => pane_y >= curr_bottom, + Direction::Left => pane_right <= curr_x, + Direction::Right => pane_x >= curr_right, + }; + + if !in_direction { + continue; + } + + // Calculate overlap on perpendicular axis and distance + let (overlap, distance) = match direction { + Direction::Up | Direction::Down => { + // Horizontal overlap + let overlap_start = curr_x.max(pane_x); + let overlap_end = curr_right.min(pane_right); + let overlap = if overlap_end > overlap_start { + (overlap_end - overlap_start) as i64 + } else { + 0 + }; + + // Vertical distance (edge to edge) + let dist = match direction { + Direction::Up => (curr_y as i64) - (pane_bottom as i64), + Direction::Down => (pane_y as i64) - (curr_bottom as i64), + _ => unreachable!(), + }; + + (overlap, dist) + } + Direction::Left | Direction::Right => { + // Vertical overlap + let overlap_start = curr_y.max(pane_y); + let overlap_end = curr_bottom.min(pane_bottom); + let overlap = if overlap_end > overlap_start { + (overlap_end - overlap_start) as i64 + } else { + 0 + }; + + // Horizontal distance (edge to edge) + let dist = match direction { + Direction::Left => (curr_x as i64) - (pane_right as i64), + Direction::Right => (pane_x as i64) - (curr_right as i64), + _ => unreachable!(), + }; + + (overlap, dist) + } + }; + + // Score: prefer more overlap (so negate it), then prefer closer distance + let score = (-overlap, distance); + + if best_score.is_none() || score < best_score.unwrap() { + best_score = Some(score); + best_idx = Some(idx); + } + } + + // If no exact directional match, try a fallback: find the nearest pane + // in the general direction (allowing for some tolerance) + if best_idx.is_none() { + for (idx, pane) in tab.panes.iter().enumerate() { + if idx == tab.active_pane { + continue; + } + + let pane_center_x = pane.x + pane.cols / 2; + let pane_center_y = pane.y + pane.rows / 2; + + // Check if pane center is in the general direction + let in_general_direction = match direction { + Direction::Up => pane_center_y < curr_center_y, + Direction::Down => pane_center_y > curr_center_y, + Direction::Left => pane_center_x < curr_center_x, + Direction::Right => pane_center_x > curr_center_x, + }; + + if !in_general_direction { + continue; + } + + // Calculate distance from center to center + let dx = (pane_center_x as i64) - (curr_center_x as i64); + let dy = (pane_center_y as i64) - (curr_center_y as i64); + let distance = dx * dx + dy * dy; // squared distance is fine for comparison + + let score = (0i64, distance); // no overlap bonus for fallback + + if best_score.is_none() || score < best_score.unwrap() { + best_score = Some(score); + best_idx = Some(idx); + } + } + } + + if let Some(idx) = best_idx { + tab.active_pane = idx; + Some((tab_id, idx)) + } else { + None + } + } + + /// Recalculates pane layout after a pane is closed. + /// Expands neighboring panes to fill the closed pane's space. + /// Prefers expanding smaller panes to balance the layout. + fn recalculate_pane_layout_after_close( + &mut self, + tab_idx: usize, + closed_x: usize, + closed_y: usize, + closed_cols: usize, + closed_rows: usize, + ) { + let Some(tab) = self.tabs.get_mut(tab_idx) else { + return; + }; + + // If only one pane remains, give it full size + if tab.panes.len() == 1 { + let pane = &mut tab.panes[0]; + pane.x = 0; + pane.y = 0; + pane.cols = self.cols; + pane.rows = self.rows; + + if let Some(session) = self.sessions.get_mut(&pane.session_id) { + session.resize(self.cols, self.rows); + } + return; + } + + let closed_right = closed_x + closed_cols; + let closed_bottom = closed_y + closed_rows; + + // Find all panes that perfectly match an edge (same width/height as closed pane) + // These are candidates for absorbing the space. + // We'll pick the smallest one to balance the layout. + + #[derive(Debug, Clone, Copy)] + enum ExpandDirection { + Left, // pane is to the left, expand right + Right, // pane is to the right, expand left + Top, // pane is above, expand down + Bottom, // pane is below, expand up + } + + let mut perfect_matches: Vec<(usize, usize, ExpandDirection)> = Vec::new(); // (idx, area, direction) + + for (idx, pane) in tab.panes.iter().enumerate() { + let pane_right = pane.x + pane.cols; + let pane_bottom = pane.y + pane.rows; + let area = pane.cols * pane.rows; + + // Left neighbor with exact same height + if pane_right == closed_x && pane.y == closed_y && pane.rows == closed_rows { + perfect_matches.push((idx, area, ExpandDirection::Left)); + } + + // Right neighbor with exact same height + if pane.x == closed_right && pane.y == closed_y && pane.rows == closed_rows { + perfect_matches.push((idx, area, ExpandDirection::Right)); + } + + // Top neighbor with exact same width + if pane_bottom == closed_y && pane.x == closed_x && pane.cols == closed_cols { + perfect_matches.push((idx, area, ExpandDirection::Top)); + } + + // Bottom neighbor with exact same width + if pane.y == closed_bottom && pane.x == closed_x && pane.cols == closed_cols { + perfect_matches.push((idx, area, ExpandDirection::Bottom)); + } + } + + // If we have perfect matches, pick the smallest pane + if !perfect_matches.is_empty() { + // Sort by area (smallest first) + perfect_matches.sort_by_key(|(_, area, _)| *area); + let (idx, _, direction) = perfect_matches[0]; + + let pane = &mut tab.panes[idx]; + match direction { + ExpandDirection::Left => { + // Pane is to the left, expand right + pane.cols += closed_cols; + } + ExpandDirection::Right => { + // Pane is to the right, expand left + pane.x = closed_x; + pane.cols += closed_cols; + } + ExpandDirection::Top => { + // Pane is above, expand down + pane.rows += closed_rows; + } + ExpandDirection::Bottom => { + // Pane is below, expand up + pane.y = closed_y; + pane.rows += closed_rows; + } + } + + if let Some(session) = self.sessions.get_mut(&pane.session_id) { + session.resize(pane.cols, pane.rows); + } + return; + } + + // No perfect match - need to expand multiple panes. + // Determine which direction has the most coverage and expand all panes on that edge. + + // Calculate coverage for each edge direction + let mut bottom_neighbors: Vec = Vec::new(); // panes below closed pane + let mut top_neighbors: Vec = Vec::new(); // panes above closed pane + let mut right_neighbors: Vec = Vec::new(); // panes to the right + let mut left_neighbors: Vec = Vec::new(); // panes to the left + + let mut bottom_coverage = 0usize; + let mut top_coverage = 0usize; + let mut right_coverage = 0usize; + let mut left_coverage = 0usize; + + for (idx, pane) in tab.panes.iter().enumerate() { + let pane_right = pane.x + pane.cols; + let pane_bottom = pane.y + pane.rows; + + // Bottom neighbors: their top edge touches closed pane's bottom edge + if pane.y == closed_bottom { + let overlap_start = pane.x.max(closed_x); + let overlap_end = pane_right.min(closed_right); + if overlap_end > overlap_start { + bottom_neighbors.push(idx); + bottom_coverage += overlap_end - overlap_start; + } + } + + // Top neighbors: their bottom edge touches closed pane's top edge + if pane_bottom == closed_y { + let overlap_start = pane.x.max(closed_x); + let overlap_end = pane_right.min(closed_right); + if overlap_end > overlap_start { + top_neighbors.push(idx); + top_coverage += overlap_end - overlap_start; + } + } + + // Right neighbors: their left edge touches closed pane's right edge + if pane.x == closed_right { + let overlap_start = pane.y.max(closed_y); + let overlap_end = pane_bottom.min(closed_bottom); + if overlap_end > overlap_start { + right_neighbors.push(idx); + right_coverage += overlap_end - overlap_start; + } + } + + // Left neighbors: their right edge touches closed pane's left edge + if pane_right == closed_x { + let overlap_start = pane.y.max(closed_y); + let overlap_end = pane_bottom.min(closed_bottom); + if overlap_end > overlap_start { + left_neighbors.push(idx); + left_coverage += overlap_end - overlap_start; + } + } + } + + // For partial matches, prefer the side with smaller total area (to balance layout) + // Calculate total area for each side + let bottom_area: usize = bottom_neighbors.iter() + .map(|&idx| tab.panes[idx].cols * tab.panes[idx].rows) + .sum(); + let top_area: usize = top_neighbors.iter() + .map(|&idx| tab.panes[idx].cols * tab.panes[idx].rows) + .sum(); + let right_area: usize = right_neighbors.iter() + .map(|&idx| tab.panes[idx].cols * tab.panes[idx].rows) + .sum(); + let left_area: usize = left_neighbors.iter() + .map(|&idx| tab.panes[idx].cols * tab.panes[idx].rows) + .sum(); + + // Build candidates: (neighbors, coverage, total_area) + let mut candidates: Vec<(&Vec, usize, usize, &str)> = Vec::new(); + if !bottom_neighbors.is_empty() { + candidates.push((&bottom_neighbors, bottom_coverage, bottom_area, "bottom")); + } + if !top_neighbors.is_empty() { + candidates.push((&top_neighbors, top_coverage, top_area, "top")); + } + if !right_neighbors.is_empty() { + candidates.push((&right_neighbors, right_coverage, right_area, "right")); + } + if !left_neighbors.is_empty() { + candidates.push((&left_neighbors, left_coverage, left_area, "left")); + } + + if candidates.is_empty() { + return; + } + + // Sort by: coverage (descending), then area (ascending - prefer smaller) + candidates.sort_by(|a, b| { + b.1.cmp(&a.1) // coverage descending + .then_with(|| a.2.cmp(&b.2)) // area ascending + }); + + let (neighbors, _, _, direction) = candidates[0]; + + // Collect session IDs to resize after modifying panes + let mut sessions_to_resize: Vec<(SessionId, usize, usize)> = Vec::new(); + + match direction { + "bottom" => { + for &idx in neighbors { + let pane = &mut tab.panes[idx]; + pane.y = closed_y; + pane.rows += closed_rows; + sessions_to_resize.push((pane.session_id, pane.cols, pane.rows)); + } + } + "top" => { + for &idx in neighbors { + let pane = &mut tab.panes[idx]; + pane.rows += closed_rows; + sessions_to_resize.push((pane.session_id, pane.cols, pane.rows)); + } + } + "right" => { + for &idx in neighbors { + let pane = &mut tab.panes[idx]; + pane.x = closed_x; + pane.cols += closed_cols; + sessions_to_resize.push((pane.session_id, pane.cols, pane.rows)); + } + } + "left" => { + for &idx in neighbors { + let pane = &mut tab.panes[idx]; + pane.cols += closed_cols; + sessions_to_resize.push((pane.session_id, pane.cols, pane.rows)); + } + } + _ => {} + } + + // Resize all affected sessions + for (session_id, cols, rows) in sessions_to_resize { + if let Some(session) = self.sessions.get_mut(&session_id) { + session.resize(cols, rows); + } + } + } + + /// Gets the currently active tab. + pub fn active_tab(&self) -> Option<&Tab> { + self.tabs.get(self.active_tab) + } + + /// Gets the currently active tab mutably. + pub fn active_tab_mut(&mut self) -> Option<&mut Tab> { + self.tabs.get_mut(self.active_tab) + } + + /// Gets the currently focused session (active pane of active tab). + pub fn focused_session(&self) -> Option<&Session> { + self.active_tab() + .and_then(|tab| tab.active_pane()) + .and_then(|pane| self.sessions.get(&pane.session_id)) + } + + /// Gets the currently focused session mutably. + pub fn focused_session_mut(&mut self) -> Option<&mut Session> { + let session_id = self.active_tab() + .and_then(|tab| tab.active_pane()) + .map(|pane| pane.session_id)?; + self.sessions.get_mut(&session_id) + } + + /// Resizes all sessions to new dimensions. + /// Recalculates pane layouts to maintain proper split ratios. + pub fn resize(&mut self, cols: usize, rows: usize) { + let old_cols = self.cols; + let old_rows = self.rows; + self.cols = cols; + self.rows = rows; + + if old_cols == 0 || old_rows == 0 || cols == 0 || rows == 0 { + return; + } + + for tab in &mut self.tabs { + if tab.panes.is_empty() { + continue; + } + + // Single pane: just give it full size + if tab.panes.len() == 1 { + let pane = &mut tab.panes[0]; + pane.x = 0; + pane.y = 0; + pane.cols = cols; + pane.rows = rows; + continue; + } + + // Multiple panes: convert to ratios, then back to cells + // This preserves the relative split positions + + // First, convert each pane's geometry to ratios (0.0 - 1.0) + let ratios: Vec<(f64, f64, f64, f64)> = tab.panes.iter().map(|pane| { + let x_ratio = pane.x as f64 / old_cols as f64; + let y_ratio = pane.y as f64 / old_rows as f64; + let w_ratio = pane.cols as f64 / old_cols as f64; + let h_ratio = pane.rows as f64 / old_rows as f64; + (x_ratio, y_ratio, w_ratio, h_ratio) + }).collect(); + + // Convert back to cell positions with new dimensions + for (pane, (x_ratio, y_ratio, w_ratio, h_ratio)) in tab.panes.iter_mut().zip(ratios.iter()) { + pane.x = (x_ratio * cols as f64).round() as usize; + pane.y = (y_ratio * rows as f64).round() as usize; + pane.cols = (w_ratio * cols as f64).round() as usize; + pane.rows = (h_ratio * rows as f64).round() as usize; + + // Ensure minimum size + pane.cols = pane.cols.max(1); + pane.rows = pane.rows.max(1); + } + + // Fix gaps and overlaps by adjusting panes that share edges + // For each pair of adjacent panes, ensure they meet exactly + Self::fix_pane_edges(&mut tab.panes, cols, rows); + } + + // Resize all sessions to match their pane sizes + for tab in &self.tabs { + for pane in &tab.panes { + if let Some(session) = self.sessions.get_mut(&pane.session_id) { + session.resize(pane.cols, pane.rows); + } + } + } + } + + /// Fixes gaps and overlaps between panes after resize. + /// Ensures adjacent panes meet exactly and edge panes extend to window boundaries. + fn fix_pane_edges(panes: &mut [Pane], cols: usize, rows: usize) { + let n = panes.len(); + if n == 0 { + return; + } + + // For each pane, check if it should extend to the window edge + for pane in panes.iter_mut() { + // If pane is at x=0, ensure it starts at 0 + if pane.x <= 1 { + let old_right = pane.x + pane.cols; + pane.x = 0; + pane.cols = old_right; // maintain right edge position + } + + // If pane is at y=0, ensure it starts at 0 + if pane.y <= 1 { + let old_bottom = pane.y + pane.rows; + pane.y = 0; + pane.rows = old_bottom; // maintain bottom edge position + } + } + + // For each pair of panes, if they're adjacent, make them meet exactly + for i in 0..n { + for j in (i + 1)..n { + // Get the two panes' boundaries + let (i_right, i_bottom) = { + let p = &panes[i]; + (p.x + p.cols, p.y + p.rows) + }; + let (j_right, j_bottom) = { + let p = &panes[j]; + (p.x + p.cols, p.y + p.rows) + }; + let (i_x, i_y) = (panes[i].x, panes[i].y); + let (j_x, j_y) = (panes[j].x, panes[j].y); + + // Check if j is to the right of i (vertical split) + // i's right edge should meet j's left edge + if i_right.abs_diff(j_x) <= 2 && + ranges_overlap(i_y, i_bottom, j_y, j_bottom) { + // They should meet - adjust j's x to match i's right edge + let meet_point = i_right; + let j_old_right = j_right; + panes[j].x = meet_point; + panes[j].cols = j_old_right.saturating_sub(meet_point).max(1); + } + + // Check if i is to the right of j + if j_right.abs_diff(i_x) <= 2 && + ranges_overlap(i_y, i_bottom, j_y, j_bottom) { + let meet_point = j_right; + let i_old_right = i_right; + panes[i].x = meet_point; + panes[i].cols = i_old_right.saturating_sub(meet_point).max(1); + } + + // Check if j is below i (horizontal split) + if i_bottom.abs_diff(j_y) <= 2 && + ranges_overlap(i_x, i_right, j_x, j_right) { + let meet_point = i_bottom; + let j_old_bottom = j_bottom; + panes[j].y = meet_point; + panes[j].rows = j_old_bottom.saturating_sub(meet_point).max(1); + } + + // Check if i is below j + if j_bottom.abs_diff(i_y) <= 2 && + ranges_overlap(i_x, i_right, j_x, j_right) { + let meet_point = j_bottom; + let i_old_bottom = i_bottom; + panes[i].y = meet_point; + panes[i].rows = i_old_bottom.saturating_sub(meet_point).max(1); + } + } + } + + // Finally, extend edge panes to window boundaries + for pane in panes.iter_mut() { + let pane_right = pane.x + pane.cols; + let pane_bottom = pane.y + pane.rows; + + // Extend to right edge if close + if pane_right >= cols.saturating_sub(2) { + pane.cols = cols.saturating_sub(pane.x).max(1); + } + + // Extend to bottom edge if close + if pane_bottom >= rows.saturating_sub(2) { + pane.rows = rows.saturating_sub(pane.y).max(1); + } + } + } + + /// Creates a protocol WindowState message. + pub fn to_protocol(&self) -> ProtocolWindowState { + ProtocolWindowState { + tabs: self.tabs.iter().map(|t| t.to_info()).collect(), + active_tab: self.active_tab, + cols: self.cols, + rows: self.rows, + } + } + + /// Returns whether any session has new output. + pub fn any_dirty(&self) -> bool { + self.sessions.values().any(|s| s.dirty) + } + + /// Marks all sessions as clean. + pub fn mark_all_clean(&mut self) { + for session in self.sessions.values_mut() { + session.mark_clean(); + } + } +}