Compare commits

...
Author SHA1 Message Date
Gregor Lohaus 98453329a1 ts-plugin-runtime 2026-09-24 15:21:12 +02:00
Gregor Lohaus a5ac986375 plugin sdk
squash
2026-09-17 14:27:57 +02:00
Gregor Lohaus 9b9fc8517d floating position fixes 2026-09-16 13:57:44 +02:00
Gregor Lohaus 8d49a993e9 better floating nac 2026-09-14 13:35:38 +02:00
Gregor Lohaus c21472b2f2 scrolling 2026-09-10 10:24:16 +02:00
Gregor Lohaus ca30e9a0ee explicit scrollback editor command 2026-09-09 12:49:47 +02:00
Gregor Lohaus c5e4e5fb85 smaller cursor corner radius 2026-09-09 11:41:06 +02:00
Gregor Lohaus d74b0acc83 fix horizontal gaps 2026-09-08 18:39:10 +02:00
27 changed files with 3763 additions and 136 deletions
+2
View File
@@ -1,3 +1,5 @@
/target/ /target/
/.tools/ /.tools/
/.worktrees /.worktrees
/sdk/node_modules/
/sdk/dist/
Generated
+670 -2
View File
@@ -37,6 +37,21 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "aligned-vec"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
dependencies = [
"equator",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "android-activity" name = "android-activity"
version = "0.6.1" version = "0.6.1"
@@ -118,6 +133,18 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -178,6 +205,148 @@ dependencies = [
"objc2 0.5.2", "objc2 0.5.2",
] ]
[[package]]
name = "boa_ast"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9221b0a090572b4a118b46c5874c0781fe4bc9546b85f0a615b8bfb609ca4ed7"
dependencies = [
"bitflags 2.13.1",
"boa_interner",
"boa_macros",
"boa_string",
"indexmap",
"num-bigint",
"rustc-hash 2.1.3",
"strum",
]
[[package]]
name = "boa_engine"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6aaf7fdc4299df68adb67e1a5440a51d34630a1f5271b9698fd74bc97b60d9"
dependencies = [
"aligned-vec",
"arrayvec",
"async-channel",
"bitflags 2.13.1",
"boa_ast",
"boa_gc",
"boa_interner",
"boa_macros",
"boa_parser",
"boa_string",
"bytemuck",
"cfg-if",
"cow-utils",
"dashmap",
"dynify",
"fast-float2",
"futures-concurrency",
"futures-lite",
"hashbrown 0.17.1",
"icu_normalizer",
"indexmap",
"intrusive-collections",
"itertools",
"num-bigint",
"num-integer",
"num-traits",
"num_enum",
"oneshot",
"pastey",
"portable-atomic",
"rand",
"regress",
"rustc-hash 2.1.3",
"ryu-js",
"serde",
"serde_json",
"small_btree",
"static_assertions",
"tag_ptr",
"tap",
"thin-vec",
"thiserror 2.0.20",
"time",
]
[[package]]
name = "boa_gc"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af82119558ab1002d1978c8459b53a3ebb506177d29a3aca37c07c1777b1d966"
dependencies = [
"arrayvec",
"boa_macros",
"boa_string",
"hashbrown 0.17.1",
"thin-vec",
]
[[package]]
name = "boa_interner"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "317c631e791f5cb7d0017e43c0a758b0eaffd40c34c4c360e2a6be7752b78cd1"
dependencies = [
"boa_gc",
"boa_macros",
"hashbrown 0.17.1",
"indexmap",
"once_cell",
"phf",
"rustc-hash 2.1.3",
"static_assertions",
]
[[package]]
name = "boa_macros"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b882fd440dc0f4b7441367cf89567bb077920356750c9a76a16c002d8ec2fa5c"
dependencies = [
"cfg-if",
"cow-utils",
"proc-macro2",
"quote",
"syn 3.0.5",
"synstructure 0.14.0",
]
[[package]]
name = "boa_parser"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "641e81b3c8cbb7a2140547fb3ea534e9edf210b4e8c920b5fa864a919e11fbcf"
dependencies = [
"bitflags 2.13.1",
"boa_ast",
"boa_interner",
"boa_macros",
"fast-float2",
"icu_properties",
"num-bigint",
"num-traits",
"regress",
"rustc-hash 2.1.3",
]
[[package]]
name = "boa_string"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30bf86c2bcff5bf2547acbfc516c5d3108ca527cb131511d749bf6fbef762c0e"
dependencies = [
"fast-float2",
"itoa",
"pastey",
"rustc-hash 2.1.3",
"ryu-js",
"static_assertions",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
@@ -306,6 +475,17 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core",
]
[[package]] [[package]]
name = "clipboard-win" name = "clipboard-win"
version = "5.4.1" version = "5.4.1"
@@ -413,6 +593,21 @@ dependencies = [
"libm", "libm",
] ]
[[package]]
name = "cow-utils"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79"
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.1" version = "1.5.1"
@@ -440,6 +635,26 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "dashmap"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]] [[package]]
name = "dispatch" name = "dispatch"
version = "0.2.0" version = "0.2.0"
@@ -497,6 +712,26 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dynify"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81acb15628a3e22358bf73de5e7e62360b8a777dbcb5fc9ac7dfa9ae73723747"
dependencies = [
"dynify-macros",
]
[[package]]
name = "dynify-macros"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ec431cd708430d5029356535259c5d645d60edd3d39c54e5eea9782d46caa7d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "ecolor" name = "ecolor"
version = "0.33.3" version = "0.33.3"
@@ -618,6 +853,12 @@ dependencies = [
"winit", "winit",
] ]
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]] [[package]]
name = "emath" name = "emath"
version = "0.33.3" version = "0.33.3"
@@ -651,6 +892,26 @@ version = "0.33.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862"
[[package]]
name = "equator"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
dependencies = [
"equator-macro",
]
[[package]]
name = "equator-macro"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -673,6 +934,38 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fast-float2"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f"
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]] [[package]]
name = "fax" name = "fax"
version = "0.2.7" version = "0.2.7"
@@ -705,6 +998,12 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.10" version = "1.1.10"
@@ -787,12 +1086,44 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "futures-concurrency"
version = "7.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6"
dependencies = [
"fixedbitset",
"futures-core",
"futures-lite",
"pin-project",
"smallvec",
]
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.34" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-io"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.34" version = "0.3.34"
@@ -842,6 +1173,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core",
] ]
[[package]] [[package]]
@@ -984,6 +1316,12 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -1007,6 +1345,17 @@ name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
@@ -1058,6 +1407,8 @@ dependencies = [
"icu_properties", "icu_properties",
"icu_provider", "icu_provider",
"smallvec", "smallvec",
"utf16_iter",
"write16",
"zerovec", "zerovec",
] ]
@@ -1160,6 +1511,27 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "intrusive-collections"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4275b20e6057cd7733fd8df8a5a31701e4fe44497dad0f3fa0e1c4fb971506be"
[[package]]
name = "itertools"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "jni" name = "jni"
version = "0.22.4" version = "0.22.4"
@@ -1505,6 +1877,32 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "num-bigint"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [
"num-integer",
"num-traits",
"serde",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -1537,6 +1935,15 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "objc" name = "objc"
version = "0.2.7" version = "0.2.7"
@@ -1823,6 +2230,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oneshot"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c"
[[package]] [[package]]
name = "orbclient" name = "orbclient"
version = "0.3.55" version = "0.3.55"
@@ -1851,6 +2264,12 @@ dependencies = [
"ttf-parser", "ttf-parser",
] ]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -1880,12 +2299,60 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_macros",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
dependencies = [
"fastrand",
"phf_shared",
]
[[package]]
name = "phf_macros"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "phf_shared"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.13" version = "1.1.13"
@@ -2002,6 +2469,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.5.0" version = "3.5.0"
@@ -2080,6 +2553,23 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -2113,6 +2603,15 @@ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
] ]
[[package]]
name = "regress"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32eef8b209c3c1c15dbad02c1f30f9539f00dc7253e0cbcdaae442a50a09d7c1"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "renderdoc-sys" name = "renderdoc-sys"
version = "1.1.0" version = "1.1.0"
@@ -2178,6 +2677,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu-js"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -2235,6 +2740,19 @@ dependencies = [
"syn 3.0.5", "syn 3.0.5",
] ]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.1.1" version = "1.1.1"
@@ -2299,6 +2817,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -2312,6 +2836,7 @@ dependencies = [
"ab_glyph", "ab_glyph",
"anyhow", "anyhow",
"base64", "base64",
"boa_engine",
"eframe", "eframe",
"egui", "egui",
"fontdb", "fontdb",
@@ -2319,7 +2844,10 @@ dependencies = [
"png", "png",
"portable-pty", "portable-pty",
"serde", "serde",
"serde_json",
"shell-words",
"toml", "toml",
"ts-rs",
"wgpu", "wgpu",
] ]
@@ -2332,6 +2860,15 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "small_btree"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ba60d2df92ba73864714808ca68c059734853e6ab722b40e1cf543ebb3a057a"
dependencies = [
"arrayvec",
]
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.16.0" version = "1.16.0"
@@ -2431,6 +2968,27 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.119" version = "2.0.119"
@@ -2464,6 +3022,44 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "synstructure"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "tag_ptr"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0e973b34477b7823833469eb0f5a3a60370fef7a453e02d751b59180d0a5a05"
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "thin-vec"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79def32ffcd477db1ff26f76dab9e3a91f0bd42a85ca96577089b24623056f9d"
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -2518,6 +3114,38 @@ dependencies = [
"zune-jpeg", "zune-jpeg",
] ]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.4" version = "0.8.4"
@@ -2620,6 +3248,28 @@ version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
[[package]]
name = "ts-rs"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
dependencies = [
"thiserror 2.0.20",
"ts-rs-macros",
]
[[package]]
name = "ts-rs-macros"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"termcolor",
]
[[package]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.25.1" version = "0.25.1"
@@ -2668,6 +3318,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "utf16_iter"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -3418,6 +4074,12 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "write16"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.4" version = "0.6.4"
@@ -3507,7 +4169,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
"synstructure", "synstructure 0.13.2",
] ]
[[package]] [[package]]
@@ -3548,7 +4210,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
"synstructure", "synstructure 0.13.2",
] ]
[[package]] [[package]]
@@ -3590,6 +4252,12 @@ version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]] [[package]]
name = "zune-core" name = "zune-core"
version = "0.5.3" version = "0.5.3"
+4
View File
@@ -9,6 +9,7 @@ license = "MIT"
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
ab_glyph = "0.2" ab_glyph = "0.2"
boa_engine = { version = "0.22", default-features = false }
fontdb = "0.23" fontdb = "0.23"
eframe = { version = "0.33", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11"] } eframe = { version = "0.33", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11"] }
egui = "0.33" egui = "0.33"
@@ -16,7 +17,10 @@ libghostty-vt = { version = "=0.2.1", features = ["png"] }
png = "0.18" png = "0.18"
portable-pty = "0.9" portable-pty = "0.9"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1"
shell-words = "1"
toml = "0.9" toml = "0.9"
ts-rs = "12"
wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] } wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] }
[dev-dependencies] [dev-dependencies]
+16
View File
@@ -18,6 +18,10 @@
# regular-only fonts retain synthetic bold/italic emphasis. # regular-only fonts retain synthetic bold/italic emphasis.
font_size = 16 font_size = 16
scrollback = 10000 scrollback = 10000
# Command for opening scrollback; omit to use $EDITOR, or vi if unset.
# Supports quoted arguments; the scrollback file path is appended automatically.
# Runs directly without shell expansion. The editor should stay in the foreground.
# scrollback_editor = "nvim -R"
window_width = 1100 window_width = 1100
window_height = 720 window_height = 720
@@ -49,6 +53,8 @@ allow_shared_memory = true
[multiplexer] [multiplexer]
max_panes = 32 max_panes = 32
# Floating panes follow the centered cascade and configured size until manually
# moved or resized. Automatic panes also follow window and configuration changes.
floating_width = 0.65 floating_width = 0.65
floating_height = 0.65 floating_height = 0.65
# Fractions of the workspace, capped at these many points on large displays. # Fractions of the workspace, capped at these many points on large displays.
@@ -60,6 +66,7 @@ floating_max_height = 640
"Alt+F" = "toggle_floats" "Alt+F" = "toggle_floats"
"Alt+M" = "new_pane" "Alt+M" = "new_pane"
"Alt+H" = "focus_left" "Alt+H" = "focus_left"
# With floating panes shown, J/K cycle forward/backward through them.
"Alt+J" = "focus_down" "Alt+J" = "focus_down"
"Alt+K" = "focus_up" "Alt+K" = "focus_up"
"Alt+L" = "focus_right" "Alt+L" = "focus_right"
@@ -67,7 +74,16 @@ floating_max_height = 640
"Alt+Shift+H" = "previous_tab" "Alt+Shift+H" = "previous_tab"
"Alt+Shift+L" = "next_tab" "Alt+Shift+L" = "next_tab"
"Alt+X" = "close_pane" "Alt+X" = "close_pane"
# Open scrollback in scrollback_editor (or $EDITOR/vi), returning on exit.
"Alt+S" = "open_scrollback"
"Ctrl+Shift+Z" = "zoom_pane" "Ctrl+Shift+Z" = "zoom_pane"
"Ctrl+Shift+R" = "reload_config" "Ctrl+Shift+R" = "reload_config"
"Ctrl+Shift+C" = "copy" "Ctrl+Shift+C" = "copy"
"Ctrl+Shift+V" = "paste" "Ctrl+Shift+V" = "paste"
# TypeScript plugins compile to JavaScript bundles and run in embedded Boa.
# See sdk/README.md. Relative paths resolve beside this configuration file.
# Restart Slerm after changing a plugin bundle or these entries.
# [[plugins]]
# id = "workspace"
# path = "plugins/workspace.js"
+4
View File
@@ -177,6 +177,9 @@
./Cargo.lock ./Cargo.lock
./config.example.toml ./config.example.toml
./src ./src
# Embedded runtime and the bindings checked by Rust tests.
./sdk/runtime.js
./sdk/src/generated.ts
]; ];
}; };
cargoLock.lockFile = ./Cargo.lock; cargoLock.lockFile = ./Cargo.lock;
@@ -207,6 +210,7 @@
checkFlags = [ checkFlags = [
"--test-threads=1" "--test-threads=1"
"--skip=app::tests::" "--skip=app::tests::"
"--skip=pane::scrollback_tests::"
"--skip=tests::pty_executes_input_and_bracketed_paste_is_sanitized" "--skip=tests::pty_executes_input_and_bracketed_paste_is_sanitized"
"--skip=tests::new_panes_start_in_the_directory_of_the_pane_they_came_from" "--skip=tests::new_panes_start_in_the_directory_of_the_pane_they_came_from"
"--skip=tests::osc7_wins_over_the_process_directory_but_only_for_this_machine" "--skip=tests::osc7_wins_over_the_process_directory_but_only_for_this_machine"
+133
View File
@@ -0,0 +1,133 @@
# Slerm TypeScript plugin SDK
Slerm runs JavaScript bundles in **embedded Boa**, with a separate worker thread
and context for each plugin. End users do not need Node.js or Bun. Plugin authors
use TypeScript and a bundler during development.
## Try the example
From the Slerm repository root:
```sh
npm ci --prefix sdk
npm run example --prefix sdk
```
Add this to your Slerm configuration, replacing the absolute path:
```toml
[[plugins]]
id = "workspace"
path = "/absolute/path/to/slerm/sdk/dist/workspace.js"
```
Start Slerm. The example opens **Workspace tools**, with controls to create a
floating pane, run a shell command, and inspect terminal text. Reopen the panel
from **Plugins → workspace → Show workspace tools**.
Bundle paths can also be relative to the configuration file's directory. Plugins
load at startup; restart Slerm after changing a bundle or the plugin configuration.
## Write a plugin
Build the SDK with `npm run build --prefix sdk`. In a separate plugin project,
install the local package with `npm install /absolute/path/to/slerm/sdk` and add
TypeScript and esbuild as development dependencies. The package has not been
published to a registry.
```ts
import { definePlugin } from "@slerm/plugin-sdk";
definePlugin({
async activate(slerm) {
await slerm.registerCommand({
command: { id: "scratch", title: "New scratch pane" },
});
},
async onEvent(event, slerm) {
if (event.event === "command" && event.command === "scratch") {
await slerm.execute({ action: "new_floating" });
}
},
});
```
Type-check your entry point, then bundle it:
```sh
npx tsc --noEmit --strict --target es2022 --module nodenext plugin.ts
npx esbuild plugin.ts --bundle --format=iife --platform=neutral --target=es2022 --outfile=plugin.js
```
Point a `[[plugins]]` entry at `plugin.js`. Each bundle calls `definePlugin` once.
All API calls return promises that resolve after Slerm applies the operation.
Events invoke `onEvent(event, slerm)`; callbacks can be async.
## Generated API
The Rust definitions in [`src/plugin_api.rs`](../src/plugin_api.rs) are the source
of truth. A Rust method list declares parameters and results; `ts-rs` generates
`src/generated.ts`, and TypeScript derives the client method signatures from those
types. Actions come from Slerm's existing Rust action enum. State, UI elements,
and events also use the same Rust types for serialization and type generation.
The generic runtime bridge does not duplicate a method list or handwritten types.
Regenerate after changing the Rust API:
```sh
nix develop -c cargo run -- --generate-plugin-sdk
npm test --prefix sdk
nix develop -c cargo test
nix develop -c cargo test bundled_typescript_example_runs_in_boa -- --ignored
```
Without Nix, run Cargo in an environment containing the project's Rust and Zig
build dependencies. Commit `sdk/src/generated.ts` with API changes. A Rust test
fails if the checked-in bindings differ from the schema. Bump `API_VERSION` for
incompatible changes; Slerm rejects bundles built for another API version.
| Method | Purpose |
| --- | --- |
| `getState()` | Read tabs, panes, titles, focus, floating state, and zoom |
| `execute({ action })` | Execute a built-in pane, tab, clipboard, or settings action |
| `focusPane({ pane })`, `focusTab({ tab })` | Focus by ID from `getState()` |
| `readTerminal({ pane })` | Read a plain-text screen and scrollback snapshot, up to 128 KiB |
| `writeTerminal({ pane, text })` | Send raw PTY input; include `\r` to submit a shell command |
| `registerCommand({ command })`, `unregisterCommand({ command })` | Manage entries in the Plugins menu |
| `setPanel({ panel })`, `removePanel({ panel })` | Create, replace, or remove a native panel |
| `reportError({ message })` | Display an error in Slerm |
Command and panel IDs are scoped to the plugin. Control IDs must be unique within
a panel. Pane and tab IDs come from Slerm and should be treated as opaque numbers.
Events are discriminated by `event.event`:
- `command`: `event.command` is the selected command ID.
- `state`: `event.state` is a snapshot when workspace metadata changes. Terminal
output changes alone do not produce this event; use `readTerminal` for snapshots.
- `ui`: `event.data` contains the panel/control IDs and either a `click`, or a
`change` with the new input `value`.
## Native UI
`setPanel` renders an egui floating window. Its `elements` accept `text`, `button`,
`input`, and `separator` variants. Inputs retain edits until replaced by another
`setPanel` call. UI focus receives typing without forwarding it to the terminal.
See [`examples/workspace.ts`](examples/workspace.ts) for a complete example.
## Runtime boundaries
The runtime supports ECMAScript and promises. This initial host does not expose
Node.js APIs, a DOM, filesystem/network APIs, or timers. Bundle imports ahead of
time and use libraries compatible with this environment. Report diagnostics with
`reportError`; `console` is not installed.
Plugins are trusted local extensions: terminal writes can run shell commands.
Boa contexts run in the same OS process as Slerm; worker threads and execution
limits are not an OS security sandbox. Slerm bounds loops, recursion, promise jobs,
request waits, bundle/message sizes, and UI counts. An uncaught callback error
stops that plugin and removes its commands and panels. Host-operation errors
reject the API promise and can be handled by the plugin.
Implementation references: [Boa](https://boajs.dev/docs/intro) and
[ts-rs](https://docs.rs/ts-rs/latest/ts_rs/).
+40
View File
@@ -0,0 +1,40 @@
import { definePlugin, type Slerm } from "../src/index.js";
let command = "pwd";
const show = (slerm: Slerm) => slerm.setPanel({ panel: {
id: "workspace", title: "Workspace tools", elements: [
{ type: "text", text: "Create a scratch terminal or run a command in the active pane." },
{ type: "button", id: "scratch", label: "New floating pane" },
{ type: "separator" },
{ type: "input", id: "command", label: "Shell command", value: command },
{ type: "button", id: "run", label: "Run in active pane" },
{ type: "button", id: "read", label: "Show terminal text" },
{ type: "button", id: "close", label: "Close tools" },
],
} });
definePlugin({
async activate(slerm) {
await slerm.registerCommand({ command: { id: "show-tools", title: "Show workspace tools" } });
await show(slerm);
},
async onEvent(event, slerm) {
if (event.event === "command" && event.command === "show-tools") await show(slerm);
if (event.event !== "ui" || event.data.panel !== "workspace") return;
const control = event.data;
if (control.kind === "change" && control.control === "command") command = control.value;
if (control.kind !== "click") return;
if (control.control === "scratch") await slerm.execute({ action: "new_floating" });
if (control.control === "close") await slerm.removePanel({ panel: "workspace" });
if (control.control === "run" || control.control === "read") {
const state = await slerm.getState();
const pane = state.tabs.find(tab => tab.id === state.activeTab)?.activePane;
if (pane === undefined) return;
if (control.control === "run") await slerm.writeTerminal({ pane, text: command + "\r" });
else await slerm.setPanel({ panel: {
id: "output", title: "Terminal snapshot",
elements: [{ type: "text", text: await slerm.readTerminal({ pane }) }],
} });
}
},
});
+515
View File
@@ -0,0 +1,515 @@
{
"name": "@slerm/plugin-sdk",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@slerm/plugin-sdk",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"esbuild": "^0.27.0",
"typescript": "^6.0.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@slerm/plugin-sdk",
"version": "0.1.0",
"description": "Generated TypeScript API for Slerm's embedded Boa plugins",
"license": "MIT",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"generate": "cargo run --manifest-path ../Cargo.toml -- --generate-plugin-sdk",
"build": "tsc -p tsconfig.json",
"check": "npm run build && tsc -p tsconfig.examples.json",
"example": "npm run check && esbuild examples/workspace.ts --bundle --format=iife --platform=neutral --target=es2022 --outfile=dist/workspace.js",
"test": "npm run example && node --test test/*.test.mjs",
"prepack": "npm run build"
},
"devDependencies": {
"esbuild": "^0.27.0",
"typescript": "^6.0.3"
}
}
+28
View File
@@ -0,0 +1,28 @@
// Generic Boa bridge. API methods and their types come from the Rust schema.
(() => {
const call = __slerm_call;
const fail = __slerm_error;
let plugin;
const api = new Proxy(Object.create(null), {
get: (_, method) => method === "then" ? undefined : async (params = {}) => {
const reply = JSON.parse(call(JSON.stringify({ ...params, method })));
if (reply.error !== undefined) throw new Error(reply.error);
return reply.result;
},
});
const invoke = (callback, ...args) => {
if (callback) Promise.resolve().then(() => callback(...args)).catch(error => fail(String(error)));
};
Object.defineProperty(globalThis, "__slerm", { value: Object.freeze({
activate(definition, version) {
if (version !== __slerm_version) throw new Error(`Unsupported Slerm API version ${version}`);
if (plugin) throw new Error("A bundle must define exactly one plugin");
plugin = definition;
invoke(plugin.activate, api);
},
}) });
Object.defineProperty(globalThis, "__slerm_dispatch", { value: event => {
if (!plugin) throw new Error("Bundle did not call definePlugin");
invoke(plugin.onEvent, event, api);
} });
})();
+30
View File
@@ -0,0 +1,30 @@
// Generated by cargo run -- --generate-plugin-sdk. Do not edit.
export const apiVersion = 1 as const;
export type Action = "split_right" | "split_down" | "new_floating" | "new_pane" | "toggle_floating" | "toggle_floats" | "next_pane" | "previous_pane" | "focus_left" | "focus_down" | "focus_up" | "focus_right" | "close_pane" | "zoom_pane" | "new_tab" | "next_tab" | "previous_tab" | "reload_config" | "copy" | "paste" | "open_scrollback";
export type Operation = { "method": "getState", } | { "method": "execute", action: Action, } | { "method": "focusPane", pane: number, } | { "method": "focusTab", tab: number, } | { "method": "readTerminal", pane: number, } | { "method": "writeTerminal", pane: number, text: string, } | { "method": "registerCommand", command: PluginCommand, } | { "method": "unregisterCommand", command: string, } | { "method": "setPanel", panel: Panel, } | { "method": "removePanel", panel: string, } | { "method": "reportError", message: string, };
export type Results = { getState: State, execute: null, focusPane: null, focusTab: null, readTerminal: string, writeTerminal: null, registerCommand: null, unregisterCommand: null, setPanel: null, removePanel: null, reportError: null, };
export type State = { activeTab: number | null, tabs: Array<TabState>, };
export type TabState = { id: number, activePane: number, floatsVisible: boolean, zoomed: boolean, panes: Array<PaneState>, };
export type PaneState = { id: number, title: string, floating: boolean, exited: boolean, };
export type PluginCommand = { id: string, title: string, };
export type Panel = { id: string, title: string, elements: Array<Element>, };
export type Element = { "type": "text", text: string, } | { "type": "button", id: string, label: string, } | { "type": "input", id: string, label: string, value: string, } | { "type": "separator" };
export type UIEvent = { "kind": "click", panel: string, control: string, } | { "kind": "change", panel: string, control: string, value: string, };
export type Event = { "event": "command", command: string, } | { "event": "state", state: State, } | { "event": "ui", data: UIEvent, };
type Params<M extends Operation["method"]> = Omit<Extract<Operation, { method: M }>, "method">;
export type Slerm = {
[M in Operation["method"]]: (...args: keyof Params<M> extends never ? [] : [params: Params<M>]) => Promise<Results[M] extends null ? void : Results[M]>;
};
+15
View File
@@ -0,0 +1,15 @@
import { apiVersion } from "./generated.js";
import type { Event, Slerm } from "./generated.js";
export * from "./generated.js";
export interface Plugin {
activate?(slerm: Slerm): void | Promise<void>;
onEvent?(event: Event, slerm: Slerm): void | Promise<void>;
}
declare const __slerm: { activate(plugin: Plugin, version: number): void };
/** Register one plugin in its embedded Boa runtime. Bundle this entry point. */
export function definePlugin(plugin: Plugin): void {
__slerm.activate(plugin, apiVersion);
}
+67
View File
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext, runInContext } from "node:vm";
import test from "node:test";
const bridge = readFileSync(new URL("../runtime.js", import.meta.url), "utf8");
const example = readFileSync(new URL("../dist/workspace.js", import.meta.url), "utf8");
const settle = () => new Promise(resolve => setImmediate(resolve));
function start(reply = () => ({ result: null })) {
const operations = [];
const errors = [];
const context = {
__slerm_version: 1,
__slerm_call: text => {
const operation = JSON.parse(text);
operations.push(operation);
return JSON.stringify(reply(operation));
},
__slerm_error: error => errors.push(error),
};
runInNewContext(bridge, context);
return { context, operations, errors };
}
test("bundled TypeScript example uses generated methods and handles UI and command events", async () => {
const host = start(operation => ({ result: operation.method === "getState"
? { activeTab: 1, tabs: [{ id: 1, activePane: 2 }] }
: operation.method === "readTerminal" ? "hello from terminal" : null }));
runInContext(example, host.context);
await settle();
assert.deepEqual(host.operations.map(op => op.method), ["registerCommand", "setPanel"]);
assert.equal(host.operations[1].panel.elements[3].type, "input");
const ui = data => host.context.__slerm_dispatch({ event: "ui", data: { panel: "workspace", ...data } });
ui({ kind: "change", control: "command", value: "echo hello" });
await settle();
ui({ kind: "click", control: "run" });
await settle();
assert.deepEqual(host.operations.at(-1), { method: "writeTerminal", pane: 2, text: "echo hello\r" });
ui({ kind: "click", control: "scratch" });
await settle();
assert.deepEqual(host.operations.at(-1), { method: "execute", action: "new_floating" });
ui({ kind: "click", control: "read" });
await settle();
assert.equal(host.operations.at(-1).panel.elements[0].text, "hello from terminal");
ui({ kind: "click", control: "close" });
await settle();
assert.equal(host.operations.at(-1).method, "removePanel");
host.context.__slerm_dispatch({ event: "command", command: "show-tools" });
await settle();
assert.equal(host.operations.at(-1).method, "setPanel");
assert.deepEqual(host.errors, []);
});
test("API mismatch and duplicate registration fail before activation", () => {
const { context } = start();
assert.throws(() => context.__slerm.activate({}, 2), /Unsupported Slerm API/);
context.__slerm.activate({}, 1);
assert.throws(() => context.__slerm.activate({}, 1), /exactly one plugin/);
});
test("host errors reject generated calls and surface callback failures", async () => {
const host = start(() => ({ error: "unknown pane" }));
runInContext(example, host.context);
await settle();
assert.deepEqual(host.errors, ["Error: unknown pane"]);
});
+19
View File
@@ -0,0 +1,19 @@
import type { Slerm, State } from "../src/index.js";
declare const api: Slerm;
const state: Promise<State> = api.getState();
const text: Promise<string> = api.readTerminal({ pane: 1 });
void state; void text;
// @ts-expect-error Generated methods reject unknown actions.
api.execute({ action: "missing" });
// @ts-expect-error Pane IDs are numbers in the JSON API.
api.focusPane({ pane: "1" });
// @ts-expect-error A terminal write requires text.
api.writeTerminal({ pane: 1 });
// @ts-expect-error Parameterless methods take no parameters.
api.getState({});
// @ts-expect-error Panel elements are generated from the Rust enum.
api.setPanel({ panel: { id: "test", title: "Test", elements: [{ type: "html", html: "" }] } });
// @ts-expect-error Return types come from the Rust method list.
const invalid: Promise<number> = api.readTerminal({ pane: 1 });
void invalid;
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"compilerOptions": { "noEmit": true, "rootDir": "." },
"include": ["examples/**/*.ts", "test/types.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"types": [],
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
+675 -19
View File
@@ -3,6 +3,8 @@ use crate::{
input, input,
layout::{Axis, Direction, Layout, PaneId, Tab}, layout::{Axis, Direction, Layout, PaneId, Tab},
pane::{self, Pane}, pane::{self, Pane},
plugin_api::{Event, Operation, PaneState, State, TabState},
plugins::{self, Host},
theme::{self, Theme}, theme::{self, Theme},
}; };
use anyhow::{Result, ensure}; use anyhow::{Result, ensure};
@@ -22,6 +24,7 @@ pub struct App {
next_id: u64, next_id: u64,
error: Option<String>, error: Option<String>,
suppressed: HashSet<egui::Key>, suppressed: HashSet<egui::Key>,
plugins: Host,
} }
/// Width kept free at the left of the tab bar for the macOS traffic lights, /// Width kept free at the left of the tab bar for the macOS traffic lights,
@@ -69,6 +72,11 @@ impl App {
cc.egui_ctx.set_visuals(theme.visuals()); cc.egui_ctx.set_visuals(theme.visuals());
cc.egui_ctx.set_fonts(fonts(&config)?); cc.egui_ctx.set_fonts(fonts(&config)?);
let pane = Pane::new(&config, &theme, &cc.egui_ctx, None)?; let pane = Pane::new(&config, &theme, &cc.egui_ctx, None)?;
let config_dir = config_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(std::path::Path::new("."));
let (plugins, errors) = Host::start(&config.plugins, config_dir, &cc.egui_ctx);
Ok(Self { Ok(Self {
config, config,
theme, theme,
@@ -77,8 +85,9 @@ impl App {
tabs: vec![Tab::new(1, 1)], tabs: vec![Tab::new(1, 1)],
current: 0, current: 0,
next_id: 2, next_id: 2,
error: None, error: (!errors.is_empty()).then(|| errors.join("\n")),
suppressed: HashSet::new(), suppressed: HashSet::new(),
plugins,
}) })
} }
@@ -235,6 +244,13 @@ impl App {
} }
} }
Action::Paste => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste), Action::Paste => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
Action::OpenScrollback => {
self.panes.get_mut(&active).unwrap().open_scrollback(
&self.config,
&self.theme,
ctx,
)?;
}
} }
Ok(()) Ok(())
} }
@@ -285,6 +301,175 @@ impl App {
} }
} }
fn plugin_state(&self) -> State {
State {
active_tab: self.tabs.get(self.current).map(|tab| tab.id),
tabs: self
.tabs
.iter()
.map(|tab| TabState {
id: tab.id,
active_pane: tab.active,
floats_visible: tab.floats_visible,
zoomed: tab.zoomed,
panes: tab
.ids()
.iter()
.map(|id| PaneState {
id: *id,
floating: tab.floats.contains(id),
title: self.panes[id].terminal.title().unwrap_or("").into(),
exited: self.panes[id].dead,
})
.collect(),
})
.collect(),
}
}
fn plugin_request(
&mut self,
index: usize,
operation: Operation,
ctx: &egui::Context,
) -> Result<serde_json::Value> {
use serde_json::json;
match operation {
Operation::GetState {} => return Ok(serde_json::to_value(self.plugin_state())?),
Operation::Execute { action } => {
ensure!(!self.tabs.is_empty(), "no active tab");
self.action(action, ctx)?;
}
Operation::FocusPane { pane } => {
let index = self
.tabs
.iter()
.position(|tab| tab.ids().contains(&pane))
.ok_or_else(|| anyhow::anyhow!("unknown pane {pane}"))?;
self.current = index;
let tab = &mut self.tabs[index];
tab.active = pane;
tab.floats_visible = tab.floats.contains(&pane);
}
Operation::FocusTab { tab } => {
self.current = self
.tabs
.iter()
.position(|candidate| candidate.id == tab)
.ok_or_else(|| anyhow::anyhow!("unknown tab {tab}"))?;
}
Operation::ReadTerminal { pane } => {
use libghostty_vt::fmt::{Formatter, FormatterOptions};
let pane = self
.panes
.get(&pane)
.ok_or_else(|| anyhow::anyhow!("unknown pane {pane}"))?;
let bytes = Formatter::new(
&pane.terminal,
FormatterOptions::new().with_trim(true).with_unwrap(true),
)?
.format_alloc(None)?;
ensure!(
bytes.len() <= 128 * 1024,
"terminal snapshot exceeds 128 KiB"
);
return Ok(json!(String::from_utf8_lossy(&bytes)));
}
Operation::WriteTerminal { pane, text } => {
self.panes
.get_mut(&pane)
.ok_or_else(|| anyhow::anyhow!("unknown pane {pane}"))?
.send(text.into_bytes())?;
}
Operation::RegisterCommand { command } => {
ensure!(plugins::valid_id(&command.id), "invalid command id");
ensure!(
!command.title.trim().is_empty() && command.title.len() <= 200,
"invalid command title"
);
let commands = &mut self.plugins.runtimes[index].commands;
ensure!(
commands.contains_key(&command.id) || commands.len() < 64,
"command limit reached"
);
commands.insert(command.id.clone(), command);
}
Operation::UnregisterCommand { command } => {
self.plugins.runtimes[index].commands.remove(&command);
}
Operation::SetPanel { panel } => {
panel.validate()?;
let panels = &mut self.plugins.runtimes[index].panels;
ensure!(
panels.contains_key(&panel.id) || panels.len() < 8,
"panel limit reached"
);
panels.insert(panel.id.clone(), panel);
}
Operation::RemovePanel { panel } => {
self.plugins.runtimes[index].panels.remove(&panel);
}
Operation::ReportError { message } => {
self.error = Some(format!(
"Plugin {}: {message}",
self.plugins.runtimes[index].id
));
}
}
Ok(serde_json::Value::Null)
}
fn poll_plugins(&mut self, ctx: &egui::Context) {
use serde_json::json;
if self.plugins.runtimes.is_empty() {
return;
}
for index in 0..self.plugins.runtimes.len() {
// Bound work per frame even when a plugin continuously sends requests.
for count in 0..32 {
let Some(request) = self.plugins.runtimes[index].poll() else {
break;
};
let response = match request {
Ok(request) => {
let response = match self.plugin_request(index, request.operation, ctx) {
Ok(result) => json!({"result": result}),
Err(error) => json!({"error": error.to_string()}),
};
request
.reply
.try_send(response)
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
Err(error) => Err(anyhow::anyhow!(error)),
};
if let Err(e) = response {
let plugin = &mut self.plugins.runtimes[index];
self.error = Some(format!("Plugin {}: {e}", plugin.id));
plugin.stop();
break;
}
if count == 31 {
ctx.request_repaint();
}
}
}
let state = self.plugin_state();
let serialized = serde_json::to_value(&state).unwrap();
for plugin in &mut self.plugins.runtimes {
if plugin.is_running() && plugin.last_state.as_ref() != Some(&serialized) {
if let Err(e) = plugin.send(Event::State {
state: state.clone(),
}) {
self.error = Some(format!("Plugin {}: {e}", plugin.id));
plugin.stop();
} else {
plugin.last_state = Some(serialized.clone());
}
}
}
}
fn keyboard(&mut self, ctx: &egui::Context) -> Result<()> { fn keyboard(&mut self, ctx: &egui::Context) -> Result<()> {
let events = ctx.input(|i| i.events.clone()); let events = ctx.input(|i| i.events.clone());
let modifiers = ctx.input(|i| i.modifiers); let modifiers = ctx.input(|i| i.modifiers);
@@ -381,15 +566,24 @@ impl eframe::App for App {
for pane in self.panes.values_mut() { for pane in self.panes.values_mut() {
pane.drain(ctx); pane.drain(ctx);
} }
self.poll_plugins(ctx);
if self.tabs.is_empty() {
return;
}
// Native plugin text fields must receive typing without forwarding it to the PTY.
let editing_ui = ctx.memory(|m| m.focused().is_some_and(|id| id != egui::Id::new("keys")));
if !editing_ui {
grab_keys(ctx); grab_keys(ctx);
if let Err(e) = self.keyboard(ctx) { if let Err(e) = self.keyboard(ctx) {
self.error = Some(e.to_string()); self.error = Some(e.to_string());
} }
}
if self.tabs.is_empty() { if self.tabs.is_empty() {
return; return;
} }
let mut action = None; let mut action = None;
let mut picked = None; let mut picked = None;
let mut plugin_command = None;
egui::TopBottomPanel::top("tabs").show(ctx, |ui| { egui::TopBottomPanel::top("tabs").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
if cfg!(target_os = "macos") { if cfg!(target_os = "macos") {
@@ -425,6 +619,7 @@ impl eframe::App for App {
("Zoom / restore", Action::ZoomPane), ("Zoom / restore", Action::ZoomPane),
("Next pane", Action::NextPane), ("Next pane", Action::NextPane),
("Close pane", Action::ClosePane), ("Close pane", Action::ClosePane),
("Open scrollback in editor", Action::OpenScrollback),
] { ] {
if ui.button(label).clicked() { if ui.button(label).clicked() {
action = Some(a); action = Some(a);
@@ -432,6 +627,20 @@ impl eframe::App for App {
} }
} }
}); });
if self.plugins.runtimes.iter().any(|p| !p.commands.is_empty()) {
ui.menu_button("Plugins", |ui| {
for (index, plugin) in self.plugins.runtimes.iter().enumerate() {
ui.menu_button(&plugin.id, |ui| {
for command in plugin.commands.values() {
if ui.button(&command.title).clicked() {
plugin_command = Some((index, command.id.clone()));
ui.close();
}
}
});
}
});
}
ui.menu_button("Settings", |ui| { ui.menu_button("Settings", |ui| {
if ui.button("Reload config").clicked() { if ui.button("Reload config").clicked() {
action = Some(Action::ReloadConfig); action = Some(Action::ReloadConfig);
@@ -496,15 +705,20 @@ impl eframe::App for App {
let pane = self.panes.get_mut(&id).unwrap(); let pane = self.panes.get_mut(&id).unwrap();
let window_id = egui::Id::new(("float", tab.id, id)); let window_id = egui::Id::new(("float", tab.id, id));
let active = tab.active == id; let active = tab.active == id;
let (pos, size) = float_placement(workspace, index, &self.config.multiplexer); let window = egui::Window::new(pane_title(id, pane))
let result = egui::Window::new(pane_title(id, pane))
.id(window_id) .id(window_id)
.title_bar(false) .title_bar(false)
.constrain_to(workspace) .constrain_to(workspace)
.default_pos(pos)
.default_size(size)
.min_size(vec2(160.0, 100.0)) .min_size(vec2(160.0, 100.0))
.frame(egui::Frame::NONE.fill(bg)) .frame(egui::Frame::NONE.fill(bg));
let result = position_float(
ctx,
window,
window_id,
index,
workspace,
&self.config.multiplexer,
)
.show(ctx, |ui| { .show(ctx, |ui| {
let rect = ui.available_rect_before_wrap(); let rect = ui.available_rect_before_wrap();
ui.allocate_rect(rect, egui::Sense::hover()); ui.allocate_rect(rect, egui::Sense::hover());
@@ -513,21 +727,31 @@ impl eframe::App for App {
tab.active = id; tab.active = id;
} }
}); });
if let Some(result) = result if let Some(result) = result {
&& result.response.contains_pointer() remember_float_interaction(ctx, window_id, &result.response);
&& ctx.input(|i| i.pointer.any_pressed()) if result.response.contains_pointer() && ctx.input(|i| i.pointer.any_pressed())
{ {
tab.active = id; tab.active = id;
} }
}
if active { if active {
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, window_id)); ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, window_id));
} }
} }
} }
let active = tab.active; let active = tab.active;
let active_visible = tab.is_visible(active);
for error in self.plugins.show_panels(ctx) {
self.error = Some(error);
}
if let Some((index, command)) = plugin_command
&& let Err(e) = self.plugins.invoke(index, &command)
{
self.error = Some(e.to_string());
}
for (id, pane) in &mut self.panes { for (id, pane) in &mut self.panes {
if let Err(e) = if let Err(e) = pane
pane.focus(*id == active && tab.is_visible(*id) && ctx.input(|i| i.focused)) .focus(*id == active && active_visible && !editing_ui && ctx.input(|i| i.focused))
{ {
pane.error = Some(e.to_string()); pane.error = Some(e.to_string());
} }
@@ -571,7 +795,7 @@ fn grab_keys(ctx: &egui::Context) {
} }
/// Corner rounding shared by a pane's border and its title bar. /// Corner rounding shared by a pane's border and its title bar.
const CORNER: u8 = 5; const CORNER: u8 = theme::CORNER_RADIUS;
fn show_pane( fn show_pane(
ui: &mut egui::Ui, ui: &mut egui::Ui,
@@ -638,7 +862,7 @@ fn show_pane(
rect, rect,
CORNER, CORNER,
Stroke::new( Stroke::new(
1.0_f32, theme::BORDER_WIDTH,
if active { if active {
accent accent
} else { } else {
@@ -653,7 +877,7 @@ fn show_pane(
/// Where a floating pane opens, from its place in the tab's float list rather than /// Where a floating pane opens, from its place in the tab's float list rather than
/// its pane id: the first one is centred and every later one steps down and right /// its pane id: the first one is centred and every later one steps down and right
/// from it, so the same slot always lands in the same spot. The cascade wraps /// from it, so the same slot always lands in the same spot. The cascade wraps
/// before it can walk a float off the far edge. /// every six panes, with each new stack shifted slightly to the right.
fn float_placement( fn float_placement(
workspace: Rect, workspace: Rect,
index: usize, index: usize,
@@ -662,9 +886,83 @@ fn float_placement(
// The fractions are of the workspace, so on a large display they hand a scratch // The fractions are of the workspace, so on a large display they hand a scratch
// terminal far more room than it needs; the maximums keep it in hand. // terminal far more room than it needs; the maximums keep it in hand.
let size = (workspace.size() * vec2(config.floating_width, config.floating_height)) let size = (workspace.size() * vec2(config.floating_width, config.floating_height))
.min(vec2(config.floating_max_width, config.floating_max_height)); .min(vec2(config.floating_max_width, config.floating_max_height))
.max(vec2(160.0, 100.0))
.min(workspace.size());
(workspace.center() - size / 2.0 + float_offset(index), size)
}
fn float_offset(index: usize) -> egui::Vec2 {
let step = (index % 6) as f32 * 28.0; let step = (index % 6) as f32 * 28.0;
(workspace.center() - size / 2.0 + vec2(step, step), size) let stack = (index / 6) as f32 * 12.0;
vec2(step + stack, step)
}
#[derive(Clone, Copy, Default)]
struct FloatPlacement {
configured_size: Option<egui::Vec2>,
manual: bool,
}
/// Opt out of automatic placement independently of the input that moved the pane.
/// Future movement commands should call this before changing its geometry.
fn mark_float_manual(ctx: &egui::Context, window_id: egui::Id) {
ctx.data_mut(|data| {
data.get_temp_mut_or_default::<FloatPlacement>(window_id.with("placement"))
.manual = true;
});
}
fn remember_float_interaction(ctx: &egui::Context, window_id: egui::Id, response: &egui::Response) {
let placement = ctx.data_mut(|data| {
data.get_temp::<FloatPlacement>(window_id.with("placement"))
.unwrap_or_default()
});
// Edge resizing has its own response, so the area's dragged flag only covers
// movement. Compare sizes during a drag to also preserve a manual resize.
let resized = placement.configured_size.is_some_and(|size| {
(response.rect.size() - size).abs().max_elem() > 1.0
&& ctx.input(|i| i.pointer.any_down() && i.pointer.is_decidedly_dragging())
});
// egui can report a drag as soon as the pointer is pressed, before it moves.
let moved = response.dragged()
&& response
.total_drag_delta()
.is_some_and(|delta| delta != egui::Vec2::ZERO);
if moved || resized {
mark_float_manual(ctx, window_id);
}
}
/// Follow configured geometry until the pane is explicitly moved or resized.
fn position_float<'a>(
ctx: &egui::Context,
window: egui::Window<'a>,
window_id: egui::Id,
index: usize,
workspace: Rect,
config: &config::Multiplexer,
) -> egui::Window<'a> {
let (pos, size) = float_placement(workspace, index, config);
let mut window = window
.default_pos(pos)
.default_size(size)
.min_size(vec2(160.0, 100.0).min(workspace.size()));
let (manual, size_changed) = ctx.data_mut(|data| {
let placement = data.get_temp_mut_or_default::<FloatPlacement>(window_id.with("placement"));
let size_changed = placement.configured_size != Some(size);
placement.configured_size = Some(size);
(placement.manual, size_changed)
});
if !manual {
window = window.current_pos(pos);
if size_changed {
// default_size alone does not update egui's remembered size. Clamp
// for this frame, then restore normal resize limits on the next one.
window = window.fixed_size(size).resizable(true);
}
}
window
} }
fn pane_title(id: PaneId, pane: &Pane) -> String { fn pane_title(id: PaneId, pane: &Pane) -> String {
@@ -680,6 +978,117 @@ fn pane_title(id: PaneId, pane: &Pane) -> String {
mod tests { mod tests {
use super::*; use super::*;
fn pump_plugins(app: &mut App, ctx: &egui::Context, done: impl Fn(&App) -> bool) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
loop {
for pane in app.panes.values_mut() {
pane.drain(ctx);
}
app.poll_plugins(ctx);
assert!(app.error.is_none(), "plugin error: {:?}", app.error);
if done(app) {
break;
}
assert!(std::time::Instant::now() < deadline, "plugin timed out");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[test]
fn embedded_plugin_controls_panes_and_native_ui() {
let ctx = egui::Context::default();
let mut app = test_app(&ctx);
let path = std::env::temp_dir().join(format!("slerm-plugin-{}.js", std::process::id()));
std::fs::write(&path, r#"
__slerm.activate({
async activate(api) {
let rejected = false;
try { await api.focusPane({pane:999}); } catch (_) { rejected = true; }
if (!rejected) throw Error('missing validation');
await api.execute({action:'new_floating'});
const state = await api.getState();
if (state.tabs[0].panes.length !== 2) throw Error('wrong state');
await api.writeTerminal({pane:state.tabs[0].activePane, text:'echo SDK_INPUT\r'});
if (typeof await api.readTerminal({pane:1}) !== 'string') throw Error('wrong terminal result');
await api.setPanel({panel:{id:'test', title:'Tools', elements:[
{type:'button', id:'done', label:'Done'},
{type:'input', id:'name', label:'Name', value:'Slerm'}
]}});
await api.registerCommand({command:{id:'ready',title:'Ready'}});
},
async onEvent(event, api) {
if (event.event === 'command') await api.execute({action:'new_tab'});
if (event.event === 'ui') {
await api.removePanel({panel:event.data.panel});
await api.registerCommand({command:{id:'passed',title:'Passed'}});
}
}
}, 1);
"#).unwrap();
let (host, errors) = Host::start(
&[config::Plugin {
id: "test".into(),
path: path.clone(),
}],
std::path::Path::new("."),
&ctx,
);
std::fs::remove_file(path).unwrap();
assert!(errors.is_empty(), "{errors:?}");
app.plugins = host;
pump_plugins(&mut app, &ctx, |app| {
app.plugins.runtimes[0].commands.contains_key("ready")
});
assert_eq!(app.tabs[0].floats.len(), 1);
assert_eq!(app.plugins.runtimes[0].panels.len(), 1);
let _ = ctx.run(egui::RawInput::default(), |ctx| {
assert!(app.plugins.show_panels(ctx).is_empty());
});
app.plugins.invoke(0, "ready").unwrap();
pump_plugins(&mut app, &ctx, |app| app.tabs.len() == 2);
app.plugins.runtimes[0]
.send(Event::Ui {
data: crate::plugin_api::UIEvent::Click {
panel: "test".into(),
control: "done".into(),
},
})
.unwrap();
pump_plugins(&mut app, &ctx, |app| {
app.plugins.runtimes[0].commands.contains_key("passed")
});
assert!(app.plugins.runtimes[0].panels.is_empty());
}
#[test]
#[ignore = "build the example first: npm run example --prefix sdk"]
fn bundled_typescript_example_runs_in_boa() {
let ctx = egui::Context::default();
let mut app = test_app(&ctx);
let (host, errors) = Host::start(
&[config::Plugin {
id: "workspace".into(),
path: "sdk/dist/workspace.js".into(),
}],
std::path::Path::new(env!("CARGO_MANIFEST_DIR")),
&ctx,
);
assert!(errors.is_empty(), "{errors:?}");
app.plugins = host;
pump_plugins(&mut app, &ctx, |app| {
app.plugins.runtimes[0].panels.contains_key("workspace")
});
app.plugins.runtimes[0]
.send(Event::Ui {
data: crate::plugin_api::UIEvent::Click {
panel: "workspace".into(),
control: "scratch".into(),
},
})
.unwrap();
pump_plugins(&mut app, &ctx, |app| app.tabs[0].floats.len() == 1);
}
fn test_app(ctx: &egui::Context) -> App { fn test_app(ctx: &egui::Context) -> App {
let config = Config { let config = Config {
shell: vec!["/bin/sh".into()], shell: vec!["/bin/sh".into()],
@@ -697,6 +1106,7 @@ mod tests {
next_id: 2, next_id: 2,
error: None, error: None,
suppressed: HashSet::new(), suppressed: HashSet::new(),
plugins: Host::default(),
} }
} }
@@ -741,6 +1151,242 @@ mod tests {
); );
} }
#[test]
fn removing_floats_repositions_survivors_in_cycle_order() {
let ctx = egui::Context::default();
let config = config::Multiplexer::default();
let workspace = Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1200.0));
let mut tab = Tab::new(1, 1);
tab.floats = vec![2, 3, 4, 5];
tab.floats_visible = true;
let render = |tab: &Tab, moved: Option<PaneId>, workspace: Rect| {
let mut positions = BTreeMap::new();
// Allow egui's initial sizing pass to settle.
for _ in 0..3 {
let _ = ctx.run(
egui::RawInput {
screen_rect: Some(workspace),
..Default::default()
},
|ctx| {
for (index, &id) in tab.floats.iter().enumerate() {
let window_id = egui::Id::new(("float", tab.id, id));
let mut window = egui::Window::new(id.to_string())
.id(window_id)
.title_bar(false)
.frame(egui::Frame::NONE);
if moved == Some(id) {
mark_float_manual(ctx, window_id);
window = window.current_pos(egui::pos2(100.0, 100.0));
}
let response =
position_float(ctx, window, window_id, index, workspace, &config)
.show(ctx, |ui| {
ui.allocate_rect(
ui.available_rect_before_wrap(),
egui::Sense::hover(),
);
})
.unwrap();
remember_float_interaction(ctx, window_id, &response.response);
positions.insert(id, response.response.rect.min);
}
},
);
}
positions
};
render(&tab, Some(2), workspace);
render(&tab, Some(5), workspace);
assert_eq!(render(&tab, None, workspace)[&2], egui::pos2(100.0, 100.0));
// Workspace changes preserve manual panes while recentering the rest.
let resized = Rect::from_min_size(egui::Pos2::ZERO, vec2(1800.0, 1400.0));
let positions = render(&tab, None, resized);
for (index, &id) in tab.floats.iter().enumerate() {
let expected = if id == 2 || id == 5 {
egui::pos2(100.0, 100.0)
} else {
float_placement(resized, index, &config).0
};
assert_eq!(positions[&id], expected);
}
render(&tab, None, workspace);
render(&tab, Some(2), workspace);
// Remove the middle pane, then the first, then append a new float.
for (removed, added) in [(3, None), (2, None), (4, Some(6))] {
tab.detach(removed);
if let Some(id) = added {
tab.floats.push(id);
}
let positions = render(&tab, None, workspace);
tab.active = tab.floats[0];
for index in 0..tab.floats.len() {
let expected = if tab.active == 2 || tab.active == 5 {
egui::pos2(100.0, 100.0)
} else {
float_placement(workspace, index, &config).0
};
assert_eq!(positions[&tab.active], expected);
tab.cycle(false);
}
assert_eq!(tab.active, tab.floats[0]);
}
}
fn render_float(
ctx: &egui::Context,
workspace: Rect,
config: &config::Multiplexer,
events: Vec<egui::Event>,
) -> Rect {
let window_id = egui::Id::new("test float");
let mut rect = Rect::NOTHING;
let _ = ctx.run(
egui::RawInput {
screen_rect: Some(workspace),
events,
..Default::default()
},
|ctx| {
let window = egui::Window::new("float")
.id(window_id)
.title_bar(false)
.constrain_to(workspace)
.min_size(vec2(160.0, 100.0))
.frame(egui::Frame::NONE);
let response = position_float(ctx, window, window_id, 0, workspace, config)
.show(ctx, |ui| {
let rect = ui.available_rect_before_wrap();
ui.allocate_rect(rect, egui::Sense::hover());
let header = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0));
ui.interact(header, ui.id().with("header"), egui::Sense::click());
let content = Rect::from_min_max(header.left_bottom(), rect.max);
ui.interact(
content,
ui.id().with("content"),
egui::Sense::click_and_drag(),
);
})
.unwrap();
remember_float_interaction(ctx, window_id, &response.response);
rect = response.response.rect;
},
);
rect
}
#[test]
fn automatic_floats_follow_workspace_and_config_changes() {
let ctx = egui::Context::default();
let mut config = config::Multiplexer::default();
for (width, height, fraction, maximum) in [
(1000.0, 800.0, 0.65, 960.0),
(800.0, 600.0, 0.65, 960.0),
(1800.0, 1400.0, 0.65, 960.0),
(1800.0, 1400.0, 0.4, 960.0),
(1800.0, 1400.0, 0.4, 500.0),
(200.0, 150.0, 0.4, 500.0),
(100.0, 80.0, 0.4, 500.0),
(1000.0, 800.0, 0.65, 960.0),
] {
config.floating_width = fraction;
config.floating_height = fraction;
config.floating_max_width = maximum;
config.floating_max_height = maximum;
let workspace = Rect::from_min_size(egui::pos2(0.0, 30.0), vec2(width, height));
let (pos, size) = float_placement(workspace, 0, &config);
for _ in 0..3 {
render_float(&ctx, workspace, &config, vec![]);
}
let rect = render_float(&ctx, workspace, &config, vec![]);
assert!((rect.min - pos).abs().max_elem() < 0.1, "{rect:?}");
assert!((rect.size() - size).abs().max_elem() < 0.1, "{rect:?}");
}
}
#[test]
fn manual_float_geometry_survives_workspace_config_and_visibility_changes() {
for gesture in ["move", "resize", "select", "click"] {
let ctx = egui::Context::default();
let window_id = egui::Id::new("test float");
let mut config = config::Multiplexer::default();
let workspace = Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0));
for _ in 0..3 {
render_float(&ctx, workspace, &config, vec![]);
}
let before = render_float(&ctx, workspace, &config, vec![]);
let start = match gesture {
"resize" => before.right_bottom() + vec2(2.0, 2.0),
"select" => before.center(),
_ => before.center_top() + vec2(0.0, 12.0),
};
let end = if gesture == "click" {
start
} else {
start + vec2(50.0, 40.0)
};
let button = |pos, pressed| egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
};
render_float(
&ctx,
workspace,
&config,
vec![egui::Event::PointerMoved(start)],
);
render_float(&ctx, workspace, &config, vec![button(start, true)]);
if gesture != "click" {
render_float(
&ctx,
workspace,
&config,
vec![egui::Event::PointerMoved(end)],
);
}
render_float(&ctx, workspace, &config, vec![button(end, false)]);
let after = render_float(&ctx, workspace, &config, vec![]);
let manual = matches!(gesture, "move" | "resize");
assert_eq!(
ctx.data_mut(|data| data
.get_temp::<FloatPlacement>(window_id.with("placement"))
.unwrap()
.manual),
manual,
"{gesture}"
);
match gesture {
"move" => {
assert_eq!(after.min, before.min + vec2(50.0, 40.0));
assert_eq!(after.size(), before.size());
}
"resize" => assert_ne!(after.size(), before.size()),
_ => assert_eq!(after, before),
}
// Hidden and zoomed floats both skip rendering their window.
let _ = ctx.run(egui::RawInput::default(), |_| {});
config.floating_width = 0.4;
config.floating_height = 0.4;
let workspace = Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 1000.0));
for _ in 0..3 {
render_float(&ctx, workspace, &config, vec![]);
}
let restored = render_float(&ctx, workspace, &config, vec![]);
let (pos, size) = float_placement(workspace, 0, &config);
assert_eq!(
restored,
if manual {
after
} else {
Rect::from_min_size(pos, size)
},
"{gesture}"
);
}
}
#[test] #[test]
fn floats_open_centered_then_cascade_and_stay_within_the_maximums() { fn floats_open_centered_then_cascade_and_stay_within_the_maximums() {
let config = config::Multiplexer::default(); let config = config::Multiplexer::default();
@@ -762,9 +1408,19 @@ mod tests {
second.x > first.x && second.y > first.y, second.x > first.x && second.y > first.y,
"later floats step off it" "later floats step off it"
); );
// Six slots later the cascade is back where it started, so it cannot walk away. // Each group of six restarts the cascade slightly to the right.
assert_eq!(float_placement(workspace, 6, &config).0, first); assert_eq!(
assert_eq!(float_placement(workspace, 7, &config).0, second); float_placement(workspace, 6, &config).0,
first + vec2(12.0, 0.0)
);
assert_eq!(
float_placement(workspace, 7, &config).0,
second + vec2(12.0, 0.0)
);
assert_eq!(
float_placement(workspace, 12, &config).0,
first + vec2(24.0, 0.0)
);
// A small workspace keeps the fractions instead of the maximums. // A small workspace keeps the fractions instead of the maximums.
let small = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0)); let small = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0));
+40 -1
View File
@@ -21,6 +21,7 @@ pub struct Config {
pub font_bold_italic_path: Option<PathBuf>, pub font_bold_italic_path: Option<PathBuf>,
pub font_fallbacks: Vec<String>, pub font_fallbacks: Vec<String>,
pub scrollback: usize, pub scrollback: usize,
pub scrollback_editor: Option<String>,
pub window_width: f32, pub window_width: f32,
pub window_height: f32, pub window_height: f32,
pub theme: String, pub theme: String,
@@ -33,6 +34,15 @@ pub struct Config {
pub kitty: Kitty, pub kitty: Kitty,
pub multiplexer: Multiplexer, pub multiplexer: Multiplexer,
pub keys: BTreeMap<String, Action>, pub keys: BTreeMap<String, Action>,
pub plugins: Vec<Plugin>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Plugin {
pub id: String,
/// JavaScript bundle path, relative to the config file.
pub path: PathBuf,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
@@ -54,7 +64,7 @@ pub struct Multiplexer {
pub floating_max_height: f32, pub floating_max_height: f32,
} }
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, ts_rs::TS)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum Action { pub enum Action {
SplitRight, SplitRight,
@@ -77,6 +87,7 @@ pub enum Action {
ReloadConfig, ReloadConfig,
Copy, Copy,
Paste, Paste,
OpenScrollback,
} }
impl Default for Kitty { impl Default for Kitty {
@@ -115,6 +126,7 @@ impl Default for Config {
font_bold_italic_path: None, font_bold_italic_path: None,
font_fallbacks: Vec::new(), font_fallbacks: Vec::new(),
scrollback: 10000, scrollback: 10000,
scrollback_editor: None,
window_width: 1100.0, window_width: 1100.0,
window_height: 720.0, window_height: 720.0,
theme: theme::DEFAULT_NAME.into(), theme: theme::DEFAULT_NAME.into(),
@@ -124,6 +136,7 @@ impl Default for Config {
colors: BTreeMap::new(), colors: BTreeMap::new(),
kitty: Kitty::default(), kitty: Kitty::default(),
multiplexer: Multiplexer::default(), multiplexer: Multiplexer::default(),
plugins: Vec::new(),
keys: [ keys: [
("Alt+F", Action::ToggleFloats), ("Alt+F", Action::ToggleFloats),
("Alt+M", Action::NewPane), ("Alt+M", Action::NewPane),
@@ -135,6 +148,7 @@ impl Default for Config {
("Alt+Shift+H", Action::PreviousTab), ("Alt+Shift+H", Action::PreviousTab),
("Alt+Shift+L", Action::NextTab), ("Alt+Shift+L", Action::NextTab),
("Alt+X", Action::ClosePane), ("Alt+X", Action::ClosePane),
("Alt+S", Action::OpenScrollback),
("Ctrl+Shift+Z", Action::ZoomPane), ("Ctrl+Shift+Z", Action::ZoomPane),
("Ctrl+Shift+R", Action::ReloadConfig), ("Ctrl+Shift+R", Action::ReloadConfig),
("Ctrl+Shift+C", Action::Copy), ("Ctrl+Shift+C", Action::Copy),
@@ -170,6 +184,18 @@ impl Config {
pub fn parse(text: &str) -> Result<Self> { pub fn parse(text: &str) -> Result<Self> {
let config: Self = toml::from_str(text)?; let config: Self = toml::from_str(text)?;
let mut plugin_ids = std::collections::HashSet::new();
for plugin in &config.plugins {
ensure!(
crate::plugins::valid_id(&plugin.id) && plugin_ids.insert(&plugin.id),
"plugin ids must be unique and contain only letters, digits, '.', '-' or '_'"
);
ensure!(
!plugin.path.as_os_str().is_empty(),
"plugin {} needs a bundle path",
plugin.id
);
}
ensure!( ensure!(
config.font_path.is_none() || config.font_family.is_none(), config.font_path.is_none() || config.font_family.is_none(),
"set either font_family or font_path, not both" "set either font_family or font_path, not both"
@@ -186,6 +212,14 @@ impl Config {
!config.shell.is_empty() && !config.shell[0].is_empty(), !config.shell.is_empty() && !config.shell[0].is_empty(),
"shell must contain an executable" "shell must contain an executable"
); );
if let Some(editor) = &config.scrollback_editor {
let command =
shell_words::split(editor).context("Invalid scrollback_editor command")?;
ensure!(
!command.is_empty() && !command[0].is_empty(),
"scrollback_editor must contain an executable"
);
}
ensure!( ensure!(
(8.0..=48.0).contains(&config.font_size), (8.0..=48.0).contains(&config.font_size),
"font_size must be 8..48" "font_size must be 8..48"
@@ -307,6 +341,10 @@ mod tests {
"font_fallbacks = [' ']", "font_fallbacks = [' ']",
"font_family = 'Test'\nfont_path = '/font.ttf'", "font_family = 'Test'\nfont_path = '/font.ttf'",
"shell = []", "shell = []",
"scrollback_editor = ''",
"scrollback_editor = ' '",
"scrollback_editor = \"''\"",
"scrollback_editor = \"'\"",
"background = '#xx0000'", "background = '#xx0000'",
"font_sze = 16", "font_sze = 16",
"[keys]\n'Hyper+Q' = 'close_pane'", "[keys]\n'Hyper+Q' = 'close_pane'",
@@ -330,6 +368,7 @@ mod tests {
("Alt+Shift+H", Action::PreviousTab), ("Alt+Shift+H", Action::PreviousTab),
("Alt+Shift+L", Action::NextTab), ("Alt+Shift+L", Action::NextTab),
("Alt+X", Action::ClosePane), ("Alt+X", Action::ClosePane),
("Alt+S", Action::OpenScrollback),
] { ] {
assert_eq!(config.keys.get(binding), Some(&action)); assert_eq!(config.keys.get(binding), Some(&action));
assert!(shortcut(binding).unwrap().modifiers.alt); assert!(shortcut(binding).unwrap().modifiers.alt);
+21 -2
View File
@@ -1,6 +1,25 @@
use anyhow::Result; use anyhow::Result;
use libghostty_vt::{Terminal, key}; use libghostty_vt::{Terminal, key};
/// Avoid libghostty-vt 0.2.1's encode_to_vec capacity calculation, which can
/// reserve too little when the destination already has some spare capacity.
pub fn append_encoded(
out: &mut Vec<u8>,
mut encode: impl FnMut(&mut [u8]) -> libghostty_vt::error::Result<usize>,
) -> Result<()> {
let mut buffer = [0; 128];
match encode(&mut buffer) {
Ok(written) => out.extend_from_slice(&buffer[..written]),
Err(libghostty_vt::Error::OutOfSpace { required }) => {
let mut buffer = vec![0; required];
let written = encode(&mut buffer)?;
out.extend_from_slice(&buffer[..written]);
}
Err(error) => return Err(error.into()),
}
Ok(())
}
pub fn mods(m: egui::Modifiers) -> key::Mods { pub fn mods(m: egui::Modifiers) -> key::Mods {
let mut out = key::Mods::empty(); let mut out = key::Mods::empty();
if m.ctrl { if m.ctrl {
@@ -71,7 +90,7 @@ pub fn encode(terminal: &Terminal<'_, '_>, events: &[egui::Event]) -> Result<Vec
}) })
.set_unshifted_codepoint(ucp) .set_unshifted_codepoint(ucp)
.set_utf8(text); .set_utf8(text);
encoder.encode_to_vec(&event, &mut out)?; append_encoded(&mut out, |buffer| encoder.encode(&event, buffer))?;
} }
egui::Event::Text(text) if !used_text.contains(&index) => { egui::Event::Text(text) if !used_text.contains(&index) => {
event event
@@ -81,7 +100,7 @@ pub fn encode(terminal: &Terminal<'_, '_>, events: &[egui::Event]) -> Result<Vec
.set_consumed_mods(key::Mods::empty()) .set_consumed_mods(key::Mods::empty())
.set_unshifted_codepoint('\0') .set_unshifted_codepoint('\0')
.set_utf8(Some(text.as_str())); .set_utf8(Some(text.as_str()));
encoder.encode_to_vec(&event, &mut out)?; append_encoded(&mut out, |buffer| encoder.encode(&event, buffer))?;
} }
egui::Event::Ime(egui::ImeEvent::Commit(text)) => { egui::Event::Ime(egui::ImeEvent::Commit(text)) => {
out.extend_from_slice(text.as_bytes()) out.extend_from_slice(text.as_bytes())
+145 -6
View File
@@ -152,6 +152,8 @@ pub struct Tab {
pub floats: Vec<PaneId>, pub floats: Vec<PaneId>,
pub active: PaneId, pub active: PaneId,
pub floats_visible: bool, pub floats_visible: bool,
// Remember both focus and zoom while floating panes are hidden.
last_active_float: Option<(PaneId, bool)>,
pub zoomed: bool, pub zoomed: bool,
} }
@@ -163,6 +165,7 @@ impl Tab {
floats: vec![], floats: vec![],
active: pane, active: pane,
floats_visible: false, floats_visible: false,
last_active_float: None,
zoomed: false, zoomed: false,
} }
} }
@@ -177,6 +180,9 @@ impl Tab {
pub fn detach(&mut self, id: PaneId) { pub fn detach(&mut self, id: PaneId) {
self.root = self.root.take().and_then(|r| r.remove(id)); self.root = self.root.take().and_then(|r| r.remove(id));
self.floats.retain(|p| *p != id); self.floats.retain(|p| *p != id);
if self.last_active_float.is_some_and(|(pane, _)| pane == id) {
self.last_active_float = None;
}
if self.floats.is_empty() { if self.floats.is_empty() {
self.floats_visible = false; self.floats_visible = false;
} }
@@ -199,19 +205,32 @@ impl Tab {
pub fn toggle_floats(&mut self) { pub fn toggle_floats(&mut self) {
self.floats_visible = !self.floats_visible; self.floats_visible = !self.floats_visible;
self.zoomed = false; let was_zoomed = std::mem::take(&mut self.zoomed);
if self.floats_visible { if self.floats_visible {
if let Some(id) = self.floats.last() { if let Some((id, zoomed)) = self
self.active = *id; .last_active_float
} .filter(|(id, _)| self.floats.contains(id))
} else if self.floats.contains(&self.active) .or_else(|| self.floats.last().map(|id| (*id, false)))
&& let Some(root) = &self.root
{ {
self.active = id;
self.zoomed = zoomed;
}
} else if self.floats.contains(&self.active) {
self.last_active_float = Some((self.active, was_zoomed));
if let Some(root) = &self.root {
self.active = root.first(); self.active = root.first();
} }
} }
}
pub fn focus_direction(&mut self, direction: Direction, rects: &[(PaneId, Rect)]) { pub fn focus_direction(&mut self, direction: Direction, rects: &[(PaneId, Rect)]) {
if self.floats_visible
&& !self.floats.is_empty()
&& matches!(direction, Direction::Down | Direction::Up)
{
self.cycle(matches!(direction, Direction::Up));
return;
}
let Some((_, source)) = rects let Some((_, source)) = rects
.iter() .iter()
.find(|(id, _)| *id == self.active && self.is_visible(*id)) .find(|(id, _)| *id == self.active && self.is_visible(*id))
@@ -322,6 +341,100 @@ mod tests {
assert!(tab.is_visible(2)); assert!(tab.is_visible(2));
} }
#[test]
fn showing_floats_restores_the_pane_active_when_hidden() {
let mut tab = Tab::new(1, 1);
tab.root.as_mut().unwrap().split(1, 2, Axis::Horizontal);
tab.floats = vec![3, 4, 5];
tab.toggle_floats();
assert_eq!(tab.active, 5);
tab.cycle(true);
assert_eq!(tab.active, 4);
tab.toggle_floats();
assert_eq!(tab.active, 1);
tab.cycle(false);
assert_eq!(tab.active, 2);
tab.toggle_floats();
assert_eq!(tab.active, 4);
tab.cycle(true);
tab.toggle_floats();
tab.toggle_floats();
assert_eq!(tab.active, 3);
tab.root = None;
tab.toggle_floats();
tab.toggle_floats();
assert_eq!(tab.active, 3);
}
#[test]
fn showing_floats_falls_back_when_remembered_pane_is_detached() {
for dock in [false, true] {
let mut tab = Tab::new(1, 1);
tab.floats = vec![2, 3];
tab.floats_visible = true;
tab.active = 2;
tab.zoomed = true;
tab.toggle_floats();
tab.detach(2);
if dock {
tab.root.as_mut().unwrap().split(1, 2, Axis::Horizontal);
}
tab.toggle_floats();
assert_eq!(tab.active, 3);
assert!(!tab.zoomed);
}
}
#[test]
fn showing_floats_restores_zoom_without_zooming_tiles() {
let mut tab = Tab::new(1, 1);
tab.root.as_mut().unwrap().split(1, 2, Axis::Horizontal);
tab.floats = vec![3, 4];
tab.floats_visible = true;
tab.active = 3;
tab.zoomed = true;
for _ in 0..2 {
tab.toggle_floats();
assert_eq!(tab.active, 1);
assert!(!tab.zoomed);
assert!(!tab.is_visible(3));
tab.cycle(false);
assert_eq!(tab.active, 2);
tab.toggle_floats();
assert_eq!(tab.active, 3);
assert!(tab.zoomed);
}
// Unzooming the float replaces its remembered zoom, even if tiles zoom.
tab.zoomed = false;
tab.toggle_floats();
tab.zoomed = true;
tab.toggle_floats();
assert_eq!(tab.active, 3);
assert!(!tab.zoomed);
}
#[test]
fn floating_zoom_survives_hiding_without_tiles() {
let mut tab = Tab::new(1, 1);
tab.root = None;
tab.floats = vec![1];
tab.floats_visible = true;
tab.zoomed = true;
tab.toggle_floats();
assert!(!tab.is_visible(1));
assert!(!tab.zoomed);
tab.toggle_floats();
assert_eq!(tab.active, 1);
assert!(tab.is_visible(1));
assert!(tab.zoomed);
}
#[test] #[test]
fn directional_focus_uses_geometry_and_ignores_hidden_or_foreign_panes() { fn directional_focus_uses_geometry_and_ignores_hidden_or_foreign_panes() {
let mut tab = Tab::new(1, 1); let mut tab = Tab::new(1, 1);
@@ -375,6 +488,30 @@ mod tests {
); );
} }
#[test]
fn up_and_down_cycle_visible_floats_without_geometry() {
let mut tab = Tab::new(1, 1);
tab.floats = vec![2, 3, 4];
tab.floats_visible = true;
for (direction, expected) in [
(Direction::Down, 2),
(Direction::Down, 3),
(Direction::Down, 4),
(Direction::Down, 2),
(Direction::Up, 4),
(Direction::Up, 3),
(Direction::Up, 2),
] {
tab.focus_direction(direction, &[]);
assert_eq!(tab.active, expected);
}
tab.toggle_floats();
assert_eq!(tab.active, 1);
tab.toggle_floats();
assert_eq!(tab.active, 2);
}
#[test] #[test]
fn visible_floats_constrain_directional_and_cyclic_navigation() { fn visible_floats_constrain_directional_and_cyclic_navigation() {
let mut tab = Tab::new(1, 1); let mut tab = Tab::new(1, 1);
@@ -395,6 +532,8 @@ mod tests {
(Direction::Right, 4), (Direction::Right, 4),
(Direction::Down, 5), (Direction::Down, 5),
(Direction::Left, 6), (Direction::Left, 6),
(Direction::Up, 5),
(Direction::Up, 4),
(Direction::Up, 3), (Direction::Up, 3),
] { ] {
tab.focus_direction(direction, &rects); tab.focus_direction(direction, &rects);
+10 -1
View File
@@ -4,6 +4,8 @@ mod fonts;
mod input; mod input;
mod layout; mod layout;
mod pane; mod pane;
mod plugin_api;
mod plugins;
mod pty; mod pty;
mod render; mod render;
#[cfg(test)] #[cfg(test)]
@@ -29,13 +31,20 @@ fn main() -> Result<()> {
} }
return Ok(()); return Ok(());
} }
"--generate-plugin-sdk" => {
let target =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("sdk/src/generated.ts");
std::fs::write(&target, plugin_api::typescript())?;
println!("Generated {}", target.display());
return Ok(());
}
"--version" => { "--version" => {
println!("slerm {}", env!("CARGO_PKG_VERSION")); println!("slerm {}", env!("CARGO_PKG_VERSION"));
return Ok(()); return Ok(());
} }
"--help" | "-h" => { "--help" | "-h" => {
println!( println!(
"slerm [--config PATH] [--check-config | --init-config | --list-themes]\n\nDefault config: {}", "slerm [--config PATH] [--check-config | --init-config | --list-themes | --generate-plugin-sdk]\n\nDefault config: {}",
path.display() path.display()
); );
return Ok(()); return Ok(());
+277 -14
View File
@@ -5,7 +5,7 @@ use crate::{
render::{Grid, Renderer, cell_size}, render::{Grid, Renderer, cell_size},
theme::{self, Theme}, theme::{self, Theme},
}; };
use anyhow::{Result, ensure}; use anyhow::{Context, Result, ensure};
use egui::{FontId, Rect, Sense, vec2}; use egui::{FontId, Rect, Sense, vec2};
use libghostty_vt::{ use libghostty_vt::{
Terminal, TerminalOptions, mouse, Terminal, TerminalOptions, mouse,
@@ -31,8 +31,193 @@ pub struct Pane {
callback_error: Rc<RefCell<Option<String>>>, callback_error: Rc<RefCell<Option<String>>>,
mouse_encoder: mouse::Encoder<'static>, mouse_encoder: mouse::Encoder<'static>,
mouse_event: mouse::Event<'static>, mouse_event: mouse::Event<'static>,
scrollback_scroll: ScrollAccumulator,
mouse_scroll: ScrollAccumulator,
selection_start: Option<PointCoordinate>, selection_start: Option<PointCoordinate>,
focused: bool, focused: bool,
saved_session: Option<(Box<Pane>, ScrollbackFile)>,
}
struct ScrollbackFile(PathBuf);
#[derive(Default)]
struct ScrollAccumulator(f32);
impl ScrollAccumulator {
fn rows(&mut self, delta: f32) -> isize {
self.0 += delta;
let rows = self.0.trunc();
self.0 -= rows;
rows as isize
}
fn wheel(&mut self, unit: egui::MouseWheelUnit, delta: f32, cell: f32, page: u16) -> isize {
self.rows(match unit {
egui::MouseWheelUnit::Point => delta / cell,
egui::MouseWheelUnit::Line => delta * 3.0,
egui::MouseWheelUnit::Page => delta * f32::from(page),
})
}
}
impl ScrollbackFile {
fn new(bytes: &[u8]) -> Result<Self> {
use std::{
fs::OpenOptions,
io::Write,
os::unix::fs::OpenOptionsExt,
sync::atomic::{AtomicU64, Ordering},
};
static NEXT: AtomicU64 = AtomicU64::new(0);
loop {
let path = std::env::temp_dir().join(format!(
"slerm-scrollback-{}-{}.txt",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
match OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
{
Ok(mut file) => {
let temporary = Self(path);
file.write_all(bytes)?;
return Ok(temporary);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error.into()),
}
}
}
}
#[cfg(test)]
mod scroll_tests {
use super::*;
#[test]
fn precise_scrolling_preserves_distance_across_frames() {
let mut scroll = ScrollAccumulator::default();
// Sub-pixel events from a slow gesture must eventually move a row.
let rows: isize = (0..128).map(|_| scroll.rows(0.25 / 16.0)).sum();
assert_eq!(rows, 2);
assert_eq!(scroll.0, 0.0);
let mut batched = ScrollAccumulator::default();
assert_eq!(batched.rows(32.0 / 16.0), rows);
assert_eq!(scroll.rows(1.75), 1);
assert_eq!(scroll.rows(0.25), 1);
assert_eq!(scroll.rows(-0.75), 0);
assert_eq!(scroll.rows(-0.25), -1);
assert_eq!(scroll.rows(0.75), 0);
assert_eq!(scroll.rows(-0.75), 0);
}
#[test]
fn mouse_tracking_respects_precise_distance_and_wheel_units() {
use egui::MouseWheelUnit::{Line, Page, Point};
let mut scroll = ScrollAccumulator::default();
assert_eq!(scroll.wheel(Point, 0.0, 16.0, 24), 0);
assert_eq!(scroll.wheel(Point, 4.0, 16.0, 24), 0);
assert_eq!(scroll.wheel(Point, 12.0, 16.0, 24), 1);
assert_eq!(scroll.wheel(Point, -32.0, 16.0, 24), -2);
assert_eq!(scroll.wheel(Line, 1.0, 16.0, 24), 3);
assert_eq!(scroll.wheel(Page, -1.0, 16.0, 24), -24);
}
}
#[cfg(test)]
mod scrollback_tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn editor_receives_history_and_restores_original_session_on_exit() {
let ctx = egui::Context::default();
let config = Config {
shell: vec![
"/bin/sh".into(),
"-c".into(),
"read answer; printf ORIGINAL_ALIVE; read answer".into(),
],
scrollback_editor: Config::parse(
"scrollback_editor = \"/bin/sh -c 'printf EDITOR_READY; read answer' editor\"",
)
.unwrap()
.scrollback_editor,
..Default::default()
};
let mut pane = Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
pane.terminal.resize(12, 2, 8, 16).unwrap();
pane.terminal
.vt_write(b"history\r\n\x1b[31mred\x1b[0m\r\nlong line that wraps\r\nlast");
pane.open_scrollback(&config, &theme::DEFAULT, &ctx)
.unwrap();
let (original, file) = pane.saved_session.as_ref().unwrap();
assert!(!original.dead);
let path = file.0.clone();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(
contents.contains("history\nred\nlong line that wraps\nlast"),
"{contents:?}"
);
assert!(!contents.contains('\x1b'));
pane.send(b"done\n".to_vec()).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while pane.saved_session.is_some() && Instant::now() < deadline {
pane.drain(&ctx);
std::thread::sleep(Duration::from_millis(10));
}
assert!(pane.saved_session.is_none());
assert!(!pane.dead);
assert!(!path.exists());
pane.send(b"continue\n".to_vec()).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
pane.drain(&ctx);
let text = libghostty_vt::fmt::Formatter::new(
&pane.terminal,
libghostty_vt::fmt::FormatterOptions::new().with_unwrap(true),
)
.unwrap()
.format_alloc(None)
.unwrap();
if String::from_utf8_lossy(&text).contains("ORIGINAL_ALIVE") {
break;
}
assert!(
Instant::now() < deadline,
"original process did not resume input"
);
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn invalid_editor_leaves_the_original_pane_intact() {
let ctx = egui::Context::default();
let config = Config {
shell: vec!["/bin/sh".into()],
..Default::default()
};
let mut pane = Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
for editor in ["", "'", "/no/such/slerm-editor"] {
assert!(
pane.open_scrollback_with_editor(&config, &theme::DEFAULT, &ctx, editor)
.is_err()
);
assert!(pane.saved_session.is_none());
assert!(!pane.dead);
}
}
}
impl Drop for ScrollbackFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
} }
/// OSC 7 carries a `file://host/path` URL. Only a directory on this machine can be /// OSC 7 carries a `file://host/path` URL. Only a directory on this machine can be
@@ -100,6 +285,8 @@ pub fn terminal(config: &Config, theme: &Theme) -> Result<Terminal<'static, 'sta
})?; })?;
term.resize(80, 24, 8, 16)?; term.resize(80, 24, 8, 16)?;
apply_config(&mut term, config, theme)?; apply_config(&mut term, config, theme)?;
term.set_default_cursor_style(Some(terminal::CursorStyle::BlockHollow))?;
term.vt_write(b"\x1b[0 q");
term.on_xtversion(|_| Some(concat!("slerm ", env!("CARGO_PKG_VERSION"))))?; term.on_xtversion(|_| Some(concat!("slerm ", env!("CARGO_PKG_VERSION"))))?;
term.on_device_attributes(|_| { term.on_device_attributes(|_| {
Some(terminal::DeviceAttributes { Some(terminal::DeviceAttributes {
@@ -177,12 +364,18 @@ impl Pane {
callback_error, callback_error,
mouse_encoder, mouse_encoder,
mouse_event, mouse_event,
scrollback_scroll: ScrollAccumulator::default(),
mouse_scroll: ScrollAccumulator::default(),
selection_start: None, selection_start: None,
focused: false, focused: false,
saved_session: None,
}) })
} }
pub fn drain(&mut self, ctx: &egui::Context) { pub fn drain(&mut self, ctx: &egui::Context) {
if let Some((session, _)) = &mut self.saved_session {
session.drain(ctx);
}
// Bound work per frame as well as queued bytes, so one busy PTY cannot starve others. // Bound work per frame as well as queued bytes, so one busy PTY cannot starve others.
let mut drained = 0; let mut drained = 0;
for _ in 0..64 { for _ in 0..64 {
@@ -208,6 +401,66 @@ impl Pane {
if drained == 64 { if drained == 64 {
ctx.request_repaint(); ctx.request_repaint();
} }
if self.dead
&& let Some((session, _file)) = self.saved_session.take()
{
*self = *session;
ctx.request_repaint();
}
}
pub fn open_scrollback(
&mut self,
config: &Config,
theme: &Theme,
ctx: &egui::Context,
) -> Result<()> {
if self.saved_session.is_some() {
return Ok(());
}
let editor = config
.scrollback_editor
.clone()
.unwrap_or_else(|| std::env::var("EDITOR").unwrap_or_else(|_| "vi".into()));
self.open_scrollback_with_editor(config, theme, ctx, &editor)
}
fn open_scrollback_with_editor(
&mut self,
config: &Config,
theme: &Theme,
ctx: &egui::Context,
editor: &str,
) -> Result<()> {
use libghostty_vt::fmt::{Formatter, FormatterOptions};
let mut command =
shell_words::split(editor).context("Invalid scrollback editor command")?;
ensure!(
!command.is_empty() && !command[0].is_empty(),
"Scrollback editor command is empty"
);
self.drain(ctx);
let bytes = Formatter::new(
&self.terminal,
FormatterOptions::new().with_trim(true).with_unwrap(true),
)?
.format_alloc(None)?;
let file = ScrollbackFile::new(&bytes).context("Cannot save scrollback")?;
command.push(
file.0
.to_str()
.context("Scrollback path is not valid UTF-8")?
.into(),
);
let mut editor_config = config.clone();
editor_config.shell = command;
let mut editor_pane = Self::new(&editor_config, theme, ctx, self.cwd().as_deref())
.context("Cannot open scrollback editor")?;
editor_pane.last_rect = self.last_rect;
let session = std::mem::replace(self, editor_pane);
self.saved_session = Some((Box::new(session), file));
ctx.request_repaint();
Ok(())
} }
pub fn send(&mut self, bytes: Vec<u8>) -> Result<()> { pub fn send(&mut self, bytes: Vec<u8>) -> Result<()> {
@@ -220,6 +473,7 @@ impl Pane {
.try_send(bytes) .try_send(bytes)
.map_err(|e| anyhow::anyhow!("PTY input queue: {e}"))?; .map_err(|e| anyhow::anyhow!("PTY input queue: {e}"))?;
self.terminal.scroll_viewport(ScrollViewport::Bottom); self.terminal.scroll_viewport(ScrollViewport::Bottom);
self.scrollback_scroll = ScrollAccumulator::default();
Ok(()) Ok(())
} }
@@ -337,6 +591,12 @@ impl Pane {
) -> Result<()> { ) -> Result<()> {
let modifiers = ui.input(|i| i.modifiers); let modifiers = ui.input(|i| i.modifiers);
let tracking = self.terminal.is_mouse_tracking()? && !modifiers.shift; let tracking = self.terminal.is_mouse_tracking()? && !modifiers.shift;
if !response.hovered() || tracking {
self.scrollback_scroll = ScrollAccumulator::default();
}
if !response.hovered() || !tracking {
self.mouse_scroll = ScrollAccumulator::default();
}
let pos = response let pos = response
.interact_pointer_pos() .interact_pointer_pos()
.or_else(|| ui.input(|i| i.pointer.hover_pos())); .or_else(|| ui.input(|i| i.pointer.hover_pos()));
@@ -385,8 +645,9 @@ impl Pane {
mouse::Action::Release mouse::Action::Release
}) })
.set_button(Some(button)); .set_button(Some(button));
self.mouse_encoder input::append_encoded(&mut data, |buffer| {
.encode_to_vec(&self.mouse_event, &mut data)?; self.mouse_encoder.encode(&self.mouse_event, buffer)
})?;
} }
egui::Event::PointerMoved(_) => { egui::Event::PointerMoved(_) => {
let button = ui.input(|i| { let button = ui.input(|i| {
@@ -401,20 +662,23 @@ impl Pane {
self.mouse_event self.mouse_event
.set_action(mouse::Action::Motion) .set_action(mouse::Action::Motion)
.set_button(button); .set_button(button);
self.mouse_encoder input::append_encoded(&mut data, |buffer| {
.encode_to_vec(&self.mouse_event, &mut data)?; self.mouse_encoder.encode(&self.mouse_event, buffer)
})?;
} }
egui::Event::MouseWheel { delta, .. } => { egui::Event::MouseWheel { unit, delta, .. } => {
let rows = self.mouse_scroll.wheel(unit, delta.y, cell.y, self.size.1);
self.mouse_event self.mouse_event
.set_action(mouse::Action::Press) .set_action(mouse::Action::Press)
.set_button(Some(if delta.y > 0.0 { .set_button(Some(if rows > 0 {
mouse::Button::Four mouse::Button::Four
} else { } else {
mouse::Button::Five mouse::Button::Five
})); }));
for _ in 0..3 { for _ in 0..rows.unsigned_abs() {
self.mouse_encoder input::append_encoded(&mut data, |buffer| {
.encode_to_vec(&self.mouse_event, &mut data)?; self.mouse_encoder.encode(&self.mouse_event, buffer)
})?;
} }
} }
_ => {} _ => {}
@@ -447,10 +711,9 @@ impl Pane {
} }
if response.hovered() { if response.hovered() {
let scroll = ui.input(|i| i.raw_scroll_delta.y); let scroll = ui.input(|i| i.raw_scroll_delta.y);
if scroll.abs() >= 1.0 { let rows = self.scrollback_scroll.rows(scroll / cell.y);
self.terminal.scroll_viewport(ScrollViewport::Delta( if rows != 0 {
-(scroll / cell.y).round() as isize, self.terminal.scroll_viewport(ScrollViewport::Delta(-rows));
));
} }
} }
} }
+163
View File
@@ -0,0 +1,163 @@
//! The source of truth for the plugin API and its generated TypeScript bindings.
use crate::config::Action;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
pub const API_VERSION: u32 = 1;
// Declare each method, its parameters and its result once. The enum drives Rust
// dispatch; the generated result map drives the TypeScript client's signatures.
macro_rules! plugin_api {
($($variant:ident => $method:ident { $($(#[$attr:meta])* $arg:ident: $ty:ty),* } -> $result:ty;)+) => {
#[derive(Debug, Deserialize, TS)]
#[serde(tag = "method", rename_all = "camelCase", deny_unknown_fields)]
pub enum Operation {
$($variant { $($(#[$attr])* $arg: $ty),* },)+
}
#[derive(TS)]
#[allow(dead_code, non_snake_case)]
pub struct Results { $(pub $method: $result,)+ }
};
}
plugin_api! {
GetState => getState {} -> State;
Execute => execute { action: Action } -> ();
FocusPane => focusPane { #[ts(type = "number")] pane: u64 } -> ();
FocusTab => focusTab { #[ts(type = "number")] tab: u64 } -> ();
ReadTerminal => readTerminal { #[ts(type = "number")] pane: u64 } -> String;
WriteTerminal => writeTerminal { #[ts(type = "number")] pane: u64, text: String } -> ();
RegisterCommand => registerCommand { command: PluginCommand } -> ();
UnregisterCommand => unregisterCommand { command: String } -> ();
SetPanel => setPanel { panel: Panel } -> ();
RemovePanel => removePanel { panel: String } -> ();
ReportError => reportError { message: String } -> ();
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct State {
#[ts(type = "number | null")]
pub active_tab: Option<u64>,
pub tabs: Vec<TabState>,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct TabState {
#[ts(type = "number")]
pub id: u64,
#[ts(type = "number")]
pub active_pane: u64,
pub floats_visible: bool,
pub zoomed: bool,
pub panes: Vec<PaneState>,
}
#[derive(Clone, Debug, Serialize, TS)]
pub struct PaneState {
#[ts(type = "number")]
pub id: u64,
pub title: String,
pub floating: bool,
pub exited: bool,
}
#[derive(Clone, Debug, Deserialize, TS)]
#[serde(deny_unknown_fields)]
pub struct PluginCommand {
pub id: String,
pub title: String,
}
#[derive(Clone, Debug, Deserialize, TS)]
#[serde(deny_unknown_fields)]
pub struct Panel {
pub id: String,
pub title: String,
pub elements: Vec<Element>,
}
#[derive(Clone, Debug, Deserialize, TS)]
#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
pub enum Element {
Text {
text: String,
},
Button {
id: String,
label: String,
},
Input {
id: String,
label: String,
value: String,
},
Separator,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum UIEvent {
Click {
panel: String,
control: String,
},
Change {
panel: String,
control: String,
value: String,
},
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(tag = "event", rename_all = "camelCase")]
pub enum Event {
Command { command: String },
State { state: State },
Ui { data: UIEvent },
}
pub fn typescript() -> String {
let mut output =
String::from("// Generated by cargo run -- --generate-plugin-sdk. Do not edit.\n\n");
output.push_str(&format!(
"export const apiVersion = {API_VERSION} as const;\n\n"
));
let config = ts_rs::Config::default();
for declaration in [
Action::decl(&config),
Operation::decl(&config),
Results::decl(&config),
State::decl(&config),
TabState::decl(&config),
PaneState::decl(&config),
PluginCommand::decl(&config),
Panel::decl(&config),
Element::decl(&config),
UIEvent::decl(&config),
Event::decl(&config),
] {
output.push_str("export ");
output.push_str(&declaration);
output.push_str("\n\n");
}
output.push_str(r#"type Params<M extends Operation["method"]> = Omit<Extract<Operation, { method: M }>, "method">;
export type Slerm = {
[M in Operation["method"]]: (...args: keyof Params<M> extends never ? [] : [params: Params<M>]) => Promise<Results[M] extends null ? void : Results[M]>;
};
"#);
output
}
#[cfg(test)]
mod tests {
#[test]
fn generated_sdk_matches_rust_api() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("sdk/src/generated.ts");
assert_eq!(
std::fs::read_to_string(path).unwrap(),
super::typescript(),
"Regenerate bindings with cargo run -- --generate-plugin-sdk"
);
}
}
+457
View File
@@ -0,0 +1,457 @@
//! Embedded Boa runtimes, one worker thread per plugin. UI mutations stay on the UI thread.
use crate::{
config::Plugin,
plugin_api::{API_VERSION, Element, Event, Operation, Panel, PluginCommand, UIEvent},
};
use anyhow::{Context as _, Result, ensure};
use boa_engine::{
Context, JsNativeError, JsResult, JsValue, NativeFunction, Source,
job::{Job, JobExecutor},
js_string,
property::Attribute,
};
use serde_json::Value;
use std::{
cell::RefCell,
collections::{BTreeMap, VecDeque},
path::Path,
rc::Rc,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{self, Receiver, SyncSender, TryRecvError},
},
thread,
time::{Duration, Instant},
};
const MAX_MESSAGE: usize = 1024 * 1024;
pub fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 80
&& id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c))
}
pub struct Pending {
pub operation: Operation,
pub reply: SyncSender<Value>,
}
struct Bridge {
requests: SyncSender<Result<Pending, String>>,
repaint: egui::Context,
stopped: Arc<AtomicBool>,
}
fn host_call(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let text = args
.first()
.unwrap_or(&JsValue::undefined())
.to_string(ctx)?
.to_std_string_escaped();
if text.len() > MAX_MESSAGE {
return Err(JsNativeError::range()
.with_message("plugin request exceeds 1 MiB")
.into());
}
let operation = serde_json::from_str(&text)
.map_err(|e| JsNativeError::typ().with_message(e.to_string()))?;
let bridge = ctx.get_data::<Bridge>().unwrap();
let (reply, response) = mpsc::sync_channel(1);
bridge
.requests
.try_send(Ok(Pending { operation, reply }))
.map_err(|_| JsNativeError::error().with_message("plugin host is unavailable"))?;
bridge.repaint.request_repaint();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if bridge.stopped.load(Ordering::Relaxed) || Instant::now() >= deadline {
return Err(JsNativeError::error()
.with_message("plugin stopped or host request timed out")
.into());
}
match response.recv_timeout(Duration::from_millis(50)) {
Ok(value) => return Ok(js_string!(serde_json::to_string(&value).unwrap()).into()),
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(_) => {
return Err(JsNativeError::error()
.with_message("plugin host disconnected")
.into());
}
}
}
}
fn host_error(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let text = args
.first()
.unwrap_or(&JsValue::undefined())
.to_string(ctx)?
.to_std_string_escaped();
let bridge = ctx.get_data::<Bridge>().unwrap();
let _ = bridge.requests.try_send(Err(text));
bridge.repaint.request_repaint();
Ok(JsValue::undefined())
}
// This runtime exposes promises, but no timers or native async jobs. Bound each
// promise checkpoint as well as JS loops, so runaway callbacks can be stopped.
struct Jobs {
queue: RefCell<VecDeque<Job>>,
stopped: Arc<AtomicBool>,
}
impl JobExecutor for Jobs {
fn enqueue_job(self: Rc<Self>, job: Job, _: &mut Context) {
self.queue.borrow_mut().push_back(job);
}
fn run_jobs(self: Rc<Self>, ctx: &mut Context) -> JsResult<()> {
for _ in 0..10_000 {
if self.stopped.load(Ordering::Relaxed) {
return Err(JsNativeError::error().with_message("plugin stopped").into());
}
let Some(job) = self.queue.borrow_mut().pop_front() else {
return Ok(());
};
match job {
Job::PromiseJob(job) => {
job.call(ctx)?;
}
Job::GenericJob(job) => {
job.call(ctx)?;
}
Job::FinalizationRegistryCleanupJob(_) => {}
_ => {
return Err(JsNativeError::error()
.with_message("unsupported plugin job")
.into());
}
}
}
Err(JsNativeError::error()
.with_message("plugin promise job limit exceeded")
.into())
}
}
fn run(source: String, events: Receiver<Event>, bridge: Bridge) -> Result<()> {
let jobs = Rc::new(Jobs {
queue: RefCell::new(VecDeque::new()),
stopped: bridge.stopped.clone(),
});
let mut ctx = Context::builder()
.job_executor(jobs)
.build()
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
ctx.runtime_limits_mut().set_loop_iteration_limit(1_000_000);
ctx.runtime_limits_mut().set_recursion_limit(128);
ctx.insert_data(bridge);
let execute = |ctx: &mut Context, source: &str| -> Result<()> {
ctx.eval(Source::from_bytes(source))
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
ctx.run_jobs().map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(())
};
ctx.register_global_builtin_callable(
js_string!("__slerm_call"),
1,
NativeFunction::from_fn_ptr(host_call),
)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
ctx.register_global_builtin_callable(
js_string!("__slerm_error"),
1,
NativeFunction::from_fn_ptr(host_error),
)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
ctx.register_global_property(
js_string!("__slerm_version"),
API_VERSION,
Attribute::READONLY,
)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
execute(&mut ctx, include_str!("../sdk/runtime.js"))?;
execute(&mut ctx, &source)?;
while let Ok(event) = events.recv() {
if ctx
.get_data::<Bridge>()
.unwrap()
.stopped
.load(Ordering::Relaxed)
{
break;
}
execute(
&mut ctx,
&format!("__slerm_dispatch({});", serde_json::to_string(&event)?),
)?;
}
Ok(())
}
pub struct Runtime {
pub id: String,
pub commands: BTreeMap<String, PluginCommand>,
pub panels: BTreeMap<String, Panel>,
pub last_state: Option<Value>,
outgoing: Option<SyncSender<Event>>,
incoming: Option<Receiver<Result<Pending, String>>>,
stopped: Arc<AtomicBool>,
}
impl Runtime {
pub fn spawn(config: &Plugin, config_dir: &Path, ctx: &egui::Context) -> Result<Self> {
let path = config_dir.join(&config.path);
let source = std::fs::read_to_string(&path)
.with_context(|| format!("Cannot read plugin {} at {}", config.id, path.display()))?;
ensure!(
source.len() <= 4 * MAX_MESSAGE,
"plugin bundle exceeds 4 MiB"
);
let (outgoing, events) = mpsc::sync_channel(64);
let (requests, incoming) = mpsc::sync_channel(64);
let stopped = Arc::new(AtomicBool::new(false));
let bridge = Bridge {
requests: requests.clone(),
repaint: ctx.clone(),
stopped: stopped.clone(),
};
let repaint = ctx.clone();
thread::Builder::new()
.name(format!("plugin-{}", config.id))
.spawn(move || {
if let Err(error) = run(source, events, bridge) {
let _ = requests.try_send(Err(error.to_string()));
repaint.request_repaint();
}
})?;
Ok(Self {
id: config.id.clone(),
commands: BTreeMap::new(),
panels: BTreeMap::new(),
last_state: None,
outgoing: Some(outgoing),
incoming: Some(incoming),
stopped,
})
}
pub fn poll(&mut self) -> Option<Result<Pending, String>> {
match self.incoming.as_ref()?.try_recv() {
Ok(request) => Some(request),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(Err("plugin runtime stopped".into())),
}
}
pub fn send(&self, event: Event) -> Result<()> {
let outgoing = self
.outgoing
.as_ref()
.ok_or_else(|| anyhow::anyhow!("plugin is stopped"))?;
ensure!(
serde_json::to_vec(&event)?.len() < MAX_MESSAGE,
"plugin event exceeds 1 MiB"
);
outgoing
.try_send(event)
.context("plugin event queue is full or disconnected")
}
pub fn is_running(&self) -> bool {
!self.stopped.load(Ordering::Relaxed)
}
pub fn stop(&mut self) {
self.stopped.store(true, Ordering::Relaxed);
self.commands.clear();
self.panels.clear();
self.outgoing.take();
self.incoming.take();
}
}
impl Drop for Runtime {
fn drop(&mut self) {
self.stop();
}
}
impl Panel {
pub fn validate(&self) -> Result<()> {
ensure!(valid_id(&self.id), "invalid panel id");
ensure!(
!self.title.trim().is_empty() && self.title.len() <= 200,
"invalid panel title"
);
ensure!(
self.elements.len() <= 128,
"a panel supports at most 128 elements"
);
let mut ids = std::collections::HashSet::new();
for element in &self.elements {
let id = match element {
Element::Button { id, .. } | Element::Input { id, .. } => Some(id),
_ => None,
};
if let Some(id) = id {
ensure!(
valid_id(id) && ids.insert(id),
"invalid or duplicate control id"
);
}
}
Ok(())
}
}
#[derive(Default)]
pub struct Host {
pub runtimes: Vec<Runtime>,
}
impl Host {
pub fn start(
configs: &[Plugin],
config_dir: &Path,
ctx: &egui::Context,
) -> (Self, Vec<String>) {
let mut host = Self::default();
let mut errors = Vec::new();
for config in configs {
match Runtime::spawn(config, config_dir, ctx) {
Ok(process) => host.runtimes.push(process),
Err(e) => errors.push(e.to_string()),
}
}
(host, errors)
}
pub fn invoke(&self, index: usize, command: &str) -> Result<()> {
let plugin = &self.runtimes[index];
ensure!(
plugin.commands.contains_key(command),
"unknown plugin command"
);
plugin.send(Event::Command {
command: command.into(),
})
}
pub fn show_panels(&mut self, ctx: &egui::Context) -> Vec<String> {
let mut errors = Vec::new();
for plugin in &mut self.runtimes {
let mut events = Vec::new();
for panel in plugin.panels.values_mut() {
egui::Window::new(&panel.title)
.id(egui::Id::new(("plugin panel", &plugin.id, &panel.id)))
.order(egui::Order::Foreground)
.default_width(280.0)
.show(ctx, |ui| {
egui::ScrollArea::vertical()
.max_height(500.0)
.show(ui, |ui| {
for element in &mut panel.elements {
match element {
Element::Text { text } => {
ui.label(text.as_str());
}
Element::Separator => {
ui.separator();
}
Element::Button { id, label } => {
if ui
.push_id(id.as_str(), |ui| {
ui.button(label.as_str())
})
.inner
.clicked()
{
events.push(Event::Ui {
data: UIEvent::Click {
panel: panel.id.clone(),
control: id.clone(),
},
});
}
}
Element::Input { id, label, value } => {
ui.label(label.as_str());
if ui
.add(
egui::TextEdit::singleline(value)
.id_source(id.as_str()),
)
.changed()
{
events.push(Event::Ui {
data: UIEvent::Change {
panel: panel.id.clone(),
control: id.clone(),
value: value.clone(),
},
});
}
}
}
}
});
});
}
for event in events {
if let Err(e) = plugin.send(event) {
errors.push(format!("{}: {e}", plugin.id));
}
}
}
errors
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn rejects_invalid_configuration_and_ui() {
assert!(crate::config::Config::parse("[[plugins]]\nid='x'\npath=''\n").is_err());
assert!(
crate::config::Config::parse(
"[[plugins]]\nid='x'\npath='a.js'\n[[plugins]]\nid='x'\npath='b.js'\n"
)
.is_err()
);
assert!(serde_json::from_str::<Operation>(r#"{"method":"unknown"}"#).is_err());
let panel: Panel =
serde_json::from_value(json!({"id":"panel", "title":"Panel", "elements":[
{"type":"button", "id":"same", "label":"One"},
{"type":"input", "id":"same", "label":"Two", "value":""}
]}))
.unwrap();
assert!(panel.validate().is_err());
}
#[test]
fn boa_reports_script_errors_and_limits_runaway_loops_and_promises() {
for source in [
"throw Error('broken plugin')",
"while (true) {}",
"function again() { Promise.resolve().then(again); } again();",
"__slerm.activate({}, 999)",
"process.exit(0)",
] {
let (_, events) = mpsc::sync_channel(1);
let (requests, _) = mpsc::sync_channel(1);
let bridge = Bridge {
requests,
repaint: egui::Context::default(),
stopped: Arc::new(AtomicBool::new(false)),
};
assert!(
run(source.into(), events, bridge).is_err(),
"source should fail: {source}"
);
}
}
}
+239 -24
View File
@@ -9,12 +9,14 @@ use libghostty_vt::{
kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator}, kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator},
render::{CellIterator, Colors, CursorVisualStyle, Dirty, RowIterator}, render::{CellIterator, Colors, CursorVisualStyle, Dirty, RowIterator},
screen::CellWide, screen::CellWide,
style::{RgbColor, Underline}, style::{RgbColor, Style, Underline},
terminal::Mode,
}; };
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
io::Cursor, io::Cursor,
sync::Arc, sync::Arc,
time::{Duration, Instant},
}; };
/// Where a pane's terminal is drawn this frame, and the colors that are slerm's /// Where a pane's terminal is drawn this frame, and the colors that are slerm's
@@ -33,11 +35,16 @@ pub fn color(c: RgbColor) -> Color32 {
} }
pub fn cell_size(painter: &Painter, font: &FontId) -> egui::Vec2 { pub fn cell_size(painter: &Painter, font: &FontId) -> egui::Vec2 {
let pixels_per_point = painter.ctx().pixels_per_point();
painter.ctx().fonts_mut(|fonts| { painter.ctx().fonts_mut(|fonts| {
vec2( // Cached glyphs are rasterized at x=0 and egui snaps their positions to
fonts.glyph_width(font, 'M').ceil().max(1.0), // physical pixels. Use a whole-pixel column pitch so that snapping
fonts.row_height(font).ceil().max(1.0), // cannot periodically insert an extra pixel between columns. Round
) // down to avoid spacing wider than the font's advance.
let width_px = (fonts.glyph_width(font, 'M') * pixels_per_point)
.floor()
.max(1.0);
vec2(width_px / pixels_per_point, fonts.row_height(font).max(1.0))
}) })
} }
@@ -138,6 +145,23 @@ struct CachedRow {
backgrounds: Vec<egui::Shape>, backgrounds: Vec<egui::Shape>,
foregrounds: Vec<egui::Shape>, foregrounds: Vec<egui::Shape>,
selection: Option<(u16, u16)>, selection: Option<(u16, u16)>,
cells: Vec<CachedCell>,
}
#[derive(PartialEq)]
struct CellAppearance {
text: String,
style: Style,
fg: Color32,
bg: Color32,
background: bool,
wide: CellWide,
}
struct CachedCell {
appearance: CellAppearance,
backgrounds: std::ops::Range<usize>,
foregrounds: std::ops::Range<usize>,
} }
fn layout_glyph(painter: &Painter, text: String, font: &FontId, height: f32) -> Arc<egui::Galley> { fn layout_glyph(painter: &Painter, text: String, font: &FontId, height: f32) -> Arc<egui::Galley> {
@@ -200,8 +224,12 @@ pub struct Renderer {
cached_selection: (Color32, Color32), cached_selection: (Color32, Color32),
cached_cell: egui::Vec2, cached_cell: egui::Vec2,
cached_cols: u16, cached_cols: u16,
frame: Vec<egui::epaint::ClippedShape>,
sync_started: Option<Instant>,
#[cfg(test)] #[cfg(test)]
rebuilt_rows: usize, rebuilt_rows: usize,
#[cfg(test)]
rebuilt_cells: usize,
} }
impl Renderer { impl Renderer {
@@ -220,8 +248,12 @@ impl Renderer {
cached_selection: (Color32::PLACEHOLDER, Color32::PLACEHOLDER), cached_selection: (Color32::PLACEHOLDER, Color32::PLACEHOLDER),
cached_cell: egui::Vec2::ZERO, cached_cell: egui::Vec2::ZERO,
cached_cols: 0, cached_cols: 0,
frame: Vec::new(),
sync_started: None,
#[cfg(test)] #[cfg(test)]
rebuilt_rows: 0, rebuilt_rows: 0,
#[cfg(test)]
rebuilt_cells: 0,
}) })
} }
@@ -251,9 +283,26 @@ impl Renderer {
if fonts_changed { if fonts_changed {
self.row_cache.clear(); self.row_cache.clear();
self.glyphs.clear(); self.glyphs.clear();
self.frame.clear();
} }
self.font_marker = Some(marker); self.font_marker = Some(marker);
self.cached_cell = cell; self.cached_cell = cell;
// Editors may clear and redraw in separate PTY reads. Keep presenting
// the last complete frame until DEC synchronized output ends. Bound
// the wait so a crashed application cannot leave the display frozen.
if term.mode(Mode::SYNC_OUTPUT)? {
let elapsed = self.sync_started.get_or_insert_with(Instant::now).elapsed();
let timeout = Duration::from_secs(1);
if elapsed < timeout && !self.frame.is_empty() {
painter.ctx().request_repaint_after(timeout - elapsed);
self.present(&painter, rect);
return Ok(());
}
} else {
self.sync_started = None;
}
let screen_rect = rect;
let rect = Rect::from_min_size(egui::Pos2::ZERO, rect.size());
let style_fonts = painter.ctx().fonts(|fonts| { let style_fonts = painter.ctx().fonts(|fonts| {
[(false, false), (true, false), (false, true), (true, true)].map(|(bold, italic)| { [(false, false), (true, false), (false, true), (true, true)].map(|(bold, italic)| {
let family = crate::fonts::family(bold, italic); let family = crate::fonts::family(bold, italic);
@@ -299,7 +348,8 @@ impl Renderer {
self.cached_colors = Some(colors.clone()); self.cached_colors = Some(colors.clone());
self.cached_selection = selected_colors; self.cached_selection = selected_colors;
self.cached_cols = cols; self.cached_cols = cols;
painter.rect_filled(rect, 0, color(colors.background)); let mut shapes = vec![egui::Shape::rect_filled(rect, 0, color(colors.background))];
let mut cursor_shapes = Vec::new();
let mut rows = self.rows.update(&snapshot)?; let mut rows = self.rows.update(&snapshot)?;
let mut text = String::new(); let mut text = String::new();
let mut y = 0; let mut y = 0;
@@ -338,18 +388,43 @@ impl Renderer {
if selected { if selected {
(bg, fg) = selected_colors; (bg, fg) = selected_colors;
} }
if explicit_bg.is_some() || style.inverse || selected {
backgrounds.push(egui::Shape::rect_filled(cell_rect, 0, bg));
}
if style.faint { if style.faint {
fg = fg.gamma_multiply(0.6); fg = fg.gamma_multiply(0.6);
} }
let wide = c.raw_cell()?.wide()?; let wide = c.raw_cell()?.wide()?;
text.clear();
c.graphemes_utf8(&mut text)?;
let appearance = CellAppearance {
text: text.clone(),
style,
fg,
bg,
background: explicit_bg.is_some() || style.inverse || selected,
wide,
};
// Dirty flags are row-granular. Compare cells so edits in one
// split don't rebuild its unchanged neighbor on the same row.
let background_start = backgrounds.len();
let foreground_start = foregrounds.len();
if let Some(old) = self.row_cache[y].cells.get(x)
&& old.appearance == appearance
{
backgrounds
.extend_from_slice(&self.row_cache[y].backgrounds[old.backgrounds.clone()]);
foregrounds
.extend_from_slice(&self.row_cache[y].foregrounds[old.foregrounds.clone()]);
} else {
#[cfg(test)]
{
self.rebuilt_cells += 1;
}
if appearance.background {
backgrounds.push(egui::Shape::rect_filled(cell_rect, 0, bg));
}
if !style.invisible if !style.invisible
&& !matches!(wide, CellWide::SpacerHead | CellWide::SpacerTail) && !matches!(wide, CellWide::SpacerHead | CellWide::SpacerTail)
&& c.graphemes_len()? > 0 && c.graphemes_len()? > 0
{ {
c.graphemes_utf8(&mut text)?;
// Placeholder cells are reserved by the Kitty protocol, not printable text. // Placeholder cells are reserved by the Kitty protocol, not printable text.
if let Some(separator) = powerline_shape(&text, cell_rect, fg) { if let Some(separator) = powerline_shape(&text, cell_rect, fg) {
foregrounds.push(separator); foregrounds.push(separator);
@@ -404,6 +479,12 @@ impl Renderer {
)); ));
} }
} }
}
cached.cells.push(CachedCell {
appearance,
backgrounds: background_start..backgrounds.len(),
foregrounds: foreground_start..foregrounds.len(),
});
x += 1; x += 1;
} }
self.row_cache[y] = cached; self.row_cache[y] = cached;
@@ -414,7 +495,7 @@ impl Renderer {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
for layer in [Layer::BelowBg, Layer::BelowText, Layer::AboveText] { for layer in [Layer::BelowBg, Layer::BelowText, Layer::AboveText] {
if layer == Layer::BelowText { if layer == Layer::BelowText {
painter.extend( shapes.extend(
self.row_cache self.row_cache
.iter() .iter()
.flat_map(|row| row.backgrounds.iter()) .flat_map(|row| row.backgrounds.iter())
@@ -426,7 +507,7 @@ impl Renderer {
); );
} }
if layer == Layer::AboveText { if layer == Layer::AboveText {
painter.extend( shapes.extend(
self.row_cache self.row_cache
.iter() .iter()
.flat_map(|row| row.foregrounds.iter()) .flat_map(|row| row.foregrounds.iter())
@@ -446,6 +527,7 @@ impl Renderer {
&mut self.placements, &mut self.placements,
&mut self.textures, &mut self.textures,
&mut seen, &mut seen,
&mut shapes,
)?; )?;
} }
self.textures.retain(|id, _| seen.contains(id)); self.textures.retain(|id, _| seen.contains(id));
@@ -460,25 +542,74 @@ impl Renderer {
match snapshot.cursor_visual_style()? { match snapshot.cursor_visual_style()? {
CursorVisualStyle::Bar if active => { CursorVisualStyle::Bar if active => {
cursor_rect.max.x = cursor_rect.min.x + 2.0; cursor_rect.max.x = cursor_rect.min.x + 2.0;
painter.rect_filled(cursor_rect, 0, c); shapes.push(egui::Shape::rect_filled(cursor_rect, 0, c));
} }
CursorVisualStyle::Underline if active => { CursorVisualStyle::Underline if active => {
cursor_rect.min.y = cursor_rect.max.y - 2.0; cursor_rect.min.y = cursor_rect.max.y - 2.0;
painter.rect_filled(cursor_rect, 0, c); shapes.push(egui::Shape::rect_filled(cursor_rect, 0, c));
}
CursorVisualStyle::Block if active => {
shapes.push(egui::Shape::rect_filled(cursor_rect, 0, c));
// Redraw the covered glyph in the background color so an
// application's solid block cursor leaves text readable.
let ink = color(colors.background);
cursor_shapes.extend(
self.row_cache[cursor.y as usize]
.foregrounds
.iter()
.cloned()
.map(|mut shape| {
shape.translate(rect.min.to_vec2());
egui::epaint::shape_transform::adjust_colors(
&mut shape,
move |color| {
if *color != Color32::TRANSPARENT {
*color = ink;
}
},
);
egui::epaint::ClippedShape {
clip_rect: cursor_rect.intersect(rect),
shape,
}
}),
);
} }
_ => { _ => {
painter.rect_stroke( shapes.push(egui::Shape::rect_stroke(
cursor_rect, cursor_rect,
0, theme::CURSOR_CORNER_RADIUS,
Stroke::new(if active { 1.5_f32 } else { 0.75_f32 }, c), Stroke::new(theme::BORDER_WIDTH, c),
egui::StrokeKind::Inside, egui::StrokeKind::Inside,
); ));
} }
} }
} }
snapshot.set_dirty(Dirty::Clean)?; snapshot.set_dirty(Dirty::Clean)?;
self.frame = shapes
.into_iter()
.map(|shape| egui::epaint::ClippedShape {
clip_rect: rect,
shape,
})
.chain(cursor_shapes)
.collect();
self.present(&painter, screen_rect);
Ok(()) Ok(())
} }
fn present(&self, painter: &Painter, rect: Rect) {
for cached in &self.frame {
let mut shape = cached.shape.clone();
shape.translate(rect.min.to_vec2());
let clip = cached
.clip_rect
.translate(rect.min.to_vec2())
.intersect(rect)
.intersect(painter.clip_rect());
painter.with_clip_rect(clip).add(shape);
}
}
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -491,6 +622,7 @@ fn paint_images(
iterator: &mut PlacementIterator<'_>, iterator: &mut PlacementIterator<'_>,
textures: &mut HashMap<u32, (u64, TextureHandle)>, textures: &mut HashMap<u32, (u64, TextureHandle)>,
seen: &mut HashSet<u32>, seen: &mut HashSet<u32>,
shapes: &mut Vec<egui::Shape>,
) -> Result<()> { ) -> Result<()> {
let graphics = term.kitty_graphics()?; let graphics = term.kitty_graphics()?;
let mut placements = iterator.update(&graphics)?; let mut placements = iterator.update(&graphics)?;
@@ -565,7 +697,12 @@ fn paint_images(
} }
images.sort_by_key(|(z, id, pid, _, _)| (*z, *id, *pid)); images.sort_by_key(|(z, id, pid, _, _)| (*z, *id, *pid));
for (_, id, _, dest, uv) in images { for (_, id, _, dest, uv) in images {
painter.image(textures[&id].1.id(), dest, uv, Color32::WHITE); shapes.push(egui::Shape::image(
textures[&id].1.id(),
dest,
uv,
Color32::WHITE,
));
} }
Ok(()) Ok(())
} }
@@ -676,6 +813,69 @@ mod tests {
.shapes .shapes
} }
#[test]
fn scrolling_one_editor_split_reuses_unchanged_cells() {
let ctx = egui::Context::default();
let mut term =
crate::pane::terminal(&crate::config::Config::default(), &theme::DEFAULT).unwrap();
term.resize(20, 5, 8, 16).unwrap();
term.vt_write(b"\x1b[?25l");
for row in 1..=5 {
term.vt_write(format!("\x1b[{row};1Hleft {row} right {row}").as_bytes());
}
let rect = Rect::from_min_size(pos2(12.0, 24.0), vec2(200.0, 120.0));
let font = FontId::monospace(16.0);
let mut renderer = Renderer::new().unwrap();
frame(&ctx, &mut renderer, &term, rect, &font);
assert_eq!(renderer.rebuilt_cells, 100);
// Simulate an editor repainting entire rows while only its left split changes.
for row in 1..=5 {
term.vt_write(format!("\x1b[{row};1Hleft {} right {row}", row + 1).as_bytes());
}
let actual = frame(&ctx, &mut renderer, &term, rect, &font);
assert_eq!(renderer.rebuilt_cells, 105, "only five digits changed");
assert_eq!(
actual,
frame(&ctx, &mut Renderer::new().unwrap(), &term, rect, &font)
);
// Rewriting identical text should reuse every cell, even on dirty rows.
term.vt_write(b"\x1b[1;1Hleft 2 right 1");
frame(&ctx, &mut renderer, &term, rect, &font);
assert_eq!(renderer.rebuilt_cells, 105);
}
#[test]
fn synchronized_updates_keep_the_last_complete_frame() {
let ctx = egui::Context::default();
let mut term =
crate::pane::terminal(&crate::config::Config::default(), &theme::DEFAULT).unwrap();
term.resize(20, 5, 8, 16).unwrap();
term.vt_write(b"\x1b[?25lleft buffer\r\nright buffer");
let rect = Rect::from_min_size(pos2(12.0, 24.0), vec2(200.0, 120.0));
let font = FontId::monospace(16.0);
let mut renderer = Renderer::new().unwrap();
let initial = frame(&ctx, &mut renderer, &term, rect, &font);
let cells = renderer.rebuilt_cells;
term.vt_write(b"\x1b[?2026h\x1b[2J\x1b[Hpartial");
assert!(term.mode(Mode::SYNC_OUTPUT).unwrap());
assert_eq!(initial, frame(&ctx, &mut renderer, &term, rect, &font));
assert_eq!(renderer.rebuilt_cells, cells);
term.vt_write(b"\rcomplete\r\nright buffer\x1b[?2026l");
let completed = frame(&ctx, &mut renderer, &term, rect, &font);
assert_ne!(initial, completed);
assert_eq!(
completed,
frame(&ctx, &mut Renderer::new().unwrap(), &term, rect, &font)
);
// A missing end marker must not freeze the terminal indefinitely.
term.vt_write(b"\x1b[?2026h\x1b[Htimeout");
assert_eq!(completed, frame(&ctx, &mut renderer, &term, rect, &font));
renderer.sync_started = Some(Instant::now() - Duration::from_secs(2));
assert_ne!(completed, frame(&ctx, &mut renderer, &term, rect, &font));
}
#[test] #[test]
fn cached_rows_match_fresh_rendering_after_terminal_changes() { fn cached_rows_match_fresh_rendering_after_terminal_changes() {
use libghostty_vt::{ use libghostty_vt::{
@@ -899,15 +1099,30 @@ mod tests {
ctx.set_fonts(crate::app::fonts(&config).unwrap()); ctx.set_fonts(crate::app::fonts(&config).unwrap());
let _ = ctx.run(egui::RawInput::default(), |ctx| { let _ = ctx.run(egui::RawInput::default(), |ctx| {
let painter = ctx.layer_painter(egui::LayerId::background()); let painter = ctx.layer_painter(egui::LayerId::background());
for size in [12.0, 16.0, 20.0, 24.0] { for size in [12.0, 13.0, 14.5, 16.0, 17.0, 20.0, 24.0] {
let font = FontId::monospace(size); let font = FontId::monospace(size);
let cell = cell_size(&painter, &font); let cell = cell_size(&painter, &font);
let line_height = ctx.fonts_mut(|fonts| fonts.row_height(&font)); let line_height = ctx.fonts_mut(|fonts| fonts.row_height(&font));
assert_eq!( assert_eq!(cell.y, line_height, "font size {size}, scale {scale}");
cell.y, // The pitch must fit within the glyph advance and differ
line_height.ceil(), // by less than one physical pixel.
"font size {size}, scale {scale}" let row = painter.layout_no_wrap("M".repeat(80), font.clone(), Color32::WHITE);
let advance: f32 = row.rows[0].glyphs.iter().map(|g| g.advance_width).sum();
assert!(
advance - 80.0 * cell.x >= -0.01 && advance - 80.0 * cell.x < 80.0 / scale,
"grid width {} vs glyph advances {advance}, size {size}, scale {scale}",
80.0 * cell.x
); );
// Check the positions egui will actually use, including a
// pane origin that falls between physical pixels.
let pitch = cell.x * scale;
for origin in [0.0, 0.37, 12.25] {
for column in 0..80 {
let left = ((origin + column as f32 * cell.x) * scale).round();
let right = ((origin + (column + 1) as f32 * cell.x) * scale).round();
assert!((right - left - pitch).abs() < 0.001);
}
}
let text = painter.layout_no_wrap("NOR Android".into(), font, Color32::WHITE); let text = painter.layout_no_wrap("NOR Android".into(), font, Color32::WHITE);
assert!( assert!(
(cell.y - text.size().y).abs() <= 1.0, (cell.y - text.size().y).abs() <= 1.0,
+71
View File
@@ -28,6 +28,29 @@ fn key(key: Key, modifiers: Modifiers) -> Event {
} }
} }
#[test]
fn cursor_defaults_to_hollow_and_respects_application_styles() {
use libghostty_vt::{RenderState, render::CursorVisualStyle};
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
let mut state = RenderState::new().unwrap();
let snapshot = state.update(&term).unwrap();
assert_eq!(
snapshot.cursor_visual_style().unwrap(),
CursorVisualStyle::BlockHollow
);
for (sequence, expected) in [
(b"\x1b[2 q", CursorVisualStyle::Block),
(b"\x1b[4 q", CursorVisualStyle::Underline),
(b"\x1b[6 q", CursorVisualStyle::Bar),
(b"\x1b[0 q", CursorVisualStyle::BlockHollow),
] {
term.vt_write(sequence);
let snapshot = state.update(&term).unwrap();
assert_eq!(snapshot.cursor_visual_style().unwrap(), expected);
}
}
#[test] #[test]
fn terminal_preserves_unicode_colors_and_application_input_modes() { fn terminal_preserves_unicode_colors_and_application_input_modes() {
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap(); let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
@@ -57,6 +80,54 @@ fn terminal_preserves_unicode_colors_and_application_input_modes() {
assert!(plain(&term).contains("hello red")); assert!(plain(&term).contains("hello red"));
} }
#[test]
fn input_encoding_grows_buffers_without_losing_events() {
let term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
let mut events = vec![Event::Text("a".into()), Event::Text("123456789".into())];
let long_text = "界".repeat(200);
events.push(Event::Text(long_text.clone()));
for _ in 0..100 {
events.push(key(Key::ArrowUp, Modifiers::NONE));
}
let expected = format!("a123456789{long_text}{}", "\x1b[A".repeat(100));
assert_eq!(input::encode(&term, &events).unwrap(), expected.as_bytes());
}
#[test]
fn mouse_encoding_appends_when_destination_has_insufficient_spare_capacity() {
use libghostty_vt::mouse;
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
term.vt_write(b"\x1b[?1000h\x1b[?1006h");
let mut encoder = mouse::Encoder::new().unwrap();
encoder
.set_options_from_terminal(&term)
.set_size(mouse::EncoderSize {
screen_width: 800,
screen_height: 600,
cell_width: 8,
cell_height: 16,
padding_top: 0,
padding_bottom: 0,
padding_left: 0,
padding_right: 0,
});
let mut event = mouse::Event::new().unwrap();
event
.set_action(mouse::Action::Press)
.set_button(Some(mouse::Button::Left))
.set_position(mouse::Position { x: 0.0, y: 0.0 });
let mut out = Vec::with_capacity(16);
out.resize(9, b'x');
for _ in 0..100 {
input::append_encoded(&mut out, |buffer| encoder.encode(&event, buffer)).unwrap();
}
assert_eq!(
out,
format!("xxxxxxxxx{}", "\x1b[<0;1;1M".repeat(100)).as_bytes()
);
}
#[test] #[test]
fn queries_return_to_the_pty_and_alternate_screen_restores() { fn queries_return_to_the_pty_and_alternate_screen_restores() {
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap(); let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
+5
View File
@@ -7,6 +7,11 @@ use libghostty_vt::style::{Palette, PaletteIndex, RgbColor};
/// integers rather than anything that needs allocating or parsing at startup. /// integers rather than anything that needs allocating or parsing at startup.
pub type Rgb = u32; pub type Rgb = u32;
/// Shared stroke width for pane borders and the default hollow cursor.
pub const BORDER_WIDTH: f32 = 1.0;
pub const CORNER_RADIUS: u8 = 5;
pub const CURSOR_CORNER_RADIUS: u8 = 2;
/// Every color slerm draws with. A theme covers the terminal grid, the sixteen /// Every color slerm draws with. A theme covers the terminal grid, the sixteen
/// ANSI colors programs address by index, and the chrome around them, so that /// ANSI colors programs address by index, and the chrome around them, so that
/// picking a light theme does not leave a dark tab bar behind. /// picking a light theme does not leave a dark tab bar behind.