feat(vt): port Ghostty's VT parsing layer

Ports the parsing half of ZIG/src/terminal into the ghostty-vt crate:
- Parser state machine with a compile-time generated parse table and
  the UTF-8 decoder
- OSC parser incl. all sub-parsers (titles, colors, clipboard, hyperlinks,
  semantic prompts/OSC 133, OSC 9 / ConEmu, iTerm2, kitty clipboard,
  color and text-sizing protocols, context signals)
- CSI, SGR, modes table with save/restore, charsets, ansi, device status,
  mouse shapes, APC, DCS (incl. XTGETTCAP), tmux control-mode parser
- color palette / X11 color names, kitty color and graphics command parser

Deviations from Zig (documented in docs/rewrite/notes/ghostty-vt-W*.md):
parser actions own their payloads instead of borrowing parser buffers;
OSC strings are Vec<u8>; non-exhaustive Zig enums gain an Other(raw)
variant; tmux regexes replaced by equivalent hand-written matchers.

Known follow-up: semantic_prompt.rs carries local copies of the printf-q
and percent decoders that belong in ghostty-os.

Test Plan:
- cargo test -p ghostty-vt: 395 passed, 1 ignored (skipped in Zig too)
- cargo clippy -p ghostty-vt --all-targets -- -D warnings: clean
This commit is contained in:
ddidderr committed 2026-10-11 21:52:41 +02:00
1 parent 864ef82acb
commit b2b248e003
44 files changed
+15040 -6

No files matched your search

Generated
+1501 -3
View File
@@ -2,6 +2,507 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[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]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.8",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.8",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex 1.3.0",
"syn 2.0.119",
]
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "blocking"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "cc"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
dependencies = [
"find-msvc-tools",
"shlex 2.0.1",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "clang-sys"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const_format"
version = "0.2.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e"
dependencies = [
"const_format_proc_macros",
"konst",
]
[[package]]
name = "const_format_proc_macros"
version = "0.2.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
dependencies = [
"proc-macro2",
"quote",
"unicode-xid",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "either"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[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 = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
"zlib-rs",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "gettext-rs"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df5e3fa8c077b15fdfa8ce130e2bc73d663b8aaa6d51b71421b8659980b1d80"
dependencies = [
"gettext-sys",
"locale_config",
]
[[package]]
name = "gettext-sys"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4caa7e4460b196107cc1bb2629488e8b6dbb7f34f6683ea6753c4ac22f590785"
dependencies = [
"cc",
"temp-dir",
]
[[package]] [[package]]
name = "ghostty-apprt-gtk" name = "ghostty-apprt-gtk"
version = "0.1.0" version = "0.1.0"
@@ -15,9 +516,12 @@ dependencies = [
name = "ghostty-config" name = "ghostty-config"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"const_format",
"ghostty-input", "ghostty-input",
"ghostty-os", "ghostty-os",
"ghostty-vt", "ghostty-vt",
"log",
"onig",
] ]
[[package]] [[package]]
@@ -37,14 +541,19 @@ dependencies = [
[[package]] [[package]]
name = "ghostty-datastruct" name = "ghostty-datastruct"
version = "0.1.0" version = "0.1.0"
dependencies = [
"half",
"thiserror",
]
[[package]] [[package]]
name = "ghostty-font" name = "ghostty-font"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ghostty-config", "log",
"ghostty-terminal", "png",
"ghostty-unicode", "thiserror",
"tiny-skia",
] ]
[[package]] [[package]]
@@ -52,11 +561,22 @@ name = "ghostty-input"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ghostty-vt", "ghostty-vt",
"indexmap",
"thiserror",
] ]
[[package]] [[package]]
name = "ghostty-os" name = "ghostty-os"
version = "0.1.0" version = "0.1.0"
dependencies = [
"gettext-rs",
"log",
"nix",
"rustix",
"temp-env",
"thiserror",
"zbus",
]
[[package]] [[package]]
name = "ghostty-renderer" name = "ghostty-renderer"
@@ -101,3 +621,981 @@ version = "0.1.0"
[[package]] [[package]]
name = "ghostty-vt" name = "ghostty-vt"
version = "0.1.0" version = "0.1.0"
dependencies = [
"base64",
"log",
"thiserror",
]
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hermit-abi"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "konst"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb"
dependencies = [
"konst_macro_rules",
]
[[package]]
name = "konst_macro_rules"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
[[package]]
name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "libc"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "locale_config"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d2c35b16f4483f6c26f0e4e9550717a2f6575bcd6f12a53ff0c490a94a6934"
dependencies = [
"lazy_static",
"objc",
"objc-foundation",
"regex",
"winapi",
]
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]]
name = "objc-foundation"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
dependencies = [
"block",
"objc",
"objc_id",
]
[[package]]
name = "objc_id"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
dependencies = [
"objc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "onig"
version = "6.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
dependencies = [
"bitflags",
"libc",
"once_cell",
"onig_sys",
]
[[package]]
name = "onig_sys"
version = "69.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
dependencies = [
"bindgen",
"cc",
"pkg-config",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.8",
]
[[package]]
name = "serde_repr"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.8",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01016da373cd8f7ef12624f796309f5c31ba8d646dd08856c02cd741d823c622"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "temp-dir"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83176759e9416cf81ee66cb6508dbfe9c96f20b8b56265a39917551c23c70964"
[[package]]
name = "temp-env"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050"
dependencies = [
"parking_lot",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.8",
]
[[package]]
name = "tiny-skia"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "toml_datetime"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.17+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
dependencies = [
"winnow",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "uuid"
version = "1.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
dependencies = [
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.8",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]]
name = "zbus"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys",
"winnow",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.8",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow",
"zvariant",
]
[[package]]
name = "zcheapstr"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
dependencies = [
"serde",
]
[[package]]
name = "zerocopy"
version = "0.8.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zvariant"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow",
"zcheapstr",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.8",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 3.0.8",
"winnow",
]
+3
View File
@@ -5,6 +5,9 @@ edition.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
base64 = "0.23.1"
log = "0.4.34"
thiserror = "2.0.21"
[lints] [lints]
+156
View File
@@ -0,0 +1,156 @@
//! ANSI types (ZIG/src/terminal/ansi.zig).
/// C0 (7-bit) control characters from ANSI.
///
/// This is not complete, control characters are only added to this
/// as the terminal emulator handles them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum C0 {
/// Null
Nul = 0x00,
/// Start of heading
Soh = 0x01,
/// Start of text
Stx = 0x02,
/// Enquiry
Enq = 0x05,
/// Bell
Bel = 0x07,
/// Backspace
Bs = 0x08,
/// Horizontal tab
Ht = 0x09,
/// Line feed
Lf = 0x0A,
/// Vertical Tab
Vt = 0x0B,
/// Form feed
Ff = 0x0C,
/// Carriage return
Cr = 0x0D,
/// Shift out
So = 0x0E,
/// Shift in
Si = 0x0F,
}
impl C0 {
/// Convert from a raw byte; unknown values return `None` (the Zig enum
/// is non-exhaustive).
pub fn from_int(v: u8) -> Option<Self> {
Some(match v {
0x00 => Self::Nul,
0x01 => Self::Soh,
0x02 => Self::Stx,
0x05 => Self::Enq,
0x07 => Self::Bel,
0x08 => Self::Bs,
0x09 => Self::Ht,
0x0A => Self::Lf,
0x0B => Self::Vt,
0x0C => Self::Ff,
0x0D => Self::Cr,
0x0E => Self::So,
0x0F => Self::Si,
_ => return None,
})
}
}
/// The SGR rendition aspects that can be set, sometimes known as attributes.
/// The value corresponds to the parameter value for the SGR command (ESC [ m).
/// Non-exhaustive in Zig; `Other` carries unknown values.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenditionAspect {
Default,
Bold,
DefaultFg,
DefaultBg,
Other(u16),
}
impl RenditionAspect {
pub fn from_int(v: u16) -> Self {
match v {
0 => Self::Default,
1 => Self::Bold,
39 => Self::DefaultFg,
49 => Self::DefaultBg,
v => Self::Other(v),
}
}
}
/// The device attribute request type (ESC [ c).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceAttributeReq {
/// Blank
Primary,
/// >
Secondary,
/// =
Tertiary,
}
/// Possible cursor styles (ESC [ q)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CursorStyle {
Default,
BlinkingBlock,
SteadyBlock,
BlinkingUnderline,
SteadyUnderline,
BlinkingBar,
SteadyBar,
}
/// The status line type for DECSSDT. Non-exhaustive in Zig.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusLineType {
None,
Indicator,
HostWritable,
Other(u16),
}
impl StatusLineType {
pub fn from_int(v: u16) -> Self {
match v {
0 => Self::None,
1 => Self::Indicator,
2 => Self::HostWritable,
v => Self::Other(v),
}
}
}
/// The display to target for status updates (DECSASD).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusDisplay {
Main,
StatusLine,
}
/// The possible modify key formats to ESC[>{a};{b}m
/// Note: this is not complete, we should add more as we support more
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModifyKeyFormat {
Legacy,
CursorKeys,
FunctionKeys,
OtherKeysNone,
OtherKeysNumericExcept,
OtherKeysNumeric,
}
/// The protection modes that can be set for the terminal. See DECSCA and
/// ESC V, W.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtectedMode {
Off,
/// ESC V, W
Iso,
/// CSI Ps " q
Dec,
}
+138
View File
@@ -0,0 +1,138 @@
//! APC command handler (ZIG/src/terminal/apc.zig). This should be hooked
//! into a stream. The start/feed/end functions are meant to be called from
//! the stream's apcStart, apcPut, and apcEnd functions, respectively.
use crate::kitty::graphics_command as kitty_gfx;
#[derive(Debug, Default)]
pub enum State {
/// We're not in the middle of an APC command yet.
#[default]
Inactive,
/// We got an unrecognized APC sequence or the APC sequence we
/// recognized became invalid. We're just dropping bytes.
Ignore,
/// We're waiting to identify the APC sequence. This is done by
/// inspecting the first byte of the sequence.
Identify,
/// Kitty graphics protocol
Kitty(kitty_gfx::Parser),
}
/// Possible APC commands.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
Kitty(kitty_gfx::Command),
}
#[derive(Debug, Default)]
pub struct Handler {
pub state: State,
}
impl Handler {
pub fn new() -> Self {
Self::default()
}
pub fn start(&mut self) {
self.state = State::Identify;
}
pub fn feed(&mut self, byte: u8) {
match &mut self.state {
State::Inactive => unreachable!("APC feed while inactive"),
// We're ignoring this APC command, likely because we don't
// recognize it so there is no need to store the data in memory.
State::Ignore => {}
// We identify the APC command by the first byte.
State::Identify => {
self.state = match byte {
// Kitty graphics protocol
b'G' => State::Kitty(kitty_gfx::Parser::new()),
// Unknown
_ => State::Ignore,
};
}
State::Kitty(p) => {
if let Err(err) = p.feed(byte) {
log::warn!("kitty graphics protocol error: {err}");
self.state = State::Ignore;
}
}
}
}
pub fn end(&mut self) -> Option<Command> {
let state = std::mem::take(&mut self.state);
match state {
State::Inactive => unreachable!("APC end while inactive"),
State::Ignore | State::Identify => None,
State::Kitty(mut p) => match p.complete() {
Ok(command) => Some(Command::Kitty(command)),
Err(err) => {
log::warn!("kitty graphics protocol error: {err}");
None
}
},
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn run(input: &str) -> Option<Command> {
let mut h = Handler::new();
h.start();
for c in input.bytes() {
h.feed(c);
}
h.end()
}
#[test]
fn unknown_apc_command() {
assert!(run("Xabcdef1234").is_none());
}
#[test]
fn garbage_kitty_command() {
assert!(run("Gabcdef1234").is_none());
}
#[test]
fn kitty_command_with_overflow_u32() {
assert!(run("Ga=p,i=10000000000").is_none());
}
#[test]
fn kitty_command_with_overflow_i32() {
assert!(run("Ga=p,i=1,z=-9999999999").is_none());
}
#[test]
fn valid_kitty_command() {
let cmd = run("Gf=24,s=10,v=20,hello=world").unwrap();
assert!(matches!(cmd, Command::Kitty(_)));
}
#[test]
fn end_resets_to_inactive() {
let mut h = Handler::new();
h.start();
h.feed(b'X');
assert!(h.end().is_none());
assert!(matches!(h.state, State::Inactive));
}
}
+121
View File
@@ -0,0 +1,121 @@
//! Character sets (ZIG/src/terminal/charsets.zig).
/// The available charset slots for a terminal.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Slots {
G0,
G1,
G2,
G3,
}
/// The name of the active slots.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ActiveSlot {
GL,
GR,
}
/// The list of supported character sets and their associated tables.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Charset {
Utf8,
Ascii,
British,
DecSpecial,
}
/// Our table length is 256 so we can contain all ASCII chars.
pub const TABLE_LEN: usize = 256;
/// The table for the given charset. The returned table is guaranteed to be
/// 256 entries that can be used to map ASCII to the given charset.
///
/// # Panics
/// Panics for `Charset::Utf8`: utf8 is not a table, callers should
/// double-check if the charset is utf8 and NOT use tables.
pub fn table(set: Charset) -> &'static [u16; TABLE_LEN] {
match set {
Charset::British => &BRITISH,
Charset::DecSpecial => &DEC_SPECIAL,
Charset::Utf8 => unreachable!("utf8 has no table"),
Charset::Ascii => &ASCII,
}
}
/// Creates a table that maps ASCII to ASCII as a getting started point.
const fn init_table() -> [u16; TABLE_LEN] {
let mut result = [0u16; TABLE_LEN];
let mut i = 0;
while i < TABLE_LEN {
result[i] = i as u16;
i += 1;
}
result
}
/// Just a basic c => c ascii table
static ASCII: [u16; TABLE_LEN] = init_table();
/// https://vt100.net/docs/vt220-rm/chapter2.html
static BRITISH: [u16; TABLE_LEN] = {
let mut t = init_table();
t[0x23] = 0x00a3;
t
};
/// https://en.wikipedia.org/wiki/DEC_Special_Graphics
static DEC_SPECIAL: [u16; TABLE_LEN] = {
let mut t = init_table();
t[0x60] = 0x25C6;
t[0x61] = 0x2592;
t[0x62] = 0x2409;
t[0x63] = 0x240C;
t[0x64] = 0x240D;
t[0x65] = 0x240A;
t[0x66] = 0x00B0;
t[0x67] = 0x00B1;
t[0x68] = 0x2424;
t[0x69] = 0x240B;
t[0x6a] = 0x2518;
t[0x6b] = 0x2510;
t[0x6c] = 0x250C;
t[0x6d] = 0x2514;
t[0x6e] = 0x253C;
t[0x6f] = 0x23BA;
t[0x70] = 0x23BB;
t[0x71] = 0x2500;
t[0x72] = 0x23BC;
t[0x73] = 0x23BD;
t[0x74] = 0x251C;
t[0x75] = 0x2524;
t[0x76] = 0x2534;
t[0x77] = 0x252C;
t[0x78] = 0x2502;
t[0x79] = 0x2264;
t[0x7a] = 0x2265;
t[0x7b] = 0x03C0;
t[0x7c] = 0x2260;
t[0x7d] = 0x00A3;
t[0x7e] = 0x00B7;
t
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_lengths() {
for set in [Charset::Ascii, Charset::British, Charset::DecSpecial] {
assert_eq!(table(set).len(), 256);
}
}
#[test]
fn table_contents() {
assert_eq!(table(Charset::British)[0x23], 0xa3);
assert_eq!(table(Charset::Ascii)[0x41], 0x41);
assert_eq!(table(Charset::DecSpecial)[0x71], 0x2500);
}
}
+1220
View File
@@ -0,0 +1,1220 @@
//! Terminal colors (ZIG/src/terminal/color.zig).
use crate::x11_color;
/// The default palette.
pub const DEFAULT: Palette = default_palette();
const fn default_palette() -> Palette {
let mut result = [Rgb { r: 0, g: 0, b: 0 }; 256];
// Named values
let mut i: usize = 0;
while i < 16 {
result[i] = match Name(i as u8).default() {
Ok(c) => c,
Err(_) => unreachable!(),
};
i += 1;
}
// Cube
assert!(i == 16);
let mut r: u8 = 0;
while r < 6 {
let mut g: u8 = 0;
while g < 6 {
let mut b: u8 = 0;
while b < 6 {
result[i] = Rgb {
r: if r == 0 { 0 } else { r * 40 + 55 },
g: if g == 0 { 0 } else { g * 40 + 55 },
b: if b == 0 { 0 } else { b * 40 + 55 },
};
i += 1;
b += 1;
}
g += 1;
}
r += 1;
}
// Gray ramp
assert!(i == 232);
while i < 256 {
let value = ((i as u8 - 232) * 10) + 8;
result[i] = Rgb {
r: value,
g: value,
b: value,
};
i += 1;
}
result
}
/// Palette is the 256 color palette.
pub type Palette = [Rgb; 256];
/// Mask that can be used to set which palette indexes were set.
/// (Zig `std.StaticBitSet(256)`.)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct PaletteMask([u64; 4]);
impl PaletteMask {
pub const fn init_empty() -> Self {
Self([0; 4])
}
pub fn is_set(&self, idx: usize) -> bool {
self.0[idx / 64] & (1 << (idx % 64)) != 0
}
pub fn set(&mut self, idx: usize) {
self.0[idx / 64] |= 1 << (idx % 64);
}
pub fn unset(&mut self, idx: usize) {
self.0[idx / 64] &= !(1 << (idx % 64));
}
pub fn count(&self) -> usize {
self.0.iter().map(|w| w.count_ones() as usize).sum()
}
/// Iterate over set indexes in ascending order.
pub fn iter(&self) -> impl Iterator<Item = usize> + '_ {
(0..256).filter(|&i| self.is_set(i))
}
}
/// Generate the 256-color palette from the user's base16 theme colors,
/// terminal background, and terminal foreground.
///
/// Motivation: The default 256-color palette uses fixed, fully-saturated
/// colors that clash with custom base16 themes, have poor readability in
/// dark shades (the first non-black shade jumps to 37% intensity instead
/// of the expected 20%), and exhibit inconsistent perceived brightness
/// across hues of the same shade (e.g., blue appears darker than green).
/// By generating the extended palette from the user's chosen colors,
/// programs can use the richer 256-color range without requiring their
/// own theme configuration, and light/dark switching works automatically.
///
/// The 216-color cube (indices 16–231) is built via trilinear
/// interpolation in CIELAB space over the 8 base colors. The base16
/// palette maps to the 8 corners of a 6×6×6 RGB cube as follows:
///
/// R=0 edge: bg → base[1] (red)
/// R=5 edge: base[6] → fg
/// G=0 edge: bg/base[6] (via R) → base[2]/base[4] (green/blue via R)
/// G=5 edge: base[1]/fg (via R) → base[3]/base[5] (yellow/magenta via R)
///
/// For each R slice, four corner colors (c0–c3) are interpolated along
/// the R axis, then for each G row two edge colors (c4–c5) are
/// interpolated along G, and finally each B cell is interpolated along B
/// to produce the final color. CIELAB interpolation ensures perceptually
/// uniform brightness transitions across different hues.
///
/// The 24-step grayscale ramp (indices 232–255) is a simple linear
/// interpolation in CIELAB from the background to the foreground,
/// excluding pure black and white (available in the cube at (0,0,0)
/// and (5,5,5)). The interpolation parameter runs from 1/25 to 24/25.
///
/// Fill `skip` with user-defined color indexes to avoid replacing them.
pub fn generate_256_color(
base: Palette,
skip: PaletteMask,
bg: Rgb,
fg: Rgb,
harmonious: bool,
) -> Palette {
// Convert the background, foreground, and 8 base theme colors into
// CIELAB space so that all interpolation is perceptually uniform.
let base8_lab: [Lab; 8] = {
let mut base8 = [
Lab::from_rgb(bg),
Lab::from_rgb(base[1]),
Lab::from_rgb(base[2]),
Lab::from_rgb(base[3]),
Lab::from_rgb(base[4]),
Lab::from_rgb(base[5]),
Lab::from_rgb(base[6]),
Lab::from_rgb(fg),
];
// For light themes (where the foreground is darker than the
// background), the cube's dark-to-light orientation is inverted
// relative to the base color mapping. When `harmonious` is false,
// swap bg and fg so the cube still runs from black (16) to
// white (231).
let is_light_theme = base8[7].l < base8[0].l;
let invert = is_light_theme && !harmonious;
if invert {
base8.swap(0, 7);
}
base8
};
// Start from the base palette so indices 0-15 are preserved as-is.
let mut result = base;
// Build the 216-color cube (indices 16-231) via trilinear interpolation
// in CIELAB. The three nested loops correspond to the R, G, and B axes
// of a 6x6x6 cube.
let mut idx: usize = 16;
for ri in 0..6 {
// R-axis corners: blend base colors along the red dimension.
let tr = ri as f32 / 5.0;
let c0 = Lab::lerp(tr, base8_lab[0], base8_lab[1]);
let c1 = Lab::lerp(tr, base8_lab[2], base8_lab[3]);
let c2 = Lab::lerp(tr, base8_lab[4], base8_lab[5]);
let c3 = Lab::lerp(tr, base8_lab[6], base8_lab[7]);
for gi in 0..6 {
// G-axis edges: blend the R-interpolated corners along green.
let tg = gi as f32 / 5.0;
let c4 = Lab::lerp(tg, c0, c1);
let c5 = Lab::lerp(tg, c2, c3);
for bi in 0..6 {
// B-axis: final interpolation along blue, then convert back to RGB.
if !skip.is_set(idx) {
let c6 = Lab::lerp(bi as f32 / 5.0, c4, c5);
result[idx] = c6.to_rgb();
}
idx += 1;
}
}
}
// Build the 24-step grayscale ramp (indices 232-255) by linearly
// interpolating in CIELAB from background to foreground. The parameter
// runs from 1/25 to 24/25, excluding the endpoints which are already
// available in the cube at (0,0,0) and (5,5,5).
for i in 0..24 {
let t = (i + 1) as f32 / 25.0;
if !skip.is_set(idx) {
let c = Lab::lerp(t, base8_lab[0], base8_lab[7]);
result[idx] = c.to_rgb();
}
idx += 1;
}
result
}
/// A palette that can have its colors changed and reset. Purposely built
/// for terminal color operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DynamicPalette {
/// The current palette including any user modifications.
pub current: Palette,
/// The original/default palette values.
pub original: Palette,
/// A bitset where each bit represents whether the corresponding
/// palette index has been modified from its default value.
pub mask: PaletteMask,
}
impl DynamicPalette {
pub const DEFAULT: DynamicPalette = DynamicPalette::init(DEFAULT);
/// Initialize a dynamic palette with a default palette.
pub const fn init(def: Palette) -> DynamicPalette {
DynamicPalette {
current: def,
original: def,
mask: PaletteMask::init_empty(),
}
}
/// Set a custom color at the given palette index.
pub fn set(&mut self, idx: u8, color: Rgb) {
self.current[idx as usize] = color;
self.mask.set(idx as usize);
}
/// Reset the color at the given palette index to its original value.
pub fn reset(&mut self, idx: u8) {
self.current[idx as usize] = self.original[idx as usize];
self.mask.unset(idx as usize);
}
/// Reset all colors to their original values.
pub fn reset_all(&mut self) {
*self = Self::init(self.original);
}
/// Change the default palette, but preserve the changed values.
pub fn change_default(&mut self, def: Palette) {
self.original = def;
// Fast path, the palette is usually not changed.
if self.mask.count() == 0 {
self.current = self.original;
return;
}
// There are usually less set than unset, so iterate over the changed
// values and override them.
let mut current = def;
for idx in self.mask.iter() {
current[idx] = self.current[idx];
}
self.current = current;
}
}
/// RGB value that can be changed and reset. This can also be totally unset
/// in every way, in which case the caller can determine their own ultimate
/// default.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct DynamicRgb {
pub override_: Option<Rgb>,
pub default: Option<Rgb>,
}
impl DynamicRgb {
pub const UNSET: DynamicRgb = DynamicRgb {
override_: None,
default: None,
};
pub fn init(def: Rgb) -> DynamicRgb {
DynamicRgb {
override_: None,
default: Some(def),
}
}
pub fn get(&self) -> Option<Rgb> {
self.override_.or(self.default)
}
pub fn set(&mut self, color: Rgb) {
self.override_ = Some(color);
}
pub fn reset(&mut self) {
self.override_ = self.default;
}
}
/// Color names in the standard 8 or 16 color palette. Remainders (any other
/// `u8`) are valid unnamed values in the 256 color palette, so this is a
/// newtype rather than an enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Name(pub u8);
/// Error returned by [`Name::default`] for unnamed palette values.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NoDefaultValue;
impl Name {
pub const BLACK: Name = Name(0);
pub const RED: Name = Name(1);
pub const GREEN: Name = Name(2);
pub const YELLOW: Name = Name(3);
pub const BLUE: Name = Name(4);
pub const MAGENTA: Name = Name(5);
pub const CYAN: Name = Name(6);
pub const WHITE: Name = Name(7);
pub const BRIGHT_BLACK: Name = Name(8);
pub const BRIGHT_RED: Name = Name(9);
pub const BRIGHT_GREEN: Name = Name(10);
pub const BRIGHT_YELLOW: Name = Name(11);
pub const BRIGHT_BLUE: Name = Name(12);
pub const BRIGHT_MAGENTA: Name = Name(13);
pub const BRIGHT_CYAN: Name = Name(14);
pub const BRIGHT_WHITE: Name = Name(15);
/// Default colors for tagged values.
pub const fn default(self) -> Result<Rgb, NoDefaultValue> {
let (r, g, b) = match self.0 {
0 => (0x1D, 0x1F, 0x21),
1 => (0xCC, 0x66, 0x66),
2 => (0xB5, 0xBD, 0x68),
3 => (0xF0, 0xC6, 0x74),
4 => (0x81, 0xA2, 0xBE),
5 => (0xB2, 0x94, 0xBB),
6 => (0x8A, 0xBE, 0xB7),
7 => (0xC5, 0xC8, 0xC6),
8 => (0x66, 0x66, 0x66),
9 => (0xD5, 0x4E, 0x53),
10 => (0xB9, 0xCA, 0x4A),
11 => (0xE7, 0xC5, 0x47),
12 => (0x7A, 0xA6, 0xDA),
13 => (0xC3, 0x97, 0xD8),
14 => (0x70, 0xC0, 0xB1),
15 => (0xEA, 0xEA, 0xEA),
_ => return Err(NoDefaultValue),
};
Ok(Rgb { r, g, b })
}
}
/// The "special colors" as denoted by xterm. These can be set via
/// OSC 5 or via OSC 4 by adding the palette length to it.
///
/// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum Special {
Bold = 0,
Underline = 1,
Blink = 2,
Reverse = 3,
Italic = 4,
}
impl Special {
pub fn osc4(self) -> u16 {
// "The special colors can also be set by adding the maximum
// number of colors (e.g., 88 or 256) to these codes in an
// OSC 4 control" - xterm ctlseqs
self as u16 + 256
}
/// Convert from the raw value (Zig `@enumFromInt` / `intToEnum`).
pub fn from_u8(v: u8) -> Option<Special> {
Some(match v {
0 => Special::Bold,
1 => Special::Underline,
2 => Special::Blink,
3 => Special::Reverse,
4 => Special::Italic,
_ => return None,
})
}
}
/// The "dynamic colors" as denoted by xterm. These can be set via
/// OSC 10 through 19.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum Dynamic {
Foreground = 10,
Background = 11,
Cursor = 12,
PointerForeground = 13,
PointerBackground = 14,
TektronixForeground = 15,
TektronixBackground = 16,
HighlightBackground = 17,
TektronixCursor = 18,
HighlightForeground = 19,
}
impl Dynamic {
/// Convert from the raw value (10..=19).
pub fn from_u8(v: u8) -> Option<Dynamic> {
Some(match v {
10 => Dynamic::Foreground,
11 => Dynamic::Background,
12 => Dynamic::Cursor,
13 => Dynamic::PointerForeground,
14 => Dynamic::PointerBackground,
15 => Dynamic::TektronixForeground,
16 => Dynamic::TektronixBackground,
17 => Dynamic::HighlightBackground,
18 => Dynamic::TektronixCursor,
19 => Dynamic::HighlightForeground,
_ => return None,
})
}
/// The next dynamic color sequentially. This is required because
/// specifying colors sequentially without their index will automatically
/// use the next dynamic color.
///
/// "Each successive parameter changes the next color in the list. The
/// value of Ps tells the starting point in the list."
pub fn next(self) -> Option<Dynamic> {
Dynamic::from_u8(self as u8 + 1)
}
}
/// Error returned when parsing a color specification fails.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidFormat;
impl std::fmt::Display for InvalidFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("invalid color format")
}
}
impl std::error::Error for InvalidFormat {}
/// RGB
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
/// Alias matching the Zig name.
pub type RGB = Rgb;
/// Zig `std.fmt.parseUnsigned(u16, value, 16)` semantics (optional leading
/// `+`; a leading `-` is only valid if the value is zero; no underscores).
fn parse_unsigned_hex_u16(value: &[u8]) -> Option<u16> {
let (neg, digits) = match value.first()? {
b'+' => (false, &value[1..]),
b'-' => (true, &value[1..]),
_ => (false, value),
};
if digits.is_empty() {
return None;
}
let mut acc: u32 = 0;
for &c in digits {
let d = (c as char).to_digit(16)?;
acc = acc * 16 + d;
if acc > u16::MAX as u32 {
return None;
}
}
if neg && acc != 0 {
return None;
}
Some(acc as u16)
}
impl Rgb {
pub fn eql(self, other: Rgb) -> bool {
self.r == other.r && self.g == other.g && self.b == other.b
}
/// Calculates the contrast ratio between two colors. The contrast
/// ration is a value between 1 and 21 where 1 is the lowest contrast
/// and 21 is the highest contrast.
///
/// https://www.w3.org/TR/WCAG20/#contrast-ratiodef
pub fn contrast(self, other: Rgb) -> f64 {
// pair[0] = lighter, pair[1] = darker
let self_lum = self.luminance();
let other_lum = other.luminance();
let pair = if self_lum > other_lum {
[self_lum, other_lum]
} else {
[other_lum, self_lum]
};
(pair[0] + 0.05) / (pair[1] + 0.05)
}
/// Calculates luminance based on the W3C formula. This returns a
/// normalized value between 0 and 1 where 0 is black and 1 is white.
///
/// https://www.w3.org/TR/WCAG20/#relativeluminancedef
pub fn luminance(self) -> f64 {
let r_lum = component_luminance(self.r);
let g_lum = component_luminance(self.g);
let b_lum = component_luminance(self.b);
0.2126 * r_lum + 0.7152 * g_lum + 0.0722 * b_lum
}
/// Calculates "perceived luminance" which is better for determining
/// light vs dark.
///
/// Source: https://www.w3.org/TR/AERT/#color-contrast
pub fn perceived_luminance(self) -> f64 {
let r = self.r as f64;
let g = self.g as f64;
let b = self.b as f64;
0.299 * (r / 255.0) + 0.587 * (g / 255.0) + 0.114 * (b / 255.0)
}
/// Parse a color from a floating point intensity value.
///
/// The value should be between 0.0 and 1.0, inclusive.
fn from_intensity(value: &[u8]) -> Result<u8, InvalidFormat> {
let s = std::str::from_utf8(value).map_err(|_| InvalidFormat)?;
let i: f64 = s.parse().map_err(|_| InvalidFormat)?;
if !(0.0..=1.0).contains(&i) && !i.is_nan() {
return Err(InvalidFormat);
}
Ok((i * 255.0) as u8)
}
/// Parse a color from a string of hexadecimal digits
///
/// The string can contain 1, 2, 3, or 4 characters and represents the color
/// value scaled in 4, 8, 12, or 16 bits, respectively.
fn from_hex(value: &[u8]) -> Result<u8, InvalidFormat> {
if value.is_empty() || value.len() > 4 {
return Err(InvalidFormat);
}
let color = parse_unsigned_hex_u16(value).ok_or(InvalidFormat)?;
let divisor: usize = match value.len() {
1 => 0xf,
2 => 0xff,
3 => 0xfff,
4 => 0xffff,
_ => unreachable!(),
};
Ok((color as usize * 255 / divisor) as u8)
}
/// Parse a color specification.
///
/// Any of the following forms are accepted:
///
/// 1. rgb:<red>/<green>/<blue>
///
/// <red>, <green>, <blue> := h | hh | hhh | hhhh
///
/// where `h` is a single hexadecimal digit.
///
/// 2. rgbi:<red>/<green>/<blue>
///
/// where <red>, <green>, and <blue> are floating point values between
/// 0.0 and 1.0 (inclusive).
///
/// 3. #rgb, #rrggbb, #rrrgggbbb #rrrrggggbbbb
///
/// where `r`, `g`, and `b` are a single hexadecimal digit.
/// These specify a color with 4, 8, 12, and 16 bits of precision
/// per color channel.
pub fn parse(value: &str) -> Result<Rgb, InvalidFormat> {
Self::parse_bytes(value.as_bytes())
}
/// Byte-slice variant of [`Rgb::parse`].
pub fn parse_bytes(value: &[u8]) -> Result<Rgb, InvalidFormat> {
if value.is_empty() {
return Err(InvalidFormat);
}
if value[0] == b'#' {
return match value.len() {
4 => Ok(Rgb {
r: Rgb::from_hex(&value[1..2])?,
g: Rgb::from_hex(&value[2..3])?,
b: Rgb::from_hex(&value[3..4])?,
}),
7 => Ok(Rgb {
r: Rgb::from_hex(&value[1..3])?,
g: Rgb::from_hex(&value[3..5])?,
b: Rgb::from_hex(&value[5..7])?,
}),
10 => Ok(Rgb {
r: Rgb::from_hex(&value[1..4])?,
g: Rgb::from_hex(&value[4..7])?,
b: Rgb::from_hex(&value[7..10])?,
}),
13 => Ok(Rgb {
r: Rgb::from_hex(&value[1..5])?,
g: Rgb::from_hex(&value[5..9])?,
b: Rgb::from_hex(&value[9..13])?,
}),
_ => Err(InvalidFormat),
};
}
// Check for X11 named colors. We allow whitespace around the edges
// of the color because Kitty allows whitespace. This is not part of
// any spec I could find.
let trimmed = {
let mut s = value;
while let [b' ', rest @ ..] = s {
s = rest;
}
while let [rest @ .., b' '] = s {
s = rest;
}
s
};
if let Some(rgb) = x11_color::get_bytes(trimmed) {
return Ok(rgb);
}
if value.len() < "rgb:a/a/a".len() || &value[0..3] != b"rgb" {
return Err(InvalidFormat);
}
let mut i: usize = 3;
let use_intensity = if value[i] == b'i' {
i += 1;
true
} else {
false
};
if value[i] != b':' {
return Err(InvalidFormat);
}
i += 1;
let component = |slice: &[u8]| {
if use_intensity {
Rgb::from_intensity(slice)
} else {
Rgb::from_hex(slice)
}
};
let r = {
let end = value[i..]
.iter()
.position(|&c| c == b'/')
.map(|p| p + i)
.ok_or(InvalidFormat)?;
let slice = &value[i..end];
i += slice.len() + 1;
component(slice)?
};
let g = {
let end = value[i..]
.iter()
.position(|&c| c == b'/')
.map(|p| p + i)
.ok_or(InvalidFormat)?;
let slice = &value[i..end];
i += slice.len() + 1;
component(slice)?
};
let b = component(&value[i..])?;
Ok(Rgb { r, g, b })
}
}
/// Calculates single-component luminance based on the W3C formula.
///
/// Expects sRGB color space which at the time of writing we don't
/// generally use but it's a good enough approximation until we fix that.
/// https://www.w3.org/TR/WCAG20/#relativeluminancedef
fn component_luminance(c: u8) -> f64 {
let normalized = c as f64 / 255.0;
if normalized <= 0.03928 {
return normalized / 12.92;
}
((normalized + 0.055) / 1.055).powf(2.4)
}
/// LAB color space
#[derive(Debug, Clone, Copy, PartialEq)]
struct Lab {
l: f32,
a: f32,
b: f32,
}
#[allow(clippy::excessive_precision)]
impl Lab {
/// RGB to LAB
fn from_rgb(rgb: Rgb) -> Lab {
// Step 1: Normalize sRGB channels from [0, 255] to [0.0, 1.0].
let mut r = rgb.r as f32 / 255.0;
let mut g = rgb.g as f32 / 255.0;
let mut b = rgb.b as f32 / 255.0;
// Step 2: Apply the inverse sRGB companding (gamma correction) to
// convert from sRGB to linear RGB.
r = if r > 0.04045 {
((r + 0.055) / 1.055).powf(2.4)
} else {
r / 12.92
};
g = if g > 0.04045 {
((g + 0.055) / 1.055).powf(2.4)
} else {
g / 12.92
};
b = if b > 0.04045 {
((b + 0.055) / 1.055).powf(2.4)
} else {
b / 12.92
};
// Step 3: Convert linear RGB to CIE XYZ using the sRGB to XYZ
// transformation matrix (D65 illuminant). The X and Z values are
// normalized by the D65 white point reference values (Xn=0.95047,
// Zn=1.08883; Yn=1.0 is implicit).
let mut x = (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) / 0.95047;
let mut y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750;
let mut z = (r * 0.0193339 + g * 0.1191920 + b * 0.9503041) / 1.08883;
// Step 4: Apply the CIE f(t) nonlinear transform to each XYZ
// component.
x = if x > 0.008856 {
x.cbrt()
} else {
7.787 * x + 16.0 / 116.0
};
y = if y > 0.008856 {
y.cbrt()
} else {
7.787 * y + 16.0 / 116.0
};
z = if z > 0.008856 {
z.cbrt()
} else {
7.787 * z + 16.0 / 116.0
};
// Step 5: Compute the final CIELAB values from the transformed XYZ.
Lab {
l: 116.0 * y - 16.0,
a: 500.0 * (x - y),
b: 200.0 * (y - z),
}
}
/// LAB to RGB
fn to_rgb(self) -> Rgb {
// Step 1: Recover the intermediate f(Y), f(X), f(Z) values from
// L*a*b* by inverting the CIELAB formulas.
let y = (self.l + 16.0) / 116.0;
let x = self.a / 500.0 + y;
let z = y - self.b / 200.0;
// Step 2: Apply the inverse CIE f(t) transform to get back to XYZ.
let x3 = x * x * x;
let y3 = y * y * y;
let z3 = z * z * z;
let xf = (if x3 > 0.008856 {
x3
} else {
(x - 16.0 / 116.0) / 7.787
}) * 0.95047;
let yf = if y3 > 0.008856 {
y3
} else {
(y - 16.0 / 116.0) / 7.787
};
let zf = (if z3 > 0.008856 {
z3
} else {
(z - 16.0 / 116.0) / 7.787
}) * 1.08883;
// Step 3: Convert CIE XYZ back to linear RGB.
let mut r = xf * 3.2404542 - yf * 1.5371385 - zf * 0.4985314;
let mut g = -xf * 0.9692660 + yf * 1.8760108 + zf * 0.0415560;
let mut b = xf * 0.0556434 - yf * 0.2040259 + zf * 1.0572252;
// Step 4: Apply sRGB companding (gamma correction).
r = if r > 0.0031308 {
1.055 * r.powf(1.0 / 2.4) - 0.055
} else {
12.92 * r
};
g = if g > 0.0031308 {
1.055 * g.powf(1.0 / 2.4) - 0.055
} else {
12.92 * g
};
b = if b > 0.0031308 {
1.055 * b.powf(1.0 / 2.4) - 0.055
} else {
12.92 * b
};
// Step 5: Clamp to [0.0, 1.0], scale to [0, 255], and round to
// the nearest integer to produce the final 8-bit sRGB values.
Rgb {
r: (r.clamp(0.0, 1.0) * 255.0 + 0.5) as u8,
g: (g.clamp(0.0, 1.0) * 255.0 + 0.5) as u8,
b: (b.clamp(0.0, 1.0) * 255.0 + 0.5) as u8,
}
}
/// Linearly interpolate between two LAB colors component-wise.
/// `t` is the interpolation factor in [0, 1]: t=0 returns `a`,
/// t=1 returns `b`, and values in between blend proportionally.
fn lerp(t: f32, a: Lab, b: Lab) -> Lab {
Lab {
l: a.l + t * (b.l - a.l),
a: a.a + t * (b.a - a.a),
b: a.b + t * (b.b - a.b),
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn rgb(r: u8, g: u8, b: u8) -> Rgb {
Rgb { r, g, b }
}
#[test]
fn special_osc4() {
assert_eq!(256, Special::Bold.osc4());
assert_eq!(257, Special::Underline.osc4());
assert_eq!(258, Special::Blink.osc4());
assert_eq!(259, Special::Reverse.osc4());
assert_eq!(260, Special::Italic.osc4());
}
#[test]
fn dynamic_next() {
use Dynamic::*;
assert_eq!(Some(Background), Foreground.next());
assert_eq!(Some(Cursor), Background.next());
assert_eq!(Some(PointerForeground), Cursor.next());
assert_eq!(Some(PointerBackground), PointerForeground.next());
assert_eq!(Some(TektronixForeground), PointerBackground.next());
assert_eq!(Some(TektronixBackground), TektronixForeground.next());
assert_eq!(Some(HighlightBackground), TektronixBackground.next());
assert_eq!(Some(TektronixCursor), HighlightBackground.next());
assert_eq!(Some(HighlightForeground), TektronixCursor.next());
assert_eq!(None, HighlightForeground.next());
}
#[test]
fn palette_default() {
for i in 0..16u8 {
assert_eq!(Name(i).default().unwrap(), DEFAULT[i as usize]);
}
// Spot checks of the cube and ramp.
assert_eq!(DEFAULT[16], rgb(0, 0, 0));
assert_eq!(DEFAULT[17], rgb(0, 0, 95));
assert_eq!(DEFAULT[231], rgb(255, 255, 255));
assert_eq!(DEFAULT[232], rgb(8, 8, 8));
assert_eq!(DEFAULT[255], rgb(238, 238, 238));
}
#[test]
fn rgb_parse() {
assert_eq!(rgb(255, 0, 0), Rgb::parse("rgbi:1.0/0/0").unwrap());
assert_eq!(rgb(127, 160, 0), Rgb::parse("rgb:7f/a0a0/0").unwrap());
assert_eq!(rgb(255, 255, 255), Rgb::parse("rgb:f/ff/fff").unwrap());
assert_eq!(rgb(255, 255, 255), Rgb::parse("#ffffff").unwrap());
assert_eq!(rgb(255, 255, 255), Rgb::parse("#fff").unwrap());
assert_eq!(rgb(255, 255, 255), Rgb::parse("#fffffffff").unwrap());
assert_eq!(rgb(255, 255, 255), Rgb::parse("#ffffffffffff").unwrap());
assert_eq!(rgb(255, 0, 16), Rgb::parse("#ff0010").unwrap());
assert_eq!(rgb(0, 0, 0), Rgb::parse("black").unwrap());
assert_eq!(rgb(255, 0, 0), Rgb::parse("red").unwrap());
assert_eq!(rgb(0, 255, 0), Rgb::parse("green").unwrap());
assert_eq!(rgb(0, 0, 255), Rgb::parse("blue").unwrap());
assert_eq!(rgb(255, 255, 255), Rgb::parse("white").unwrap());
assert_eq!(rgb(124, 252, 0), Rgb::parse("LawnGreen").unwrap());
assert_eq!(rgb(0, 250, 154), Rgb::parse("medium spring green").unwrap());
assert_eq!(rgb(34, 139, 34), Rgb::parse(" Forest Green ").unwrap());
// Invalid format
for bad in [
"rgb;",
"rgb:",
":a/a/a",
"a/a/a",
"rgb:a/a/a/",
"rgb:00000///",
"rgb:000/",
"rgbi:a/a/a",
"rgb:0.5/0.0/1.0",
"rgb:not/hex/zz",
"#",
"#ff",
"#ffff",
"#fffff",
"#gggggg",
] {
assert_eq!(Err(InvalidFormat), Rgb::parse(bad), "{bad}");
}
}
#[test]
fn dynamic_palette_init() {
let p = DynamicPalette::init(DEFAULT);
assert_eq!(DEFAULT, p.current);
assert_eq!(DEFAULT, p.original);
assert_eq!(0, p.mask.count());
}
#[test]
fn dynamic_palette_set() {
let mut p = DynamicPalette::init(DEFAULT);
let new_color = rgb(255, 0, 0);
p.set(0, new_color);
assert_eq!(new_color, p.current[0]);
assert!(p.mask.is_set(0));
assert_eq!(1, p.mask.count());
assert_eq!(DEFAULT[0], p.original[0]);
}
#[test]
fn dynamic_palette_reset() {
let mut p = DynamicPalette::init(DEFAULT);
p.set(0, rgb(255, 0, 0));
assert!(p.mask.is_set(0));
p.reset(0);
assert_eq!(DEFAULT[0], p.current[0]);
assert!(!p.mask.is_set(0));
assert_eq!(0, p.mask.count());
}
#[test]
fn dynamic_palette_reset_all() {
let mut p = DynamicPalette::init(DEFAULT);
let c = rgb(255, 0, 0);
p.set(0, c);
p.set(5, c);
p.set(10, c);
assert_eq!(3, p.mask.count());
p.reset_all();
assert_eq!(DEFAULT, p.current);
assert_eq!(DEFAULT, p.original);
assert_eq!(0, p.mask.count());
}
#[test]
fn dynamic_palette_change_default_with_no_changes() {
let mut p = DynamicPalette::init(DEFAULT);
let mut new_palette = DEFAULT;
new_palette[0] = rgb(100, 100, 100);
p.change_default(new_palette);
assert_eq!(new_palette, p.original);
assert_eq!(new_palette, p.current);
assert_eq!(0, p.mask.count());
}
#[test]
fn dynamic_palette_change_default_preserves_changes() {
let mut p = DynamicPalette::init(DEFAULT);
let custom = rgb(255, 0, 0);
p.set(5, custom);
assert!(p.mask.is_set(5));
let mut new_palette = DEFAULT;
new_palette[0] = rgb(100, 100, 100);
new_palette[5] = rgb(50, 50, 50);
p.change_default(new_palette);
assert_eq!(new_palette, p.original);
assert_eq!(new_palette[0], p.current[0]);
assert_eq!(custom, p.current[5]);
assert!(p.mask.is_set(5));
assert_eq!(1, p.mask.count());
}
#[test]
fn dynamic_palette_change_default_with_multiple_changes() {
let mut p = DynamicPalette::init(DEFAULT);
let red = rgb(255, 0, 0);
let green = rgb(0, 255, 0);
let blue = rgb(0, 0, 255);
p.set(1, red);
p.set(2, green);
p.set(3, blue);
let mut new_palette = DEFAULT;
new_palette[0] = rgb(50, 50, 50);
new_palette[1] = rgb(60, 60, 60);
p.change_default(new_palette);
assert_eq!(new_palette[0], p.current[0]);
assert_eq!(red, p.current[1]);
assert_eq!(green, p.current[2]);
assert_eq!(blue, p.current[3]);
assert_eq!(3, p.mask.count());
}
fn approx(expected: f32, actual: f32) {
assert!((expected - actual).abs() <= 0.5, "{expected} vs {actual}");
}
#[test]
fn lab_from_rgb() {
let white = Lab::from_rgb(rgb(255, 255, 255));
approx(100.0, white.l);
approx(0.0, white.a);
approx(0.0, white.b);
let black = Lab::from_rgb(rgb(0, 0, 0));
approx(0.0, black.l);
approx(0.0, black.a);
approx(0.0, black.b);
let red = Lab::from_rgb(rgb(255, 0, 0));
approx(53.23, red.l);
approx(80.11, red.a);
approx(67.22, red.b);
let green = Lab::from_rgb(rgb(0, 128, 0));
approx(46.23, green.l);
approx(-51.70, green.a);
approx(49.90, green.b);
let blue = Lab::from_rgb(rgb(0, 0, 255));
approx(32.30, blue.l);
approx(79.20, blue.a);
approx(-107.86, blue.b);
}
#[test]
fn generate256_color_base16_preserved() {
let bg = rgb(0, 0, 0);
let fg = rgb(255, 255, 255);
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
for i in 0..16 {
assert_eq!(DEFAULT[i], palette[i]);
}
}
#[test]
fn generate256_color_cube_corners_match_base_colors() {
let bg = rgb(0, 0, 0);
let fg = rgb(255, 255, 255);
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
assert_eq!(bg, palette[16]);
assert_eq!(fg, palette[231]);
}
#[test]
fn generate256_color_cube_corners_black_white_with_harmonious_false() {
let black = rgb(0, 0, 0);
let white = rgb(255, 255, 255);
let dark = generate_256_color(DEFAULT, PaletteMask::init_empty(), black, white, false);
assert_eq!(black, dark[16]);
assert_eq!(white, dark[231]);
let light = generate_256_color(DEFAULT, PaletteMask::init_empty(), white, black, false);
assert_eq!(black, light[16]);
assert_eq!(white, light[231]);
}
#[test]
fn generate256_color_light_theme_cube_corners_with_harmonious_true() {
let white = rgb(255, 255, 255);
let black = rgb(0, 0, 0);
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), white, black, true);
assert_eq!(white, palette[16]);
assert_eq!(black, palette[231]);
}
#[test]
fn generate256_color_grayscale_ramp_monotonic_luminance() {
let bg = rgb(0, 0, 0);
let fg = rgb(255, 255, 255);
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
let mut prev_lum = 0.0;
for c in &palette[232..256] {
let lum = c.luminance();
assert!(lum >= prev_lum);
prev_lum = lum;
}
}
#[test]
fn generate256_color_skip_mask_preserves_original_colors() {
let bg = rgb(0, 0, 0);
let fg = rgb(255, 255, 255);
let mut skip = PaletteMask::init_empty();
skip.set(20);
skip.set(100);
skip.set(240);
let palette = generate_256_color(DEFAULT, skip, bg, fg, false);
assert_eq!(DEFAULT[20], palette[20]);
assert_eq!(DEFAULT[100], palette[100]);
assert_eq!(DEFAULT[240], palette[240]);
assert!(!palette[21].eql(DEFAULT[21]));
}
#[test]
fn generate256_color_dark_theme_harmonious_has_no_effect() {
let bg = rgb(0, 0, 0);
let fg = rgb(255, 255, 255);
let normal = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
let harmonious = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, true);
for i in 16..256 {
assert_eq!(normal[i], harmonious[i]);
}
}
#[test]
fn generate256_color_light_theme_harmonious_skips_inversion() {
let bg = rgb(255, 255, 255);
let fg = rgb(0, 0, 0);
let inverted = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
let harmonious = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, true);
assert_eq!(bg, harmonious[16]);
assert!(!inverted[16].eql(bg));
let differ = (16..232)
.filter(|&i| !inverted[i].eql(harmonious[i]))
.count();
assert!(differ > 0);
}
#[test]
fn generate256_color_light_theme_harmonious_grayscale_ramp() {
let bg = rgb(255, 255, 255);
let fg = rgb(0, 0, 0);
{
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
let mut prev_lum = 0.0;
for c in &palette[232..256] {
let lum = c.luminance();
assert!(lum >= prev_lum);
prev_lum = lum;
}
}
{
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, true);
let mut prev_lum = 1.0;
for c in &palette[232..256] {
let lum = c.luminance();
assert!(lum <= prev_lum);
prev_lum = lum;
}
}
}
#[test]
fn lab_to_rgb() {
let cases = [
rgb(255, 255, 255),
rgb(0, 0, 0),
rgb(255, 0, 0),
rgb(0, 128, 0),
rgb(0, 0, 255),
rgb(128, 128, 128),
rgb(64, 224, 208),
];
for expected in cases {
let actual = Lab::from_rgb(expected).to_rgb();
assert_eq!(expected, actual);
}
}
}
+91
View File
@@ -0,0 +1,91 @@
//! CSI command enums (ZIG/src/terminal/csi.zig).
/// Modes for the ED CSI command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum EraseDisplay {
Below = 0,
Above = 1,
Complete = 2,
Scrollback = 3,
/// This is an extension added by Kitty to move the viewport into the
/// scrollback and then erase the display.
ScrollComplete = 22,
}
impl EraseDisplay {
/// Non-exhaustive in spirit: unknown values yield `None`.
pub fn from_int(v: u16) -> Option<Self> {
Some(match v {
0 => Self::Below,
1 => Self::Above,
2 => Self::Complete,
3 => Self::Scrollback,
22 => Self::ScrollComplete,
_ => return None,
})
}
}
/// Modes for the EL CSI command. Non-exhaustive in Zig so that conversion
/// never fails since the inputs are user-generated; `Other` carries the raw value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EraseLine {
Right,
Left,
Complete,
RightUnlessPendingWrap,
Other(u8),
}
impl EraseLine {
pub fn from_int(v: u8) -> Self {
match v {
0 => Self::Right,
1 => Self::Left,
2 => Self::Complete,
4 => Self::RightUnlessPendingWrap,
v => Self::Other(v),
}
}
}
/// Modes for the TBC (tab clear) command. Non-exhaustive like [`EraseLine`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabClear {
Current,
All,
Other(u8),
}
impl TabClear {
pub fn from_int(v: u8) -> Self {
match v {
0 => Self::Current,
3 => Self::All,
v => Self::Other(v),
}
}
}
/// Style formats for terminal size reports (XTWINOPS).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SizeReportStyle {
Csi14T,
Csi16T,
Csi18T,
Csi21T,
}
/// XTWINOPS CSI 22/23
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TitlePushPop {
pub op: TitlePushPopOp,
pub index: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TitlePushPopOp {
Push,
Pop,
}
+371
View File
@@ -0,0 +1,371 @@
//! DCS command handler (ZIG/src/terminal/dcs.zig). This should be hooked
//! into a stream. The hook/put/unhook functions are meant to be called from
//! the stream's dcsHook, dcsPut, and dcsUnhook functions, respectively.
use crate::parser::Dcs;
use crate::tmux::control::{Notification, Parser as TmuxParser};
/// DCS command handler.
#[derive(Debug)]
pub struct Handler {
pub state: State,
/// Maximum bytes any DCS command can take. This is to prevent
/// malicious input from causing us to allocate too much memory.
/// This is arbitrarily set to 1MB today, increase if needed.
pub max_bytes: usize,
}
impl Default for Handler {
fn default() -> Self {
Self {
state: State::Inactive,
max_bytes: 1024 * 1024,
}
}
}
struct Hook {
state: State,
command: Option<Command>,
}
impl Handler {
pub fn new() -> Self {
Self::default()
}
pub fn hook(&mut self, dcs: &Dcs) -> Option<Command> {
debug_assert!(matches!(self.state, State::Inactive));
// Initialize our state to ignore in case of error
self.state = State::Ignore;
// Try to parse the hook.
let Some(hk) = self.try_hook(dcs) else {
log::info!("unknown DCS hook: {dcs:?}");
return None;
};
self.state = hk.state;
hk.command
}
fn try_hook(&self, dcs: &Dcs) -> Option<Hook> {
let params = dcs.params();
match dcs.intermediates() {
[] => match dcs.final_ {
// Tmux control mode
b'p' => {
// Tmux control mode must start with ESC P 1000 p
if params.len() != 1 || params[0] != 1000 {
return None;
}
let mut parser = TmuxParser::new();
parser.max_bytes = self.max_bytes;
Some(Hook {
state: State::Tmux(Box::new(parser)),
command: Some(Command::Tmux(Notification::Enter)),
})
}
_ => None,
},
[b'+'] => match dcs.final_ {
// XTGETTCAP
// https://github.com/mitchellh/ghostty/issues/517
b'q' => Some(Hook {
state: State::Xtgettcap(Vec::with_capacity(128)),
command: None,
}),
_ => None,
},
[b'$'] => match dcs.final_ {
// DECRQSS
b'q' => Some(Hook {
state: State::Decrqss(DecrqssBuffer::default()),
command: None,
}),
_ => None,
},
_ => None,
}
}
/// Put a byte into the DCS handler. This will return a command
/// if a command needs to be executed.
pub fn put(&mut self, byte: u8) -> Option<Command> {
let Ok(v) = self.try_put(byte) else {
// On error we just discard our state and ignore the rest
log::info!("error putting byte into DCS handler err=OutOfMemory");
self.discard();
self.state = State::Ignore;
return None;
};
v
}
fn try_put(&mut self, byte: u8) -> Result<Option<Command>, ()> {
match &mut self.state {
State::Inactive | State::Ignore => {}
State::Tmux(tmux) => {
return match tmux.put(byte) {
Ok(n) => Ok(n.map(Command::Tmux)),
Err(_) => Err(()),
};
}
State::Xtgettcap(list) => {
if list.len() >= self.max_bytes {
return Err(());
}
list.push(byte);
}
State::Decrqss(buffer) => {
if buffer.len >= buffer.data.len() {
return Err(());
}
buffer.data[buffer.len] = byte;
buffer.len += 1;
}
}
Ok(None)
}
pub fn unhook(&mut self) -> Option<Command> {
let state = std::mem::replace(&mut self.state, State::Inactive);
match state {
State::Inactive | State::Ignore => None,
State::Tmux(_) => Some(Command::Tmux(Notification::Exit)),
State::Xtgettcap(mut list) => {
list.make_ascii_uppercase();
Some(Command::Xtgettcap(Xtgettcap { data: list, i: 0 }))
}
State::Decrqss(buffer) => Some(Command::Decrqss(match buffer.len {
0 => Decrqss::None,
1 => match buffer.data[0] {
b'm' => Decrqss::Sgr,
b'r' => Decrqss::Decstbm,
b's' => Decrqss::Decslrm,
_ => Decrqss::None,
},
2 => match buffer.data[0] {
b' ' => match buffer.data[1] {
b'q' => Decrqss::Decscusr,
_ => Decrqss::None,
},
_ => Decrqss::None,
},
_ => unreachable!(),
})),
}
}
fn discard(&mut self) {
self.state = State::Inactive;
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
/// XTGETTCAP
Xtgettcap(Xtgettcap),
/// DECRQSS
Decrqss(Decrqss),
/// Tmux control mode
Tmux(Notification),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Xtgettcap {
pub data: Vec<u8>,
pub i: usize,
}
impl Xtgettcap {
/// Returns the next terminfo key being requested and None
/// when there are no more keys. The returned value is NOT hex-decoded
/// because we expect to use a comptime lookup table.
#[allow(clippy::should_implement_trait)]
pub fn next(&mut self) -> Option<&[u8]> {
let items = &self.data;
if self.i >= items.len() {
return None;
}
let rem = &items[self.i..];
let idx = rem.iter().position(|&b| b == b';').unwrap_or(rem.len());
// Note that if we're at the end, idx + 1 is len + 1 so we're over
// the end but that's okay because our check above is >= so we'll
// never read.
self.i += idx + 1;
Some(&rem[..idx])
}
}
/// Supported DECRQSS settings
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decrqss {
None,
Sgr,
Decscusr,
Decstbm,
Decslrm,
}
#[derive(Debug, Default)]
pub struct DecrqssBuffer {
data: [u8; 2],
len: usize,
}
#[derive(Debug, Default)]
pub enum State {
/// We're not in a DCS state at the moment.
#[default]
Inactive,
/// We're hooked, but its an unknown DCS command or one that went
/// invalid due to some bad input, so we're ignoring the rest.
Ignore,
/// XTGETTCAP
Xtgettcap(Vec<u8>),
/// DECRQSS
Decrqss(DecrqssBuffer),
/// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode
Tmux(Box<TmuxParser>),
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn dcs(inter: &[u8], params: &[u16], final_: u8) -> Dcs {
Dcs::new(inter, params, final_)
}
#[test]
fn unknown_dcs_command() {
let mut h = Handler::new();
assert!(h.hook(&dcs(b"", &[], b'A')).is_none());
assert!(matches!(h.state, State::Ignore));
assert!(h.unhook().is_none());
assert!(matches!(h.state, State::Inactive));
}
fn xtgettcap(input: &str) -> Xtgettcap {
let mut h = Handler::new();
assert!(h.hook(&dcs(b"+", &[], b'q')).is_none());
for b in input.bytes() {
let _ = h.put(b);
}
match h.unhook().unwrap() {
Command::Xtgettcap(x) => x,
_ => panic!("expected xtgettcap"),
}
}
#[test]
fn xtgettcap_command() {
let mut cmd = xtgettcap("536D756C78");
assert_eq!(cmd.next(), Some(&b"536D756C78"[..]));
assert_eq!(cmd.next(), None);
}
#[test]
fn xtgettcap_mixed_case() {
let mut cmd = xtgettcap("536d756C78");
assert_eq!(cmd.next(), Some(&b"536D756C78"[..]));
assert_eq!(cmd.next(), None);
}
#[test]
fn xtgettcap_command_multiple_keys() {
let mut cmd = xtgettcap("536D756C78;536D756C78");
assert_eq!(cmd.next(), Some(&b"536D756C78"[..]));
assert_eq!(cmd.next(), Some(&b"536D756C78"[..]));
assert_eq!(cmd.next(), None);
}
#[test]
fn xtgettcap_command_invalid_data() {
let mut cmd = xtgettcap("who;536D756C78");
assert_eq!(cmd.next(), Some(&b"WHO"[..]));
assert_eq!(cmd.next(), Some(&b"536D756C78"[..]));
assert_eq!(cmd.next(), None);
}
#[test]
fn xtgettcap_max_bytes() {
let mut h = Handler::new();
h.max_bytes = 2;
assert!(h.hook(&dcs(b"+", &[], b'q')).is_none());
for b in b"abc" {
let _ = h.put(*b);
}
assert!(matches!(h.state, State::Ignore));
assert!(h.unhook().is_none());
}
#[test]
fn decrqss_command() {
let mut h = Handler::new();
assert!(h.hook(&dcs(b"$", &[], b'q')).is_none());
let _ = h.put(b'm');
assert_eq!(h.unhook(), Some(Command::Decrqss(Decrqss::Sgr)));
}
#[test]
fn decrqss_invalid_command() {
let mut h = Handler::new();
assert!(h.hook(&dcs(b"$", &[], b'q')).is_none());
let _ = h.put(b'z');
assert_eq!(h.unhook(), Some(Command::Decrqss(Decrqss::None)));
h.discard();
assert!(h.hook(&dcs(b"$", &[], b'q')).is_none());
let _ = h.put(b'"');
let _ = h.put(b' ');
let _ = h.put(b'q');
assert!(h.unhook().is_none());
}
#[test]
fn tmux_enter_and_implicit_exit() {
let mut h = Handler::new();
let cmd = h.hook(&dcs(b"", &[1000], b'p')).unwrap();
assert_eq!(cmd, Command::Tmux(Notification::Enter));
let cmd = h.unhook().unwrap();
assert_eq!(cmd, Command::Tmux(Notification::Exit));
}
#[test]
fn tmux_requires_1000_param() {
let mut h = Handler::new();
assert!(h.hook(&dcs(b"", &[1], b'p')).is_none());
assert!(matches!(h.state, State::Ignore));
}
}
+76
View File
@@ -0,0 +1,76 @@
//! Device status requests (ZIG/src/terminal/device_status.zig).
/// The available device status requests. Each is identified by a numeric
/// value plus whether it uses the "?" prefix.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Request {
OperatingStatus,
CursorPosition,
ColorScheme,
}
impl Request {
/// The numeric value of the request.
pub fn value(self) -> u16 {
ENTRIES
.iter()
.find(|e| e.request == self)
.map_or(0, |e| e.value)
}
/// Whether the request uses the "?" prefix.
pub fn question(self) -> bool {
ENTRIES
.iter()
.find(|e| e.request == self)
.is_some_and(|e| e.question)
}
}
struct Entry {
request: Request,
value: u16,
question: bool,
}
/// The full list of device status request entries.
const ENTRIES: &[Entry] = &[
Entry {
request: Request::OperatingStatus,
value: 5,
question: false,
},
Entry {
request: Request::CursorPosition,
value: 6,
question: false,
},
Entry {
request: Request::ColorScheme,
value: 996,
question: true,
},
];
pub fn req_from_int(v: u16, question: bool) -> Option<Request> {
ENTRIES
.iter()
.find(|e| e.value == v && e.question == question)
.map(|e| e.request)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn req_from_int_lookup() {
assert_eq!(req_from_int(5, false), Some(Request::OperatingStatus));
assert_eq!(req_from_int(6, false), Some(Request::CursorPosition));
assert_eq!(req_from_int(996, true), Some(Request::ColorScheme));
assert_eq!(req_from_int(996, false), None);
assert_eq!(req_from_int(5, true), None);
assert_eq!(Request::ColorScheme.value(), 996);
assert!(Request::ColorScheme.question());
}
}
+114
View File
@@ -0,0 +1,114 @@
//! Kitty color protocol (ZIG/src/terminal/kitty/color.zig).
use crate::color::Rgb;
use crate::osc::Terminator;
use std::fmt;
/// OSC 21 payload.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct OSC {
/// list of requests
pub list: Vec<Request>,
/// We must reply with the same string terminator (ST) as used in the
/// request.
pub terminator: Terminator,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Request {
Query(Kind),
Set { key: Kind, color: Rgb },
Reset(Kind),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Special {
Foreground,
Background,
SelectionForeground,
SelectionBackground,
Cursor,
CursorText,
VisualBell,
SecondTransparentBackground,
}
impl Special {
/// The tag name (Zig `@tagName`).
pub fn name(self) -> &'static str {
match self {
Special::Foreground => "foreground",
Special::Background => "background",
Special::SelectionForeground => "selection_foreground",
Special::SelectionBackground => "selection_background",
Special::Cursor => "cursor",
Special::CursorText => "cursor_text",
Special::VisualBell => "visual_bell",
Special::SecondTransparentBackground => "second_transparent_background",
}
}
/// Zig `std.meta.stringToEnum`.
pub fn from_name(s: &[u8]) -> Option<Special> {
Some(match s {
b"foreground" => Special::Foreground,
b"background" => Special::Background,
b"selection_foreground" => Special::SelectionForeground,
b"selection_background" => Special::SelectionBackground,
b"cursor" => Special::Cursor,
b"cursor_text" => Special::CursorText,
b"visual_bell" => Special::VisualBell,
b"second_transparent_background" => Special::SecondTransparentBackground,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Kind {
Palette(u8),
Special(Special),
}
impl Kind {
/// Number of distinct kinds (255 + number of specials).
pub const MAX: usize = u8::MAX as usize + 8;
pub fn parse(key: &[u8]) -> Option<Kind> {
if let Some(s) = Special::from_name(key) {
return Some(Kind::Special(s));
}
// Zig parseUnsigned(u8, key, 10): optional '+', digits only.
let digits = match key.first()? {
b'+' => &key[1..],
_ => key,
};
if digits.is_empty() || !digits.iter().all(u8::is_ascii_digit) {
return None;
}
let s = std::str::from_utf8(digits).ok()?;
s.parse::<u8>().ok().map(Kind::Palette)
}
}
impl fmt::Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Kind::Palette(p) => write!(f, "{p}"),
Kind::Special(s) => f.write_str(s.name()),
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn osc_kitty_color_protocol_kind_string() {
assert_eq!("foreground", Kind::Special(Special::Foreground).to_string());
assert_eq!("42", Kind::Palette(42).to_string());
}
}
@@ -0,0 +1,1391 @@
//! Kitty graphics protocol command parser
//! (ZIG/src/terminal/kitty/graphics_command.zig).
use std::collections::HashMap;
/// The key-value pairs for the control information for a command. The
/// keys are always single characters and the values are either single
/// characters or 32-bit unsigned integers.
///
/// For the value of this: if the value is a single printable ASCII character
/// it is the ASCII code. Otherwise, it is parsed as a 32-bit unsigned integer.
type Kv = HashMap<u8, u32>;
/// Errors from the command parser.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum Error {
#[error("invalid format")]
InvalidFormat,
#[error("invalid data")]
InvalidData,
#[error("invalid character")]
InvalidCharacter,
#[error("overflow")]
Overflow,
}
/// Mirrors `std.fmt.parseInt(T, buf, 10)` for 32-bit targets: optional sign,
/// at least one digit, range checked.
fn parse_int(buf: &[u8], signed: bool) -> Result<u32, Error> {
// kv_temp is at most 11 bytes so an i64 always suffices unless there
// are overflow digits, which we detect via checked arithmetic.
let (neg, digits) = match buf.first() {
Some(b'+') if buf.len() > 1 => (false, &buf[1..]),
Some(b'-') if buf.len() > 1 => (true, &buf[1..]),
_ => (false, buf),
};
if digits.is_empty() {
return Err(Error::InvalidCharacter);
}
let mut v: i64 = 0;
let mut overflow = false;
for &c in digits {
if !c.is_ascii_digit() {
return Err(Error::InvalidCharacter);
}
match v
.checked_mul(10)
.and_then(|x| x.checked_add(i64::from(c - b'0')))
{
Some(x) => v = x,
None => overflow = true,
}
}
if overflow {
return Err(Error::Overflow);
}
if neg {
v = -v;
}
if signed {
i32::try_from(v)
.map(|x| x as u32)
.map_err(|_| Error::Overflow)
} else {
u32::try_from(v).map_err(|_| Error::Overflow)
}
}
/// Decode base64 payload data. The reference implementation uses simdutf,
/// which ignores ASCII whitespace and tolerates missing padding.
pub fn decode_base64(input: &[u8]) -> Result<Vec<u8>, Error> {
use base64::Engine;
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
const ENGINE: GeneralPurpose = GeneralPurpose::new(
&base64::alphabet::STANDARD,
GeneralPurposeConfig::new()
.with_decode_padding_mode(DecodePaddingMode::Indifferent)
.with_decode_allow_trailing_bits(true),
);
let cleaned: Vec<u8> = input
.iter()
.copied()
.filter(|b| !matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0c))
.collect();
let trimmed_len = cleaned
.iter()
.rposition(|&b| b != b'=')
.map_or(0, |i| i + 1);
ENGINE
.decode(&cleaned[..trimmed_len])
.map_err(|_| Error::InvalidData)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
/// Parsing k/v pairs. The "ignore" variants are in that state
/// but ignore any data because we know they're invalid.
ControlKey,
ControlKeyIgnore,
ControlValue,
ControlValueIgnore,
/// Collecting the data payload blob.
Data,
}
/// Command parser parses the Kitty graphics protocol escape sequence.
#[derive(Debug)]
pub struct Parser {
/// The list of KV pairs that we're building up.
kv: Kv,
/// Buffer to store the key/value of a KV pair. The value of a KV pair is
/// at most a 32-bit integer which at most is 10 characters (4294967295),
/// plus one character for the sign bit on signed ints.
kv_temp: [u8; 11],
kv_temp_len: usize,
/// Current kv key
kv_current: u8,
/// The bytes of the data payload.
data: Vec<u8>,
state: State,
}
impl Default for Parser {
fn default() -> Self {
Self::new()
}
}
impl Parser {
pub fn new() -> Self {
Self {
kv: HashMap::new(),
kv_temp: [0; 11],
kv_temp_len: 0,
kv_current: 0,
data: Vec::new(),
state: State::ControlKey,
}
}
/// Parse a complete command string.
pub fn parse_string(data: &[u8]) -> Result<Command, Error> {
let mut parser = Self::new();
for &c in data {
parser.feed(c)?;
}
parser.complete()
}
/// Feed a single byte to the parser.
///
/// The first byte to start parsing should be the byte immediately following
/// the "G" in the APC sequence, i.e. "\x1b_G123" the first byte should
/// be "1".
pub fn feed(&mut self, c: u8) -> Result<(), Error> {
match self.state {
State::ControlKey => match c {
// '=' means the key is complete and we're moving to the value.
b'=' => {
if self.kv_temp_len == 1 {
self.kv_current = self.kv_temp[0];
self.kv_temp_len = 0;
self.state = State::ControlValue;
} else {
// All control keys are a single character right now so
// if we're not a single character just ignore follow-up
// data.
self.state = State::ControlValueIgnore;
self.kv_temp_len = 0;
}
}
// This can be encountered if we have a sequence with no
// control data, only payload data (i.e. "\x1b_G;<data>").
//
// Kitty treats this as valid so we do as well.
b';' => self.state = State::Data,
_ => self.accumulate_value(c, State::ControlKeyIgnore),
},
State::ControlKeyIgnore => {
if c == b'=' {
self.state = State::ControlValueIgnore;
}
}
State::ControlValue => match c {
// move to next key
b',' => self.finish_value(State::ControlKey)?,
// move to data
b';' => self.finish_value(State::Data)?,
_ => self.accumulate_value(c, State::ControlValueIgnore),
},
State::ControlValueIgnore => match c {
b',' => self.state = State::ControlKeyIgnore,
b';' => self.state = State::Data,
_ => {}
},
State::Data => self.data.push(c),
}
Ok(())
}
/// Complete the parsing. This must be called after all the
/// bytes have been fed to the parser. The payload data is moved into the
/// resulting command.
pub fn complete(&mut self) -> Result<Command, Error> {
match self.state {
// We can't ever end in the control key state and be valid.
// This means the command looked something like "a=1,b"
State::ControlKey | State::ControlKeyIgnore => return Err(Error::InvalidFormat),
// Some commands (i.e. placements) end without extra data so
// we end in the value state. i.e. "a=1,b=2"
State::ControlValue => self.finish_value(State::Data)?,
State::ControlValueIgnore => {}
// Most commands end in data, i.e. "a=1,b=2;1234"
State::Data => {}
}
// Determine our action, which is always a single character.
let action: u8 = match self.kv.get(&b'a') {
None => b't',
Some(&v) => u8::try_from(v).map_err(|_| Error::InvalidFormat)?,
};
let kv = &self.kv;
let control = match action {
b'q' => Control::Query(Transmission::parse(kv)?),
b't' => Control::Transmit(Transmission::parse(kv)?),
b'T' => Control::TransmitAndDisplay {
transmission: Transmission::parse(kv)?,
display: Display::parse(kv)?,
},
b'p' => Control::Display(Display::parse(kv)?),
b'd' => Control::Delete(Delete::parse(kv)?),
b'f' => Control::TransmitAnimationFrame(AnimationFrameLoading::parse(kv)?),
b'a' => Control::ControlAnimation(AnimationControl::parse(kv)?),
b'c' => Control::ComposeAnimation(AnimationFrameComposition::parse(kv)?),
_ => return Err(Error::InvalidFormat),
};
// Determine our quiet value
let quiet = match self.kv.get(&b'q') {
Some(0) => Quiet::No,
Some(1) => Quiet::Ok,
Some(2) => Quiet::Failures,
Some(_) => return Err(Error::InvalidFormat),
None => Quiet::No,
};
Ok(Command {
control,
quiet,
data: self.decode_data()?,
})
}
/// Decodes the payload data from base64 and returns it. This consumes
/// the collected payload bytes.
fn decode_data(&mut self) -> Result<Vec<u8>, Error> {
if self.data.is_empty() {
return Ok(Vec::new());
}
let data = std::mem::take(&mut self.data);
decode_base64(&data).inspect_err(|e| {
log::warn!("failed to decode base64 payload data: {e}");
})
}
fn accumulate_value(&mut self, c: u8, overflow_state: State) {
let idx = self.kv_temp_len;
self.kv_temp_len += 1;
if self.kv_temp_len > self.kv_temp.len() {
self.state = overflow_state;
self.kv_temp_len = 0;
return;
}
self.kv_temp[idx] = c;
}
fn finish_value(&mut self, next_state: State) -> Result<(), Error> {
// We can move states right away, we don't use it.
self.state = next_state;
// Check for ASCII chars first
if self.kv_temp_len == 1 {
let c = self.kv_temp[0];
if !c.is_ascii_digit() {
self.kv.insert(self.kv_current, u32::from(c));
self.kv_temp_len = 0;
return Ok(());
}
}
// Handle integer fields, parsing signed fields accordingly. We still
// store the fields as u32 as they can be bitcast back later during
// building of the higher-level command tree.
let buf = &self.kv_temp[..self.kv_temp_len];
let v = match self.kv_current {
b'z' | b'H' | b'V' => parse_int(buf, true)?,
_ => parse_int(buf, false)?,
};
self.kv.insert(self.kv_current, v);
// Clear our temp buffer
self.kv_temp_len = 0;
Ok(())
}
}
/// Represents a possible response to a command.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Response {
pub id: u32,
pub image_number: u32,
pub placement_id: u32,
pub message: String,
}
impl Default for Response {
fn default() -> Self {
Self {
id: 0,
image_number: 0,
placement_id: 0,
message: "OK".to_string(),
}
}
}
impl Response {
pub fn encode(&self, writer: &mut impl std::io::Write) -> std::io::Result<()> {
// We only encode a result if we have either an id or an image number.
if self.id == 0 && self.image_number == 0 {
return Ok(());
}
// Used to cheaply keep track if we need to add a comma before
// the next key-value pair.
let mut prior = false;
writer.write_all(b"\x1b_G")?;
if self.id > 0 {
prior = true;
write!(writer, "i={}", self.id)?;
}
if self.image_number > 0 {
if prior {
writer.write_all(b",")?;
} else {
prior = true;
}
write!(writer, "I={}", self.image_number)?;
}
if self.placement_id > 0 {
if prior {
writer.write_all(b",")?;
}
write!(writer, "p={}", self.placement_id)?;
}
writer.write_all(b";")?;
writer.write_all(self.message.as_bytes())?;
writer.write_all(b"\x1b\\")?;
Ok(())
}
/// Returns true if this response is not an error.
pub fn ok(&self) -> bool {
self.message == "OK"
}
/// Empty response
pub fn empty(&self) -> bool {
self.id == 0 && self.image_number == 0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Command {
pub control: Control,
pub quiet: Quiet,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
/// q
Query,
/// t
Transmit,
/// T
TransmitAndDisplay,
/// p
Display,
/// d
Delete,
/// f
TransmitAnimationFrame,
/// a
ControlAnimation,
/// c
ComposeAnimation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Quiet {
/// 0
#[default]
No,
/// 1
Ok,
/// 2
Failures,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Control {
Query(Transmission),
Transmit(Transmission),
TransmitAndDisplay {
transmission: Transmission,
display: Display,
},
Display(Display),
Delete(Delete),
TransmitAnimationFrame(AnimationFrameLoading),
ControlAnimation(AnimationControl),
ComposeAnimation(AnimationFrameComposition),
}
impl Control {
pub fn action(&self) -> Action {
match self {
Control::Query(_) => Action::Query,
Control::Transmit(_) => Action::Transmit,
Control::TransmitAndDisplay { .. } => Action::TransmitAndDisplay,
Control::Display(_) => Action::Display,
Control::Delete(_) => Action::Delete,
Control::TransmitAnimationFrame(_) => Action::TransmitAnimationFrame,
Control::ControlAnimation(_) => Action::ControlAnimation,
Control::ComposeAnimation(_) => Action::ComposeAnimation,
}
}
}
impl Command {
/// Take ownership over the data in this command, leaving it empty.
pub fn to_owned_data(&mut self) -> Vec<u8> {
std::mem::take(&mut self.data)
}
/// Returns the transmission data if it has any.
pub fn transmission(&self) -> Option<Transmission> {
match &self.control {
Control::Query(t) | Control::Transmit(t) => Some(*t),
Control::TransmitAndDisplay { transmission, .. } => Some(*transmission),
_ => None,
}
}
/// Returns the display data if it has any.
pub fn display(&self) -> Option<Display> {
match &self.control {
Control::Display(d) => Some(*d),
Control::TransmitAndDisplay { display, .. } => Some(*display),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Transmission {
/// f
pub format: TransmissionFormat,
/// t
pub medium: TransmissionMedium,
/// s
pub width: u32,
/// v
pub height: u32,
/// S
pub size: u32,
/// O
pub offset: u32,
/// i
pub image_id: u32,
/// I
pub image_number: u32,
/// p
pub placement_id: u32,
/// o
pub compression: TransmissionCompression,
/// m
pub more_chunks: bool,
}
impl Default for Transmission {
fn default() -> Self {
Self {
format: TransmissionFormat::Rgba,
medium: TransmissionMedium::Direct,
width: 0,
height: 0,
size: 0,
offset: 0,
image_id: 0,
image_number: 0,
placement_id: 0,
compression: TransmissionCompression::None,
more_chunks: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransmissionFormat {
/// 24
Rgb,
/// 32
Rgba,
/// 100
Png,
// The following are not supported directly via the protocol
// but they are formats that a png may decode to that we
// support.
GrayAlpha,
Gray,
}
impl TransmissionFormat {
/// Bytes per pixel.
///
/// # Panics
/// Panics for `Png`, which must be validated before.
pub fn bpp(self) -> u8 {
match self {
Self::Gray => 1,
Self::GrayAlpha => 2,
Self::Rgb => 3,
Self::Rgba => 4,
Self::Png => unreachable!("png must be validated before"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransmissionMedium {
/// d
Direct,
/// f
File,
/// t
TemporaryFile,
/// s
SharedMemory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransmissionCompression {
None,
/// z
ZlibDeflate,
}
fn get(kv: &Kv, k: u8) -> Option<u32> {
kv.get(&k).copied()
}
impl Transmission {
fn parse(kv: &Kv) -> Result<Transmission, Error> {
let mut result = Transmission::default();
if let Some(v) = get(kv, b'f') {
result.format = match v {
24 => TransmissionFormat::Rgb,
32 => TransmissionFormat::Rgba,
100 => TransmissionFormat::Png,
_ => return Err(Error::InvalidFormat),
};
}
if let Some(v) = get(kv, b't') {
let c = u8::try_from(v).map_err(|_| Error::InvalidFormat)?;
result.medium = match c {
b'd' => TransmissionMedium::Direct,
b'f' => TransmissionMedium::File,
b't' => TransmissionMedium::TemporaryFile,
b's' => TransmissionMedium::SharedMemory,
_ => return Err(Error::InvalidFormat),
};
}
if let Some(v) = get(kv, b's') {
result.width = v;
}
if let Some(v) = get(kv, b'v') {
result.height = v;
}
if let Some(v) = get(kv, b'S') {
result.size = v;
}
if let Some(v) = get(kv, b'O') {
result.offset = v;
}
if let Some(v) = get(kv, b'i') {
result.image_id = v;
}
if let Some(v) = get(kv, b'I') {
result.image_number = v;
}
if let Some(v) = get(kv, b'p') {
result.placement_id = v;
}
if let Some(v) = get(kv, b'o') {
let c = u8::try_from(v).map_err(|_| Error::InvalidFormat)?;
result.compression = match c {
b'z' => TransmissionCompression::ZlibDeflate,
_ => return Err(Error::InvalidFormat),
};
}
// If the transmission medium is a local-only medium, ignore the "m"
// key. The Kitty graphics protocol specification does not explicitly
// call out this behavior (although the "m" key is only mentioned in
// connection with remote clients) but that's how it's implemented in
// Kitty and at least one client (mpv) relies on this behavior when
// using the shared memory transmission medium.
//
// https://sw.kovidgoyal.net/kitty/graphics-protocol/#the-transmission-medium
// https://github.com/kovidgoyal/kitty/blob/ccc3bee9af794f332b4e9adcd714a649f639c397/kitty/graphics.c#L547-L592
if result.medium == TransmissionMedium::Direct
&& let Some(v) = get(kv, b'm')
{
result.more_chunks = v > 0;
}
Ok(result)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Display {
/// i
pub image_id: u32,
/// I
pub image_number: u32,
/// p
pub placement_id: u32,
/// x
pub x: u32,
/// y
pub y: u32,
/// w
pub width: u32,
/// h
pub height: u32,
/// X
pub x_offset: u32,
/// Y
pub y_offset: u32,
/// c
pub columns: u32,
/// r
pub rows: u32,
/// C
pub cursor_movement: CursorMovement,
/// U
pub virtual_placement: bool,
/// P
pub parent_id: u32,
/// Q
pub parent_placement_id: u32,
/// H
pub horizontal_offset: i32,
/// V
pub vertical_offset: i32,
/// z
pub z: i32,
}
impl Default for Display {
fn default() -> Self {
Self {
image_id: 0,
image_number: 0,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
x_offset: 0,
y_offset: 0,
columns: 0,
rows: 0,
cursor_movement: CursorMovement::After,
virtual_placement: false,
parent_id: 0,
parent_placement_id: 0,
horizontal_offset: 0,
vertical_offset: 0,
z: 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CursorMovement {
/// 0
After,
/// 1
None,
}
impl Display {
fn parse(kv: &Kv) -> Result<Display, Error> {
let mut r = Display::default();
if let Some(v) = get(kv, b'i') {
r.image_id = v;
}
if let Some(v) = get(kv, b'I') {
r.image_number = v;
}
if let Some(v) = get(kv, b'p') {
r.placement_id = v;
}
if let Some(v) = get(kv, b'x') {
r.x = v;
}
if let Some(v) = get(kv, b'y') {
r.y = v;
}
if let Some(v) = get(kv, b'w') {
r.width = v;
}
if let Some(v) = get(kv, b'h') {
r.height = v;
}
if let Some(v) = get(kv, b'X') {
r.x_offset = v;
}
if let Some(v) = get(kv, b'Y') {
r.y_offset = v;
}
if let Some(v) = get(kv, b'c') {
r.columns = v;
}
if let Some(v) = get(kv, b'r') {
r.rows = v;
}
if let Some(v) = get(kv, b'C') {
r.cursor_movement = match v {
0 => CursorMovement::After,
1 => CursorMovement::None,
_ => return Err(Error::InvalidFormat),
};
}
if let Some(v) = get(kv, b'U') {
r.virtual_placement = match v {
0 => false,
1 => true,
_ => return Err(Error::InvalidFormat),
};
}
if let Some(v) = get(kv, b'z') {
// We can bitcast here because of how we parse it earlier.
r.z = v as i32;
}
if let Some(v) = get(kv, b'P') {
r.parent_id = v;
}
if let Some(v) = get(kv, b'Q') {
r.parent_placement_id = v;
}
if let Some(v) = get(kv, b'H') {
r.horizontal_offset = v as i32;
}
if let Some(v) = get(kv, b'V') {
r.vertical_offset = v as i32;
}
Ok(r)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompositionMode {
/// 0
AlphaBlend,
/// 1
Overwrite,
}
fn composition_mode(v: u32) -> Result<CompositionMode, Error> {
match v {
0 => Ok(CompositionMode::AlphaBlend),
1 => Ok(CompositionMode::Overwrite),
_ => Err(Error::InvalidFormat),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct AnimationBackground {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
impl AnimationBackground {
/// Equivalent of the Zig `@bitCast` from a `u32` (r is the low byte).
pub fn from_int(v: u32) -> Self {
Self {
r: v as u8,
g: (v >> 8) as u8,
b: (v >> 16) as u8,
a: (v >> 24) as u8,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AnimationFrameLoading {
/// x
pub x: u32,
/// y
pub y: u32,
/// c
pub create_frame: u32,
/// r
pub edit_frame: u32,
/// z
pub gap_ms: u32,
/// X
pub composition_mode: CompositionMode,
/// Y
pub background: AnimationBackground,
}
impl Default for AnimationFrameLoading {
fn default() -> Self {
Self {
x: 0,
y: 0,
create_frame: 0,
edit_frame: 0,
gap_ms: 0,
composition_mode: CompositionMode::AlphaBlend,
background: AnimationBackground::default(),
}
}
}
impl AnimationFrameLoading {
fn parse(kv: &Kv) -> Result<Self, Error> {
let mut r = Self::default();
if let Some(v) = get(kv, b'x') {
r.x = v;
}
if let Some(v) = get(kv, b'y') {
r.y = v;
}
if let Some(v) = get(kv, b'c') {
r.create_frame = v;
}
if let Some(v) = get(kv, b'r') {
r.edit_frame = v;
}
if let Some(v) = get(kv, b'z') {
r.gap_ms = v;
}
if let Some(v) = get(kv, b'X') {
r.composition_mode = composition_mode(v)?;
}
if let Some(v) = get(kv, b'Y') {
r.background = AnimationBackground::from_int(v);
}
Ok(r)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AnimationFrameComposition {
/// c
pub frame: u32,
/// r
pub edit_frame: u32,
/// x
pub x: u32,
/// y
pub y: u32,
/// w
pub width: u32,
/// h
pub height: u32,
/// X
pub left_edge: u32,
/// Y
pub top_edge: u32,
/// C
pub composition_mode: CompositionMode,
}
impl Default for AnimationFrameComposition {
fn default() -> Self {
Self {
frame: 0,
edit_frame: 0,
x: 0,
y: 0,
width: 0,
height: 0,
left_edge: 0,
top_edge: 0,
composition_mode: CompositionMode::AlphaBlend,
}
}
}
impl AnimationFrameComposition {
fn parse(kv: &Kv) -> Result<Self, Error> {
let mut r = Self::default();
if let Some(v) = get(kv, b'c') {
r.frame = v;
}
if let Some(v) = get(kv, b'r') {
r.edit_frame = v;
}
if let Some(v) = get(kv, b'x') {
r.x = v;
}
if let Some(v) = get(kv, b'y') {
r.y = v;
}
if let Some(v) = get(kv, b'w') {
r.width = v;
}
if let Some(v) = get(kv, b'h') {
r.height = v;
}
if let Some(v) = get(kv, b'X') {
r.left_edge = v;
}
if let Some(v) = get(kv, b'Y') {
r.top_edge = v;
}
if let Some(v) = get(kv, b'C') {
r.composition_mode = composition_mode(v)?;
}
Ok(r)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnimationAction {
/// 0
Invalid,
/// 1
Stop,
/// 2
RunWait,
/// 3
Run,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AnimationControl {
/// s
pub action: AnimationAction,
/// r
pub frame: u32,
/// z
pub gap_ms: u32,
/// c
pub current_frame: u32,
/// v
pub loops: u32,
}
impl Default for AnimationControl {
fn default() -> Self {
Self {
action: AnimationAction::Invalid,
frame: 0,
gap_ms: 0,
current_frame: 0,
loops: 0,
}
}
}
impl AnimationControl {
fn parse(kv: &Kv) -> Result<Self, Error> {
let mut r = Self::default();
if let Some(v) = get(kv, b's') {
r.action = match v {
0 => AnimationAction::Invalid,
1 => AnimationAction::Stop,
2 => AnimationAction::RunWait,
3 => AnimationAction::Run,
_ => return Err(Error::InvalidFormat),
};
}
if let Some(v) = get(kv, b'r') {
r.frame = v;
}
if let Some(v) = get(kv, b'z') {
r.gap_ms = v;
}
if let Some(v) = get(kv, b'c') {
r.current_frame = v;
}
if let Some(v) = get(kv, b'v') {
r.loops = v;
}
Ok(r)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Delete {
/// a/A
All(bool),
/// i/I
Id {
/// uppercase
delete: bool,
/// i
image_id: u32,
/// p
placement_id: u32,
},
/// n/N
Newest {
delete: bool,
/// I
image_number: u32,
/// p
placement_id: u32,
},
/// c/C
IntersectCursor(bool),
/// f/F
AnimationFrames(bool),
/// p/P
IntersectCell { delete: bool, x: u32, y: u32 },
/// q/Q
IntersectCellZ {
delete: bool,
x: u32,
y: u32,
z: i32,
},
/// r/R
Range {
delete: bool,
/// x
first: u32,
/// y
last: u32,
},
/// x/X
Column { delete: bool, x: u32 },
/// y/Y
Row { delete: bool, y: u32 },
/// z/Z
Z { delete: bool, z: i32 },
}
impl Delete {
fn parse(kv: &Kv) -> Result<Delete, Error> {
let what: u8 = match get(kv, b'd') {
None => b'a',
Some(v) => u8::try_from(v).map_err(|_| Error::InvalidFormat)?,
};
let up = what.is_ascii_uppercase();
let or0 = |k: u8| get(kv, k).unwrap_or(0);
Ok(match what {
b'a' | b'A' => Delete::All(what == b'A'),
b'i' | b'I' => Delete::Id {
delete: what == b'I',
image_id: or0(b'i'),
placement_id: or0(b'p'),
},
b'n' | b'N' => Delete::Newest {
delete: what == b'N',
image_number: or0(b'I'),
placement_id: or0(b'p'),
},
b'c' | b'C' => Delete::IntersectCursor(what == b'C'),
b'f' | b'F' => Delete::AnimationFrames(what == b'F'),
b'p' | b'P' => Delete::IntersectCell {
delete: up,
x: or0(b'x'),
y: or0(b'y'),
},
b'q' | b'Q' => Delete::IntersectCellZ {
delete: up,
x: or0(b'x'),
y: or0(b'y'),
// We can bitcast here because of how we parse it earlier.
z: or0(b'z') as i32,
},
b'r' | b'R' => {
let x = get(kv, b'x').ok_or(Error::InvalidFormat)?;
let y = get(kv, b'y').ok_or(Error::InvalidFormat)?;
if x > y {
return Err(Error::InvalidFormat);
}
Delete::Range {
delete: up,
first: x,
last: y,
}
}
b'x' | b'X' => Delete::Column {
delete: up,
x: or0(b'x'),
},
b'y' | b'Y' => Delete::Row {
delete: up,
y: or0(b'y'),
},
b'z' | b'Z' => Delete::Z {
delete: up,
z: or0(b'z') as i32,
},
_ => return Err(Error::InvalidFormat),
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn parse(input: &str) -> Result<Command, Error> {
let mut p = Parser::new();
for c in input.bytes() {
p.feed(c)?;
}
p.complete()
}
#[test]
fn transmission_command() {
let command = parse("f=24,s=10,v=20").unwrap();
let Control::Transmit(v) = command.control else {
panic!("expected transmit")
};
assert_eq!(v.format, TransmissionFormat::Rgb);
assert_eq!(v.width, 10);
assert_eq!(v.height, 20);
}
#[test]
fn transmission_ignores_m_if_medium_is_not_direct() {
let command = parse("a=t,t=t,m=1").unwrap();
let Control::Transmit(v) = command.control else {
panic!("expected transmit")
};
assert_eq!(v.medium, TransmissionMedium::TemporaryFile);
assert!(!v.more_chunks);
}
#[test]
fn transmission_respects_m_if_medium_is_direct() {
let command = parse("a=t,t=d,m=1").unwrap();
let Control::Transmit(v) = command.control else {
panic!("expected transmit")
};
assert_eq!(v.medium, TransmissionMedium::Direct);
assert!(v.more_chunks);
}
#[test]
fn query_command() {
let command = parse("i=31,s=1,v=1,a=q,t=d,f=24;QUFBQQ").unwrap();
let Control::Query(v) = command.control else {
panic!("expected query")
};
assert_eq!(v.medium, TransmissionMedium::Direct);
assert_eq!(v.width, 1);
assert_eq!(v.height, 1);
assert_eq!(v.image_id, 31);
assert_eq!(command.data, b"AAAA");
}
#[test]
fn display_command() {
let command = parse("a=p,U=1,i=31,c=80,r=120").unwrap();
let Control::Display(v) = command.control else {
panic!("expected display")
};
assert_eq!(v.columns, 80);
assert_eq!(v.rows, 120);
assert_eq!(v.image_id, 31);
assert!(v.virtual_placement);
}
#[test]
fn delete_command() {
let command = parse("a=d,d=p,x=3,y=4").unwrap();
let Control::Delete(Delete::IntersectCell { delete, x, y }) = command.control else {
panic!("expected delete intersect_cell")
};
assert!(!delete);
assert_eq!(x, 3);
assert_eq!(y, 4);
}
#[test]
fn no_control_data() {
let command = parse(";QUFBQQ").unwrap();
assert!(matches!(command.control, Control::Transmit(_)));
assert_eq!(command.data, b"AAAA");
}
#[test]
fn ignore_unknown_keys_long() {
let command = parse("f=24,s=10,v=20,hello=world").unwrap();
let Control::Transmit(v) = command.control else {
panic!("expected transmit")
};
assert_eq!(v.format, TransmissionFormat::Rgb);
assert_eq!(v.width, 10);
assert_eq!(v.height, 20);
}
#[test]
fn ignore_very_long_values() {
let command = parse("f=24,s=10,v=2000000000000000000000000000000000000000").unwrap();
let Control::Transmit(v) = command.control else {
panic!("expected transmit")
};
assert_eq!(v.format, TransmissionFormat::Rgb);
assert_eq!(v.width, 10);
assert_eq!(v.height, 0);
}
#[test]
fn ensure_very_large_negative_values_dont_get_skipped() {
let command = parse("a=p,i=1,z=-2000000000").unwrap();
let Control::Display(v) = command.control else {
panic!("expected display")
};
assert_eq!(v.image_id, 1);
assert_eq!(v.z, -2_000_000_000);
}
#[test]
fn ensure_proper_overflow_error_for_u32() {
assert_eq!(parse("a=p,i=10000000000").unwrap_err(), Error::Overflow);
}
#[test]
fn ensure_proper_overflow_error_for_i32() {
assert_eq!(parse("a=p,i=1,z=-9999999999").unwrap_err(), Error::Overflow);
}
#[test]
fn all_i32_values() {
let c = parse("a=p,i=1,z=-1").unwrap();
let Control::Display(v) = c.control else {
panic!()
};
assert_eq!((v.image_id, v.z), (1, -1));
let c = parse("a=p,i=1,H=-1").unwrap();
let Control::Display(v) = c.control else {
panic!()
};
assert_eq!((v.image_id, v.horizontal_offset), (1, -1));
let c = parse("a=p,i=1,V=-1").unwrap();
let Control::Display(v) = c.control else {
panic!()
};
assert_eq!((v.image_id, v.vertical_offset), (1, -1));
}
fn encode(r: &Response) -> Vec<u8> {
let mut buf = Vec::new();
r.encode(&mut buf).unwrap();
buf
}
#[test]
fn response_encode_nothing_without_id_or_image_number() {
assert_eq!(encode(&Response::default()), b"");
}
#[test]
fn response_encode_with_only_image_id() {
let r = Response {
id: 4,
..Default::default()
};
assert_eq!(encode(&r), b"\x1b_Gi=4;OK\x1b\\");
}
#[test]
fn response_encode_with_only_image_number() {
let r = Response {
image_number: 4,
..Default::default()
};
assert_eq!(encode(&r), b"\x1b_GI=4;OK\x1b\\");
}
#[test]
fn response_encode_with_image_id_and_number() {
let r = Response {
id: 12,
image_number: 4,
..Default::default()
};
assert_eq!(encode(&r), b"\x1b_Gi=12,I=4;OK\x1b\\");
}
fn range(input: &str) -> Result<(bool, u32, u32), Error> {
match parse(input)?.control {
Control::Delete(Delete::Range {
delete,
first,
last,
}) => Ok((delete, first, last)),
_ => panic!("expected range"),
}
}
#[test]
fn delete_range_command_1() {
assert_eq!(range("a=d,d=r,x=3,y=4"), Ok((false, 3, 4)));
}
#[test]
fn delete_range_command_2() {
assert_eq!(range("a=d,d=R,x=5,y=11"), Ok((true, 5, 11)));
}
#[test]
fn delete_range_command_3() {
assert_eq!(range("a=d,d=R,x=5,y=4"), Err(Error::InvalidFormat));
}
#[test]
fn delete_range_command_4() {
assert_eq!(range("a=d,d=R,x=5"), Err(Error::InvalidFormat));
}
#[test]
fn delete_range_command_5() {
assert_eq!(range("a=d,d=R,y=5"), Err(Error::InvalidFormat));
}
#[test]
fn base64_decode_variants() {
assert_eq!(decode_base64(b"aGVsbG8gd29ybGQ=").unwrap(), b"hello world");
assert_eq!(decode_base64(b"aGVsbG8gd29ybGQ").unwrap(), b"hello world");
assert!(decode_base64(b"!!!!").is_err());
}
#[test]
fn parse_string_works() {
let c = Parser::parse_string(b"a=q,i=1;QUFBQQ").unwrap();
assert_eq!(c.data, b"AAAA");
}
}
+51
View File
@@ -170,4 +170,55 @@ mod tests {
stack.pop(100); stack.pop(100);
assert_eq!(stack, FlagStack::default()); assert_eq!(stack, FlagStack::default());
} }
#[test]
fn flag_stack_push_pop() {
let mut stack = FlagStack::default();
stack.push(Flags {
disambiguate: true,
..Flags::DISABLED
});
assert_eq!(
stack.current(),
Flags {
disambiguate: true,
..Flags::DISABLED
}
);
stack.pop(1);
assert_eq!(stack.current(), Flags::DISABLED);
}
#[test]
fn flag_stack_pop_big_number() {
let mut stack = FlagStack::default();
stack.pop(100);
assert_eq!(stack.current(), Flags::DISABLED);
}
#[test]
fn flag_stack_set() {
let mut stack = FlagStack::default();
let dis = Flags {
disambiguate: true,
..Flags::DISABLED
};
let ev = Flags {
report_events: true,
..Flags::DISABLED
};
stack.set(SetMode::Set, dis);
assert_eq!(stack.current(), dis);
stack.set(SetMode::Or, ev);
assert_eq!(
stack.current(),
Flags {
disambiguate: true,
report_events: true,
..Flags::DISABLED
}
);
stack.set(SetMode::Not, ev);
assert_eq!(stack.current(), dis);
}
} }
+2
View File
@@ -1,3 +1,5 @@
//! Kitty protocol types (ZIG/src/terminal/kitty.zig). //! Kitty protocol types (ZIG/src/terminal/kitty.zig).
pub mod color;
pub mod graphics_command;
pub mod key; pub mod key;
+15
View File
@@ -1,5 +1,20 @@
//! ghostty-vt: VT parsing layer of Ghostty (parser, OSC/CSI/DCS/APC, SGR, //! ghostty-vt: VT parsing layer of Ghostty (parser, OSC/CSI/DCS/APC, SGR,
//! colors, modes, stream). See docs/rewrite/ARCHITECTURE.md. //! colors, modes, stream). See docs/rewrite/ARCHITECTURE.md.
pub mod ansi;
pub mod apc;
pub mod charsets;
pub mod color;
pub mod csi;
pub mod dcs;
pub mod device_status;
pub mod kitty; pub mod kitty;
pub mod modes;
pub mod mouse_shape;
pub mod osc;
pub mod parse_table;
pub mod parser; pub mod parser;
pub mod sgr;
pub mod tmux;
pub mod utf8_decoder;
pub mod x11_color;
+387
View File
@@ -0,0 +1,387 @@
//! Terminal modes (ZIG/src/terminal/modes.zig): an enum of supported modes,
//! a packed bit storage for mode values, and a state struct that handles
//! save/restore.
//!
//! In Zig the enum, packed struct and lookup are generated at comptime from
//! one table. Here the single [`ENTRIES`] table drives everything and the
//! `Mode` discriminant doubles as the bit index in [`ModePacked`].
/// A struct that maintains the state of all the settable modes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ModeState {
/// The values of the current modes.
pub values: ModePacked,
/// The saved values. We only allow saving each mode once.
/// This is in line with other terminals that implement XTSAVE
/// and XTRESTORE. We can improve this in the future if it becomes
/// a real-world issue but we need to be aware of a DoS vector.
pub saved: ModePacked,
/// The default values for the modes. This is used to reset
/// the modes to their default values during reset.
pub default: ModePacked,
}
impl ModeState {
/// Reset the modes to their default values. This also clears the
/// saved state.
pub fn reset(&mut self) {
self.values = self.default;
self.saved = ModePacked::default();
}
/// Set a mode to a value.
pub fn set(&mut self, mode: Mode, value: bool) {
self.values.set(mode, value);
}
/// Get the value of a mode.
pub fn get(&self, mode: Mode) -> bool {
self.values.get(mode)
}
/// Save the state of the given mode. This can then be restored
/// with restore. This will only be accurate if the previous
/// mode was saved exactly once and not restored. Otherwise this
/// will just keep restoring the last stored value in memory.
pub fn save(&mut self, mode: Mode) {
let v = self.values.get(mode);
self.saved.set(mode, v);
}
/// See save. This will return the restored value.
pub fn restore(&mut self, mode: Mode) -> bool {
let v = self.saved.get(mode);
self.values.set(mode, v);
v
}
}
/// Packed storage of all the settable modes (one bit per mode). This
/// shouldn't be used directly but rather through the [`ModeState`] struct.
/// Defaults match each entry's default value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ModePacked(u64);
impl Default for ModePacked {
fn default() -> Self {
let mut bits = 0u64;
for e in ENTRIES {
if e.default {
bits |= 1 << (e.mode as u64);
}
}
Self(bits)
}
}
impl ModePacked {
pub fn get(&self, mode: Mode) -> bool {
self.0 & (1 << (mode as u64)) != 0
}
pub fn set(&mut self, mode: Mode, value: bool) {
if value {
self.0 |= 1 << (mode as u64);
} else {
self.0 &= !(1 << (mode as u64));
}
}
/// Raw bits.
pub fn bits(&self) -> u64 {
self.0
}
}
/// The available modes. See [`ENTRIES`] for numeric values.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum Mode {
DisableKeyboard,
Insert,
SendReceiveMode,
Linefeed,
CursorKeys,
M132Column,
SlowScroll,
ReverseColors,
Origin,
Wraparound,
Autorepeat,
MouseEventX10,
CursorBlinking,
CursorVisible,
EnableMode3,
ReverseWrap,
AltScreenLegacy,
KeypadKeys,
EnableLeftAndRightMargin,
MouseEventNormal,
MouseEventButton,
MouseEventAny,
FocusEvent,
MouseFormatUtf8,
MouseFormatSgr,
MouseAlternateScroll,
MouseFormatUrxvt,
MouseFormatSgrPixels,
IgnoreKeypadWithNumlock,
AltEscPrefix,
AltSendsEscape,
ReverseWrapExtended,
AltScreen,
SaveCursor,
AltScreenSaveCursorClearEnter,
BracketedPaste,
SynchronizedOutput,
GraphemeCluster,
ReportColorScheme,
InBandSizeReports,
}
impl Mode {
/// The entry for this mode.
pub fn entry(self) -> &'static Entry {
&ENTRIES[self as usize]
}
/// The numeric value of the mode.
pub fn value(self) -> u16 {
self.entry().value
}
/// True if this is an ANSI mode, false if its a DEC mode (?-prefixed).
pub fn ansi(self) -> bool {
self.entry().ansi
}
/// The snake_case name of the mode (as in the Zig tag name).
pub fn name(self) -> &'static str {
self.entry().name
}
}
/// Look up a mode from its numeric value and ANSI-ness.
pub fn mode_from_int(v: u16, ansi: bool) -> Option<Mode> {
ENTRIES
.iter()
.find(|e| !e.disabled && e.value == v && e.ansi == ansi)
.map(|e| e.mode)
}
/// A single entry of a possible mode we support.
#[derive(Debug)]
pub struct Entry {
pub mode: Mode,
pub name: &'static str,
pub value: u16,
pub default: bool,
/// True if this is an ANSI mode, false if its a DEC mode (?-prefixed).
pub ansi: bool,
/// If true, this mode is disabled and Ghostty will not allow it to be
/// set or queried. The mode enum still has it, allowing Ghostty developers
/// to develop a mode without exposing it to real users.
pub disabled: bool,
}
#[allow(non_snake_case)]
const fn Entry(mode: Mode, name: &'static str, value: u16, default: bool, ansi: bool) -> Entry {
Entry {
mode,
name,
value,
default,
ansi,
disabled: false,
}
}
/// The full list of available entries, in `Mode` declaration order.
pub const ENTRIES: &[Entry; 40] = &[
Entry(Mode::DisableKeyboard, "disable_keyboard", 2, false, true),
Entry(Mode::Insert, "insert", 4, false, true),
Entry(Mode::SendReceiveMode, "send_receive_mode", 12, true, true),
Entry(Mode::Linefeed, "linefeed", 20, false, true),
Entry(Mode::CursorKeys, "cursor_keys", 1, false, false),
Entry(Mode::M132Column, "132_column", 3, false, false),
Entry(Mode::SlowScroll, "slow_scroll", 4, false, false),
Entry(Mode::ReverseColors, "reverse_colors", 5, false, false),
Entry(Mode::Origin, "origin", 6, false, false),
Entry(Mode::Wraparound, "wraparound", 7, true, false),
Entry(Mode::Autorepeat, "autorepeat", 8, false, false),
Entry(Mode::MouseEventX10, "mouse_event_x10", 9, false, false),
Entry(Mode::CursorBlinking, "cursor_blinking", 12, false, false),
Entry(Mode::CursorVisible, "cursor_visible", 25, true, false),
Entry(Mode::EnableMode3, "enable_mode_3", 40, false, false),
Entry(Mode::ReverseWrap, "reverse_wrap", 45, false, false),
Entry(Mode::AltScreenLegacy, "alt_screen_legacy", 47, false, false),
Entry(Mode::KeypadKeys, "keypad_keys", 66, false, false),
Entry(
Mode::EnableLeftAndRightMargin,
"enable_left_and_right_margin",
69,
false,
false,
),
Entry(
Mode::MouseEventNormal,
"mouse_event_normal",
1000,
false,
false,
),
Entry(
Mode::MouseEventButton,
"mouse_event_button",
1002,
false,
false,
),
Entry(Mode::MouseEventAny, "mouse_event_any", 1003, false, false),
Entry(Mode::FocusEvent, "focus_event", 1004, false, false),
Entry(
Mode::MouseFormatUtf8,
"mouse_format_utf8",
1005,
false,
false,
),
Entry(Mode::MouseFormatSgr, "mouse_format_sgr", 1006, false, false),
Entry(
Mode::MouseAlternateScroll,
"mouse_alternate_scroll",
1007,
true,
false,
),
Entry(
Mode::MouseFormatUrxvt,
"mouse_format_urxvt",
1015,
false,
false,
),
Entry(
Mode::MouseFormatSgrPixels,
"mouse_format_sgr_pixels",
1016,
false,
false,
),
Entry(
Mode::IgnoreKeypadWithNumlock,
"ignore_keypad_with_numlock",
1035,
true,
false,
),
Entry(Mode::AltEscPrefix, "alt_esc_prefix", 1036, true, false),
Entry(Mode::AltSendsEscape, "alt_sends_escape", 1039, false, false),
Entry(
Mode::ReverseWrapExtended,
"reverse_wrap_extended",
1045,
false,
false,
),
Entry(Mode::AltScreen, "alt_screen", 1047, false, false),
Entry(Mode::SaveCursor, "save_cursor", 1048, false, false),
Entry(
Mode::AltScreenSaveCursorClearEnter,
"alt_screen_save_cursor_clear_enter",
1049,
false,
false,
),
Entry(Mode::BracketedPaste, "bracketed_paste", 2004, false, false),
Entry(
Mode::SynchronizedOutput,
"synchronized_output",
2026,
false,
false,
),
Entry(
Mode::GraphemeCluster,
"grapheme_cluster",
2027,
false,
false,
),
Entry(
Mode::ReportColorScheme,
"report_color_scheme",
2031,
false,
false,
),
Entry(
Mode::InBandSizeReports,
"in_band_size_reports",
2048,
false,
false,
),
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entries_in_order() {
for (i, e) in ENTRIES.iter().enumerate() {
assert_eq!(e.mode as usize, i);
}
assert!(ENTRIES.len() <= 64);
}
#[test]
fn mode_from_int_lookup() {
assert_eq!(mode_from_int(4, true), Some(Mode::Insert));
assert_eq!(mode_from_int(9, true), None);
assert_eq!(mode_from_int(9, false), Some(Mode::MouseEventX10));
assert_eq!(mode_from_int(14, true), None);
}
#[test]
fn mode_state() {
let mut state = ModeState::default();
// Normal set/get
assert!(!state.get(Mode::CursorKeys));
state.set(Mode::CursorKeys, true);
assert!(state.get(Mode::CursorKeys));
// Save/restore
state.save(Mode::CursorKeys);
state.set(Mode::CursorKeys, false);
assert!(!state.get(Mode::CursorKeys));
assert!(state.restore(Mode::CursorKeys));
assert!(state.get(Mode::CursorKeys));
}
#[test]
fn defaults_and_reset() {
let mut state = ModeState::default();
assert!(state.get(Mode::Wraparound));
assert!(state.get(Mode::CursorVisible));
assert!(state.get(Mode::SendReceiveMode));
assert!(!state.get(Mode::Origin));
state.set(Mode::Wraparound, false);
state.reset();
assert!(state.get(Mode::Wraparound));
}
#[test]
fn mode_tag_order() {
// Mirrors Zig ModeTag "order": value in low bits, ansi in the top bit.
assert_eq!(Mode::CursorKeys.value(), 1);
assert!(!Mode::CursorKeys.ansi());
}
#[test]
fn packed_size() {
// Zig asserts @sizeOf(ModePacked) == 8.
assert_eq!(std::mem::size_of::<ModePacked>(), 8);
}
}
+125
View File
@@ -0,0 +1,125 @@
//! Mouse cursor shapes (ZIG/src/terminal/mouse_shape.zig).
/// The possible cursor shapes. Not all app runtimes support these shapes.
/// The shapes are always based on the W3C supported cursor styles so we
/// can have a cross platform list.
// Must be kept in sync with ghostty_cursor_shape_e
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[repr(i32)]
pub enum MouseShape {
#[default]
Default,
ContextMenu,
Help,
Pointer,
Progress,
Wait,
Cell,
Crosshair,
Text,
VerticalText,
Alias,
Copy,
Move,
NoDrop,
NotAllowed,
Grab,
Grabbing,
AllScroll,
ColResize,
RowResize,
NResize,
EResize,
SResize,
WResize,
NeResize,
NwResize,
SeResize,
SwResize,
EwResize,
NsResize,
NeswResize,
NwseResize,
ZoomIn,
ZoomOut,
}
impl MouseShape {
/// Build cursor shape from string or None if its unknown.
pub fn from_string(v: &str) -> Option<MouseShape> {
Some(match v {
"default" => MouseShape::Default,
"context-menu" => MouseShape::ContextMenu,
"help" => MouseShape::Help,
"pointer" => MouseShape::Pointer,
"progress" => MouseShape::Progress,
"wait" => MouseShape::Wait,
"cell" => MouseShape::Cell,
"crosshair" => MouseShape::Crosshair,
"text" => MouseShape::Text,
"vertical-text" => MouseShape::VerticalText,
"alias" => MouseShape::Alias,
"copy" => MouseShape::Copy,
"move" => MouseShape::Move,
"no-drop" => MouseShape::NoDrop,
"not-allowed" => MouseShape::NotAllowed,
"grab" => MouseShape::Grab,
"grabbing" => MouseShape::Grabbing,
"all-scroll" => MouseShape::AllScroll,
"col-resize" => MouseShape::ColResize,
"row-resize" => MouseShape::RowResize,
"n-resize" => MouseShape::NResize,
"e-resize" => MouseShape::EResize,
"s-resize" => MouseShape::SResize,
"w-resize" => MouseShape::WResize,
"ne-resize" => MouseShape::NeResize,
"nw-resize" => MouseShape::NwResize,
"se-resize" => MouseShape::SeResize,
"sw-resize" => MouseShape::SwResize,
"ew-resize" => MouseShape::EwResize,
"ns-resize" => MouseShape::NsResize,
"nesw-resize" => MouseShape::NeswResize,
"nwse-resize" => MouseShape::NwseResize,
"zoom-in" => MouseShape::ZoomIn,
"zoom-out" => MouseShape::ZoomOut,
"left_ptr" => MouseShape::Default,
"question_arrow" => MouseShape::Help,
"hand" => MouseShape::Pointer,
"left_ptr_watch" => MouseShape::Progress,
"watch" => MouseShape::Wait,
"cross" => MouseShape::Crosshair,
"xterm" => MouseShape::Text,
"dnd-link" => MouseShape::Alias,
"dnd-copy" => MouseShape::Copy,
"dnd-move" => MouseShape::Move,
"dnd-no-drop" => MouseShape::NoDrop,
"crossed_circle" => MouseShape::NotAllowed,
"hand1" => MouseShape::Grab,
"right_side" => MouseShape::EResize,
"top_side" => MouseShape::NResize,
"top_right_corner" => MouseShape::NeResize,
"top_left_corner" => MouseShape::NwResize,
"bottom_side" => MouseShape::SResize,
"bottom_right_corner" => MouseShape::SeResize,
"bottom_left_corner" => MouseShape::SwResize,
"left_side" => MouseShape::WResize,
"fleur" => MouseShape::AllScroll,
_ => return None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_shape_from_string() {
assert_eq!(
MouseShape::from_string("default"),
Some(MouseShape::Default)
);
assert_eq!(MouseShape::from_string("xterm"), Some(MouseShape::Text));
assert_eq!(MouseShape::from_string("nope"), None);
}
}
+33
View File
@@ -0,0 +1,33 @@
//! Specialized encodings used in some OSC protocols
//! (ZIG/src/terminal/osc/encoding.zig).
/// Kitty defines "Escape code safe UTF-8" as valid UTF-8 with the
/// additional requirement of not containing any C0 escape codes
/// (0x00-0x1f), DEL (0x7f) and C1 escape codes (0x80-0x9f).
///
/// Used by OSC 66 (text sizing) and OSC 99 (Kitty notifications).
///
/// See: https://sw.kovidgoyal.net/kitty/desktop-notifications/#safe-utf8
pub fn is_safe_utf8(s: &[u8]) -> bool {
let Ok(s) = std::str::from_utf8(s) else {
return false;
};
!s.chars()
.any(|c| matches!(c as u32, 0x00..=0x1f | 0x7f | 0x80..=0x9f))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn is_safe_utf8_cases() {
assert!(is_safe_utf8(b"Hello world!"));
assert!(is_safe_utf8("安全的ユニコード☀️".as_bytes()));
assert!(!is_safe_utf8(b"No linebreaks\nallowed"));
assert!(!is_safe_utf8(b"\x07no bells"));
assert!(!is_safe_utf8(b"\x1b]9;no OSCs\x1b\\\x1b[m"));
assert!(!is_safe_utf8(b"\x9f8-bit escapes are clever, but no"));
}
}
+741
View File
@@ -0,0 +1,741 @@
//! OSC (Operating System Command) related functions and types
//! (ZIG/src/terminal/osc.zig).
//!
//! OSC is another set of control sequences for terminal programs that start with
//! "ESC ]". Unlike CSI or standard ESC sequences, they may contain strings
//! and other irregular formatting so a dedicated parser is created to handle it.
//!
//! Rust port notes (W1):
//! - Zig `[:0]const u8` fields become owned `Vec<u8>` (no NUL terminator, not
//! necessarily valid UTF-8).
//! - Zig's "fixed buffer vs. allocating writer" is a single `Vec<u8>` with an
//! optional maximum length (`MAX_BUF`, which always reserves one byte for
//! the NUL terminator the Zig code writes). The Zig `alloc: ?Allocator` is
//! `alloc: bool` ("an allocator is available").
//! - `Parser::end` returns an owned `Command` instead of a pointer into the
//! parser.
pub mod encoding;
pub mod parsers;
pub use parsers::color;
pub use parsers::semantic_prompt;
use crate::kitty::color as kitty_color;
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
/// This generally shouldn't ever be set except as an initial zero value.
/// Ignore it.
Invalid,
/// Set the window title of the terminal
///
/// If title mode 0 is set text is expect to be hex encoded (i.e. utf-8
/// with each code unit further encoded with two hex digits).
///
/// If title mode 2 is set or the terminal is setup for unconditional
/// utf-8 titles text is interpreted as utf-8. Else text is interpreted
/// as latin1.
ChangeWindowTitle(Vec<u8>),
/// Set the icon of the terminal window. The name of the icon is not
/// well defined, so this is currently ignored by Ghostty at the time
/// of writing this. We just parse it so that we don't get parse errors
/// in the log.
ChangeWindowIcon(Vec<u8>),
/// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md
SemanticPrompt(SemanticPrompt),
/// Set or get clipboard contents. If data is null, then the current
/// clipboard contents are sent to the pty. If data is set, this
/// contents is set on the clipboard.
ClipboardContents {
kind: u8,
data: Vec<u8>,
},
/// OSC 7. Reports the current working directory of the shell. This is
/// a moderately flawed escape sequence but one that many major terminals
/// support so we also support it. To understand the flaws, read through
/// this terminal-wg issue: https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/20
ReportPwd {
/// The reported pwd value. This is not checked for validity. It should
/// be a file URL but it is up to the caller to utilize this value.
value: Vec<u8>,
},
/// OSC 22. Set the mouse shape. There doesn't seem to be a standard
/// naming scheme for cursors but it looks like terminals such as Foot
/// are moving towards using the W3C CSS cursor names. For OSC parsing,
/// we just parse whatever string is given.
MouseShape {
value: Vec<u8>,
},
/// OSC color operations to set, reset, or report color settings. Some OSCs
/// allow multiple operations to be specified in a single OSC so we need a
/// list-like datastructure to manage them.
///
/// Currently, these OSCs are handled by `color_operation`:
///
/// 4, 5, 10-19, 104, 105, 110-119
ColorOperation {
op: color::Operation,
requests: color::List,
terminator: Terminator,
},
/// Kitty color protocol, OSC 21
/// https://sw.kovidgoyal.net/kitty/color-stack/#id1
KittyColorProtocol(kitty_color::OSC),
/// Show a desktop notification (OSC 9 or OSC 777)
ShowDesktopNotification {
title: Vec<u8>,
body: Vec<u8>,
},
/// Start a hyperlink (OSC 8)
HyperlinkStart {
id: Option<Vec<u8>>,
uri: Vec<u8>,
},
/// End a hyperlink (OSC 8)
HyperlinkEnd,
/// ConEmu sleep (OSC 9;1)
ConemuSleep {
duration_ms: u16,
},
/// ConEmu show GUI message box (OSC 9;2)
ConemuShowMessageBox(Vec<u8>),
/// ConEmu change tab title (OSC 9;3)
ConemuChangeTabTitle(ConemuChangeTabTitle),
/// ConEmu progress report (OSC 9;4)
ConemuProgressReport(ProgressReport),
/// ConEmu wait input (OSC 9;5)
ConemuWaitInput,
/// ConEmu GUI macro (OSC 9;6)
ConemuGuimacro(Vec<u8>),
/// ConEmu run process (OSC 9;7)
ConemuRunProcess(Vec<u8>),
/// ConEmu output environment variable (OSC 9;8)
ConemuOutputEnvironmentVariable(Vec<u8>),
/// ConEmu XTerm keyboard and output emulation (OSC 9;10)
/// https://conemu.github.io/en/TerminalModes.html
ConemuXtermEmulation {
/// None => do not change
/// Some(false) => turn off
/// Some(true) => turn on
keyboard: Option<bool>,
/// None => do not change
/// Some(false) => turn off
/// Some(true) => turn on
output: Option<bool>,
},
/// ConEmu comment (OSC 9;11)
ConemuComment(Vec<u8>),
/// Kitty text sizing protocol (OSC 66)
KittyTextSizing(parsers::kitty_text_sizing::OSC),
KittyClipboardProtocol(KittyClipboardProtocol),
/// OSC 3008. Hierarchical context signalling (UAPI spec).
/// https://uapi-group.org/specifications/specs/osc_context/
ContextSignal(parsers::context_signal::Command),
}
pub type SemanticPrompt = parsers::semantic_prompt::Command;
pub type KittyClipboardProtocol = parsers::kitty_clipboard_protocol::OSC;
/// Payload of `Command::ConemuChangeTabTitle`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConemuChangeTabTitle {
Reset,
Value(Vec<u8>),
}
/// The discriminant of a [`Command`] (Zig `Command.Key`). Order matches Zig.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Key {
Invalid,
ChangeWindowTitle,
ChangeWindowIcon,
SemanticPrompt,
ClipboardContents,
ReportPwd,
MouseShape,
ColorOperation,
KittyColorProtocol,
ShowDesktopNotification,
HyperlinkStart,
HyperlinkEnd,
ConemuSleep,
ConemuShowMessageBox,
ConemuChangeTabTitle,
ConemuProgressReport,
ConemuWaitInput,
ConemuGuimacro,
ConemuRunProcess,
ConemuOutputEnvironmentVariable,
ConemuXtermEmulation,
ConemuComment,
KittyTextSizing,
KittyClipboardProtocol,
ContextSignal,
}
impl Command {
pub fn key(&self) -> Key {
match self {
Command::Invalid => Key::Invalid,
Command::ChangeWindowTitle(_) => Key::ChangeWindowTitle,
Command::ChangeWindowIcon(_) => Key::ChangeWindowIcon,
Command::SemanticPrompt(_) => Key::SemanticPrompt,
Command::ClipboardContents { .. } => Key::ClipboardContents,
Command::ReportPwd { .. } => Key::ReportPwd,
Command::MouseShape { .. } => Key::MouseShape,
Command::ColorOperation { .. } => Key::ColorOperation,
Command::KittyColorProtocol(_) => Key::KittyColorProtocol,
Command::ShowDesktopNotification { .. } => Key::ShowDesktopNotification,
Command::HyperlinkStart { .. } => Key::HyperlinkStart,
Command::HyperlinkEnd => Key::HyperlinkEnd,
Command::ConemuSleep { .. } => Key::ConemuSleep,
Command::ConemuShowMessageBox(_) => Key::ConemuShowMessageBox,
Command::ConemuChangeTabTitle(_) => Key::ConemuChangeTabTitle,
Command::ConemuProgressReport(_) => Key::ConemuProgressReport,
Command::ConemuWaitInput => Key::ConemuWaitInput,
Command::ConemuGuimacro(_) => Key::ConemuGuimacro,
Command::ConemuRunProcess(_) => Key::ConemuRunProcess,
Command::ConemuOutputEnvironmentVariable(_) => Key::ConemuOutputEnvironmentVariable,
Command::ConemuXtermEmulation { .. } => Key::ConemuXtermEmulation,
Command::ConemuComment(_) => Key::ConemuComment,
Command::KittyTextSizing(_) => Key::KittyTextSizing,
Command::KittyClipboardProtocol(_) => Key::KittyClipboardProtocol,
Command::ContextSignal(_) => Key::ContextSignal,
}
}
}
/// ConEmu progress report (OSC 9;4).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProgressReport {
pub state: ProgressState,
pub progress: Option<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProgressState {
Remove,
Set,
Error,
Indeterminate,
Pause,
}
/// The terminator used to end an OSC command. For OSC commands that demand
/// a response, we try to match the terminator used in the request since that
/// is most likely to be accepted by the calling program.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Terminator {
/// The preferred string terminator is ESC followed by \
#[default]
St,
/// Some applications and terminals use BELL (0x07) as the string terminator.
Bel,
}
impl Terminator {
/// Initialize the terminator based on the last byte seen. If the
/// last byte is a BEL then we use BEL, otherwise we just assume ST.
pub fn init(ch: Option<u8>) -> Terminator {
match ch {
Some(0x07) => Terminator::Bel,
_ => Terminator::St,
}
}
/// The terminator as a string.
pub fn string(self) -> &'static [u8] {
match self {
Terminator::St => b"\x1b\\",
Terminator::Bel => b"\x07",
}
}
}
impl std::fmt::Display for Terminator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// The terminator is always ASCII.
f.write_str(std::str::from_utf8(self.string()).unwrap_or(""))
}
}
/// State of the OSC parser.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
Start,
Invalid,
// OSC command prefixes. Not all of these are valid OSCs, but may be
// needed to "bridge" to a valid OSC (e.g. to support OSC 777 we need to
// have a state "77" even though there is no OSC 77).
S0,
S1,
S2,
S3,
S4,
S5,
S6,
S7,
S8,
S9,
S30,
S300,
S3008,
S10,
S11,
S12,
S13,
S14,
S15,
S16,
S17,
S18,
S19,
S21,
S22,
S52,
S55,
S66,
S77,
S104,
S110,
S111,
S112,
S113,
S114,
S115,
S116,
S117,
S118,
S119,
S133,
S552,
S777,
S1337,
S5522,
}
/// How OSC payload data is being accumulated.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Writer {
/// Not accumulating (still parsing the numeric prefix).
None,
/// Fixed-size buffer of `MAX_BUF` bytes.
Fixed,
/// Growable buffer.
Allocating,
}
#[derive(Debug, Clone)]
pub struct Parser {
/// Whether an allocator is available, i.e. whether data longer than
/// MAX_BUF may be accepted for some commands (e.g. OSC 52), and whether
/// commands that require an allocator are processed. (Zig:
/// `alloc: ?Allocator`.)
pub alloc: bool,
/// Current state of the parser.
pub state: State,
/// Buffer for temporary storage of OSC data
buffer: Vec<u8>,
writer: Writer,
}
impl Default for Parser {
fn default() -> Self {
Self::init(false)
}
}
impl Parser {
/// Maximum size of a "normal" OSC.
pub const MAX_BUF: usize = 2048;
pub fn init(alloc: bool) -> Parser {
Parser {
alloc,
state: State::Start,
buffer: Vec::new(),
writer: Writer::None,
}
}
/// Reset the parser state.
pub fn reset(&mut self) {
self.state = State::Start;
self.buffer.clear();
self.writer = Writer::None;
}
/// Make sure that we have an allocator. If we don't, set the state to
/// invalid so that any additional OSC data is discarded.
fn ensure_allocator(&mut self) -> bool {
if self.alloc {
return true;
}
log::warn!(
"An allocator is required to process OSC {:?} but none was provided.",
self.state
);
self.state = State::Invalid;
false
}
/// Set up a fixed Writer to collect the rest of the OSC data.
fn write_to_fixed(&mut self) {
self.writer = Writer::Fixed;
}
/// Set up an allocating Writer to collect the rest of the OSC data. If we
/// don't have an allocator, fall back to writing to a fixed buffer and
/// hope that it's big enough.
fn write_to_allocating(&mut self) {
if !self.alloc {
self.write_to_fixed();
return;
}
self.writer = Writer::Allocating;
}
/// Helper for the per-command parsers. Mirrors the Zig prologue
/// `writer orelse invalid; writer.writeByte(0) catch invalid;
/// writer.buffered()`: returns a copy of the accumulated data (without
/// the NUL terminator) or `None` (and marks the parser invalid) if no
/// data was being accumulated or there was no room for the terminator.
pub fn data(&mut self) -> Option<Vec<u8>> {
if self.writer == Writer::None {
self.state = State::Invalid;
return None;
}
// Reserve room for the NUL terminator.
if self.writer == Writer::Fixed && self.buffer.len() >= Self::MAX_BUF {
self.state = State::Invalid;
return None;
}
Some(self.buffer.clone())
}
/// Like [`Parser::data`] but without reserving room for the NUL
/// terminator (for parsers that, like Zig's color parsers, read
/// `writer.buffered()` directly).
pub fn buffered(&mut self) -> Option<Vec<u8>> {
if self.writer == Writer::None {
self.state = State::Invalid;
return None;
}
Some(self.buffer.clone())
}
/// Whether any payload writer has been set up (Zig `parser.writer != null`).
pub fn has_writer(&self) -> bool {
self.writer != Writer::None
}
fn write_byte(&mut self, c: u8) {
match self.writer {
Writer::None => unreachable!(),
Writer::Fixed => {
if self.buffer.len() >= Self::MAX_BUF {
// Overflow: discard any further input.
self.state = State::Invalid;
} else {
self.buffer.push(c);
}
}
Writer::Allocating => self.buffer.push(c),
}
}
/// Consume the next character c and advance the parser state.
pub fn next(&mut self, c: u8) {
#[allow(clippy::enum_glob_use)]
use State::*;
// If the state becomes invalid for any reason, just discard
// any further input.
if self.state == Invalid {
return;
}
// If a writer has been initialized, we just accumulate the rest of the
// OSC sequence in the writer's buffer and skip the state machine.
if self.writer != Writer::None {
self.write_byte(c);
return;
}
match self.state {
// handled above, so should never be here
Invalid => unreachable!(),
Start => {
self.state = match c {
b'0' => S0,
b'1' => S1,
b'2' => S2,
b'3' => S3,
b'4' => S4,
b'5' => S5,
b'6' => S6,
b'7' => S7,
b'8' => S8,
b'9' => S9,
_ => Invalid,
}
}
S3 => {
self.state = match c {
b'0' => S30,
_ => Invalid,
}
}
S30 => {
self.state = match c {
b'0' => S300,
_ => Invalid,
}
}
S300 => {
self.state = match c {
b'8' => S3008,
_ => Invalid,
}
}
S3008 => match c {
b';' => self.write_to_fixed(),
_ => self.state = Invalid,
},
S1 => match c {
b';' => self.write_to_fixed(),
b'0' => self.state = S10,
b'1' => self.state = S11,
b'2' => self.state = S12,
b'3' => self.state = S13,
b'4' => self.state = S14,
b'5' => self.state = S15,
b'6' => self.state = S16,
b'7' => self.state = S17,
b'8' => self.state = S18,
b'9' => self.state = S19,
_ => self.state = Invalid,
},
S10 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
b'4' => self.state = S104,
_ => self.state = Invalid,
},
S104 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
_ => self.state = Invalid,
},
S11 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
b'0' => self.state = S110,
b'1' => self.state = S111,
b'2' => self.state = S112,
b'3' => self.state = S113,
b'4' => self.state = S114,
b'5' => self.state = S115,
b'6' => self.state = S116,
b'7' => self.state = S117,
b'8' => self.state = S118,
b'9' => self.state = S119,
_ => self.state = Invalid,
},
S4 | S12 | S14 | S15 | S16 | S17 | S18 | S19 | S21 | S110 | S111 | S112 | S113
| S114 | S115 | S116 | S117 | S118 | S119 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
_ => self.state = Invalid,
},
S13 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
b'3' => self.state = S133,
_ => self.state = Invalid,
},
S2 => match c {
b';' => self.write_to_fixed(),
b'1' => self.state = S21,
b'2' => self.state = S22,
_ => self.state = Invalid,
},
S5 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
b'2' => self.state = S52,
b'5' => self.state = S55,
_ => self.state = Invalid,
},
S6 => match c {
b'6' => self.state = S66,
_ => self.state = Invalid,
},
S52 | S66 => match c {
b';' => self.write_to_allocating(),
_ => self.state = Invalid,
},
S55 => match c {
b'2' => self.state = S552,
_ => self.state = Invalid,
},
S7 => match c {
b';' => self.write_to_fixed(),
b'7' => self.state = S77,
_ => self.state = Invalid,
},
S77 => match c {
b'7' => self.state = S777,
_ => self.state = Invalid,
},
S133 => match c {
b';' => self.write_to_fixed(),
b'7' => self.state = S1337,
_ => self.state = Invalid,
},
S552 => match c {
b'2' => self.state = S5522,
_ => self.state = Invalid,
},
S1337 => match c {
b';' => self.write_to_fixed(),
_ => self.state = Invalid,
},
S5522 => match c {
b';' => self.write_to_allocating(),
_ => self.state = Invalid,
},
S0 | S22 | S777 | S8 | S9 => match c {
b';' => self.write_to_fixed(),
_ => self.state = Invalid,
},
}
}
/// End the sequence and return the command, if any. If the return value
/// is None, then no valid command was found. The optional terminator_ch
/// is the final character in the OSC sequence. This is used to determine
/// the response terminator.
pub fn end(&mut self, terminator_ch: Option<u8>) -> Option<Command> {
#[allow(clippy::enum_glob_use)]
use State::*;
match self.state {
Start => None,
Invalid => None,
S0 | S2 => parsers::change_window_title::parse(self, terminator_ch),
S1 => parsers::change_window_icon::parse(self, terminator_ch),
S4 | S5 | S10 | S11 | S12 | S13 | S14 | S15 | S16 | S17 | S18 | S19 | S104 | S110
| S111 | S112 | S113 | S114 | S115 | S116 | S117 | S118 | S119 => {
parsers::color::parse(self, terminator_ch)
}
S7 => parsers::report_pwd::parse(self, terminator_ch),
S8 => parsers::hyperlink::parse(self, terminator_ch),
S9 => parsers::osc9::parse(self, terminator_ch),
S21 => parsers::kitty_color::parse(self, terminator_ch),
S22 => parsers::mouse_shape::parse(self, terminator_ch),
S52 => parsers::clipboard_operation::parse(self, terminator_ch),
S55 => None,
S3 | S30 | S300 => None,
S3008 => parsers::context_signal::parse(self, terminator_ch),
S6 => None,
S66 => parsers::kitty_text_sizing::parse(self, terminator_ch),
S77 => None,
S133 => parsers::semantic_prompt::parse(self, terminator_ch),
S552 => None,
S777 => parsers::rxvt_extension::parse(self, terminator_ch),
S1337 => parsers::iterm2::parse(self, terminator_ch),
S5522 => parsers::kitty_clipboard_protocol::parse(self, terminator_ch),
}
}
}
@@ -0,0 +1,24 @@
//! OSC 1 (ZIG/src/terminal/osc/parsers/change_window_icon.zig).
use crate::osc::{Command, Parser};
/// Parse OSC 1
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
let data = parser.data()?;
Some(Command::ChangeWindowIcon(data))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn osc_1_change_window_icon() {
let mut p = Parser::init(false);
for c in b"1;ab" {
p.next(*c);
}
let cmd = p.end(None).unwrap();
assert_eq!(cmd, Command::ChangeWindowIcon(b"ab".to_vec()));
}
}
@@ -0,0 +1,92 @@
//! OSC 0 and OSC 2 (ZIG/src/terminal/osc/parsers/change_window_title.zig).
use crate::osc::{Command, Parser};
/// Parse OSC 0 and OSC 2
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
let data = parser.data()?;
Some(Command::ChangeWindowTitle(data))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn feed(p: &mut Parser, s: &[u8]) {
for c in s {
p.next(*c);
}
}
#[test]
fn osc_0_change_window_title() {
let mut p = Parser::init(false);
feed(&mut p, b"0;ab");
assert_eq!(
p.end(None).unwrap(),
Command::ChangeWindowTitle(b"ab".to_vec())
);
}
#[test]
fn osc_0_longer_than_buffer() {
let mut p = Parser::init(false);
let mut input = b"0;".to_vec();
input.extend(std::iter::repeat_n(b'a', Parser::MAX_BUF + 2));
feed(&mut p, &input);
assert!(p.end(None).is_none());
}
#[test]
fn osc_0_one_shorter_than_buffer_length() {
let mut p = Parser::init(false);
let title = vec![b'a'; Parser::MAX_BUF - 1];
let mut input = b"0;".to_vec();
input.extend(&title);
feed(&mut p, &input);
assert_eq!(p.end(None).unwrap(), Command::ChangeWindowTitle(title));
}
#[test]
fn osc_0_exactly_at_buffer_length() {
let mut p = Parser::init(false);
let mut input = b"0;".to_vec();
input.extend(vec![b'a'; Parser::MAX_BUF]);
feed(&mut p, &input);
// This should be None because we always reserve space for a null terminator.
assert!(p.end(None).is_none());
}
#[test]
fn osc_2_change_window_title_with_2() {
let mut p = Parser::init(false);
feed(&mut p, b"2;ab");
assert_eq!(
p.end(None).unwrap(),
Command::ChangeWindowTitle(b"ab".to_vec())
);
}
#[test]
fn osc_2_change_window_title_with_utf8() {
let mut p = Parser::init(false);
feed(&mut p, b"2;");
// '—' EM DASH U+2014 (E2 80 94)
feed(&mut p, &[0xE2, 0x80, 0x94]);
p.next(b' ');
// '‐' HYPHEN U+2010 (E2 80 90)
// Intententionally chosen to conflict with the 0x90 C1 control
feed(&mut p, &[0xE2, 0x80, 0x90]);
assert_eq!(
p.end(None).unwrap(),
Command::ChangeWindowTitle("— ‐".as_bytes().to_vec())
);
}
#[test]
fn osc_2_change_window_title_empty() {
let mut p = Parser::init(false);
feed(&mut p, b"2;");
assert_eq!(p.end(None).unwrap(), Command::ChangeWindowTitle(Vec::new()));
}
}
@@ -0,0 +1,91 @@
//! OSC 52 (ZIG/src/terminal/osc/parsers/clipboard_operation.zig).
use crate::osc::{Command, Parser, State};
/// Parse OSC 52
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
debug_assert_eq!(parser.state, State::S52);
let data = parser.data()?;
// Zig's `data` includes the NUL terminator, so `data.len == 1` there
// means the payload is empty.
if data.is_empty() {
parser.state = State::Invalid;
return None;
}
if data[0] == b';' {
Some(Command::ClipboardContents {
kind: b'c',
data: data[1..].to_vec(),
})
} else {
if data.len() < 2 {
parser.state = State::Invalid;
return None;
}
if data[1] != b';' {
parser.state = State::Invalid;
return None;
}
Some(Command::ClipboardContents {
kind: data[0],
data: data[2..].to_vec(),
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn run(alloc: bool, input: &[u8]) -> Option<Command> {
let mut p = Parser::init(alloc);
for c in input {
p.next(*c);
}
p.end(None)
}
#[test]
fn osc_52_get_set_clipboard() {
assert_eq!(
run(false, b"52;s;?").unwrap(),
Command::ClipboardContents {
kind: b's',
data: b"?".to_vec()
}
);
}
#[test]
fn osc_52_get_set_clipboard_optional_parameter() {
assert_eq!(
run(false, b"52;;?").unwrap(),
Command::ClipboardContents {
kind: b'c',
data: b"?".to_vec()
}
);
}
#[test]
fn osc_52_get_set_clipboard_with_allocator() {
assert_eq!(
run(true, b"52;s;?").unwrap(),
Command::ClipboardContents {
kind: b's',
data: b"?".to_vec()
}
);
}
#[test]
fn osc_52_clear_clipboard() {
assert_eq!(
run(false, b"52;;").unwrap(),
Command::ClipboardContents {
kind: b'c',
data: Vec::new()
}
);
}
}
+564
View File
@@ -0,0 +1,564 @@
//! OSC color operations: 4, 5, 10-19, 104, 105, 110-119
//! (ZIG/src/terminal/osc/parsers/color.zig).
use crate::color::{Dynamic as DynamicColor, Rgb, Special as SpecialColor};
use crate::osc::{Command, Parser, State, Terminator};
/// The possible operations we support for colors.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Operation {
Osc4,
Osc5,
Osc10,
Osc11,
Osc12,
Osc13,
Osc14,
Osc15,
Osc16,
Osc17,
Osc18,
Osc19,
Osc104,
Osc105,
Osc110,
Osc111,
Osc112,
Osc113,
Osc114,
Osc115,
Osc116,
Osc117,
Osc118,
Osc119,
}
/// Parse OSCs 4, 5, 10-19, 104, 110-119
pub fn parse(parser: &mut Parser, terminator_ch: Option<u8>) -> Option<Command> {
if !parser.alloc {
parser.state = State::Invalid;
return None;
}
// If we've collected any extra data parse that, otherwise use an empty
// string.
let data = if parser.has_writer() {
parser.buffered()?
} else {
Vec::new()
};
// Check and make sure that we're parsing the correct OSCs
let op = match parser.state {
State::S4 => Operation::Osc4,
State::S5 => Operation::Osc5,
State::S10 => Operation::Osc10,
State::S11 => Operation::Osc11,
State::S12 => Operation::Osc12,
State::S13 => Operation::Osc13,
State::S14 => Operation::Osc14,
State::S15 => Operation::Osc15,
State::S16 => Operation::Osc16,
State::S17 => Operation::Osc17,
State::S18 => Operation::Osc18,
State::S19 => Operation::Osc19,
State::S104 => Operation::Osc104,
State::S110 => Operation::Osc110,
State::S111 => Operation::Osc111,
State::S112 => Operation::Osc112,
State::S113 => Operation::Osc113,
State::S114 => Operation::Osc114,
State::S115 => Operation::Osc115,
State::S116 => Operation::Osc116,
State::S117 => Operation::Osc117,
State::S118 => Operation::Osc118,
State::S119 => Operation::Osc119,
_ => {
parser.state = State::Invalid;
return None;
}
};
Some(Command::ColorOperation {
op,
requests: parse_color(op, &data),
terminator: Terminator::init(terminator_ch),
})
}
/// Zig `std.mem.tokenizeScalar(u8, buf, ';')`: skips empty tokens.
fn tokenize(buf: &[u8]) -> impl Iterator<Item = &[u8]> {
buf.split(|&c| c == b';').filter(|t| !t.is_empty())
}
/// Zig `std.fmt.parseInt(u9, s, 10)`: optional sign, digits only.
fn parse_u9(s: &[u8]) -> Option<u16> {
let (neg, digits) = match s.first()? {
b'+' => (false, &s[1..]),
b'-' => (true, &s[1..]),
_ => (false, s),
};
if digits.is_empty() {
return None;
}
let mut acc: u32 = 0;
for &c in digits {
if !c.is_ascii_digit() {
return None;
}
acc = acc * 10 + (c - b'0') as u32;
if acc > 511 {
return None;
}
}
if neg && acc != 0 {
return None;
}
Some(acc as u16)
}
/// Parse any color operation string. This should NOT include the operation
/// itself, but only the body of the operation. e.g. for "4;a;b;c" the body
/// should be "a;b;c" and the operation should be set accordingly.
///
/// Color parsing is fairly complicated so we pull this out to a specialized
/// function rather than go through our OSC parsing state machine. This is
/// much slower and requires more memory (since we need to buffer the full
/// request) but grants us an easier to understand and testable implementation.
///
/// If color changing ends up being a bottleneck we can optimize this later.
pub fn parse_color(op: Operation, buf: &[u8]) -> List {
let mut it = tokenize(buf);
match op {
Operation::Osc4 => parse_get_set_ansi_color(Operation::Osc4, &mut it),
Operation::Osc5 => parse_get_set_ansi_color(Operation::Osc5, &mut it),
Operation::Osc104 => parse_reset_ansi_color(Operation::Osc104, &mut it),
Operation::Osc105 => parse_reset_ansi_color(Operation::Osc105, &mut it),
Operation::Osc10 => parse_get_set_dynamic_color(DynamicColor::Foreground, &mut it),
Operation::Osc11 => parse_get_set_dynamic_color(DynamicColor::Background, &mut it),
Operation::Osc12 => parse_get_set_dynamic_color(DynamicColor::Cursor, &mut it),
Operation::Osc13 => parse_get_set_dynamic_color(DynamicColor::PointerForeground, &mut it),
Operation::Osc14 => parse_get_set_dynamic_color(DynamicColor::PointerBackground, &mut it),
Operation::Osc15 => parse_get_set_dynamic_color(DynamicColor::TektronixForeground, &mut it),
Operation::Osc16 => parse_get_set_dynamic_color(DynamicColor::TektronixBackground, &mut it),
Operation::Osc17 => parse_get_set_dynamic_color(DynamicColor::HighlightBackground, &mut it),
Operation::Osc18 => parse_get_set_dynamic_color(DynamicColor::TektronixCursor, &mut it),
Operation::Osc19 => parse_get_set_dynamic_color(DynamicColor::HighlightForeground, &mut it),
Operation::Osc110 => parse_reset_dynamic_color(DynamicColor::Foreground, &mut it),
Operation::Osc111 => parse_reset_dynamic_color(DynamicColor::Background, &mut it),
Operation::Osc112 => parse_reset_dynamic_color(DynamicColor::Cursor, &mut it),
Operation::Osc113 => parse_reset_dynamic_color(DynamicColor::PointerForeground, &mut it),
Operation::Osc114 => parse_reset_dynamic_color(DynamicColor::PointerBackground, &mut it),
Operation::Osc115 => parse_reset_dynamic_color(DynamicColor::TektronixForeground, &mut it),
Operation::Osc116 => parse_reset_dynamic_color(DynamicColor::TektronixBackground, &mut it),
Operation::Osc117 => parse_reset_dynamic_color(DynamicColor::HighlightBackground, &mut it),
Operation::Osc118 => parse_reset_dynamic_color(DynamicColor::TektronixCursor, &mut it),
Operation::Osc119 => parse_reset_dynamic_color(DynamicColor::HighlightForeground, &mut it),
}
}
/// Map a parsed numeric color to a target for OSC 4/5/104/105.
/// `special_only` is true for OSC 5/105; otherwise 0-255 is the palette and
/// 256+ is the special offset.
fn ansi_target(color: u16, special_only: bool) -> Option<Target> {
if special_only {
// std.math.cast(u3, color) then intToEnum
let v = u8::try_from(color).ok().filter(|&v| v < 8)?;
return SpecialColor::from_u8(v).map(Target::Special);
}
if let Ok(idx) = u8::try_from(color) {
return Some(Target::Palette(idx));
}
let v = u8::try_from(color - 256).ok().filter(|&v| v < 8)?;
SpecialColor::from_u8(v).map(Target::Special)
}
/// OSC 4/5
fn parse_get_set_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [u8]>) -> List {
// Note: in ANY error scenario below we return the accumulated results.
// This matches the xterm behavior (see misc.c ChangeAnsiColorRequest)
let mut result = List::new();
loop {
// We expect a `c; spec` pair. If either doesn't exist then
// we return the results up to this point.
let Some(color_str) = it.next() else {
return result;
};
let Some(spec_str) = it.next() else {
return result;
};
// Color must be numeric. u9 because that'll fit our palette + special
let Some(color) = parse_u9(color_str) else {
return result;
};
// Parse the color.
let Some(target) = ansi_target(color, op == Operation::Osc5) else {
return result;
};
// "?" always results in a query.
if spec_str == b"?" {
result.push(Request::Query(target));
continue;
}
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
return result;
};
result.push(Request::Set(ColoredTarget { target, color: rgb }));
}
}
/// OSC 104/105: Reset ANSI Colors
fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [u8]>) -> List {
// Note: xterm stops parsing the reset list on any error, but we're
// more flexible and try the next value. This matches the behavior of
// Kitty and I don't see a downside to being more flexible here. Hopefully
// no one depends on the exact behavior of xterm.
let mut result = List::new();
loop {
let Some(color_str) = it.next() else {
// If no parameters are given, we reset the full table.
if result.is_empty() {
result.push(match op {
Operation::Osc104 => Request::ResetPalette,
Operation::Osc105 => Request::ResetSpecial,
_ => unreachable!(),
});
}
return result;
};
// Empty color strings are ignored, not treated as an error.
if color_str.is_empty() {
continue;
}
// Color must be numeric. u9 because that'll fit our palette + special
let Some(color) = parse_u9(color_str) else {
continue;
};
// Parse the color.
let Some(target) = ansi_target(color, op == Operation::Osc105) else {
continue;
};
result.push(Request::Reset(target));
}
}
/// OSC 10-19: Get/Set Dynamic Colors
fn parse_get_set_dynamic_color<'a>(
start: DynamicColor,
it: &mut impl Iterator<Item = &'a [u8]>,
) -> List {
// Note: in ANY error scenario below we return the accumulated results.
// This matches the xterm behavior (see misc.c ChangeColorsRequest)
let mut result = List::new();
let mut color = start;
loop {
let Some(spec_str) = it.next() else {
return result;
};
if spec_str == b"?" {
result.push(Request::Query(Target::Dynamic(color)));
} else {
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
return result;
};
result.push(Request::Set(ColoredTarget {
target: Target::Dynamic(color),
color: rgb,
}));
}
// Each successive value uses the next color so long as it exists.
match color.next() {
Some(n) => color = n,
None => return result,
}
}
}
/// OSC 110-119: Reset Dynamic Colors
fn parse_reset_dynamic_color<'a>(
color: DynamicColor,
it: &mut impl Iterator<Item = &'a [u8]>,
) -> List {
let mut result = List::new();
if it.next().is_some() {
return result;
}
result.push(Request::Reset(Target::Dynamic(color)));
result
}
/// The list of requests in a color OSC. (Zig uses a SegmentedList to
/// avoid copying; a `Vec` is equivalent.)
pub type List = Vec<Request>;
/// A single operation related to the terminal color palette.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Request {
Set(ColoredTarget),
Query(Target),
Reset(Target),
ResetPalette,
ResetSpecial,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
Palette(u8),
Special(SpecialColor),
Dynamic(DynamicColor),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ColoredTarget {
pub target: Target,
pub color: Rgb,
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
const RED: Rgb = Rgb { r: 255, g: 0, b: 0 };
const BLUE: Rgb = Rgb { r: 0, g: 0, b: 255 };
fn set(target: Target, color: Rgb) -> Request {
Request::Set(ColoredTarget { target, color })
}
const SPECIALS: [SpecialColor; 5] = [
SpecialColor::Bold,
SpecialColor::Underline,
SpecialColor::Blink,
SpecialColor::Reverse,
SpecialColor::Italic,
];
const DYNAMICS: [(DynamicColor, Operation, Operation); 10] = [
(
DynamicColor::Foreground,
Operation::Osc10,
Operation::Osc110,
),
(
DynamicColor::Background,
Operation::Osc11,
Operation::Osc111,
),
(DynamicColor::Cursor, Operation::Osc12, Operation::Osc112),
(
DynamicColor::PointerForeground,
Operation::Osc13,
Operation::Osc113,
),
(
DynamicColor::PointerBackground,
Operation::Osc14,
Operation::Osc114,
),
(
DynamicColor::TektronixForeground,
Operation::Osc15,
Operation::Osc115,
),
(
DynamicColor::TektronixBackground,
Operation::Osc16,
Operation::Osc116,
),
(
DynamicColor::HighlightBackground,
Operation::Osc17,
Operation::Osc117,
),
(
DynamicColor::TektronixCursor,
Operation::Osc18,
Operation::Osc118,
),
(
DynamicColor::HighlightForeground,
Operation::Osc19,
Operation::Osc119,
),
];
#[test]
fn osc_4_empty_param() {
let mut p = Parser::init(false);
for ch in "4;;".bytes() {
p.next(ch);
}
assert!(p.end(Some(0x1b)).is_none());
}
#[test]
fn osc_4() {
// Test every palette index
for idx in 0..u8::MAX {
// printf '\e]4;0;red\\'
let list = parse_color(Operation::Osc4, format!("{idx};red").as_bytes());
assert_eq!(vec![set(Target::Palette(idx), RED)], list);
// printf '\e]4;0;?\\'
let list = parse_color(Operation::Osc4, format!("{idx};?").as_bytes());
assert_eq!(vec![Request::Query(Target::Palette(idx))], list);
// Trailing invalid data produces results up to that point
let list = parse_color(Operation::Osc4, format!("{idx};red;").as_bytes());
assert_eq!(vec![set(Target::Palette(idx), RED)], list);
// Whitespace doesn't produce a working value in xterm but we
// allow it because Kitty does and it seems harmless.
let list = parse_color(Operation::Osc4, format!("{idx};red ").as_bytes());
assert_eq!(vec![set(Target::Palette(idx), RED)], list);
}
// Test every special color
for (i, special) in SPECIALS.iter().enumerate() {
let list = parse_color(Operation::Osc4, format!("{};red", 256 + i).as_bytes());
assert_eq!(vec![set(Target::Special(*special), RED)], list);
}
}
#[test]
fn osc_5() {
for (i, special) in SPECIALS.iter().enumerate() {
let list = parse_color(Operation::Osc5, format!("{i};red").as_bytes());
assert_eq!(vec![set(Target::Special(*special), RED)], list);
}
}
#[test]
fn osc_4_multiple_requests() {
let list = parse_color(Operation::Osc4, b"0;red;1;blue");
assert_eq!(
vec![set(Target::Palette(0), RED), set(Target::Palette(1), BLUE)],
list
);
// Multiple requests with same index overwrite each other
let list = parse_color(Operation::Osc4, b"0;red;0;blue");
assert_eq!(
vec![set(Target::Palette(0), RED), set(Target::Palette(0), BLUE)],
list
);
}
#[test]
fn osc_104() {
for idx in 0..u8::MAX {
let list = parse_color(Operation::Osc104, idx.to_string().as_bytes());
assert_eq!(vec![Request::Reset(Target::Palette(idx))], list);
}
for (i, special) in SPECIALS.iter().enumerate() {
let list = parse_color(Operation::Osc104, (256 + i).to_string().as_bytes());
assert_eq!(vec![Request::Reset(Target::Special(*special))], list);
}
}
#[test]
fn osc_104_empty_index() {
let list = parse_color(Operation::Osc104, b"0;;1");
assert_eq!(
vec![
Request::Reset(Target::Palette(0)),
Request::Reset(Target::Palette(1))
],
list
);
}
#[test]
fn osc_104_invalid_index() {
let list = parse_color(Operation::Osc104, b"ffff;1");
assert_eq!(vec![Request::Reset(Target::Palette(1))], list);
}
#[test]
fn osc_104_reset_all() {
let list = parse_color(Operation::Osc104, b"");
assert_eq!(vec![Request::ResetPalette], list);
}
#[test]
fn osc_105_reset_all() {
let list = parse_color(Operation::Osc105, b"");
assert_eq!(vec![Request::ResetSpecial], list);
}
// OSC 10-19: Get/Set Dynamic Colors
#[test]
fn osc_10_to_19_dynamic() {
for (color, op, _) in DYNAMICS {
// printf '\e]10;red\e\\'
let list = parse_color(op, b"red");
assert_eq!(vec![set(Target::Dynamic(color), RED)], list);
}
}
#[test]
fn osc_10_to_19_dynamic_multiple() {
// printf '\e]11;red;blue\e\\'
let list = parse_color(Operation::Osc11, b"red;blue");
assert_eq!(
vec![
set(Target::Dynamic(DynamicColor::Background), RED),
set(Target::Dynamic(DynamicColor::Cursor), BLUE)
],
list
);
}
// OSC 110-119: Reset Dynamic Colors
#[test]
fn osc_110_to_119_reset_dynamic() {
for (color, _, op) in DYNAMICS {
let expected = vec![Request::Reset(Target::Dynamic(color))];
// printf '\e]110\e\\'
assert_eq!(expected, parse_color(op, b""));
// xterm allows a trailing semicolon.
assert_eq!(expected, parse_color(op, b";"));
// xterm does NOT allow any whitespace
assert_eq!(0, parse_color(op, b" ").len());
}
}
#[test]
fn osc_112_incomplete_sequence() {
// https://github.com/darrenstarr/VtNetCore/pull/14
let mut p = Parser::init(true);
for ch in b"112" {
p.next(*ch);
}
let cmd = p.end(Some(0x07)).unwrap();
let Command::ColorOperation {
op,
requests,
terminator,
} = cmd
else {
panic!("wrong command");
};
assert_eq!(Terminator::Bel, terminator);
assert_eq!(Operation::Osc112, op);
assert_eq!(
vec![Request::Reset(Target::Dynamic(DynamicColor::Cursor))],
requests
);
}
}
@@ -0,0 +1,498 @@
//! OSC 3008: Hierarchical Context Signalling (UAPI spec)
//! Specification: https://uapi-group.org/specifications/specs/osc_context/
//! (ZIG/src/terminal/osc/parsers/context_signal.zig)
//!
//! OSC 3008 allows programs to signal context changes to the terminal emulator.
//! Each context has an identifier and metadata fields. Contexts are hierarchical
//! and form a stack.
use crate::osc::{Command as OscCommand, Parser, State};
/// Maximum length of a context identifier (per spec).
const MAX_CONTEXT_ID_LEN: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
/// OSC 3008;start=<id> - initiates, updates, or returns to a context.
Start,
/// OSC 3008;end=<id> - terminates a context.
End,
}
/// A single OSC 3008 context signal command.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Command {
pub action: Action,
/// The context identifier. Must be 1-64 characters in the 32..126 byte range.
pub id: Vec<u8>,
/// Raw unparsed metadata fields after the context ID.
/// Fields are semicolon-separated key=value pairs.
/// Parsed lazily via `read_option`.
pub metadata: Vec<u8>,
}
/// Value of a decoded metadata field (Zig `Field.Type()`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldValue<'a> {
Type(ContextType),
Exit(ExitStatus),
U64(u64),
Str(&'a [u8]),
}
impl Command {
/// Read a metadata field value from the raw fields string.
/// Returns None if the field is not present or malformed.
pub fn read_option(&self, option: Field) -> Option<FieldValue<'_>> {
option.read(&self.metadata)
}
pub fn context_type(&self) -> Option<ContextType> {
match self.read_option(Field::Type)? {
FieldValue::Type(v) => Some(v),
_ => None,
}
}
pub fn exit(&self) -> Option<ExitStatus> {
match self.read_option(Field::Exit)? {
FieldValue::Exit(v) => Some(v),
_ => None,
}
}
/// For the numeric fields (`pid`, `pidfdid`, `status`).
pub fn read_u64(&self, option: Field) -> Option<u64> {
match self.read_option(option)? {
FieldValue::U64(v) => Some(v),
_ => None,
}
}
/// For the string fields.
pub fn read_str(&self, option: Field) -> Option<&[u8]> {
match self.read_option(option)? {
FieldValue::Str(v) => Some(v),
_ => None,
}
}
}
/// Context types defined by the specification.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextType {
Boot,
Container,
Vm,
Elevate,
Chpriv,
Subcontext,
Remote,
Shell,
Command,
App,
Service,
Session,
}
impl ContextType {
pub fn parse(value: &[u8]) -> Option<ContextType> {
Some(match value {
b"boot" => ContextType::Boot,
b"container" => ContextType::Container,
b"vm" => ContextType::Vm,
b"elevate" => ContextType::Elevate,
b"chpriv" => ContextType::Chpriv,
b"subcontext" => ContextType::Subcontext,
b"remote" => ContextType::Remote,
b"shell" => ContextType::Shell,
b"command" => ContextType::Command,
b"app" => ContextType::App,
b"service" => ContextType::Service,
b"session" => ContextType::Session,
_ => return None,
})
}
}
/// Exit status for the `exit` end-sequence field.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitStatus {
Success,
Failure,
Crash,
Interrupt,
}
impl ExitStatus {
pub fn parse(value: &[u8]) -> Option<ExitStatus> {
Some(match value {
b"success" => ExitStatus::Success,
b"failure" => ExitStatus::Failure,
b"crash" => ExitStatus::Crash,
b"interrupt" => ExitStatus::Interrupt,
_ => return None,
})
}
}
/// Metadata fields that can appear in OSC 3008 sequences.
/// Fields are read lazily from the raw string using the `read` method.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Field {
// Start sequence fields
Type,
User,
Hostname,
Machineid,
Bootid,
Pid,
Pidfdid,
Comm,
Cwd,
Cmdline,
Vm,
Container,
Targetuser,
Targethost,
Sessionid,
// End sequence fields
Exit,
Status,
Signal,
}
impl Field {
fn key(self) -> &'static str {
match self {
Field::Type => "type",
Field::User => "user",
Field::Hostname => "hostname",
Field::Machineid => "machineid",
Field::Bootid => "bootid",
Field::Pid => "pid",
Field::Pidfdid => "pidfdid",
Field::Comm => "comm",
Field::Cwd => "cwd",
Field::Cmdline => "cmdline",
Field::Vm => "vm",
Field::Container => "container",
Field::Targetuser => "targetuser",
Field::Targethost => "targethost",
Field::Sessionid => "sessionid",
Field::Exit => "exit",
Field::Status => "status",
Field::Signal => "signal",
}
}
/// Read the field value from the raw fields string.
///
/// The raw fields string contains semicolon-separated key=value pairs
/// e.g. "type=container;user=lennart;hostname=zeta".
///
/// Unknown or malformed fields are ignored per the specification.
pub fn read(self, raw: &[u8]) -> Option<FieldValue<'_>> {
let key = self.key().as_bytes();
for full in raw.split(|&c| c == b';') {
let value = match full.iter().position(|&c| c == b'=') {
Some(eql) if &full[..eql] == key => &full[eql + 1..],
_ => continue,
};
return match self {
Field::Type => ContextType::parse(value).map(FieldValue::Type),
Field::Exit => ExitStatus::parse(value).map(FieldValue::Exit),
Field::Pid | Field::Pidfdid | Field::Status => {
if value.iter().any(|c| !c.is_ascii_digit()) {
return None;
}
std::str::from_utf8(value)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(FieldValue::U64)
}
// String fields
_ => {
if value.is_empty() {
None
} else {
Some(FieldValue::Str(value))
}
}
};
}
// Not found
None
}
}
/// Parse OSC 3008: hierarchical context signalling.
///
/// Expected data format (after "3008;" prefix has been consumed by the state machine):
/// start=<id>[;<field>=<value>]*
/// end=<id>[;<field>=<value>]*
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<OscCommand> {
let data = parser.buffered()?;
if data.is_empty() {
parser.state = State::Invalid;
return None;
}
// Determine the action (start= or end=)
let (action, prefix_len) = if data.starts_with(b"start=") {
(Action::Start, b"start=".len())
} else if data.starts_with(b"end=") {
(Action::End, b"end=".len())
} else {
log::warn!(
"OSC 3008: expected 'start=' or 'end=' prefix, got: {}",
String::from_utf8_lossy(&data[..data.len().min(10)])
);
parser.state = State::Invalid;
return None;
};
// Skip past the "start=" or "end=" prefix
let rest = &data[prefix_len..];
if rest.is_empty() {
log::warn!("OSC 3008: missing context ID");
parser.state = State::Invalid;
return None;
}
// Extract the context ID (up to the first semicolon or end of data)
let id_end = rest.iter().position(|&c| c == b';').unwrap_or(rest.len());
let id = &rest[..id_end];
// Validate context ID length (1-64 chars per spec)
if id.is_empty() || id.len() > MAX_CONTEXT_ID_LEN {
log::warn!(
"OSC 3008: context ID length {} out of range (1-{})",
id.len(),
MAX_CONTEXT_ID_LEN
);
parser.state = State::Invalid;
return None;
}
// Validate context ID characters (32..126 byte range per spec)
for &c in id {
if !(0x20..=0x7e).contains(&c) {
log::warn!("OSC 3008: invalid character 0x{c:02x} in context ID");
parser.state = State::Invalid;
return None;
}
}
// Extract raw metadata fields (everything after the ID)
let metadata = if id_end < rest.len() {
rest[id_end + 1..].to_vec()
} else {
Vec::new()
};
Some(OscCommand::ContextSignal(Command {
action,
id: id.to_vec(),
metadata,
}))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn run(input: &str) -> Option<OscCommand> {
let mut p = Parser::init(false);
for &ch in input.as_bytes() {
p.next(ch);
}
p.end(None)
}
fn cs(input: &str) -> Command {
match run(input) {
Some(OscCommand::ContextSignal(c)) => c,
other => panic!("unexpected {other:?}"),
}
}
#[test]
fn osc_3008_basic_start_command() {
let c = cs("3008;start=abc123");
assert_eq!(Action::Start, c.action);
assert_eq!(b"abc123", &c.id[..]);
assert_eq!(b"", &c.metadata[..]);
}
#[test]
fn osc_3008_basic_end_command() {
let c = cs("3008;end=abc123");
assert_eq!(Action::End, c.action);
assert_eq!(b"abc123", &c.id[..]);
assert_eq!(b"", &c.metadata[..]);
}
#[test]
fn osc_3008_start_with_metadata_fields() {
let c = cs(
"3008;start=bed86fab93af4328bbed0a1224af6d40;type=container;user=lennart;hostname=zeta",
);
assert_eq!(Action::Start, c.action);
assert_eq!(b"bed86fab93af4328bbed0a1224af6d40", &c.id[..]);
assert_eq!(Some(ContextType::Container), c.context_type());
assert_eq!(b"lennart", c.read_str(Field::User).unwrap());
assert_eq!(b"zeta", c.read_str(Field::Hostname).unwrap());
}
#[test]
fn osc_3008_start_with_all_common_fields() {
let c = cs(
"3008;start=myctx;type=shell;user=root;hostname=myhost;machineid=3deb5353d3ba43d08201c136a47ead7b;bootid=d4a3d0fdf2e24fdea6d971ce73f4fbf2;pid=1062862;pidfdid=1063162;comm=bash",
);
assert_eq!(Some(ContextType::Shell), c.context_type());
assert_eq!(b"root", c.read_str(Field::User).unwrap());
assert_eq!(b"myhost", c.read_str(Field::Hostname).unwrap());
assert_eq!(
b"3deb5353d3ba43d08201c136a47ead7b",
c.read_str(Field::Machineid).unwrap()
);
assert_eq!(
b"d4a3d0fdf2e24fdea6d971ce73f4fbf2",
c.read_str(Field::Bootid).unwrap()
);
assert_eq!(Some(1062862), c.read_u64(Field::Pid));
assert_eq!(Some(1063162), c.read_u64(Field::Pidfdid));
assert_eq!(b"bash", c.read_str(Field::Comm).unwrap());
}
#[test]
fn osc_3008_end_with_exit_metadata() {
let c = cs("3008;end=myctx;exit=success;status=0");
assert_eq!(Action::End, c.action);
assert_eq!(b"myctx", &c.id[..]);
assert_eq!(Some(ExitStatus::Success), c.exit());
assert_eq!(Some(0), c.read_u64(Field::Status));
}
#[test]
fn osc_3008_end_with_failure_exit() {
let c = cs("3008;end=myctx;exit=failure;status=1;signal=SIGKILL");
assert_eq!(Some(ExitStatus::Failure), c.exit());
assert_eq!(Some(1), c.read_u64(Field::Status));
assert_eq!(b"SIGKILL", c.read_str(Field::Signal).unwrap());
}
#[test]
fn osc_3008_unknown_fields_are_ignored() {
let c = cs("3008;start=myctx;type=shell;unknownfield=value;user=root");
assert_eq!(Some(ContextType::Shell), c.context_type());
assert_eq!(b"root", c.read_str(Field::User).unwrap());
}
#[test]
fn osc_3008_missing_field_returns_null() {
let c = cs("3008;start=myctx;user=lennart");
assert!(c.context_type().is_none());
assert!(c.read_str(Field::Hostname).is_none());
assert!(c.read_u64(Field::Pid).is_none());
}
#[test]
fn osc_3008_invalid_prefix() {
assert!(run("3008;bogus=abc123").is_none());
}
#[test]
fn osc_3008_empty_data() {
assert!(run("3008;start=").is_none());
}
#[test]
fn osc_3008_max_length_context_id() {
let id = "a".repeat(64);
let c = cs(&format!("3008;start={id}"));
assert_eq!(id.as_bytes(), &c.id[..]);
}
#[test]
fn osc_3008_over_length_context_id() {
let id = "a".repeat(65);
assert!(run(&format!("3008;start={id}")).is_none());
}
#[test]
fn osc_3008_context_type_enum_coverage() {
let types = [
("boot", ContextType::Boot),
("container", ContextType::Container),
("vm", ContextType::Vm),
("elevate", ContextType::Elevate),
("chpriv", ContextType::Chpriv),
("subcontext", ContextType::Subcontext),
("remote", ContextType::Remote),
("shell", ContextType::Shell),
("command", ContextType::Command),
("app", ContextType::App),
("service", ContextType::Service),
("session", ContextType::Session),
];
for (s, e) in types {
assert_eq!(Some(e), ContextType::parse(s.as_bytes()));
}
assert!(ContextType::parse(b"invalid").is_none());
}
#[test]
fn osc_3008_exit_status_enum_coverage() {
assert_eq!(Some(ExitStatus::Success), ExitStatus::parse(b"success"));
assert_eq!(Some(ExitStatus::Failure), ExitStatus::parse(b"failure"));
assert_eq!(Some(ExitStatus::Crash), ExitStatus::parse(b"crash"));
assert_eq!(Some(ExitStatus::Interrupt), ExitStatus::parse(b"interrupt"));
assert!(ExitStatus::parse(b"invalid").is_none());
}
#[test]
fn osc_3008_spec_example_container_start() {
let c = cs(
"3008;start=bed86fab93af4328bbed0a1224af6d40;type=container;user=lennart;hostname=zeta;machineid=3deb5353d3ba43d08201c136a47ead7b;bootid=d4a3d0fdf2e24fdea6d971ce73f4fbf2;pid=1062862;pidfdid=1063162;comm=systemd-nspawn;container=foobar",
);
assert_eq!(Action::Start, c.action);
assert_eq!(b"bed86fab93af4328bbed0a1224af6d40", &c.id[..]);
assert_eq!(Some(ContextType::Container), c.context_type());
assert_eq!(b"lennart", c.read_str(Field::User).unwrap());
assert_eq!(b"zeta", c.read_str(Field::Hostname).unwrap());
assert_eq!(b"systemd-nspawn", c.read_str(Field::Comm).unwrap());
assert_eq!(b"foobar", c.read_str(Field::Container).unwrap());
assert_eq!(Some(1062862), c.read_u64(Field::Pid));
}
#[test]
fn osc_3008_spec_example_context_end() {
let c = cs("3008;end=bed86fab93af4328bbed0a1224af6d40");
assert_eq!(Action::End, c.action);
assert_eq!(b"bed86fab93af4328bbed0a1224af6d40", &c.id[..]);
}
#[test]
fn osc_3008_cwd_and_cmdline_fields() {
let c = cs("3008;start=myctx;type=command;cwd=/home/user;cmdline=ls -la");
assert_eq!(b"/home/user", c.read_str(Field::Cwd).unwrap());
assert_eq!(b"ls -la", c.read_str(Field::Cmdline).unwrap());
}
#[test]
fn osc_3008_start_command_with_no_fields() {
let c = cs("3008;start=simpleid");
assert_eq!(Action::Start, c.action);
assert_eq!(b"simpleid", &c.id[..]);
assert!(c.context_type().is_none());
assert!(c.read_str(Field::User).is_none());
assert!(c.exit().is_none());
}
}
@@ -0,0 +1,143 @@
//! OSC 8 hyperlinks (ZIG/src/terminal/osc/parsers/hyperlink.zig).
use crate::osc::{Command, Parser, State};
/// Parse OSC 8 hyperlinks
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
let data = parser.data()?;
let Some(s) = data.iter().position(|&b| b == b';') else {
parser.state = State::Invalid;
return None;
};
let uri = data[s + 1..].to_vec();
let mut id: Option<Vec<u8>> = None;
// The params section is `key=value` pairs separated by ':'. This follows
// the Zig implementation byte for byte: ':' and the ';' terminator are
// turned into NULs and the segments are walked by searching for the next
// NUL starting one past the segment start.
let mut kvs = data[..s].to_vec();
kvs.push(0);
for b in &mut kvs {
if *b == b':' {
*b = 0;
}
}
let mut kv_start = 0;
while kv_start < kvs.len() {
let Some(kv_end) = kvs
.iter()
.skip(kv_start + 1)
.position(|&b| b == 0)
.map(|i| i + kv_start + 1)
else {
break;
};
let kv = &kvs[kv_start..kv_end];
let Some(v) = kv.iter().position(|&b| b == b'=') else {
break;
};
let key = &kv[..v];
let value = &kv[v + 1..];
if key == b"id" {
if !value.is_empty() {
id = Some(value.to_vec());
}
} else {
log::warn!(
"unknown hyperlink option: '{}'",
String::from_utf8_lossy(key)
);
}
kv_start = kv_end + 1;
}
if uri.is_empty() {
if id.is_some() {
parser.state = State::Invalid;
return None;
}
return Some(Command::HyperlinkEnd);
}
Some(Command::HyperlinkStart { id, uri })
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn run(input: &[u8]) -> Option<Command> {
let mut p = Parser::init(false);
for c in input {
p.next(*c);
}
p.end(Some(0x1b))
}
fn start(id: Option<&str>, uri: &str) -> Command {
Command::HyperlinkStart {
id: id.map(|s| s.as_bytes().to_vec()),
uri: uri.as_bytes().to_vec(),
}
}
#[test]
fn osc_8_hyperlink() {
assert_eq!(
run(b"8;;http://example.com").unwrap(),
start(None, "http://example.com")
);
}
#[test]
fn osc_8_hyperlink_with_id_set() {
assert_eq!(
run(b"8;id=foo;http://example.com").unwrap(),
start(Some("foo"), "http://example.com")
);
}
#[test]
fn osc_8_hyperlink_with_empty_id() {
assert_eq!(
run(b"8;id=;http://example.com").unwrap(),
start(None, "http://example.com")
);
}
#[test]
fn osc_8_hyperlink_with_incomplete_key() {
assert_eq!(
run(b"8;id;http://example.com").unwrap(),
start(None, "http://example.com")
);
}
#[test]
fn osc_8_hyperlink_with_empty_key() {
assert_eq!(
run(b"8;=value;http://example.com").unwrap(),
start(None, "http://example.com")
);
}
#[test]
fn osc_8_hyperlink_with_empty_key_and_id() {
assert_eq!(
run(b"8;=value:id=foo;http://example.com").unwrap(),
start(Some("foo"), "http://example.com")
);
}
#[test]
fn osc_8_hyperlink_with_empty_uri() {
assert!(run(b"8;id=foo;").is_none());
}
#[test]
fn osc_8_hyperlink_end() {
assert_eq!(run(b"8;;").unwrap(), Command::HyperlinkEnd);
}
}
+284
View File
@@ -0,0 +1,284 @@
//! OSC 1337 (iTerm2) (ZIG/src/terminal/osc/parsers/iterm2.zig).
use crate::osc::{Command, Parser, State};
/// iTerm2 OSC 1337 keys. Lookup is ASCII case-insensitive.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Key {
AddAnnotation,
AddHiddenAnnotation,
Block,
Button,
ClearCapturedOutput,
ClearScrollback,
Copy,
CopyToClipboard,
CurrentDir,
CursorShape,
Custom,
Disinter,
EndCopy,
File,
FileEnd,
FilePart,
HighlightCursorLine,
MultipartFile,
OpenURL,
PopKeyLabels,
PushKeyLabels,
RemoteHost,
ReportCellSize,
ReportVariable,
RequestAttention,
RequestUpload,
SetBackgroundImageFile,
SetBadgeFormat,
SetColors,
SetKeyLabel,
SetMark,
SetProfile,
SetUserVar,
ShellIntegrationVersion,
StealFocus,
UnicodeVersion,
}
const KEYS: &[(&str, Key)] = &[
("AddAnnotation", Key::AddAnnotation),
("AddHiddenAnnotation", Key::AddHiddenAnnotation),
("Block", Key::Block),
("Button", Key::Button),
("ClearCapturedOutput", Key::ClearCapturedOutput),
("ClearScrollback", Key::ClearScrollback),
("Copy", Key::Copy),
("CopyToClipboard", Key::CopyToClipboard),
("CurrentDir", Key::CurrentDir),
("CursorShape", Key::CursorShape),
("Custom", Key::Custom),
("Disinter", Key::Disinter),
("EndCopy", Key::EndCopy),
("File", Key::File),
("FileEnd", Key::FileEnd),
("FilePart", Key::FilePart),
("HighlightCursorLine", Key::HighlightCursorLine),
("MultipartFile", Key::MultipartFile),
("OpenURL", Key::OpenURL),
("PopKeyLabels", Key::PopKeyLabels),
("PushKeyLabels", Key::PushKeyLabels),
("RemoteHost", Key::RemoteHost),
("ReportCellSize", Key::ReportCellSize),
("ReportVariable", Key::ReportVariable),
("RequestAttention", Key::RequestAttention),
("RequestUpload", Key::RequestUpload),
("SetBackgroundImageFile", Key::SetBackgroundImageFile),
("SetBadgeFormat", Key::SetBadgeFormat),
("SetColors", Key::SetColors),
("SetKeyLabel", Key::SetKeyLabel),
("SetMark", Key::SetMark),
("SetProfile", Key::SetProfile),
("SetUserVar", Key::SetUserVar),
("ShellIntegrationVersion", Key::ShellIntegrationVersion),
("StealFocus", Key::StealFocus),
("UnicodeVersion", Key::UnicodeVersion),
];
fn lookup(s: &[u8]) -> Option<Key> {
KEYS.iter()
.find(|(name, _)| name.as_bytes().eq_ignore_ascii_case(s))
.map(|(_, k)| *k)
}
/// Parse OSC 1337
/// https://iterm2.com/documentation-escape-codes.html
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
debug_assert_eq!(parser.state, State::S1337);
let data = parser.data()?;
let (key_str, value): (&[u8], Option<&[u8]>) = match data.iter().position(|&b| b == b'=') {
None => (&data[..], None),
Some(index) => (&data[..index], Some(&data[index + 1..])),
};
// (Zig sets `parser.command = .invalid` and returns null on all of the
// failure paths below.)
let key = lookup(key_str)?;
match key {
Key::Copy => {
let value = value?;
// Sending a blank entry to clear the clipboard is an OSC 52-ism,
// make sure that is invalid here.
if value.is_empty() {
return None;
}
// base64 value must be prefixed by a colon
if value[0] != b':' {
return None;
}
let value = &value[1..];
// Sending a blank entry to clear the clipboard is an OSC 52-ism,
// make sure that is invalid here.
if value.is_empty() {
return None;
}
// Sending a '?' to query the clipboard is an OSC 52-ism, make sure
// that is invalid here.
if value == b"?" {
return None;
}
// It would be better to check for valid base64 data here, but that
// would mean parsing the base64 data twice in the "normal" case.
Some(Command::ClipboardContents {
kind: b'c',
data: value.to_vec(),
})
}
Key::CurrentDir => {
let value = value?;
if value.is_empty() {
return None;
}
Some(Command::ReportPwd {
value: value.to_vec(),
})
}
_ => {
log::debug!("unimplemented OSC 1337: {key:?}");
None
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
fn run(input: &str) -> Option<Command> {
let mut p = Parser::init(true);
for ch in input.bytes() {
p.next(ch);
}
p.end(Some(0x1b))
}
#[test]
fn osc_1337_test_valid_unimplemented_key_with_no_value() {
assert!(run("1337;SetBadgeFormat").is_none());
}
#[test]
fn osc_1337_test_valid_unimplemented_key_with_empty_value() {
assert!(run("1337;SetBadgeFormat=").is_none());
}
#[test]
fn osc_1337_test_valid_unimplemented_key_with_non_empty_value() {
assert!(run("1337;SetBadgeFormat=abc123").is_none());
}
#[test]
fn osc_1337_test_valid_key_with_lower_case_and_with_no_value() {
assert!(run("1337;setbadgeformat").is_none());
}
#[test]
fn osc_1337_test_valid_key_with_lower_case_and_with_empty_value() {
assert!(run("1337;setbadgeformat=").is_none());
}
#[test]
fn osc_1337_test_valid_key_with_lower_case_and_with_non_empty_value() {
assert!(run("1337;setbadgeformat=abc123").is_none());
}
#[test]
fn osc_1337_test_invalid_key_with_no_value() {
assert!(run("1337;BobrKurwa").is_none());
}
#[test]
fn osc_1337_test_invalid_key_with_empty_value() {
assert!(run("1337;BobrKurwa=").is_none());
}
#[test]
fn osc_1337_test_invalid_key_with_non_empty_value() {
assert!(run("1337;BobrKurwa=abc123").is_none());
}
#[test]
fn osc_1337_test_copy_with_no_value() {
assert!(run("1337;Copy").is_none());
}
#[test]
fn osc_1337_test_copy_with_empty_value() {
assert!(run("1337;Copy=").is_none());
}
#[test]
fn osc_1337_test_copy_with_only_prefix_colon() {
assert!(run("1337;Copy=:").is_none());
}
#[test]
fn osc_1337_test_copy_with_question_mark() {
assert!(run("1337;Copy=:?").is_none());
}
// Skipped in Zig too (`return error.SkipZigTest`): for performance reasons
// base64 validity is not checked.
#[test]
#[ignore = "skipped in Zig: base64 is not validated for performance"]
fn osc_1337_test_copy_with_non_empty_value_that_is_invalid_base64() {
assert!(run("1337;Copy=:abc123").is_none());
}
#[test]
fn osc_1337_test_copy_with_non_empty_value_that_is_valid_base64_but_not_prefixed_with_a_colon()
{
assert!(run("1337;Copy=YWJjMTIz").is_none());
}
#[test]
fn osc_1337_test_copy_with_non_empty_value_that_is_valid_base64() {
assert_eq!(
run("1337;Copy=:YWJjMTIz"),
Some(Command::ClipboardContents {
kind: b'c',
data: b"YWJjMTIz".to_vec()
})
);
}
#[test]
fn osc_1337_test_current_dir_with_no_value() {
assert!(run("1337;CurrentDir").is_none());
}
#[test]
fn osc_1337_test_current_dir_with_empty_value() {
assert!(run("1337;CurrentDir=").is_none());
}
#[test]
fn osc_1337_test_current_dir_with_non_empty_value() {
assert_eq!(
run("1337;CurrentDir=abc123"),
Some(Command::ReportPwd {
value: b"abc123".to_vec()
})
);
}
}
@@ -0,0 +1,658 @@
//! Kitty's clipboard protocol (OSC 5522)
//! Specification: https://sw.kovidgoyal.net/kitty/clipboard/
//! https://rockorager.dev/misc/bracketed-paste-mime/
//! (ZIG/src/terminal/osc/parsers/kitty_clipboard_protocol.zig)
use crate::osc::{Command, Parser, State, Terminator};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OSC {
/// The raw metadata that was received. It can be parsed by using the
/// `read_option` method (or the typed helpers).
pub metadata: Vec<u8>,
/// The raw payload. It may be Base64 encoded, check the `e` option.
pub payload: Option<Vec<u8>>,
/// The terminator that was used in case we need to send a response.
pub terminator: Terminator,
}
/// Value of a decoded option (Zig `Option.Type()`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptionValue<'a> {
Str(&'a [u8]),
Loc(Location),
Status(Status),
Type(Operation),
}
impl OSC {
/// Decode an option from the metadata.
pub fn read_option(&self, key: Opt) -> Option<OptionValue<'_>> {
key.read(&self.metadata)
}
pub fn id(&self) -> Option<&[u8]> {
match self.read_option(Opt::Id)? {
OptionValue::Str(s) => Some(s),
_ => None,
}
}
pub fn loc(&self) -> Option<Location> {
match self.read_option(Opt::Loc)? {
OptionValue::Loc(v) => Some(v),
_ => None,
}
}
pub fn mime(&self) -> Option<&[u8]> {
match self.read_option(Opt::Mime)? {
OptionValue::Str(s) => Some(s),
_ => None,
}
}
pub fn name(&self) -> Option<&[u8]> {
match self.read_option(Opt::Name)? {
OptionValue::Str(s) => Some(s),
_ => None,
}
}
pub fn password(&self) -> Option<&[u8]> {
match self.read_option(Opt::Password)? {
OptionValue::Str(s) => Some(s),
_ => None,
}
}
pub fn pw(&self) -> Option<&[u8]> {
match self.read_option(Opt::Pw)? {
OptionValue::Str(s) => Some(s),
_ => None,
}
}
pub fn status(&self) -> Option<Status> {
match self.read_option(Opt::Status)? {
OptionValue::Status(v) => Some(v),
_ => None,
}
}
pub fn type_(&self) -> Option<Operation> {
match self.read_option(Opt::Type)? {
OptionValue::Type(v) => Some(v),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Location {
Primary,
}
impl Location {
pub fn init(s: &[u8]) -> Option<Location> {
match s {
b"primary" => Some(Location::Primary),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Operation {
Read,
Walias,
Wdata,
Write,
}
impl Operation {
pub fn init(s: &[u8]) -> Option<Operation> {
match s {
b"read" => Some(Operation::Read),
b"walias" => Some(Operation::Walias),
b"wdata" => Some(Operation::Wdata),
b"write" => Some(Operation::Write),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(clippy::upper_case_acronyms)]
pub enum Status {
DATA,
DONE,
EBUSY,
EINVAL,
EIO,
ENOSYS,
EPERM,
OK,
}
impl Status {
pub fn init(s: &[u8]) -> Option<Status> {
match s {
b"DATA" => Some(Status::DATA),
b"DONE" => Some(Status::DONE),
b"EBUSY" => Some(Status::EBUSY),
b"EINVAL" => Some(Status::EINVAL),
b"EIO" => Some(Status::EIO),
b"ENOSYS" => Some(Status::ENOSYS),
b"EPERM" => Some(Status::EPERM),
b"OK" => Some(Status::OK),
_ => None,
}
}
}
/// Option keys (Zig `Option`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Opt {
Id,
Loc,
Mime,
Name,
Password,
Pw,
Status,
Type,
}
/// Zig `std.ascii.isWhitespace`.
fn is_ws(c: u8) -> bool {
matches!(c, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c)
}
fn trim_ws(mut s: &[u8]) -> &[u8] {
while let [first, rest @ ..] = s {
if is_ws(*first) {
s = rest;
} else {
break;
}
}
while let [rest @ .., last] = s {
if is_ws(*last) {
s = rest;
} else {
break;
}
}
s
}
fn index_of_from(hay: &[u8], from: usize, needle: u8) -> Option<usize> {
hay[from..]
.iter()
.position(|&c| c == needle)
.map(|i| i + from)
}
impl Opt {
pub fn tag_name(self) -> &'static str {
match self {
Opt::Id => "id",
Opt::Loc => "loc",
Opt::Mime => "mime",
Opt::Name => "name",
Opt::Password => "password",
Opt::Pw => "pw",
Opt::Status => "status",
Opt::Type => "type",
}
}
/// Read the option value from the raw metadata string.
pub fn read(self, metadata: &[u8]) -> Option<OptionValue<'_>> {
let tag = self.tag_name().as_bytes();
let value: &[u8] = 'value: {
let mut pos: usize = 0;
while pos < metadata.len() {
// skip any whitespace
while pos < metadata.len() && is_ws(metadata[pos]) {
pos += 1;
}
// bail if we are out of metadata
if pos >= metadata.len() {
return None;
}
if !metadata[pos..].starts_with(tag) {
// this isn't the key we are looking for, skip to the next option, or bail if
// there is no next option
pos = index_of_from(metadata, pos, b':')?;
pos += 1;
continue;
}
// skip past the key
pos += tag.len();
// skip any whitespace
while pos < metadata.len() && is_ws(metadata[pos]) {
pos += 1;
}
// bail if we are out of metadata
if pos >= metadata.len() {
return None;
}
// a valid option has an '='
if metadata[pos] != b'=' {
return None;
}
// the end of the value is bounded by a ':' or the end of the metadata
let end = index_of_from(metadata, pos, b':').unwrap_or(metadata.len());
let start = pos + 1;
// strip any leading or trailing whitespace
break 'value trim_ws(&metadata[start..end]);
}
// the key was not found
return None;
};
// return the parsed value
match self {
Opt::Id => parse_identifier(value).map(OptionValue::Str),
Opt::Loc => Location::init(value).map(OptionValue::Loc),
Opt::Mime | Opt::Name | Opt::Password | Opt::Pw => Some(OptionValue::Str(value)),
Opt::Status => Status::init(value).map(OptionValue::Status),
Opt::Type => Operation::init(value).map(OptionValue::Type),
}
}
}
/// Characters that are valid in identifiers.
const VALID_IDENTIFIER_CHARACTERS: &[u8] =
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_+.";
fn is_valid_identifier(s: &[u8]) -> bool {
if s.is_empty() {
return false;
}
s.iter().all(|c| VALID_IDENTIFIER_CHARACTERS.contains(c))
}
fn parse_identifier(s: &[u8]) -> Option<&[u8]> {
if is_valid_identifier(s) {
Some(s)
} else {
None
}
}
pub fn parse(parser: &mut Parser, terminator_ch: Option<u8>) -> Option<Command> {
debug_assert_eq!(parser.state, State::S5522);
let data = parser.buffered()?;
let (metadata, payload) = match data.iter().position(|&c| c == b';') {
None => (data, None),
Some(start) => (data[..start].to_vec(), Some(data[start + 1..].to_vec())),
};
Some(Command::KittyClipboardProtocol(OSC {
metadata,
payload,
terminator: Terminator::init(terminator_ch),
}))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn run(input: &str) -> OSC {
let mut p = Parser::init(true);
for &ch in input.as_bytes() {
p.next(ch);
}
match p.end(Some(0x1b)) {
Some(Command::KittyClipboardProtocol(o)) => o,
other => panic!("unexpected {other:?}"),
}
}
#[allow(clippy::option_option)]
/// Expected values for every option; `None` means "must be null".
#[derive(Default)]
struct Exp<'a> {
metadata: Option<&'a str>,
payload: Option<Option<&'a str>>,
id: Option<&'a str>,
loc: Option<Location>,
mime: Option<&'a str>,
name: Option<&'a str>,
password: Option<&'a str>,
pw: Option<&'a str>,
status: Option<Status>,
type_: Option<Operation>,
}
fn check(c: &OSC, e: &Exp) {
if let Some(m) = e.metadata {
assert_eq!(m.as_bytes(), &c.metadata[..]);
}
if let Some(p) = e.payload {
assert_eq!(p.map(|s| s.as_bytes().to_vec()), c.payload);
}
assert_eq!(e.id.map(str::as_bytes), c.id());
assert_eq!(e.loc, c.loc());
assert_eq!(e.mime.map(str::as_bytes), c.mime());
assert_eq!(e.name.map(str::as_bytes), c.name());
assert_eq!(e.password.map(str::as_bytes), c.password());
assert_eq!(e.pw.map(str::as_bytes), c.pw());
assert_eq!(e.status, c.status());
assert_eq!(e.type_, c.type_());
}
#[test]
fn osc_5522_empty_metadata_and_missing_payload() {
let c = run("5522;");
check(
&c,
&Exp {
metadata: Some(""),
payload: Some(None),
..Default::default()
},
);
}
#[test]
fn osc_5522_empty_metadata_and_empty_payload() {
let c = run("5522;;");
check(
&c,
&Exp {
metadata: Some(""),
payload: Some(Some("")),
..Default::default()
},
);
}
#[test]
fn osc_5522_non_empty_metadata_and_payload() {
let c = run("5522;type=read;dGV4dC9wbGFpbg==");
check(
&c,
&Exp {
metadata: Some("type=read"),
payload: Some(Some("dGV4dC9wbGFpbg==")),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_empty_id() {
let c = run("5522;id=");
assert!(c.id().is_none());
}
#[test]
fn osc_5522_valid_id() {
let c = run("5522;id=5c076ad9-d36f-4705-847b-d4dbf356cc0d");
assert_eq!(b"5c076ad9-d36f-4705-847b-d4dbf356cc0d", c.id().unwrap());
}
#[test]
fn osc_5522_invalid_id() {
let c = run("5522;id=*42*");
assert!(c.id().is_none());
}
#[test]
fn osc_5522_invalid_status() {
let c = run("5522;status=BOBR");
assert!(c.status().is_none());
}
#[test]
fn osc_5522_valid_status() {
let c = run("5522;status=DONE");
assert_eq!(Some(Status::DONE), c.status());
}
#[test]
fn osc_5522_invalid_location() {
let c = run("5522;loc=bobr");
assert!(c.loc().is_none());
}
#[test]
fn osc_5522_valid_location() {
let c = run("5522;loc=primary");
assert_eq!(Some(Location::Primary), c.loc());
}
#[test]
fn osc_5522_password_1() {
let c = run("5522;pw=R2hvc3R0eQ==:name=Qk9CUiBLVVJXQQ==");
assert_eq!(b"R2hvc3R0eQ==", c.pw().unwrap());
assert_eq!(b"Qk9CUiBLVVJXQQ==", c.name().unwrap());
}
#[test]
fn osc_5522_password_2() {
let c = run("5522;password=R2hvc3R0eQ==");
assert_eq!(b"R2hvc3R0eQ==", c.password().unwrap());
}
#[test]
fn osc_5522_example_1() {
let c = run("5522;type=read:status=OK");
check(
&c,
&Exp {
payload: Some(None),
status: Some(Status::OK),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_2() {
let c = run("5522;type=read:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==");
check(
&c,
&Exp {
payload: Some(Some("R2hvc3R0eQ==")),
mime: Some("dGV4dC9wbGFpbg=="),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_3() {
let c = run("5522;type=read:status=OK");
check(
&c,
&Exp {
payload: Some(None),
status: Some(Status::OK),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_4() {
let c = run("5522;type=write");
check(
&c,
&Exp {
payload: Some(None),
type_: Some(Operation::Write),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_5() {
let c = run("5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==");
check(
&c,
&Exp {
payload: Some(Some("R2hvc3R0eQ==")),
mime: Some("dGV4dC9wbGFpbg=="),
type_: Some(Operation::Wdata),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_6() {
let c = run("5522;type=wdata");
check(
&c,
&Exp {
payload: Some(None),
type_: Some(Operation::Wdata),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_7() {
let c = run("5522;type=write:status=DONE");
check(
&c,
&Exp {
payload: Some(None),
status: Some(Status::DONE),
type_: Some(Operation::Write),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_8() {
let c = run("5522;type=write:status=EPERM");
check(
&c,
&Exp {
payload: Some(None),
status: Some(Status::EPERM),
type_: Some(Operation::Write),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_9() {
let c = run("5522;type=walias:mime=dGV4dC9wbGFpbg==;dGV4dC9odG1sIGFwcGxpY2F0aW9uL2pzb24=");
check(
&c,
&Exp {
payload: Some(Some("dGV4dC9odG1sIGFwcGxpY2F0aW9uL2pzb24=")),
mime: Some("dGV4dC9wbGFpbg=="),
type_: Some(Operation::Walias),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_10() {
let c = run("5522;type=read:status=OK:password=Qk9CUiBLVVJXQQ==");
check(
&c,
&Exp {
payload: Some(None),
password: Some("Qk9CUiBLVVJXQQ=="),
status: Some(Status::OK),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_11() {
let c = run("5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==");
check(
&c,
&Exp {
payload: Some(None),
mime: Some("dGV4dC9wbGFpbg=="),
status: Some(Status::DATA),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_12() {
let c = run("5522;type=read:mime=dGV4dC9wbGFpbg==:password=Qk9CUiBLVVJXQQ==");
check(
&c,
&Exp {
payload: Some(None),
mime: Some("dGV4dC9wbGFpbg=="),
password: Some("Qk9CUiBLVVJXQQ=="),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_13() {
let c = run("5522;type=read:status=OK");
check(
&c,
&Exp {
payload: Some(None),
status: Some(Status::OK),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_14() {
let c = run("5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;Qk9CUiBLVVJXQQ==");
check(
&c,
&Exp {
payload: Some(Some("Qk9CUiBLVVJXQQ==")),
mime: Some("dGV4dC9wbGFpbg=="),
status: Some(Status::DATA),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_15() {
let c = run("5522;type=read:status=OK");
check(
&c,
&Exp {
payload: Some(None),
status: Some(Status::OK),
type_: Some(Operation::Read),
..Default::default()
},
);
}
}
@@ -0,0 +1,197 @@
//! OSC 21, the Kitty Color Protocol (ZIG/src/terminal/osc/parsers/kitty_color.zig).
use crate::color::Rgb;
use crate::kitty::color::{Kind, OSC, Request};
use crate::osc::{Command, Parser, State, Terminator};
/// Parse OSC 21, the Kitty Color Protocol.
pub fn parse(parser: &mut Parser, terminator_ch: Option<u8>) -> Option<Command> {
debug_assert_eq!(parser.state, State::S21);
if !parser.alloc {
parser.state = State::Invalid;
return None;
}
if !parser.has_writer() {
parser.state = State::Invalid;
return None;
}
let data = parser.buffered()?;
let mut osc = OSC {
list: Vec::new(),
terminator: Terminator::init(terminator_ch),
};
for kv in data.split(|&c| c == b';') {
if osc.list.len() >= Kind::MAX * 2 {
log::warn!("exceeded limit for number of keys in kitty color protocol, ignoring");
parser.state = State::Invalid;
return None;
}
let (k, rest) = match kv.iter().position(|&c| c == b'=') {
Some(p) => (&kv[..p], &kv[p + 1..]),
None => (kv, &kv[kv.len()..]),
};
if k.is_empty() {
log::warn!("zero length key in kitty color protocol");
continue;
}
let Some(key) = Kind::parse(k) else {
log::warn!(
"unknown key in kitty color protocol: {}",
String::from_utf8_lossy(k)
);
continue;
};
let value = rest.trim_ascii_matching(b' ');
if value.is_empty() {
osc.list.push(Request::Reset(key));
} else if value == b"?" {
osc.list.push(Request::Query(key));
} else if let Ok(color) = Rgb::parse_bytes(value) {
osc.list.push(Request::Set { key, color });
} else {
log::warn!(
"invalid color format in kitty color protocol: {}",
String::from_utf8_lossy(value)
);
}
}
Some(Command::KittyColorProtocol(osc))
}
trait TrimMatching {
fn trim_ascii_matching(&self, c: u8) -> &[u8];
}
impl TrimMatching for [u8] {
fn trim_ascii_matching(&self, c: u8) -> &[u8] {
let mut s = self;
while let [first, rest @ ..] = s {
if *first != c {
break;
}
s = rest;
}
while let [rest @ .., last] = s {
if *last != c {
break;
}
s = rest;
}
s
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::kitty::color::Special;
const INPUT: &str = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0";
fn feed(p: &mut Parser, s: &str) {
for ch in s.bytes() {
p.next(ch);
}
}
fn rgb(r: u8, g: u8, b: u8) -> Rgb {
Rgb { r, g, b }
}
#[test]
fn osc_21_kitty_color_protocol() {
let mut p = Parser::init(true);
feed(&mut p, INPUT);
let cmd = p.end(Some(0x1b)).unwrap();
let Command::KittyColorProtocol(osc) = cmd else {
panic!("wrong command");
};
let list = osc.list;
assert_eq!(9, list.len());
assert_eq!(Request::Query(Kind::Special(Special::Foreground)), list[0]);
assert_eq!(
Request::Set {
key: Kind::Special(Special::Background),
color: rgb(0xf0, 0xf8, 0xff)
},
list[1]
);
assert_eq!(
Request::Set {
key: Kind::Special(Special::Cursor),
color: rgb(0xf0, 0xf8, 0xff)
},
list[2]
);
assert_eq!(Request::Reset(Kind::Special(Special::CursorText)), list[3]);
assert_eq!(Request::Reset(Kind::Special(Special::VisualBell)), list[4]);
assert_eq!(
Request::Query(Kind::Special(Special::SelectionBackground)),
list[5]
);
assert_eq!(
Request::Set {
key: Kind::Special(Special::SelectionBackground),
color: rgb(0xaa, 0xbb, 0xcc)
},
list[6]
);
assert_eq!(Request::Query(Kind::Palette(2)), list[7]);
assert_eq!(
Request::Set {
key: Kind::Palette(3),
color: rgb(0xff, 0xff, 0xff)
},
list[8]
);
}
#[test]
fn osc_21_kitty_color_protocol_without_allocator() {
let mut p = Parser::init(false);
feed(&mut p, "21;foreground=?");
assert!(p.end(Some(0x1b)).is_none());
}
#[test]
fn osc_21_kitty_color_protocol_double_reset() {
let mut p = Parser::init(true);
feed(&mut p, INPUT);
let cmd = p.end(Some(0x1b)).unwrap();
assert!(matches!(cmd, Command::KittyColorProtocol(_)));
p.reset();
p.reset();
}
#[test]
fn osc_21_kitty_color_protocol_reset_after_invalid() {
let mut p = Parser::init(true);
feed(&mut p, INPUT);
let cmd = p.end(Some(0x1b)).unwrap();
assert!(matches!(cmd, Command::KittyColorProtocol(_)));
p.reset();
assert_eq!(State::Start, p.state);
p.next(b'X');
assert_eq!(State::Invalid, p.state);
p.reset();
}
#[test]
fn osc_21_kitty_color_protocol_no_key() {
let mut p = Parser::init(true);
feed(&mut p, "21;");
let cmd = p.end(Some(0x1b)).unwrap();
let Command::KittyColorProtocol(osc) = cmd else {
panic!("wrong command");
};
assert_eq!(0, osc.list.len());
}
}
@@ -0,0 +1,266 @@
//! Kitty's text sizing protocol (OSC 66)
//! Specification: https://sw.kovidgoyal.net/kitty/text-sizing-protocol/
//! (ZIG/src/terminal/osc/parsers/kitty_text_sizing.zig)
use crate::osc::{Command, Parser, State, encoding};
pub const MAX_PAYLOAD_LENGTH: usize = 4096;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VAlign {
#[default]
Top,
Bottom,
Center,
}
impl VAlign {
fn from_int(v: u8) -> Option<VAlign> {
match v {
0 => Some(VAlign::Top),
1 => Some(VAlign::Bottom),
2 => Some(VAlign::Center),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HAlign {
#[default]
Left,
Right,
Center,
}
impl HAlign {
fn from_int(v: u8) -> Option<HAlign> {
match v {
0 => Some(HAlign::Left),
1 => Some(HAlign::Right),
2 => Some(HAlign::Center),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OSC {
/// 1 - 7 (u3)
pub scale: u8,
/// 0 - 7 (u3), 0 means default
pub width: u8,
/// u4
pub numerator: u8,
/// u4
pub denominator: u8,
pub valign: VAlign,
pub halign: HAlign,
pub text: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UpdateError {
UnknownKey,
InvalidValue,
}
/// Equivalent of Zig `std.fmt.parseInt(u4, value, 10)`.
fn parse_u4(value: &[u8]) -> Option<u8> {
let (neg, digits) = match value.first() {
Some(b'+') => (false, &value[1..]),
Some(b'-') => (true, &value[1..]),
_ => (false, value),
};
if digits.is_empty() {
return None;
}
let mut acc: u32 = 0;
for &c in digits {
if !c.is_ascii_digit() {
return None;
}
acc = acc * 10 + (c - b'0') as u32;
if acc > 15 {
return None;
}
}
if neg && acc != 0 {
return None;
}
Some(acc as u8)
}
impl OSC {
fn update(&mut self, key: u8, value: &[u8]) -> Result<(), UpdateError> {
// All values are numeric, so we can do a small hack here
let v = parse_u4(value).ok_or(UpdateError::InvalidValue)?;
match key {
b's' => {
if v == 0 || v > 7 {
return Err(UpdateError::InvalidValue);
}
self.scale = v;
}
b'w' => {
if v > 7 {
return Err(UpdateError::InvalidValue);
}
self.width = v;
}
b'n' => self.numerator = v,
b'd' => self.denominator = v,
b'v' => self.valign = VAlign::from_int(v).ok_or(UpdateError::InvalidValue)?,
b'h' => self.halign = HAlign::from_int(v).ok_or(UpdateError::InvalidValue)?,
_ => return Err(UpdateError::UnknownKey),
}
Ok(())
}
}
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
debug_assert_eq!(parser.state, State::S66);
let data = parser.data()?;
let Some(payload_start) = data.iter().position(|&c| c == b';') else {
log::warn!("missing semicolon before payload");
parser.state = State::Invalid;
return None;
};
let payload = &data[payload_start + 1..];
// Payload has to be a URL-safe UTF-8 string,
// and be under the size limit.
if payload.len() > MAX_PAYLOAD_LENGTH {
log::warn!("payload is too long");
parser.state = State::Invalid;
return None;
}
if !encoding::is_safe_utf8(payload) {
log::warn!("payload is not escape code safe UTF-8");
parser.state = State::Invalid;
return None;
}
let mut cmd = OSC {
scale: 1,
width: 0,
numerator: 0,
denominator: 0,
valign: VAlign::Top,
halign: HAlign::Left,
text: payload.to_vec(),
};
// Parse any arguments if given
if payload_start > 0 {
for kv in data[..payload_start].split(|&c| c == b':') {
let mut it = kv.split(|&c| c == b'=');
let Some(k) = it.next() else {
log::warn!("missing key");
continue;
};
if k.len() != 1 {
log::warn!("key must be a single character");
continue;
}
let Some(value) = it.next() else {
log::warn!("missing value");
continue;
};
if let Err(err) = cmd.update(k[0], value) {
match err {
UpdateError::UnknownKey => log::warn!("unknown key: '{}'", k[0] as char),
UpdateError::InvalidValue => {
log::warn!("invalid value for key '{}': {:?}", k[0] as char, err);
}
}
}
}
}
Some(Command::KittyTextSizing(cmd))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn run(input: &[u8]) -> Option<Command> {
let mut p = Parser::init(false);
for &ch in input {
p.next(ch);
}
p.end(Some(0x1b))
}
fn sizing(input: &[u8]) -> OSC {
match run(input) {
Some(Command::KittyTextSizing(o)) => o,
other => panic!("unexpected {other:?}"),
}
}
#[test]
fn osc_66_empty_parameters() {
let c = sizing(b"66;;bobr");
assert_eq!(1, c.scale);
assert_eq!(b"bobr", &c.text[..]);
}
#[test]
fn osc_66_single_parameter() {
let c = sizing(b"66;s=2;kurwa");
assert_eq!(2, c.scale);
assert_eq!(b"kurwa", &c.text[..]);
}
#[test]
fn osc_66_multiple_parameters() {
let c = sizing(b"66;s=2:w=7:n=13:d=15:v=1:h=2;long");
assert_eq!(2, c.scale);
assert_eq!(7, c.width);
assert_eq!(13, c.numerator);
assert_eq!(15, c.denominator);
assert_eq!(VAlign::Bottom, c.valign);
assert_eq!(HAlign::Center, c.halign);
assert_eq!(b"long", &c.text[..]);
}
#[test]
fn osc_66_scale_is_zero() {
let c = sizing(b"66;s=0;nope");
assert_eq!(1, c.scale);
}
#[test]
fn osc_66_invalid_parameters() {
let c = sizing(b"66;w=8:v=3:n=16;");
assert_eq!(0, c.width);
assert_eq!(VAlign::Top, c.valign);
assert_eq!(0, c.numerator);
}
#[test]
fn osc_66_utf8() {
let c = sizing("66;;👻魑魅魍魉ゴースッティ".as_bytes());
assert_eq!("👻魑魅魍魉ゴースッティ".as_bytes(), &c.text[..]);
}
#[test]
fn osc_66_unsafe_utf8() {
assert!(run(b"66;;\n").is_none());
}
#[test]
fn osc_66_overlong_utf8() {
let mut input = b"66;;".to_vec();
input.extend(b"bobr".repeat(1025));
assert!(run(&input).is_none());
}
}
+17
View File
@@ -0,0 +1,17 @@
//! OSC parsers (ZIG/src/terminal/osc/parsers.zig).
pub mod change_window_icon;
pub mod change_window_title;
pub mod clipboard_operation;
pub mod color;
pub mod context_signal;
pub mod hyperlink;
pub mod iterm2;
pub mod kitty_clipboard_protocol;
pub mod kitty_color;
pub mod kitty_text_sizing;
pub mod mouse_shape;
pub mod osc9;
pub mod report_pwd;
pub mod rxvt_extension;
pub mod semantic_prompt;
@@ -0,0 +1,29 @@
//! OSC 22 (ZIG/src/terminal/osc/parsers/mouse_shape.zig).
use crate::osc::{Command, Parser, State};
/// Parse OSC 22
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
debug_assert_eq!(parser.state, State::S22);
let data = parser.data()?;
Some(Command::MouseShape { value: data })
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn osc_22_pointer_cursor() {
let mut p = Parser::init(false);
for c in b"22;pointer" {
p.next(*c);
}
assert_eq!(
p.end(None).unwrap(),
Command::MouseShape {
value: b"pointer".to_vec()
}
);
}
}
+557
View File
@@ -0,0 +1,557 @@
//! OSC 9 (iTerm2 notification / ConEmu extensions)
//! (ZIG/src/terminal/osc/parsers/osc9.zig).
use super::semantic_prompt;
use crate::osc::{Command, ConemuChangeTabTitle, Parser, ProgressReport, ProgressState};
/// Parse a base-10 unsigned integer from raw bytes (Zig `parseUnsigned`).
fn parse_unsigned<T: std::str::FromStr>(bytes: &[u8]) -> Option<T> {
std::str::from_utf8(bytes).ok()?.parse().ok()
}
/// Parse OSC 9, which could be an iTerm2 notification or a ConEmu extension.
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
// Zig prologue: writer must exist. (The NUL-terminator capacity check is
// folded into `data()`.)
let data = parser.data()?;
// Check first to see if this is a ConEmu OSC
// https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC
if let Some(cmd) = conemu(&data) {
return Some(cmd);
}
// If it's not a ConEmu OSC, it's an iTerm2 notification
Some(Command::ShowDesktopNotification {
title: Vec::new(),
body: data,
})
}
/// Returns None where the Zig code does `break :conemu`.
fn conemu(data: &[u8]) -> Option<Command> {
if data.is_empty() {
return None;
}
let msg = |skip: usize| data[skip..].to_vec();
match data[0] {
// Check for OSC 9;1 9;10 9;11 9;12
b'1' => {
if data.len() < 2 {
return None;
}
match data[1] {
// OSC 9;1 sleep
b';' => Some(Command::ConemuSleep {
duration_ms: match parse_unsigned::<u16>(&data[2..]) {
Some(num) => num.min(10_000),
None => 100,
},
}),
// OSC 9;10 xterm keyboard and output emulation
b'0' => {
if data.len() == 2 {
return Some(Command::ConemuXtermEmulation {
keyboard: Some(true),
output: Some(true),
});
}
if data.len() < 4 {
return None;
}
if data[2] != b';' {
return None;
}
let (keyboard, output) = match data[3] {
b'0' => (Some(false), Some(false)),
b'1' => (Some(true), Some(true)),
b'2' => (None, Some(false)),
b'3' => (None, Some(true)),
_ => return None,
};
Some(Command::ConemuXtermEmulation { keyboard, output })
}
// OSC 9;11 comment
b'1' => {
if data.len() < 3 {
return None;
}
if data[2] != b';' {
return None;
}
Some(Command::ConemuComment(msg(3)))
}
// OSC 9;12 mark prompt start
b'2' => Some(Command::SemanticPrompt(semantic_prompt::Command::init(
semantic_prompt::Action::FreshLineNewPrompt,
))),
_ => None,
}
}
// OSC 9;2 show message box
b'2' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
Some(Command::ConemuShowMessageBox(msg(2)))
}
// OSC 9;3 change tab title
b'3' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
if data.len() == 2 {
return Some(Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Reset));
}
Some(Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Value(
msg(2),
)))
}
// OSC 9;4 progress report
b'4' => {
if data.len() < 2 || data[1] != b';' || data.len() < 3 {
return None;
}
let mut report = match data[2] {
b'0' => ProgressReport {
state: ProgressState::Remove,
progress: None,
},
b'1' => ProgressReport {
state: ProgressState::Set,
progress: Some(0),
},
b'2' => ProgressReport {
state: ProgressState::Error,
progress: None,
},
b'3' => ProgressReport {
state: ProgressState::Indeterminate,
progress: None,
},
b'4' => ProgressReport {
state: ProgressState::Pause,
progress: None,
},
_ => return None,
};
match report.state {
ProgressState::Remove | ProgressState::Indeterminate => {}
ProgressState::Set | ProgressState::Error | ProgressState::Pause => {
if data.len() >= 4 && data[3] == b';' {
// parse the progress value
report.progress =
parse_unsigned::<usize>(&data[4..]).map(|v| v.min(100) as u8);
}
}
}
Some(Command::ConemuProgressReport(report))
}
// OSC 9;5 wait for input
b'5' => Some(Command::ConemuWaitInput),
// OSC 9;6 guimacro
b'6' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
Some(Command::ConemuGuimacro(msg(2)))
}
// OSC 9;7 run process
b'7' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
Some(Command::ConemuRunProcess(msg(2)))
}
// OSC 9;8 output environment variable
b'8' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
Some(Command::ConemuOutputEnvironmentVariable(msg(2)))
}
// OSC 9;9 current working directory
b'9' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
Some(Command::ReportPwd { value: msg(2) })
}
_ => None,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
fn run(input: &str) -> Command {
let mut p = Parser::init(false);
for ch in input.bytes() {
p.next(ch);
}
p.end(Some(0x1b)).unwrap()
}
fn notif(input: &str, body: &str) {
match run(input) {
Command::ShowDesktopNotification { title, body: b } => {
assert_eq!(title, b"");
assert_eq!(b, body.as_bytes());
}
c => panic!("unexpected {c:?}"),
}
}
fn progress(input: &str, state: ProgressState, progress: Option<u8>) {
assert_eq!(
run(input),
Command::ConemuProgressReport(ProgressReport { state, progress })
);
}
fn xterm(input: &str, keyboard: Option<bool>, output: Option<bool>) {
assert_eq!(
run(input),
Command::ConemuXtermEmulation { keyboard, output }
);
}
#[test]
fn osc_9_show_desktop_notification() {
notif("9;Hello world", "Hello world");
}
#[test]
fn osc_9_show_single_character_desktop_notification() {
notif("9;H", "H");
}
#[test]
fn osc_9_1_conemu_sleep() {
assert_eq!(run("9;1;420"), Command::ConemuSleep { duration_ms: 420 });
}
#[test]
fn osc_9_1_conemu_sleep_with_no_value_default_to_100ms() {
assert_eq!(run("9;1;"), Command::ConemuSleep { duration_ms: 100 });
}
#[test]
fn osc_9_1_conemu_sleep_cannot_exceed_10000ms() {
assert_eq!(
run("9;1;12345"),
Command::ConemuSleep { duration_ms: 10000 }
);
}
#[test]
fn osc_9_1_conemu_sleep_invalid_input() {
assert_eq!(run("9;1;foo"), Command::ConemuSleep { duration_ms: 100 });
}
#[test]
fn osc_9_1_conemu_sleep_desktop_notification_1() {
notif("9;1", "1");
}
#[test]
fn osc_9_1_conemu_sleep_desktop_notification_2() {
notif("9;1a", "1a");
}
#[test]
fn osc_9_2_conemu_message_box() {
assert_eq!(
run("9;2;hello world"),
Command::ConemuShowMessageBox(b"hello world".to_vec())
);
}
#[test]
fn osc_9_2_conemu_message_box_invalid_input() {
notif("9;2", "2");
}
#[test]
fn osc_9_2_conemu_message_box_empty_message() {
assert_eq!(run("9;2;"), Command::ConemuShowMessageBox(Vec::new()));
}
#[test]
fn osc_9_2_conemu_message_box_spaces_only_message() {
assert_eq!(
run("9;2; "),
Command::ConemuShowMessageBox(b" ".to_vec())
);
}
#[test]
fn osc_9_2_message_box_desktop_notification_1() {
notif("9;2", "2");
}
#[test]
fn osc_9_2_message_box_desktop_notification_2() {
notif("9;2a", "2a");
}
#[test]
fn osc_9_3_conemu_change_tab_title() {
assert_eq!(
run("9;3;foo bar"),
Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Value(b"foo bar".to_vec()))
);
}
#[test]
fn osc_9_3_conemu_change_tab_title_reset() {
assert_eq!(
run("9;3;"),
Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Reset)
);
}
#[test]
fn osc_9_3_conemu_change_tab_title_spaces_only() {
assert_eq!(
run("9;3; "),
Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Value(b" ".to_vec()))
);
}
#[test]
fn osc_9_3_change_tab_title_desktop_notification_1() {
notif("9;3", "3");
}
#[test]
fn osc_9_3_message_box_desktop_notification_2() {
notif("9;3a", "3a");
}
#[test]
fn osc_9_4_conemu_progress_set() {
progress("9;4;1;100", ProgressState::Set, Some(100));
}
#[test]
fn osc_9_4_conemu_progress_set_overflow() {
progress("9;4;1;900", ProgressState::Set, Some(100));
}
#[test]
fn osc_9_4_conemu_progress_set_single_digit() {
progress("9;4;1;9", ProgressState::Set, Some(9));
}
#[test]
fn osc_9_4_conemu_progress_set_double_digit() {
progress("9;4;1;94", ProgressState::Set, Some(94));
}
#[test]
fn osc_9_4_conemu_progress_set_extra_semicolon_ignored() {
progress("9;4;1;100", ProgressState::Set, Some(100));
}
#[test]
fn osc_9_4_conemu_progress_remove_with_no_progress() {
progress("9;4;0;", ProgressState::Remove, None);
}
#[test]
fn osc_9_4_conemu_progress_remove_with_double_semicolon() {
progress("9;4;0;;", ProgressState::Remove, None);
}
#[test]
fn osc_9_4_conemu_progress_remove_ignores_progress() {
progress("9;4;0;100", ProgressState::Remove, None);
}
#[test]
fn osc_9_4_conemu_progress_remove_extra_semicolon() {
match run("9;4;0;100;") {
Command::ConemuProgressReport(r) => assert_eq!(r.state, ProgressState::Remove),
c => panic!("unexpected {c:?}"),
}
}
#[test]
fn osc_9_4_conemu_progress_error() {
progress("9;4;2", ProgressState::Error, None);
}
#[test]
fn osc_9_4_conemu_progress_error_with_progress() {
progress("9;4;2;100", ProgressState::Error, Some(100));
}
#[test]
fn osc_9_4_progress_pause() {
progress("9;4;4", ProgressState::Pause, None);
}
#[test]
fn osc_9_4_conemu_progress_pause_with_progress() {
progress("9;4;4;100", ProgressState::Pause, Some(100));
}
#[test]
fn osc_9_4_progress_desktop_notification_1() {
notif("9;4", "4");
}
#[test]
fn osc_9_4_progress_desktop_notification_2() {
notif("9;4;", "4;");
}
#[test]
fn osc_9_4_progress_desktop_notification_3() {
notif("9;4;5", "4;5");
}
#[test]
fn osc_9_4_progress_desktop_notification_4() {
notif("9;4;5a", "4;5a");
}
#[test]
fn osc_9_5_conemu_wait_input() {
assert_eq!(run("9;5"), Command::ConemuWaitInput);
}
#[test]
fn osc_9_5_conemu_wait_ignores_trailing_characters() {
assert_eq!(run("9;5;foo"), Command::ConemuWaitInput);
}
#[test]
fn osc_9_6_conemu_guimacro_1() {
assert_eq!(run("9;6;a"), Command::ConemuGuimacro(b"a".to_vec()));
}
#[test]
fn osc_9_6_conemu_guimacro_2() {
assert_eq!(run("9;6;ab"), Command::ConemuGuimacro(b"ab".to_vec()));
}
#[test]
fn osc_9_6_conemu_guimacro_3_incomplete_desktop_notification() {
notif("9;6", "6");
}
#[test]
fn osc_9_7_conemu_run_process_1() {
assert_eq!(run("9;7;ab"), Command::ConemuRunProcess(b"ab".to_vec()));
}
#[test]
fn osc_9_7_conemu_run_process_2() {
assert_eq!(run("9;7;"), Command::ConemuRunProcess(Vec::new()));
}
#[test]
fn osc_9_7_conemu_run_process_incomplete_desktop_notification() {
notif("9;7", "7");
}
#[test]
fn osc_9_8_conemu_output_environment_variable_1() {
assert_eq!(
run("9;8;ab"),
Command::ConemuOutputEnvironmentVariable(b"ab".to_vec())
);
}
#[test]
fn osc_9_8_conemu_output_environment_variable_2() {
assert_eq!(
run("9;8;"),
Command::ConemuOutputEnvironmentVariable(Vec::new())
);
}
#[test]
fn osc_9_8_conemu_output_environment_variable_incomplete_desktop_notification() {
notif("9;8", "8");
}
#[test]
fn osc_9_9_conemu_set_current_working_directory() {
assert_eq!(
run("9;9;ab"),
Command::ReportPwd {
value: b"ab".to_vec()
}
);
}
#[test]
fn osc_9_9_conemu_set_current_working_directory_incomplete_desktop_notification() {
notif("9;9", "9");
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_1() {
xterm("9;10", Some(true), Some(true));
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_2() {
xterm("9;10;0", Some(false), Some(false));
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_3() {
xterm("9;10;1", Some(true), Some(true));
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_4() {
xterm("9;10;2", None, Some(false));
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_5() {
xterm("9;10;3", None, Some(true));
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_6() {
notif("9;10;4", "10;4");
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_7() {
notif("9;10;", "10;");
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_8() {
notif("9;10;abc", "10;abc");
}
#[test]
fn osc_9_11_conemu_comment() {
assert_eq!(run("9;11;ab"), Command::ConemuComment(b"ab".to_vec()));
}
#[test]
fn osc_9_11_conemu_comment_incomplete_desktop_notification() {
notif("9;11", "11");
}
#[test]
fn osc_9_12_conemu_mark_prompt_start_1() {
assert!(matches!(run("9;12"), Command::SemanticPrompt(_)));
}
#[test]
fn osc_9_12_conemu_mark_prompt_start_2() {
assert!(matches!(run("9;12;abc"), Command::SemanticPrompt(_)));
}
}
@@ -0,0 +1,40 @@
//! OSC 7 (ZIG/src/terminal/osc/parsers/report_pwd.zig).
use crate::osc::{Command, Parser};
/// Parse OSC 7
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
let data = parser.data()?;
Some(Command::ReportPwd { value: data })
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn osc_7_report_pwd() {
let mut p = Parser::init(false);
for c in b"7;file:///tmp/example" {
p.next(*c);
}
assert_eq!(
p.end(None).unwrap(),
Command::ReportPwd {
value: b"file:///tmp/example".to_vec()
}
);
}
#[test]
fn osc_7_report_pwd_empty() {
let mut p = Parser::init(false);
for c in b"7;" {
p.next(*c);
}
assert_eq!(
p.end(None).unwrap(),
Command::ReportPwd { value: Vec::new() }
);
}
}
@@ -0,0 +1,51 @@
//! OSC 777 (ZIG/src/terminal/osc/parsers/rxvt_extension.zig).
use crate::osc::{Command, Parser, State};
/// Parse OSC 777
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
let data = parser.data()?;
let Some(k) = data.iter().position(|&b| b == b';') else {
parser.state = State::Invalid;
return None;
};
let ext = &data[..k];
if ext != b"notify" {
log::warn!("unknown rxvt extension: {}", String::from_utf8_lossy(ext));
parser.state = State::Invalid;
return None;
}
let Some(t) = data[k + 1..]
.iter()
.position(|&b| b == b';')
.map(|i| i + k + 1)
else {
log::warn!("rxvt notify extension is missing the title");
parser.state = State::Invalid;
return None;
};
Some(Command::ShowDesktopNotification {
title: data[k + 1..t].to_vec(),
body: data[t + 1..].to_vec(),
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn osc_777_show_desktop_notification_with_title() {
let mut p = Parser::init(false);
for c in b"777;notify;Title;Body" {
p.next(*c);
}
assert_eq!(
p.end(Some(0x1b)).unwrap(),
Command::ShowDesktopNotification {
title: b"Title".to_vec(),
body: b"Body".to_vec()
}
);
}
}
@@ -0,0 +1,998 @@
//! OSC 133 semantic prompts
//! (ZIG/src/terminal/osc/parsers/semantic_prompt.zig).
//!
//! <https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md>
use crate::osc::{Command as OscCommand, Parser, State};
/// Error returned when decoding a command line fails (Zig
/// `error.DecodeError`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DecodeError;
impl std::fmt::Display for DecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("decode error")
}
}
impl std::error::Error for DecodeError {}
/// A single semantic prompt command.
///
/// Technically according to the spec, not all commands have options
/// but it is easier to be "liberal in what we accept" here since
/// all except one do and the spec does also say to ignore unknown
/// options. So, I think this is a fair interpretation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Command {
pub action: Action,
pub options_unvalidated: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
FreshLine, // 'L'
FreshLineNewPrompt, // 'A'
NewCommand, // 'N'
PromptStart, // 'P'
EndPromptStartInput, // 'B'
EndPromptStartInputTerminateEol, // 'I'
EndInputStartOutput, // 'C'
EndCommand, // 'D'
}
impl Command {
pub fn init(action: Action) -> Command {
Command {
action,
options_unvalidated: Vec::new(),
}
}
/// Read an option for this command. Returns None if unset or invalid.
pub fn read_option(&self, option: OptionKey) -> Option<OptionValue<'_>> {
option.read(&self.options_unvalidated)
}
/// Typed convenience: `aid` option.
pub fn aid(&self) -> Option<&[u8]> {
match self.read_option(OptionKey::Aid)? {
OptionValue::Bytes(b) => Some(b),
_ => None,
}
}
/// Typed convenience: `cl` option.
pub fn cl(&self) -> Option<Click> {
match self.read_option(OptionKey::Cl)? {
OptionValue::Click(c) => Some(c),
_ => None,
}
}
/// Typed convenience: `k` option.
pub fn prompt_kind(&self) -> Option<PromptKind> {
match self.read_option(OptionKey::PromptKind)? {
OptionValue::PromptKind(c) => Some(c),
_ => None,
}
}
/// Typed convenience: `err` option.
pub fn err(&self) -> Option<&[u8]> {
match self.read_option(OptionKey::Err)? {
OptionValue::Bytes(b) => Some(b),
_ => None,
}
}
/// Typed convenience: `redraw` option.
pub fn redraw(&self) -> Option<Redraw> {
match self.read_option(OptionKey::Redraw)? {
OptionValue::Redraw(c) => Some(c),
_ => None,
}
}
/// Typed convenience: `special_key` option.
pub fn special_key(&self) -> Option<bool> {
match self.read_option(OptionKey::SpecialKey)? {
OptionValue::Bool(c) => Some(c),
_ => None,
}
}
/// Typed convenience: `click_events` option.
pub fn click_events(&self) -> Option<bool> {
match self.read_option(OptionKey::ClickEvents)? {
OptionValue::Bool(c) => Some(c),
_ => None,
}
}
/// Typed convenience: `exit_code` (first positional value of 'D').
pub fn exit_code(&self) -> Option<i32> {
match self.read_option(OptionKey::ExitCode)? {
OptionValue::Int(c) => Some(c),
_ => None,
}
}
/// Write the decoded command line (if any) to the writer. If an error
/// occurs garbage may have been written to the writer.
///
/// TODO(integration): uses local copies of `printfQDecode` /
/// `urlPercentDecode` (ZIG/src/os/string_encoding.zig); switch to
/// `ghostty-os` once available (that crate would need to be a dependency
/// of ghostty-vt, so these remain local for now).
pub fn write_command_line(&self, writer: &mut Vec<u8>) -> Result<(), DecodeError> {
if let Some(OptionValue::Bytes(command_line)) = self.read_option(OptionKey::Cmdline) {
return printf_q_decode(writer, command_line);
}
if let Some(OptionValue::Bytes(command_line)) = self.read_option(OptionKey::CmdlineUrl) {
return url_percent_decode(writer, command_line);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptionKey {
Aid,
Cl,
PromptKind,
Err,
Cmdline,
CmdlineUrl,
// https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
// Kitty supports a "redraw" option for prompt_start. This is extended
// by Ghostty with the "last" option. See Redraw the type for more details.
Redraw,
// Use a special key instead of arrow keys to move the cursor on
// mouse click. Useful if arrow keys have side-effets like triggering
// auto-complete. The shell integration script should bind the special
// key as needed.
// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
SpecialKey,
// If true, the shell is capable of handling mouse click events.
// Ghostty will then send a click event to the shell when the user
// clicks somewhere in the prompt. The shell can then move the cursor
// to that position or perform some other appropriate action. If false,
// Ghostty may generate a number of fake key events to move the cursor
// which is not very robust.
// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
ClickEvents,
// Not technically an option that can be set with k=v and only
// present currently with command 'D' but its easier to just
// parse it into our options.
ExitCode,
}
/// The value of an option; the variant is determined by the `Option` (Zig
/// `Option.Type()`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptionValue<'a> {
Bytes(&'a [u8]),
Click(Click),
PromptKind(PromptKind),
Redraw(Redraw),
Bool(bool),
Int(i32),
}
impl OptionKey {
fn key(self) -> &'static [u8] {
match self {
OptionKey::Aid => b"aid",
OptionKey::Cl => b"cl",
OptionKey::PromptKind => b"k",
OptionKey::Err => b"err",
OptionKey::Redraw => b"redraw",
OptionKey::SpecialKey => b"special_key",
OptionKey::ClickEvents => b"click_events",
OptionKey::Cmdline => b"cmdline",
OptionKey::CmdlineUrl => b"cmdline_url",
// special case, handled before ever calling key
OptionKey::ExitCode => unreachable!(),
}
}
/// Read the option value from the raw options string.
///
/// The raw options string is the raw unparsed data after the
/// OSC 133 command. e.g. for `133;A;aid=14;cl=line`, the
/// raw options string would be `aid=14;cl=line`.
///
/// Any errors in the raw string will return None since the OSC133
/// specification says to ignore unknown or malformed options.
pub fn read(self, raw: &[u8]) -> Option<OptionValue<'_>> {
let mut remaining = raw;
'outer: while !remaining.is_empty() {
// Length of the next value is up to the `;` or the
// end of the string.
let len = remaining
.iter()
.position(|&b| b == b';')
.unwrap_or(remaining.len());
// Grab our full value and move our cursor past the `;`
let full = &remaining[..len];
// If we're looking for exit_code we special case it.
// as the first value.
if self == OptionKey::ExitCode {
return std::str::from_utf8(full)
.ok()
.and_then(|s| s.parse::<i32>().ok())
.map(OptionValue::Int);
}
// Parse our key=value and verify our key matches our
// expectation.
let value = 'value: {
if let Some(eql_idx) = full
.iter()
.position(|&b| b == b'=')
.filter(|&i| full[..i] == *self.key())
{
break 'value &full[eql_idx + 1..];
}
// No match!
if len < remaining.len() {
remaining = &remaining[len + 1..];
continue 'outer;
}
break 'outer;
};
return match self {
OptionKey::Aid | OptionKey::Err | OptionKey::Cmdline | OptionKey::CmdlineUrl => {
Some(OptionValue::Bytes(value))
}
OptionKey::Cl => Click::init(value).map(OptionValue::Click),
OptionKey::PromptKind => {
if value.len() == 1 {
PromptKind::init(value[0]).map(OptionValue::PromptKind)
} else {
None
}
}
OptionKey::Redraw => match value {
b"0" => Some(OptionValue::Redraw(Redraw::False)),
b"1" => Some(OptionValue::Redraw(Redraw::True)),
b"last" => Some(OptionValue::Redraw(Redraw::Last)),
_ => None,
},
OptionKey::SpecialKey | OptionKey::ClickEvents => match value {
b"0" => Some(OptionValue::Bool(false)),
b"1" => Some(OptionValue::Bool(true)),
_ => None,
},
// Handled above
OptionKey::ExitCode => unreachable!(),
};
}
// Not found
None
}
}
/// The `cl` option specifies what kind of cursor key sequences are handled
/// by the application for click-to-move-cursor functionality.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Click {
/// Value: "line". Allows motion within a single input line using standard
/// left/right arrow escape sequences. Only a single left/right sequence
/// should be emitted for double-width characters.
Line,
/// Value: "m". Allows movement between different lines in the same group,
/// but only using left/right arrow escape sequences.
Multiple,
/// Value: "v". Like `multiple` but cursor up/down should be used. The
/// terminal should be conservative when moving between lines: move the
/// cursor left to the start of line, emit the needed up/down sequences,
/// then move the cursor right to the clicked destination.
ConservativeVertical,
/// Value: "w". Like `conservative_vertical` but specifies that there are
/// no spurious spaces at the end of the line, and the application editor
/// handles "smart vertical movement" (moving 2 lines up from position 20,
/// where the intermediate line is 15 chars wide and the destination is
/// 18 chars wide, ends at position 18).
SmartVertical,
}
impl Click {
pub fn init(value: &[u8]) -> Option<Click> {
if value.len() == 1 {
match value[0] {
b'm' => Some(Click::Multiple),
b'v' => Some(Click::ConservativeVertical),
b'w' => Some(Click::SmartVertical),
_ => None,
}
} else if value == b"line" {
Some(Click::Line)
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptKind {
Initial,
Right,
Continuation,
Secondary,
}
impl PromptKind {
pub fn init(c: u8) -> Option<PromptKind> {
match c {
b'i' => Some(PromptKind::Initial),
b'r' => Some(PromptKind::Right),
b'c' => Some(PromptKind::Continuation),
b's' => Some(PromptKind::Secondary),
_ => None,
}
}
}
/// The values for the `redraw` extension to OSC133. This was
/// started by Kitty[1] and extended by Ghostty (the "last" option).
///
/// [1]: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Redraw {
/// The shell supports redrawing the full prompt and all continuations.
/// This is the default value, it does not need to be explicitly set
/// unless it is to reset a prior other value.
True,
/// The shell does NOT support redrawing. In this case, Ghostty will NOT
/// clear any prompt lines on resize.
False,
/// The shell supports redrawing only the LAST line of the prompt.
/// Ghostty will only clear the last line of the prompt on resize.
///
/// This is specifically introduced because Bash only redraws the last
/// line. It is literally the only shell that does this and it does this
/// because its bad and they should feel bad. Don't be like Bash.
Last,
}
/// Parse OSC 133, semantic prompts
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<OscCommand> {
let data = parser.buffered()?;
if data.is_empty() {
parser.state = State::Invalid;
return None;
}
// All valid cases terminate within this block. Any fallthroughs
// are invalid.
let action = match data[0] {
b'A' => Action::FreshLineNewPrompt,
b'B' => Action::EndPromptStartInput,
b'I' => Action::EndPromptStartInputTerminateEol,
b'C' => Action::EndInputStartOutput,
b'D' => Action::EndCommand,
b'N' => Action::NewCommand,
b'P' => Action::PromptStart,
b'L' => {
if data.len() > 1 {
parser.state = State::Invalid;
return None;
}
return Some(OscCommand::SemanticPrompt(Command::init(Action::FreshLine)));
}
_ => {
parser.state = State::Invalid;
return None;
}
};
let mut cmd = Command::init(action);
if data.len() > 1 {
if data[1] != b';' {
parser.state = State::Invalid;
return None;
}
cmd.options_unvalidated = data[2..].to_vec();
}
Some(OscCommand::SemanticPrompt(cmd))
}
// TODO(integration): local copies of ZIG/src/os/string_encoding.zig.
/// Decode data that has been encoded in the same way that `bash`'s
/// `printf %q` encodes a string and write it to the writer. If an error is
/// returned garbage may have been written to the buffer.
pub fn printf_q_decode(writer: &mut Vec<u8>, buf: &[u8]) -> Result<(), DecodeError> {
let data: &[u8] = if buf.starts_with(b"$'") {
// Strip off `$''` quoting.
if buf.len() < 3 || !buf.ends_with(b"'") {
return Err(DecodeError);
}
&buf[2..buf.len() - 1]
} else if buf.starts_with(b"'") {
// Strip off `''` quoting.
if buf.len() < 2 || !buf.ends_with(b"'") {
return Err(DecodeError);
}
&buf[1..buf.len() - 1]
} else {
buf
};
let mut src = 0;
while src < data.len() {
match data[src] {
b'\\' => {
if src + 1 >= data.len() {
return Err(DecodeError);
}
match data[src + 1] {
c @ (b' ' | b'\\' | b'"' | b'\'' | b'$') => writer.push(c),
b'e' => writer.push(0x1b),
b'n' => writer.push(b'\n'),
b'r' => writer.push(b'\r'),
b't' => writer.push(b'\t'),
b'v' => writer.push(0x0b),
_ => return Err(DecodeError),
}
src += 2;
}
c => {
writer.push(c);
src += 1;
}
}
}
Ok(())
}
/// Decode data that has been URL percent encoded and write it to the given
/// buffer. If an error is returned garbage may have been written.
pub fn url_percent_decode(writer: &mut Vec<u8>, buf: &[u8]) -> Result<(), DecodeError> {
fn hex(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
let mut src = 0;
while src < buf.len() {
match buf[src] {
b'%' => {
if src + 2 >= buf.len() {
return Err(DecodeError);
}
match (hex(buf[src + 1]), hex(buf[src + 2])) {
(Some(a), Some(b)) => {
writer.push((a << 4) | b);
src += 3;
}
_ => return Err(DecodeError),
}
}
c => {
writer.push(c);
src += 1;
}
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
fn run(input: &str) -> Option<Command> {
let mut p = Parser::init(false);
for ch in input.bytes() {
p.next(ch);
}
match p.end(None) {
Some(OscCommand::SemanticPrompt(c)) => Some(c),
Some(c) => panic!("unexpected {c:?}"),
None => None,
}
}
fn cmdline(input: &str) -> (Command, Result<Vec<u8>, DecodeError>) {
let cmd = run(input).unwrap();
assert_eq!(cmd.action, Action::EndInputStartOutput);
let mut w = Vec::new();
let r = cmd.write_command_line(&mut w).map(|()| w);
(cmd, r)
}
fn cmdline_ok(input: &str, expected: &str) {
assert_eq!(cmdline(input).1.unwrap(), expected.as_bytes());
}
fn cmdline_err(input: &str) {
assert_eq!(cmdline(input).1, Err(DecodeError));
}
#[test]
fn osc_133_end_input_start_output() {
let cmd = run("133;C").unwrap();
assert_eq!(cmd.action, Action::EndInputStartOutput);
assert!(cmd.aid().is_none());
assert!(cmd.cl().is_none());
}
#[test]
fn osc_133_end_input_start_output_extra_contents() {
assert!(run("133;Cextra").is_none());
}
#[test]
fn osc_133_end_input_start_output_with_options() {
let cmd = run("133;C;aid=foo").unwrap();
assert_eq!(cmd.action, Action::EndInputStartOutput);
assert_eq!(cmd.aid().unwrap(), b"foo");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline() {
cmdline_ok("133;C;cmdline=echo bobr kurwa", "echo bobr kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_3() {
cmdline_ok("133;C;cmdline=echo bobr\\nkurwa", "echo bobr\nkurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_4() {
cmdline_ok("133;C;cmdline=$'echo bobr kurwa'", "echo bobr kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_5() {
cmdline_ok("133;C;cmdline='echo bobr kurwa'", "echo bobr kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_6() {
cmdline_err("133;C;cmdline='echo bobr kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_7() {
cmdline_err("133;C;cmdline=$'echo bobr kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_8() {
cmdline_err("133;C;cmdline=$'");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_9() {
cmdline_ok("133;C;cmdline=", "");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_1() {
cmdline_ok("133;C;cmdline_url=echo bobr kurwa", "echo bobr kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_2() {
cmdline_ok("133;C;cmdline_url=echo bobr%20kurwa", "echo bobr kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_3() {
cmdline_ok("133;C;cmdline_url=echo bobr%3bkurwa", "echo bobr;kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_4() {
cmdline_err("133;C;cmdline_url=echo bobr%3kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_5() {
cmdline_err("133;C;cmdline_url=echo bobr%kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_6() {
cmdline_ok("133;C;cmdline_url=echo bobr kurwa%20", "echo bobr kurwa ");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_7() {
cmdline_err("133;C;cmdline_url=echo bobr kurwa%2");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_8() {
cmdline_err("133;C;cmdline_url=echo bobr kurwa%");
}
#[test]
fn osc_133_fresh_line() {
assert_eq!(run("133;L").unwrap().action, Action::FreshLine);
}
#[test]
fn osc_133_fresh_line_extra_contents() {
// Random
assert!(run("133;Lol").is_none());
// Options
assert!(run("133;L;aid=foo").is_none());
}
#[test]
fn osc_133_fresh_line_new_prompt() {
let cmd = run("133;A").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert!(cmd.aid().is_none());
assert!(cmd.cl().is_none());
}
#[test]
fn osc_133_fresh_line_new_prompt_with_aid() {
let cmd = run("133;A;aid=14").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert_eq!(cmd.aid().unwrap(), b"14");
}
#[test]
fn osc_133_fresh_line_new_prompt_with_equals_in_aid() {
let cmd = run("133;A;aid=a=b").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert_eq!(cmd.aid().unwrap(), b"a=b");
}
#[test]
fn osc_133_fresh_line_new_prompt_with_cl_line() {
let cmd = run("133;A;cl=line").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert_eq!(cmd.cl(), Some(Click::Line));
}
#[test]
fn osc_133_fresh_line_new_prompt_with_cl_m() {
let cmd = run("133;A;cl=m").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert_eq!(cmd.cl(), Some(Click::Multiple));
}
#[test]
fn osc_133_fresh_line_new_prompt_with_invalid_cl() {
let cmd = run("133;A;cl=invalid").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert!(cmd.cl().is_none());
}
#[test]
fn osc_133_fresh_line_new_prompt_with_trailing_semicolon() {
let cmd = run("133;A;").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
}
#[test]
fn osc_133_fresh_line_new_prompt_with_bare_key() {
let cmd = run("133;A;barekey").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert!(cmd.aid().is_none());
assert!(cmd.cl().is_none());
}
#[test]
fn osc_133_fresh_line_new_prompt_with_multiple_options() {
let cmd = run("133;A;aid=foo;cl=line").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert_eq!(cmd.aid().unwrap(), b"foo");
assert_eq!(cmd.cl(), Some(Click::Line));
}
#[test]
fn osc_133_fresh_line_new_prompt_default_redraw() {
let cmd = run("133;A").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert!(cmd.redraw().is_none());
}
#[test]
fn osc_133_fresh_line_new_prompt_with_redraw_0() {
let cmd = run("133;A;redraw=0").unwrap();
assert_eq!(cmd.redraw(), Some(Redraw::False));
}
#[test]
fn osc_133_fresh_line_new_prompt_with_redraw_1() {
let cmd = run("133;A;redraw=1").unwrap();
assert_eq!(cmd.redraw(), Some(Redraw::True));
}
#[test]
fn osc_133_fresh_line_new_prompt_with_invalid_redraw() {
let cmd = run("133;A;redraw=x").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert!(cmd.redraw().is_none());
}
#[test]
fn osc_133_prompt_start() {
let cmd = run("133;P").unwrap();
assert_eq!(cmd.action, Action::PromptStart);
assert!(cmd.prompt_kind().is_none());
}
#[test]
fn osc_133_prompt_start_with_k_i() {
let cmd = run("133;P;k=i").unwrap();
assert_eq!(cmd.action, Action::PromptStart);
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Initial));
}
#[test]
fn osc_133_prompt_start_with_k_r() {
let cmd = run("133;P;k=r").unwrap();
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Right));
}
#[test]
fn osc_133_prompt_start_with_k_c() {
let cmd = run("133;P;k=c").unwrap();
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Continuation));
}
#[test]
fn osc_133_prompt_start_with_k_s() {
let cmd = run("133;P;k=s").unwrap();
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Secondary));
}
#[test]
fn osc_133_prompt_start_with_invalid_k() {
let cmd = run("133;P;k=x").unwrap();
assert_eq!(cmd.action, Action::PromptStart);
assert!(cmd.prompt_kind().is_none());
}
#[test]
fn osc_133_prompt_start_extra_contents() {
assert!(run("133;Pextra").is_none());
}
#[test]
fn osc_133_new_command() {
let cmd = run("133;N").unwrap();
assert_eq!(cmd.action, Action::NewCommand);
assert!(cmd.aid().is_none());
assert!(cmd.cl().is_none());
}
#[test]
fn osc_133_new_command_with_aid() {
let cmd = run("133;N;aid=foo").unwrap();
assert_eq!(cmd.action, Action::NewCommand);
assert_eq!(cmd.aid().unwrap(), b"foo");
}
#[test]
fn osc_133_new_command_with_cl_line() {
let cmd = run("133;N;cl=line").unwrap();
assert_eq!(cmd.action, Action::NewCommand);
assert_eq!(cmd.cl(), Some(Click::Line));
}
#[test]
fn osc_133_new_command_with_multiple_options() {
let cmd = run("133;N;aid=foo;cl=line").unwrap();
assert_eq!(cmd.action, Action::NewCommand);
assert_eq!(cmd.aid().unwrap(), b"foo");
assert_eq!(cmd.cl(), Some(Click::Line));
}
#[test]
fn osc_133_new_command_extra_contents() {
assert!(run("133;Nextra").is_none());
}
#[test]
fn osc_133_end_prompt_start_input() {
let cmd = run("133;B").unwrap();
assert_eq!(cmd.action, Action::EndPromptStartInput);
}
#[test]
fn osc_133_end_prompt_start_input_extra_contents() {
assert!(run("133;Bextra").is_none());
}
#[test]
fn osc_133_end_prompt_start_input_with_options() {
let cmd = run("133;B;aid=foo").unwrap();
assert_eq!(cmd.action, Action::EndPromptStartInput);
assert_eq!(cmd.aid().unwrap(), b"foo");
}
#[test]
fn osc_133_end_prompt_start_input_terminate_eol() {
let cmd = run("133;I").unwrap();
assert_eq!(cmd.action, Action::EndPromptStartInputTerminateEol);
}
#[test]
fn osc_133_end_prompt_start_input_terminate_eol_extra_contents() {
assert!(run("133;Iextra").is_none());
}
#[test]
fn osc_133_end_prompt_start_input_terminate_eol_with_options() {
let cmd = run("133;I;aid=foo").unwrap();
assert_eq!(cmd.action, Action::EndPromptStartInputTerminateEol);
assert_eq!(cmd.aid().unwrap(), b"foo");
}
#[test]
fn osc_133_end_command() {
let cmd = run("133;D").unwrap();
assert_eq!(cmd.action, Action::EndCommand);
assert!(cmd.exit_code().is_none());
assert!(cmd.aid().is_none());
assert!(cmd.err().is_none());
}
#[test]
fn osc_133_end_command_extra_contents() {
assert!(run("133;Dextra").is_none());
}
#[test]
fn osc_133_end_command_with_exit_code_0() {
let cmd = run("133;D;0").unwrap();
assert_eq!(cmd.action, Action::EndCommand);
assert_eq!(cmd.exit_code(), Some(0));
}
#[test]
fn osc_133_end_command_with_exit_code_and_aid() {
let cmd = run("133;D;12;aid=foo").unwrap();
assert_eq!(cmd.action, Action::EndCommand);
assert_eq!(cmd.aid().unwrap(), b"foo");
assert_eq!(cmd.exit_code(), Some(12));
}
fn bytes(v: Option<OptionValue<'_>>) -> Option<&[u8]> {
match v {
Some(OptionValue::Bytes(b)) => Some(b),
_ => None,
}
}
#[test]
fn option_read_aid() {
let r = |s: &'static str| bytes(OptionKey::Aid.read(s.as_bytes()));
assert_eq!(r("aid=test123").unwrap(), b"test123");
assert_eq!(r("cl=line;aid=myaid;k=i").unwrap(), b"myaid");
assert!(r("cl=line;k=i").is_none());
assert_eq!(r("aid=").unwrap(), b"");
assert_eq!(r("k=i;aid=last").unwrap(), b"last");
assert_eq!(r("aid=first;k=i").unwrap(), b"first");
assert!(r("").is_none());
assert!(r("aid").is_none());
assert_eq!(r(";;aid=value;;").unwrap(), b"value");
}
#[test]
fn option_read_cl() {
let r = |s: &'static str| OptionKey::Cl.read(s.as_bytes());
assert_eq!(r("cl=line"), Some(OptionValue::Click(Click::Line)));
assert_eq!(r("cl=m"), Some(OptionValue::Click(Click::Multiple)));
assert_eq!(
r("cl=v"),
Some(OptionValue::Click(Click::ConservativeVertical))
);
assert_eq!(r("cl=w"), Some(OptionValue::Click(Click::SmartVertical)));
assert!(r("cl=invalid").is_none());
assert!(r("aid=foo").is_none());
}
#[test]
fn option_read_prompt_kind() {
let r = |s: &'static str| OptionKey::PromptKind.read(s.as_bytes());
assert_eq!(r("k=i"), Some(OptionValue::PromptKind(PromptKind::Initial)));
assert_eq!(r("k=r"), Some(OptionValue::PromptKind(PromptKind::Right)));
assert_eq!(
r("k=c"),
Some(OptionValue::PromptKind(PromptKind::Continuation))
);
assert_eq!(
r("k=s"),
Some(OptionValue::PromptKind(PromptKind::Secondary))
);
assert!(r("k=x").is_none());
assert!(r("k=ii").is_none());
assert!(r("k=").is_none());
}
#[test]
fn option_read_err() {
assert_eq!(
bytes(OptionKey::Err.read(b"err=some_error")).unwrap(),
b"some_error"
);
assert!(OptionKey::Err.read(b"aid=foo").is_none());
}
#[test]
fn option_read_redraw() {
let r = |s: &'static str| OptionKey::Redraw.read(s.as_bytes());
assert_eq!(r("redraw=1"), Some(OptionValue::Redraw(Redraw::True)));
assert_eq!(r("redraw=0"), Some(OptionValue::Redraw(Redraw::False)));
assert_eq!(r("redraw=last"), Some(OptionValue::Redraw(Redraw::Last)));
assert!(r("redraw=2").is_none());
assert!(r("redraw=10").is_none());
assert!(r("redraw=").is_none());
}
#[test]
fn option_read_special_key() {
let r = |s: &'static str| OptionKey::SpecialKey.read(s.as_bytes());
assert_eq!(r("special_key=1"), Some(OptionValue::Bool(true)));
assert_eq!(r("special_key=0"), Some(OptionValue::Bool(false)));
assert!(r("special_key=x").is_none());
}
#[test]
fn option_read_click_events() {
let r = |s: &'static str| OptionKey::ClickEvents.read(s.as_bytes());
assert_eq!(r("click_events=1"), Some(OptionValue::Bool(true)));
assert_eq!(r("click_events=0"), Some(OptionValue::Bool(false)));
assert!(r("click_events=yes").is_none());
}
#[test]
fn option_read_exit_code() {
let r = |s: &'static str| OptionKey::ExitCode.read(s.as_bytes());
assert_eq!(r("42"), Some(OptionValue::Int(42)));
assert_eq!(r("0"), Some(OptionValue::Int(0)));
assert_eq!(r("-1"), Some(OptionValue::Int(-1)));
assert!(r("abc").is_none());
assert_eq!(r("127;aid=foo"), Some(OptionValue::Int(127)));
}
#[test]
fn string_encoding_extra_printf_q_and_percent() {
let mut w = Vec::new();
printf_q_decode(&mut w, b"bobr\\ kurwa").unwrap();
assert_eq!(w, b"bobr kurwa");
assert_eq!(
printf_q_decode(&mut Vec::new(), b"bobr\\dkurwa"),
Err(DecodeError)
);
assert_eq!(
printf_q_decode(&mut Vec::new(), b"bobr kurwa\\"),
Err(DecodeError)
);
}
}
+527
View File
@@ -0,0 +1,527 @@
//! The primary export of this file is `TABLE`, which contains a
//! compile-time-generated state transition table for VT emulation
//! (ZIG/src/terminal/parse_table.zig).
//!
//! This is based on the vt100.net state machine:
//! https://vt100.net/emu/dec_ansi_parser
//! But has some modifications:
//!
//! * csi_param accepts the colon character (':') since the SGR command
//! accepts colon as a valid parameter value.
use crate::parser::{State, TransitionAction as A};
const NUM_STATES: usize = 14;
const STATES: [State; NUM_STATES] = [
State::Ground,
State::Escape,
State::EscapeIntermediate,
State::CsiEntry,
State::CsiIntermediate,
State::CsiParam,
State::CsiIgnore,
State::DcsEntry,
State::DcsParam,
State::DcsIntermediate,
State::DcsPassthrough,
State::DcsIgnore,
State::OscString,
State::SosPmApcString,
];
/// Transition is the transition to take within the table
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Transition {
pub state: State,
pub action: A,
}
/// The type of the state transition table: `[byte][state]`.
pub type Table = [[Transition; NUM_STATES]; 256];
type OptionalTable = [[Option<Transition>; NUM_STATES]; 256];
/// The state transition table, indexed `TABLE[byte][state as usize]`.
pub const TABLE: Table = gen_table();
/// Function to generate the full state transition table for VT emulation.
const fn gen_table() -> Table {
// We accumulate using an "optional" table so we can detect unset entries.
let mut result: OptionalTable = [[None; NUM_STATES]; 256];
// anywhere transitions
let mut s = 0;
while s < NUM_STATES {
let source = STATES[s];
// anywhere => ground
single(&mut result, 0x18, source, State::Ground, A::Execute);
single(&mut result, 0x1A, source, State::Ground, A::Execute);
range(&mut result, 0x80, 0x8F, source, State::Ground, A::Execute);
range(&mut result, 0x91, 0x97, source, State::Ground, A::Execute);
single(&mut result, 0x99, source, State::Ground, A::Execute);
single(&mut result, 0x9A, source, State::Ground, A::Execute);
single(&mut result, 0x9C, source, State::Ground, A::None);
// anywhere => escape
single(&mut result, 0x1B, source, State::Escape, A::None);
// anywhere => sos_pm_apc_string
single(&mut result, 0x98, source, State::SosPmApcString, A::None);
single(&mut result, 0x9E, source, State::SosPmApcString, A::None);
single(&mut result, 0x9F, source, State::SosPmApcString, A::None);
// anywhere => csi_entry
single(&mut result, 0x9B, source, State::CsiEntry, A::None);
// anywhere => dcs_entry
single(&mut result, 0x90, source, State::DcsEntry, A::None);
// anywhere => osc_string
single(&mut result, 0x9D, source, State::OscString, A::None);
s += 1;
}
// ground
{
// events
single(&mut result, 0x19, State::Ground, State::Ground, A::Execute);
range(
&mut result,
0,
0x17,
State::Ground,
State::Ground,
A::Execute,
);
range(
&mut result,
0x1C,
0x1F,
State::Ground,
State::Ground,
A::Execute,
);
range(
&mut result,
0x20,
0x7F,
State::Ground,
State::Ground,
A::Print,
);
}
// escape_intermediate
{
let source = State::EscapeIntermediate;
single(&mut result, 0x19, source, source, A::Execute);
range(&mut result, 0, 0x17, source, source, A::Execute);
range(&mut result, 0x1C, 0x1F, source, source, A::Execute);
range(&mut result, 0x20, 0x2F, source, source, A::Collect);
single(&mut result, 0x7F, source, source, A::Ignore);
// => ground
range(
&mut result,
0x30,
0x7E,
source,
State::Ground,
A::EscDispatch,
);
}
// sos_pm_apc_string
{
let source = State::SosPmApcString;
// events
single(&mut result, 0x19, source, source, A::ApcPut);
range(&mut result, 0, 0x17, source, source, A::ApcPut);
range(&mut result, 0x1C, 0x1F, source, source, A::ApcPut);
range(&mut result, 0x20, 0x7F, source, source, A::ApcPut);
}
// escape
{
let source = State::Escape;
// events
single(&mut result, 0x19, source, source, A::Execute);
range(&mut result, 0, 0x17, source, source, A::Execute);
range(&mut result, 0x1C, 0x1F, source, source, A::Execute);
single(&mut result, 0x7F, source, source, A::Ignore);
// => ground
range(
&mut result,
0x30,
0x4F,
source,
State::Ground,
A::EscDispatch,
);
range(
&mut result,
0x51,
0x57,
source,
State::Ground,
A::EscDispatch,
);
range(
&mut result,
0x60,
0x7E,
source,
State::Ground,
A::EscDispatch,
);
single(&mut result, 0x59, source, State::Ground, A::EscDispatch);
single(&mut result, 0x5A, source, State::Ground, A::EscDispatch);
single(&mut result, 0x5C, source, State::Ground, A::EscDispatch);
// => escape_intermediate
range(
&mut result,
0x20,
0x2F,
source,
State::EscapeIntermediate,
A::Collect,
);
// => sos_pm_apc_string
single(&mut result, 0x58, source, State::SosPmApcString, A::None);
single(&mut result, 0x5E, source, State::SosPmApcString, A::None);
single(&mut result, 0x5F, source, State::SosPmApcString, A::None);
// => dcs_entry
single(&mut result, 0x50, source, State::DcsEntry, A::None);
// => csi_entry
single(&mut result, 0x5B, source, State::CsiEntry, A::None);
// => osc_string
single(&mut result, 0x5D, source, State::OscString, A::None);
}
// dcs_entry
{
let source = State::DcsEntry;
// events
single(&mut result, 0x19, source, source, A::Ignore);
range(&mut result, 0, 0x17, source, source, A::Ignore);
range(&mut result, 0x1C, 0x1F, source, source, A::Ignore);
single(&mut result, 0x7F, source, source, A::Ignore);
// => dcs_intermediate
range(
&mut result,
0x20,
0x2F,
source,
State::DcsIntermediate,
A::Collect,
);
// => dcs_ignore
single(&mut result, 0x3A, source, State::DcsIgnore, A::None);
// => dcs_param
range(&mut result, 0x30, 0x39, source, State::DcsParam, A::Param);
single(&mut result, 0x3B, source, State::DcsParam, A::Param);
range(&mut result, 0x3C, 0x3F, source, State::DcsParam, A::Collect);
// => dcs_passthrough
range(
&mut result,
0x40,
0x7E,
source,
State::DcsPassthrough,
A::None,
);
}
// dcs_intermediate
{
let source = State::DcsIntermediate;
// events
single(&mut result, 0x19, source, source, A::Ignore);
range(&mut result, 0, 0x17, source, source, A::Ignore);
range(&mut result, 0x1C, 0x1F, source, source, A::Ignore);
range(&mut result, 0x20, 0x2F, source, source, A::Collect);
single(&mut result, 0x7F, source, source, A::Ignore);
// => dcs_ignore
range(&mut result, 0x30, 0x3F, source, State::DcsIgnore, A::None);
// => dcs_passthrough
range(
&mut result,
0x40,
0x7E,
source,
State::DcsPassthrough,
A::None,
);
}
// dcs_ignore
{
let source = State::DcsIgnore;
// events
single(&mut result, 0x19, source, source, A::Ignore);
range(&mut result, 0, 0x17, source, source, A::Ignore);
range(&mut result, 0x1C, 0x1F, source, source, A::Ignore);
}
// dcs_param
{
let source = State::DcsParam;
// events
single(&mut result, 0x19, source, source, A::Ignore);
range(&mut result, 0, 0x17, source, source, A::Ignore);
range(&mut result, 0x1C, 0x1F, source, source, A::Ignore);
range(&mut result, 0x30, 0x39, source, source, A::Param);
single(&mut result, 0x3B, source, source, A::Param);
single(&mut result, 0x7F, source, source, A::Ignore);
// => dcs_ignore
single(&mut result, 0x3A, source, State::DcsIgnore, A::None);
range(&mut result, 0x3C, 0x3F, source, State::DcsIgnore, A::None);
// => dcs_intermediate
range(
&mut result,
0x20,
0x2F,
source,
State::DcsIntermediate,
A::Collect,
);
// => dcs_passthrough
range(
&mut result,
0x40,
0x7E,
source,
State::DcsPassthrough,
A::None,
);
}
// dcs_passthrough
{
let source = State::DcsPassthrough;
// events
single(&mut result, 0x19, source, source, A::Put);
range(&mut result, 0, 0x17, source, source, A::Put);
range(&mut result, 0x1C, 0x1F, source, source, A::Put);
range(&mut result, 0x20, 0x7E, source, source, A::Put);
single(&mut result, 0x7F, source, source, A::Ignore);
}
// csi_param
{
let source = State::CsiParam;
// events
single(&mut result, 0x19, source, source, A::Execute);
range(&mut result, 0, 0x17, source, source, A::Execute);
range(&mut result, 0x1C, 0x1F, source, source, A::Execute);
range(&mut result, 0x30, 0x39, source, source, A::Param);
single(&mut result, 0x3A, source, source, A::Param);
single(&mut result, 0x3B, source, source, A::Param);
single(&mut result, 0x7F, source, source, A::Ignore);
// => ground
range(
&mut result,
0x40,
0x7E,
source,
State::Ground,
A::CsiDispatch,
);
// => csi_ignore
range(&mut result, 0x3C, 0x3F, source, State::CsiIgnore, A::None);
// => csi_intermediate
range(
&mut result,
0x20,
0x2F,
source,
State::CsiIntermediate,
A::Collect,
);
}
// csi_ignore
{
let source = State::CsiIgnore;
// events
single(&mut result, 0x19, source, source, A::Execute);
range(&mut result, 0, 0x17, source, source, A::Execute);
range(&mut result, 0x1C, 0x1F, source, source, A::Execute);
range(&mut result, 0x20, 0x3F, source, source, A::Ignore);
single(&mut result, 0x7F, source, source, A::Ignore);
// => ground
range(&mut result, 0x40, 0x7E, source, State::Ground, A::None);
}
// csi_intermediate
{
let source = State::CsiIntermediate;
// events
single(&mut result, 0x19, source, source, A::Execute);
range(&mut result, 0, 0x17, source, source, A::Execute);
range(&mut result, 0x1C, 0x1F, source, source, A::Execute);
range(&mut result, 0x20, 0x2F, source, source, A::Collect);
single(&mut result, 0x7F, source, source, A::Ignore);
// => ground
range(
&mut result,
0x40,
0x7E,
source,
State::Ground,
A::CsiDispatch,
);
// => csi_ignore
range(&mut result, 0x30, 0x3F, source, State::CsiIgnore, A::None);
}
// csi_entry
{
let source = State::CsiEntry;
// events
single(&mut result, 0x19, source, source, A::Execute);
range(&mut result, 0, 0x17, source, source, A::Execute);
range(&mut result, 0x1C, 0x1F, source, source, A::Execute);
single(&mut result, 0x7F, source, source, A::Ignore);
// => ground
range(
&mut result,
0x40,
0x7E,
source,
State::Ground,
A::CsiDispatch,
);
// => csi_ignore
single(&mut result, 0x3A, source, State::CsiIgnore, A::None);
// => csi_intermediate
range(
&mut result,
0x20,
0x2F,
source,
State::CsiIntermediate,
A::Collect,
);
// => csi_param
range(&mut result, 0x30, 0x39, source, State::CsiParam, A::Param);
single(&mut result, 0x3B, source, State::CsiParam, A::Param);
range(&mut result, 0x3C, 0x3F, source, State::CsiParam, A::Collect);
}
// osc_string
{
let source = State::OscString;
// events
single(&mut result, 0x19, source, source, A::Ignore);
range(&mut result, 0, 0x06, source, source, A::Ignore);
range(&mut result, 0x08, 0x17, source, source, A::Ignore);
range(&mut result, 0x1C, 0x1F, source, source, A::Ignore);
range(&mut result, 0x20, 0xFF, source, source, A::OscPut);
// XTerm accepts either BEL or ST for terminating OSC
// sequences, and when returning information, uses the same
// terminator used in a query.
single(&mut result, 0x07, source, State::Ground, A::None);
}
// Create our immutable version
let mut fin: Table = [[Transition {
state: State::Ground,
action: A::None,
}; NUM_STATES]; 256];
let mut i = 0;
while i < 256 {
let mut j = 0;
while j < NUM_STATES {
fin[i][j] = match result[i][j] {
Some(t) => t,
None => transition(STATES[j], A::None),
};
j += 1;
}
i += 1;
}
fin
}
const fn single(t: &mut OptionalTable, c: u8, s0: State, s1: State, a: A) {
t[c as usize][s0 as usize] = Some(transition(s1, a));
}
const fn range(t: &mut OptionalTable, from: u8, to: u8, s0: State, s1: State, a: A) {
let mut i = from;
loop {
single(t, i, s0, s1, a);
// If 'to' is 0xFF, our next pass would overflow.
if i == to {
break;
}
i += 1;
}
}
const fn transition(state: State, action: A) -> Transition {
Transition { state, action }
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn table_builds() {
// This forces evaluation of the table, so we're just testing
// that it succeeds in creation.
let _ = &TABLE;
assert_eq!(
TABLE[b'a' as usize][State::Ground as usize],
transition(State::Ground, A::Print)
);
assert_eq!(
TABLE[0x1B][State::CsiParam as usize],
transition(State::Escape, A::None)
);
}
}
+935 -3
View File
@@ -1,9 +1,20 @@
//! VT-series parser for escape and control sequences //! VT-series parser for escape and control sequences
//! (ZIG/src/terminal/Parser.zig). //! (ZIG/src/terminal/Parser.zig).
//! //!
//! NOTE(W1): this file is owned by work package W1. `MAX_PARAMS` and //! This is implemented directly as the state machine described on
//! `SepList` were pre-created by the orchestrator because `sgr` (W2) //! vt100.net: https://vt100.net/emu/dec_ansi_parser
//! depends on them; keep their API stable. //!
//! `MAX_PARAMS` and `SepList` were pre-created by the orchestrator because
//! `sgr` depends on them; keep their API stable.
//!
//! Rust port note: Zig's `Action` borrows slices out of the parser
//! (`csi_dispatch.params` etc., "only valid until the next call to next").
//! Here the CSI/ESC/DCS payloads are small fixed-size inline copies exposed
//! through `params()` / `intermediates()` accessors, so an `Action` owns its
//! data and is independent of the parser's lifetime.
use crate::osc;
use crate::parse_table::TABLE;
/// Maximum number of CSI parameters. This is a const in Ghostty so we /// Maximum number of CSI parameters. This is a const in Ghostty so we
/// match it. /// match it.
@@ -50,3 +61,924 @@ impl SepList {
self.0 self.0
} }
} }
/// Maximum number of intermediate characters during parsing. This is
/// 4 because we also use the intermediates array for UTF8 decoding which
/// can be at most 4 bytes.
pub const MAX_INTERMEDIATE: usize = 4;
/// States for the state machine
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum State {
Ground,
Escape,
EscapeIntermediate,
CsiEntry,
CsiIntermediate,
CsiParam,
CsiIgnore,
DcsEntry,
DcsParam,
DcsIntermediate,
DcsPassthrough,
DcsIgnore,
OscString,
SosPmApcString,
}
/// Transition action is an action that can be taken during a state
/// transition. This is more of an internal action, not one used by
/// end users, typically.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TransitionAction {
None,
Ignore,
Print,
Execute,
Collect,
Param,
EscDispatch,
CsiDispatch,
Put,
OscPut,
ApcPut,
}
/// The separator used for CSI params.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sep {
Semicolon = 0,
Colon = 1,
}
/// CSI dispatch payload (Zig `Action.CSI`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Csi {
intermediates_buf: [u8; MAX_INTERMEDIATE],
intermediates_len: u8,
params_buf: [u16; MAX_PARAMS],
params_len: u8,
/// The list of separators used for CSI params. The value of the
/// bit can be mapped to Sep. The index of this bit set specifies
/// the separator AFTER that param. For example: 0;4:3 would have
/// index 1 set.
pub params_sep: SepList,
pub final_: u8,
}
impl Csi {
pub fn intermediates(&self) -> &[u8] {
&self.intermediates_buf[..self.intermediates_len as usize]
}
pub fn params(&self) -> &[u16] {
&self.params_buf[..self.params_len as usize]
}
}
impl std::fmt::Display for Csi {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"ESC [ {} {:?} {}",
String::from_utf8_lossy(self.intermediates()),
self.params(),
self.final_ as char
)
}
}
/// ESC dispatch payload (Zig `Action.ESC`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Esc {
intermediates_buf: [u8; MAX_INTERMEDIATE],
intermediates_len: u8,
pub final_: u8,
}
impl Esc {
pub fn intermediates(&self) -> &[u8] {
&self.intermediates_buf[..self.intermediates_len as usize]
}
}
impl std::fmt::Display for Esc {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"ESC {} {}",
String::from_utf8_lossy(self.intermediates()),
self.final_ as char
)
}
}
/// DCS hook payload (Zig `Action.DCS`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Dcs {
intermediates_buf: [u8; MAX_INTERMEDIATE],
intermediates_len: u8,
params_buf: [u16; MAX_PARAMS],
params_len: u8,
pub final_: u8,
}
impl Dcs {
/// Build a DCS from slices (extra elements beyond the maximums are
/// dropped). Useful for tests and for consumers constructing hooks.
pub fn new(intermediates: &[u8], params: &[u16], final_: u8) -> Self {
let mut d = Dcs {
intermediates_buf: [0; MAX_INTERMEDIATE],
intermediates_len: 0,
params_buf: [0; MAX_PARAMS],
params_len: 0,
final_,
};
let n = intermediates.len().min(MAX_INTERMEDIATE);
d.intermediates_buf[..n].copy_from_slice(&intermediates[..n]);
d.intermediates_len = n as u8;
let n = params.len().min(MAX_PARAMS);
d.params_buf[..n].copy_from_slice(&params[..n]);
d.params_len = n as u8;
d
}
pub fn intermediates(&self) -> &[u8] {
&self.intermediates_buf[..self.intermediates_len as usize]
}
pub fn params(&self) -> &[u16] {
&self.params_buf[..self.params_len as usize]
}
}
/// Action is the action that a caller of the parser is expected to
/// take as a result of some input character.
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
/// Draw character to the screen. This is a unicode codepoint.
Print(u32),
/// Execute the C0 or C1 function.
Execute(u8),
/// Execute the CSI command.
CsiDispatch(Csi),
/// Execute the ESC command.
EscDispatch(Esc),
/// Execute the OSC command.
OscDispatch(osc::Command),
/// DCS-related events.
DcsHook(Dcs),
DcsPut(u8),
DcsUnhook,
/// APC data
ApcStart,
ApcPut(u8),
ApcEnd,
}
/// The VT parser state machine.
#[derive(Debug, Clone)]
pub struct Parser {
/// Current state of the state machine
pub state: State,
/// Intermediate tracking.
intermediates: [u8; MAX_INTERMEDIATE],
intermediates_idx: u8,
/// Param tracking, building
params: [u16; MAX_PARAMS],
params_sep: SepList,
params_idx: u8,
param_acc: u16,
param_acc_idx: u8,
/// Parser for OSC sequences
pub osc_parser: osc::Parser,
}
impl Default for Parser {
fn default() -> Self {
Self::new()
}
}
impl Parser {
pub fn new() -> Parser {
Parser {
state: State::Ground,
intermediates: [0; MAX_INTERMEDIATE],
intermediates_idx: 0,
params: [0; MAX_PARAMS],
params_sep: SepList::init_empty(),
params_idx: 0,
param_acc: 0,
param_acc_idx: 0,
osc_parser: osc::Parser::init(false),
}
}
/// Next consumes the next character c and returns the actions to execute.
/// Up to 3 actions may need to be executed -- in order -- representing
/// the state exit, transition, and entry actions.
pub fn next(&mut self, c: u8) -> [Option<Action>; 3] {
let effect = TABLE[c as usize][self.state as usize];
let next_state = effect.state;
let action = effect.action;
// When going from one state to another, the actions take place in this order:
//
// 1. exit action from old state
// 2. transition action
// 3. entry action to new state
// Exit depends on current state
let exit = if self.state == next_state {
None
} else {
match self.state {
State::OscString => self.osc_parser.end(Some(c)).map(Action::OscDispatch),
State::DcsPassthrough => Some(Action::DcsUnhook),
State::SosPmApcString => Some(Action::ApcEnd),
_ => None,
}
};
let transition = self.do_action(action, c);
// Entry depends on new state
let entry = if self.state == next_state {
None
} else {
match next_state {
State::Escape | State::DcsEntry | State::CsiEntry => {
self.clear();
None
}
State::OscString => {
self.osc_parser.reset();
None
}
State::DcsPassthrough => 'dcs_hook: {
// Ignore too many parameters
if self.params_idx as usize >= MAX_PARAMS {
break 'dcs_hook None;
}
// Finalize parameters
if self.param_acc_idx > 0 {
self.params[self.params_idx as usize] = self.param_acc;
self.params_idx += 1;
}
Some(Action::DcsHook(Dcs::new(
&self.intermediates[..self.intermediates_idx as usize],
&self.params[..self.params_idx as usize],
c,
)))
}
State::SosPmApcString => Some(Action::ApcStart),
_ => None,
}
};
// After generating the actions, we set our next state.
self.state = next_state;
[exit, transition, entry]
}
#[inline]
pub fn collect(&mut self, c: u8) {
if self.intermediates_idx as usize >= MAX_INTERMEDIATE {
log::warn!("invalid intermediates count");
return;
}
self.intermediates[self.intermediates_idx as usize] = c;
self.intermediates_idx += 1;
}
#[inline]
fn do_action(&mut self, action: TransitionAction, c: u8) -> Option<Action> {
match action {
TransitionAction::None | TransitionAction::Ignore => None,
TransitionAction::Print => Some(Action::Print(c as u32)),
TransitionAction::Execute => Some(Action::Execute(c)),
TransitionAction::Collect => {
self.collect(c);
None
}
TransitionAction::Param => {
// Semicolon separates parameters. If we encounter a semicolon
// we need to store and move on to the next parameter.
if c == b';' || c == b':' {
// Ignore too many parameters
if self.params_idx as usize >= MAX_PARAMS {
return None;
}
// Set param final value
self.params[self.params_idx as usize] = self.param_acc;
if c == b':' {
self.params_sep.set(self.params_idx as usize);
}
self.params_idx += 1;
// Reset current param value to 0
self.param_acc = 0;
self.param_acc_idx = 0;
return None;
}
// A numeric value. Add it to our accumulator.
self.param_acc = self.param_acc.saturating_mul(10);
self.param_acc = self.param_acc.saturating_add((c - b'0') as u16);
// Increment our accumulator index. If we overflow then
// we're out of bounds and we exit immediately.
self.param_acc_idx = self.param_acc_idx.wrapping_add(1);
// The client is expected to perform no action.
None
}
TransitionAction::OscPut => {
self.osc_parser.next(c);
None
}
TransitionAction::CsiDispatch => {
// Ignore too many parameters
if self.params_idx as usize >= MAX_PARAMS {
return None;
}
// Finalize parameters if we have one
if self.param_acc_idx > 0 {
self.params[self.params_idx as usize] = self.param_acc;
self.params_idx += 1;
}
let mut csi = Csi {
intermediates_buf: self.intermediates,
intermediates_len: self.intermediates_idx,
params_buf: [0; MAX_PARAMS],
params_len: self.params_idx,
params_sep: self.params_sep,
final_: c,
};
csi.params_buf[..self.params_idx as usize]
.copy_from_slice(&self.params[..self.params_idx as usize]);
// We only allow colon or mixed separators for the 'm' command.
if c != b'm' && self.params_sep.count() > 0 {
log::warn!(
"CSI colon or mixed separators only allowed for 'm' command, got: {csi}"
);
return None;
}
Some(Action::CsiDispatch(csi))
}
TransitionAction::EscDispatch => Some(Action::EscDispatch(Esc {
intermediates_buf: self.intermediates,
intermediates_len: self.intermediates_idx,
final_: c,
})),
TransitionAction::Put => Some(Action::DcsPut(c)),
TransitionAction::ApcPut => Some(Action::ApcPut(c)),
}
}
#[inline]
pub fn clear(&mut self) {
self.intermediates_idx = 0;
self.params_idx = 0;
self.params_sep = SepList::init_empty();
self.param_acc = 0;
self.param_acc_idx = 0;
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::osc::Command;
fn feed_quiet(p: &mut Parser, s: &[u8]) {
for &c in s {
let a = p.next(c);
assert!(a[0].is_none());
assert!(a[1].is_none());
assert!(a[2].is_none());
}
}
fn csi(a: &[Option<Action>; 3]) -> Csi {
match a[1].as_ref().expect("csi_dispatch") {
Action::CsiDispatch(c) => *c,
other => panic!("expected csi_dispatch, got {other:?}"),
}
}
#[test]
fn parser_basic() {
let mut p = Parser::new();
p.next(0x9E);
assert_eq!(p.state, State::SosPmApcString);
p.next(0x9C);
assert_eq!(p.state, State::Ground);
{
let a = p.next(b'a');
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(matches!(a[1], Some(Action::Print(_))));
assert!(a[2].is_none());
}
{
let a = p.next(0x19);
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(matches!(a[1], Some(Action::Execute(_))));
assert!(a[2].is_none());
}
}
#[test]
fn esc_esc_paren_b() {
let mut p = Parser::new();
p.next(0x1B);
p.next(b'(');
let a = p.next(b'B');
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(a[2].is_none());
let Some(Action::EscDispatch(d)) = &a[1] else {
panic!("expected esc_dispatch");
};
assert_eq!(d.final_, b'B');
assert_eq!(d.intermediates(), b"(");
}
#[test]
fn csi_esc_bracket_h() {
let mut p = Parser::new();
p.next(0x1B);
p.next(0x5B);
let a = p.next(0x48);
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(a[2].is_none());
let d = csi(&a);
assert_eq!(d.final_, 0x48);
assert_eq!(d.params().len(), 0);
}
#[test]
fn csi_esc_bracket_1_semicolon_4_h() {
let mut p = Parser::new();
p.next(0x1B);
p.next(0x5B);
p.next(0x31); // 1
p.next(0x3B); // ;
p.next(0x34); // 4
let a = p.next(0x48); // H
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(a[2].is_none());
let d = csi(&a);
assert_eq!(d.final_, b'H');
assert_eq!(d.params(), &[1, 4]);
}
#[test]
fn csi_sgr_esc_bracket_38_colon_2_m() {
let mut p = Parser::new();
p.next(0x1B);
p.next(b'[');
p.next(b'3');
p.next(b'8');
p.next(b':');
p.next(b'2');
let a = p.next(b'm');
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(a[2].is_none());
let d = csi(&a);
assert_eq!(d.final_, b'm');
assert_eq!(d.params(), &[38, 2]);
assert!(d.params_sep.is_set(0));
assert!(!d.params_sep.is_set(1));
}
#[test]
fn csi_sgr_colon_followed_by_semicolon() {
let mut p = Parser::new();
p.next(0x1B);
feed_quiet(&mut p, b"[48:2");
{
let a = p.next(b'm');
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(matches!(a[1], Some(Action::CsiDispatch(_))));
assert!(a[2].is_none());
}
p.next(0x1B);
p.next(b'[');
{
let a = p.next(b'H');
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(matches!(a[1], Some(Action::CsiDispatch(_))));
assert!(a[2].is_none());
}
}
#[test]
fn csi_sgr_mixed_colon_and_semicolon() {
let mut p = Parser::new();
p.next(0x1B);
feed_quiet(&mut p, b"[38:5:1;48:5:0");
let a = p.next(b'm');
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(matches!(a[1], Some(Action::CsiDispatch(_))));
assert!(a[2].is_none());
}
#[test]
fn csi_sgr_esc_bracket_48_colon_2_m() {
let mut p = Parser::new();
p.next(0x1B);
feed_quiet(&mut p, b"[48:2:240:143:104");
let a = p.next(b'm');
assert_eq!(p.state, State::Ground);
let d = csi(&a);
assert_eq!(d.final_, b'm');
assert_eq!(d.params(), &[48, 2, 240, 143, 104]);
for (i, expect) in [true, true, true, true, false].iter().enumerate() {
assert_eq!(d.params_sep.is_set(i), *expect, "sep {i}");
}
}
#[test]
fn csi_sgr_esc_bracket_4_colon_3_m_colon() {
let mut p = Parser::new();
p.next(0x1B);
p.next(b'[');
p.next(b'4');
p.next(b':');
p.next(b'3');
let a = p.next(b'm');
assert_eq!(p.state, State::Ground);
let d = csi(&a);
assert_eq!(d.final_, b'm');
assert_eq!(d.params(), &[4, 3]);
assert!(d.params_sep.is_set(0));
assert!(!d.params_sep.is_set(1));
}
#[test]
fn csi_sgr_with_many_blank_and_colon() {
let mut p = Parser::new();
p.next(0x1B);
feed_quiet(&mut p, b"[58:2::240:143:104");
let a = p.next(b'm');
assert_eq!(p.state, State::Ground);
let d = csi(&a);
assert_eq!(d.final_, b'm');
assert_eq!(d.params(), &[58, 2, 0, 240, 143, 104]);
for (i, expect) in [true, true, true, true, true, false].iter().enumerate() {
assert_eq!(d.params_sep.is_set(i), *expect, "sep {i}");
}
}
// This is from a Kakoune actual SGR sequence.
#[test]
fn csi_sgr_mixed_colon_and_semicolon_with_blank() {
let mut p = Parser::new();
p.next(0x1B);
feed_quiet(&mut p, b"[;4:3;38;2;175;175;215;58:2::190:80:70");
let a = p.next(b'm');
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(a[2].is_none());
let d = csi(&a);
assert_eq!(d.final_, b'm');
assert_eq!(
d.params(),
&[0, 4, 3, 38, 2, 175, 175, 215, 58, 2, 0, 190, 80, 70]
);
let set = [1usize, 8, 9, 10, 11, 12];
for i in 0..14 {
assert_eq!(d.params_sep.is_set(i), set.contains(&i), "sep {i}");
}
}
// This is from a Kakoune actual SGR sequence also.
#[test]
fn csi_sgr_mixed_colon_and_semicolon_setting_underline_bg_fg() {
let mut p = Parser::new();
p.next(0x1B);
feed_quiet(
&mut p,
b"[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136",
);
let a = p.next(b'm');
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(a[2].is_none());
let d = csi(&a);
assert_eq!(d.final_, b'm');
assert_eq!(
d.params(),
&[
4, 3, 38, 2, 51, 51, 51, 48, 2, 170, 170, 170, 58, 2, 255, 97, 136
]
);
for i in 0..17 {
assert_eq!(d.params_sep.is_set(i), i == 0, "sep {i}");
}
}
#[test]
fn csi_colon_for_non_m_final() {
let mut p = Parser::new();
p.next(0x1B);
feed_quiet(&mut p, b"[38:2h");
assert_eq!(p.state, State::Ground);
}
#[test]
fn csi_request_mode_decrqm() {
let mut p = Parser::new();
p.next(0x1B);
feed_quiet(&mut p, b"[?2026$");
let a = p.next(b'p');
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(a[2].is_none());
let d = csi(&a);
assert_eq!(d.final_, b'p');
assert_eq!(d.intermediates(), b"?$");
assert_eq!(d.params(), &[2026]);
}
#[test]
fn csi_change_cursor() {
let mut p = Parser::new();
p.next(0x1B);
feed_quiet(&mut p, b"[3 ");
let a = p.next(b'q');
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(a[2].is_none());
let d = csi(&a);
assert_eq!(d.final_, b'q');
assert_eq!(d.intermediates(), b" ");
assert_eq!(d.params(), &[3]);
}
#[test]
fn osc_change_window_title() {
let mut p = Parser::new();
for c in [0x1B, b']', b'0', b';', b'a', b'b', b'c'] {
p.next(c);
}
let a = p.next(0x07); // BEL
assert_eq!(p.state, State::Ground);
assert!(a[1].is_none());
assert!(a[2].is_none());
assert_eq!(
a[0],
Some(Action::OscDispatch(Command::ChangeWindowTitle(
b"abc".to_vec()
)))
);
}
#[test]
fn osc_change_window_title_end_in_esc() {
let mut p = Parser::new();
for c in [0x1B, b']', b'0', b';', b'a', b'b', b'c'] {
p.next(c);
}
let a = p.next(0x1B);
p.next(b'\\');
assert_eq!(p.state, State::Ground);
assert!(a[1].is_none());
assert!(a[2].is_none());
assert_eq!(
a[0],
Some(Action::OscDispatch(Command::ChangeWindowTitle(
b"abc".to_vec()
)))
);
}
// https://github.com/darrenstarr/VtNetCore/pull/14
// Saw this on HN, decided to add a test case because why not.
#[test]
fn osc_112_incomplete_sequence() {
let mut p = Parser::new();
p.osc_parser.alloc = true;
for c in [0x1B, b']', b'1', b'1', b'2'] {
p.next(c);
}
let a = p.next(0x07);
assert_eq!(p.state, State::Ground);
assert!(a[1].is_none());
assert!(a[2].is_none());
let Some(Action::OscDispatch(Command::ColorOperation {
op,
requests,
terminator,
})) = a[0].clone()
else {
panic!("expected color_operation");
};
assert_eq!(terminator, osc::Terminator::Bel);
assert_eq!(op, osc::color::Operation::Osc112);
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0],
osc::color::Request::Reset(osc::color::Target::Dynamic(crate::color::Dynamic::Cursor))
);
}
#[test]
fn osc_104_empty() {
let mut p = Parser::new();
p.osc_parser.alloc = true;
for c in [0x1B, b']', b'1', b'0', b'4'] {
p.next(c);
}
let a = p.next(0x07);
assert_eq!(p.state, State::Ground);
assert!(a[1].is_none());
assert!(a[2].is_none());
let Some(Action::OscDispatch(Command::ColorOperation {
op,
requests,
terminator,
})) = a[0].clone()
else {
panic!("expected color_operation");
};
assert_eq!(terminator, osc::Terminator::Bel);
assert_eq!(op, osc::color::Operation::Osc104);
assert_eq!(requests.len(), 1);
assert_eq!(requests[0], osc::color::Request::ResetPalette);
}
#[test]
fn csi_too_many_params() {
let mut p = Parser::new();
p.next(0x1B);
p.next(b'[');
for _ in 0..100 {
p.next(b'1');
p.next(b';');
}
p.next(b'1');
let a = p.next(b'C');
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(a[1].is_none());
assert!(a[2].is_none());
}
#[test]
fn csi_sgr_with_up_to_our_max_parameters() {
for max in 1..=MAX_PARAMS {
let mut p = Parser::new();
p.next(0x1B);
p.next(b'[');
for _ in 0..max - 1 {
p.next(b'1');
p.next(b';');
}
p.next(b'2');
let a = p.next(b'H');
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(a[2].is_none());
let c = csi(&a);
assert_eq!(c.params().len(), max);
assert_eq!(c.params()[max - 1], 2);
}
}
#[test]
fn csi_sgr_beyond_our_max_drops_it() {
// Has to be +2 for the loops below
let max = MAX_PARAMS + 2;
let mut p = Parser::new();
p.next(0x1B);
p.next(b'[');
for _ in 0..max - 1 {
p.next(b'1');
p.next(b';');
}
p.next(b'2');
let a = p.next(b'H');
assert_eq!(p.state, State::Ground);
assert!(a[0].is_none());
assert!(a[1].is_none());
assert!(a[2].is_none());
}
#[test]
fn dcs_xtgettcap() {
let mut p = Parser::new();
p.next(0x1B);
feed_quiet(&mut p, b"P+");
let a = p.next(b'q');
assert_eq!(p.state, State::DcsPassthrough);
assert!(a[0].is_none());
assert!(a[1].is_none());
let Some(Action::DcsHook(hook)) = &a[2] else {
panic!("expected dcs_hook");
};
assert_eq!(hook.intermediates(), b"+");
assert_eq!(hook.params(), &[] as &[u16]);
assert_eq!(hook.final_, b'q');
}
#[test]
fn dcs_params() {
let mut p = Parser::new();
p.next(0x1B);
feed_quiet(&mut p, b"P1000");
let a = p.next(b'p');
assert_eq!(p.state, State::DcsPassthrough);
assert!(a[0].is_none());
assert!(a[1].is_none());
let Some(Action::DcsHook(hook)) = &a[2] else {
panic!("expected dcs_hook");
};
assert_eq!(hook.params(), &[1000]);
assert_eq!(hook.final_, b'p');
}
#[test]
fn dcs_too_many_params() {
// Regression test for a crash found by fuzzing (afl). When a DCS
// sequence has more than MAX_PARAMS parameters and param_acc_idx > 0,
// entering dcs_passthrough wrote to params[params_idx] without a
// bounds check, causing an out-of-bounds access.
let mut p = Parser::new();
p.next(0x1B); // ESC
p.next(b'P'); // DCS entry
// Feed a digit then MAX_PARAMS semicolons to fill all param slots.
p.next(b'6');
for _ in 0..MAX_PARAMS {
p.next(b';');
}
// Feed another digit so param_acc_idx > 0 while params_idx == MAX_PARAMS.
p.next(b'7');
// A final byte triggers entry to dcs_passthrough. The DCS should
// be dropped entirely, consistent with how CSI handles overflow.
let a = p.next(b'p');
assert!(a[0].is_none());
assert!(a[1].is_none());
assert!(a[2].is_none());
}
}
+782
View File
@@ -0,0 +1,782 @@
255 250 250 snow
248 248 255 ghost white
248 248 255 GhostWhite
245 245 245 white smoke
245 245 245 WhiteSmoke
220 220 220 gainsboro
255 250 240 floral white
255 250 240 FloralWhite
253 245 230 old lace
253 245 230 OldLace
250 240 230 linen
250 235 215 antique white
250 235 215 AntiqueWhite
255 239 213 papaya whip
255 239 213 PapayaWhip
255 235 205 blanched almond
255 235 205 BlanchedAlmond
255 228 196 bisque
255 218 185 peach puff
255 218 185 PeachPuff
255 222 173 navajo white
255 222 173 NavajoWhite
255 228 181 moccasin
255 248 220 cornsilk
255 255 240 ivory
255 250 205 lemon chiffon
255 250 205 LemonChiffon
255 245 238 seashell
240 255 240 honeydew
245 255 250 mint cream
245 255 250 MintCream
240 255 255 azure
240 248 255 alice blue
240 248 255 AliceBlue
230 230 250 lavender
255 240 245 lavender blush
255 240 245 LavenderBlush
255 228 225 misty rose
255 228 225 MistyRose
255 255 255 white
0 0 0 black
47 79 79 dark slate gray
47 79 79 DarkSlateGray
47 79 79 dark slate grey
47 79 79 DarkSlateGrey
105 105 105 dim gray
105 105 105 DimGray
105 105 105 dim grey
105 105 105 DimGrey
112 128 144 slate gray
112 128 144 SlateGray
112 128 144 slate grey
112 128 144 SlateGrey
119 136 153 light slate gray
119 136 153 LightSlateGray
119 136 153 light slate grey
119 136 153 LightSlateGrey
190 190 190 gray
190 190 190 grey
190 190 190 x11 gray
190 190 190 X11Gray
190 190 190 x11 grey
190 190 190 X11Grey
128 128 128 web gray
128 128 128 WebGray
128 128 128 web grey
128 128 128 WebGrey
211 211 211 light grey
211 211 211 LightGrey
211 211 211 light gray
211 211 211 LightGray
25 25 112 midnight blue
25 25 112 MidnightBlue
0 0 128 navy
0 0 128 navy blue
0 0 128 NavyBlue
100 149 237 cornflower blue
100 149 237 CornflowerBlue
72 61 139 dark slate blue
72 61 139 DarkSlateBlue
106 90 205 slate blue
106 90 205 SlateBlue
123 104 238 medium slate blue
123 104 238 MediumSlateBlue
132 112 255 light slate blue
132 112 255 LightSlateBlue
0 0 205 medium blue
0 0 205 MediumBlue
65 105 225 royal blue
65 105 225 RoyalBlue
0 0 255 blue
30 144 255 dodger blue
30 144 255 DodgerBlue
0 191 255 deep sky blue
0 191 255 DeepSkyBlue
135 206 235 sky blue
135 206 235 SkyBlue
135 206 250 light sky blue
135 206 250 LightSkyBlue
70 130 180 steel blue
70 130 180 SteelBlue
176 196 222 light steel blue
176 196 222 LightSteelBlue
173 216 230 light blue
173 216 230 LightBlue
176 224 230 powder blue
176 224 230 PowderBlue
175 238 238 pale turquoise
175 238 238 PaleTurquoise
0 206 209 dark turquoise
0 206 209 DarkTurquoise
72 209 204 medium turquoise
72 209 204 MediumTurquoise
64 224 208 turquoise
0 255 255 cyan
0 255 255 aqua
224 255 255 light cyan
224 255 255 LightCyan
95 158 160 cadet blue
95 158 160 CadetBlue
102 205 170 medium aquamarine
102 205 170 MediumAquamarine
127 255 212 aquamarine
0 100 0 dark green
0 100 0 DarkGreen
85 107 47 dark olive green
85 107 47 DarkOliveGreen
143 188 143 dark sea green
143 188 143 DarkSeaGreen
46 139 87 sea green
46 139 87 SeaGreen
60 179 113 medium sea green
60 179 113 MediumSeaGreen
32 178 170 light sea green
32 178 170 LightSeaGreen
152 251 152 pale green
152 251 152 PaleGreen
0 255 127 spring green
0 255 127 SpringGreen
124 252 0 lawn green
124 252 0 LawnGreen
0 255 0 green
0 255 0 lime
0 255 0 x11 green
0 255 0 X11Green
0 128 0 web green
0 128 0 WebGreen
127 255 0 chartreuse
0 250 154 medium spring green
0 250 154 MediumSpringGreen
173 255 47 green yellow
173 255 47 GreenYellow
50 205 50 lime green
50 205 50 LimeGreen
154 205 50 yellow green
154 205 50 YellowGreen
34 139 34 forest green
34 139 34 ForestGreen
107 142 35 olive drab
107 142 35 OliveDrab
189 183 107 dark khaki
189 183 107 DarkKhaki
240 230 140 khaki
238 232 170 pale goldenrod
238 232 170 PaleGoldenrod
250 250 210 light goldenrod yellow
250 250 210 LightGoldenrodYellow
255 255 224 light yellow
255 255 224 LightYellow
255 255 0 yellow
255 215 0 gold
238 221 130 light goldenrod
238 221 130 LightGoldenrod
218 165 32 goldenrod
184 134 11 dark goldenrod
184 134 11 DarkGoldenrod
188 143 143 rosy brown
188 143 143 RosyBrown
205 92 92 indian red
205 92 92 IndianRed
139 69 19 saddle brown
139 69 19 SaddleBrown
160 82 45 sienna
205 133 63 peru
222 184 135 burlywood
245 245 220 beige
245 222 179 wheat
244 164 96 sandy brown
244 164 96 SandyBrown
210 180 140 tan
210 105 30 chocolate
178 34 34 firebrick
165 42 42 brown
233 150 122 dark salmon
233 150 122 DarkSalmon
250 128 114 salmon
255 160 122 light salmon
255 160 122 LightSalmon
255 165 0 orange
255 140 0 dark orange
255 140 0 DarkOrange
255 127 80 coral
240 128 128 light coral
240 128 128 LightCoral
255 99 71 tomato
255 69 0 orange red
255 69 0 OrangeRed
255 0 0 red
255 105 180 hot pink
255 105 180 HotPink
255 20 147 deep pink
255 20 147 DeepPink
255 192 203 pink
255 182 193 light pink
255 182 193 LightPink
219 112 147 pale violet red
219 112 147 PaleVioletRed
176 48 96 maroon
176 48 96 x11 maroon
176 48 96 X11Maroon
128 0 0 web maroon
128 0 0 WebMaroon
199 21 133 medium violet red
199 21 133 MediumVioletRed
208 32 144 violet red
208 32 144 VioletRed
255 0 255 magenta
255 0 255 fuchsia
238 130 238 violet
221 160 221 plum
218 112 214 orchid
186 85 211 medium orchid
186 85 211 MediumOrchid
153 50 204 dark orchid
153 50 204 DarkOrchid
148 0 211 dark violet
148 0 211 DarkViolet
138 43 226 blue violet
138 43 226 BlueViolet
160 32 240 purple
160 32 240 x11 purple
160 32 240 X11Purple
128 0 128 web purple
128 0 128 WebPurple
147 112 219 medium purple
147 112 219 MediumPurple
216 191 216 thistle
255 250 250 snow1
238 233 233 snow2
205 201 201 snow3
139 137 137 snow4
255 245 238 seashell1
238 229 222 seashell2
205 197 191 seashell3
139 134 130 seashell4
255 239 219 AntiqueWhite1
238 223 204 AntiqueWhite2
205 192 176 AntiqueWhite3
139 131 120 AntiqueWhite4
255 228 196 bisque1
238 213 183 bisque2
205 183 158 bisque3
139 125 107 bisque4
255 218 185 PeachPuff1
238 203 173 PeachPuff2
205 175 149 PeachPuff3
139 119 101 PeachPuff4
255 222 173 NavajoWhite1
238 207 161 NavajoWhite2
205 179 139 NavajoWhite3
139 121 94 NavajoWhite4
255 250 205 LemonChiffon1
238 233 191 LemonChiffon2
205 201 165 LemonChiffon3
139 137 112 LemonChiffon4
255 248 220 cornsilk1
238 232 205 cornsilk2
205 200 177 cornsilk3
139 136 120 cornsilk4
255 255 240 ivory1
238 238 224 ivory2
205 205 193 ivory3
139 139 131 ivory4
240 255 240 honeydew1
224 238 224 honeydew2
193 205 193 honeydew3
131 139 131 honeydew4
255 240 245 LavenderBlush1
238 224 229 LavenderBlush2
205 193 197 LavenderBlush3
139 131 134 LavenderBlush4
255 228 225 MistyRose1
238 213 210 MistyRose2
205 183 181 MistyRose3
139 125 123 MistyRose4
240 255 255 azure1
224 238 238 azure2
193 205 205 azure3
131 139 139 azure4
131 111 255 SlateBlue1
122 103 238 SlateBlue2
105 89 205 SlateBlue3
71 60 139 SlateBlue4
72 118 255 RoyalBlue1
67 110 238 RoyalBlue2
58 95 205 RoyalBlue3
39 64 139 RoyalBlue4
0 0 255 blue1
0 0 238 blue2
0 0 205 blue3
0 0 139 blue4
30 144 255 DodgerBlue1
28 134 238 DodgerBlue2
24 116 205 DodgerBlue3
16 78 139 DodgerBlue4
99 184 255 SteelBlue1
92 172 238 SteelBlue2
79 148 205 SteelBlue3
54 100 139 SteelBlue4
0 191 255 DeepSkyBlue1
0 178 238 DeepSkyBlue2
0 154 205 DeepSkyBlue3
0 104 139 DeepSkyBlue4
135 206 255 SkyBlue1
126 192 238 SkyBlue2
108 166 205 SkyBlue3
74 112 139 SkyBlue4
176 226 255 LightSkyBlue1
164 211 238 LightSkyBlue2
141 182 205 LightSkyBlue3
96 123 139 LightSkyBlue4
198 226 255 SlateGray1
185 211 238 SlateGray2
159 182 205 SlateGray3
108 123 139 SlateGray4
202 225 255 LightSteelBlue1
188 210 238 LightSteelBlue2
162 181 205 LightSteelBlue3
110 123 139 LightSteelBlue4
191 239 255 LightBlue1
178 223 238 LightBlue2
154 192 205 LightBlue3
104 131 139 LightBlue4
224 255 255 LightCyan1
209 238 238 LightCyan2
180 205 205 LightCyan3
122 139 139 LightCyan4
187 255 255 PaleTurquoise1
174 238 238 PaleTurquoise2
150 205 205 PaleTurquoise3
102 139 139 PaleTurquoise4
152 245 255 CadetBlue1
142 229 238 CadetBlue2
122 197 205 CadetBlue3
83 134 139 CadetBlue4
0 245 255 turquoise1
0 229 238 turquoise2
0 197 205 turquoise3
0 134 139 turquoise4
0 255 255 cyan1
0 238 238 cyan2
0 205 205 cyan3
0 139 139 cyan4
151 255 255 DarkSlateGray1
141 238 238 DarkSlateGray2
121 205 205 DarkSlateGray3
82 139 139 DarkSlateGray4
127 255 212 aquamarine1
118 238 198 aquamarine2
102 205 170 aquamarine3
69 139 116 aquamarine4
193 255 193 DarkSeaGreen1
180 238 180 DarkSeaGreen2
155 205 155 DarkSeaGreen3
105 139 105 DarkSeaGreen4
84 255 159 SeaGreen1
78 238 148 SeaGreen2
67 205 128 SeaGreen3
46 139 87 SeaGreen4
154 255 154 PaleGreen1
144 238 144 PaleGreen2
124 205 124 PaleGreen3
84 139 84 PaleGreen4
0 255 127 SpringGreen1
0 238 118 SpringGreen2
0 205 102 SpringGreen3
0 139 69 SpringGreen4
0 255 0 green1
0 238 0 green2
0 205 0 green3
0 139 0 green4
127 255 0 chartreuse1
118 238 0 chartreuse2
102 205 0 chartreuse3
69 139 0 chartreuse4
192 255 62 OliveDrab1
179 238 58 OliveDrab2
154 205 50 OliveDrab3
105 139 34 OliveDrab4
202 255 112 DarkOliveGreen1
188 238 104 DarkOliveGreen2
162 205 90 DarkOliveGreen3
110 139 61 DarkOliveGreen4
255 246 143 khaki1
238 230 133 khaki2
205 198 115 khaki3
139 134 78 khaki4
255 236 139 LightGoldenrod1
238 220 130 LightGoldenrod2
205 190 112 LightGoldenrod3
139 129 76 LightGoldenrod4
255 255 224 LightYellow1
238 238 209 LightYellow2
205 205 180 LightYellow3
139 139 122 LightYellow4
255 255 0 yellow1
238 238 0 yellow2
205 205 0 yellow3
139 139 0 yellow4
255 215 0 gold1
238 201 0 gold2
205 173 0 gold3
139 117 0 gold4
255 193 37 goldenrod1
238 180 34 goldenrod2
205 155 29 goldenrod3
139 105 20 goldenrod4
255 185 15 DarkGoldenrod1
238 173 14 DarkGoldenrod2
205 149 12 DarkGoldenrod3
139 101 8 DarkGoldenrod4
255 193 193 RosyBrown1
238 180 180 RosyBrown2
205 155 155 RosyBrown3
139 105 105 RosyBrown4
255 106 106 IndianRed1
238 99 99 IndianRed2
205 85 85 IndianRed3
139 58 58 IndianRed4
255 130 71 sienna1
238 121 66 sienna2
205 104 57 sienna3
139 71 38 sienna4
255 211 155 burlywood1
238 197 145 burlywood2
205 170 125 burlywood3
139 115 85 burlywood4
255 231 186 wheat1
238 216 174 wheat2
205 186 150 wheat3
139 126 102 wheat4
255 165 79 tan1
238 154 73 tan2
205 133 63 tan3
139 90 43 tan4
255 127 36 chocolate1
238 118 33 chocolate2
205 102 29 chocolate3
139 69 19 chocolate4
255 48 48 firebrick1
238 44 44 firebrick2
205 38 38 firebrick3
139 26 26 firebrick4
255 64 64 brown1
238 59 59 brown2
205 51 51 brown3
139 35 35 brown4
255 140 105 salmon1
238 130 98 salmon2
205 112 84 salmon3
139 76 57 salmon4
255 160 122 LightSalmon1
238 149 114 LightSalmon2
205 129 98 LightSalmon3
139 87 66 LightSalmon4
255 165 0 orange1
238 154 0 orange2
205 133 0 orange3
139 90 0 orange4
255 127 0 DarkOrange1
238 118 0 DarkOrange2
205 102 0 DarkOrange3
139 69 0 DarkOrange4
255 114 86 coral1
238 106 80 coral2
205 91 69 coral3
139 62 47 coral4
255 99 71 tomato1
238 92 66 tomato2
205 79 57 tomato3
139 54 38 tomato4
255 69 0 OrangeRed1
238 64 0 OrangeRed2
205 55 0 OrangeRed3
139 37 0 OrangeRed4
255 0 0 red1
238 0 0 red2
205 0 0 red3
139 0 0 red4
255 20 147 DeepPink1
238 18 137 DeepPink2
205 16 118 DeepPink3
139 10 80 DeepPink4
255 110 180 HotPink1
238 106 167 HotPink2
205 96 144 HotPink3
139 58 98 HotPink4
255 181 197 pink1
238 169 184 pink2
205 145 158 pink3
139 99 108 pink4
255 174 185 LightPink1
238 162 173 LightPink2
205 140 149 LightPink3
139 95 101 LightPink4
255 130 171 PaleVioletRed1
238 121 159 PaleVioletRed2
205 104 137 PaleVioletRed3
139 71 93 PaleVioletRed4
255 52 179 maroon1
238 48 167 maroon2
205 41 144 maroon3
139 28 98 maroon4
255 62 150 VioletRed1
238 58 140 VioletRed2
205 50 120 VioletRed3
139 34 82 VioletRed4
255 0 255 magenta1
238 0 238 magenta2
205 0 205 magenta3
139 0 139 magenta4
255 131 250 orchid1
238 122 233 orchid2
205 105 201 orchid3
139 71 137 orchid4
255 187 255 plum1
238 174 238 plum2
205 150 205 plum3
139 102 139 plum4
224 102 255 MediumOrchid1
209 95 238 MediumOrchid2
180 82 205 MediumOrchid3
122 55 139 MediumOrchid4
191 62 255 DarkOrchid1
178 58 238 DarkOrchid2
154 50 205 DarkOrchid3
104 34 139 DarkOrchid4
155 48 255 purple1
145 44 238 purple2
125 38 205 purple3
85 26 139 purple4
171 130 255 MediumPurple1
159 121 238 MediumPurple2
137 104 205 MediumPurple3
93 71 139 MediumPurple4
255 225 255 thistle1
238 210 238 thistle2
205 181 205 thistle3
139 123 139 thistle4
0 0 0 gray0
0 0 0 grey0
3 3 3 gray1
3 3 3 grey1
5 5 5 gray2
5 5 5 grey2
8 8 8 gray3
8 8 8 grey3
10 10 10 gray4
10 10 10 grey4
13 13 13 gray5
13 13 13 grey5
15 15 15 gray6
15 15 15 grey6
18 18 18 gray7
18 18 18 grey7
20 20 20 gray8
20 20 20 grey8
23 23 23 gray9
23 23 23 grey9
26 26 26 gray10
26 26 26 grey10
28 28 28 gray11
28 28 28 grey11
31 31 31 gray12
31 31 31 grey12
33 33 33 gray13
33 33 33 grey13
36 36 36 gray14
36 36 36 grey14
38 38 38 gray15
38 38 38 grey15
41 41 41 gray16
41 41 41 grey16
43 43 43 gray17
43 43 43 grey17
46 46 46 gray18
46 46 46 grey18
48 48 48 gray19
48 48 48 grey19
51 51 51 gray20
51 51 51 grey20
54 54 54 gray21
54 54 54 grey21
56 56 56 gray22
56 56 56 grey22
59 59 59 gray23
59 59 59 grey23
61 61 61 gray24
61 61 61 grey24
64 64 64 gray25
64 64 64 grey25
66 66 66 gray26
66 66 66 grey26
69 69 69 gray27
69 69 69 grey27
71 71 71 gray28
71 71 71 grey28
74 74 74 gray29
74 74 74 grey29
77 77 77 gray30
77 77 77 grey30
79 79 79 gray31
79 79 79 grey31
82 82 82 gray32
82 82 82 grey32
84 84 84 gray33
84 84 84 grey33
87 87 87 gray34
87 87 87 grey34
89 89 89 gray35
89 89 89 grey35
92 92 92 gray36
92 92 92 grey36
94 94 94 gray37
94 94 94 grey37
97 97 97 gray38
97 97 97 grey38
99 99 99 gray39
99 99 99 grey39
102 102 102 gray40
102 102 102 grey40
105 105 105 gray41
105 105 105 grey41
107 107 107 gray42
107 107 107 grey42
110 110 110 gray43
110 110 110 grey43
112 112 112 gray44
112 112 112 grey44
115 115 115 gray45
115 115 115 grey45
117 117 117 gray46
117 117 117 grey46
120 120 120 gray47
120 120 120 grey47
122 122 122 gray48
122 122 122 grey48
125 125 125 gray49
125 125 125 grey49
127 127 127 gray50
127 127 127 grey50
130 130 130 gray51
130 130 130 grey51
133 133 133 gray52
133 133 133 grey52
135 135 135 gray53
135 135 135 grey53
138 138 138 gray54
138 138 138 grey54
140 140 140 gray55
140 140 140 grey55
143 143 143 gray56
143 143 143 grey56
145 145 145 gray57
145 145 145 grey57
148 148 148 gray58
148 148 148 grey58
150 150 150 gray59
150 150 150 grey59
153 153 153 gray60
153 153 153 grey60
156 156 156 gray61
156 156 156 grey61
158 158 158 gray62
158 158 158 grey62
161 161 161 gray63
161 161 161 grey63
163 163 163 gray64
163 163 163 grey64
166 166 166 gray65
166 166 166 grey65
168 168 168 gray66
168 168 168 grey66
171 171 171 gray67
171 171 171 grey67
173 173 173 gray68
173 173 173 grey68
176 176 176 gray69
176 176 176 grey69
179 179 179 gray70
179 179 179 grey70
181 181 181 gray71
181 181 181 grey71
184 184 184 gray72
184 184 184 grey72
186 186 186 gray73
186 186 186 grey73
189 189 189 gray74
189 189 189 grey74
191 191 191 gray75
191 191 191 grey75
194 194 194 gray76
194 194 194 grey76
196 196 196 gray77
196 196 196 grey77
199 199 199 gray78
199 199 199 grey78
201 201 201 gray79
201 201 201 grey79
204 204 204 gray80
204 204 204 grey80
207 207 207 gray81
207 207 207 grey81
209 209 209 gray82
209 209 209 grey82
212 212 212 gray83
212 212 212 grey83
214 214 214 gray84
214 214 214 grey84
217 217 217 gray85
217 217 217 grey85
219 219 219 gray86
219 219 219 grey86
222 222 222 gray87
222 222 222 grey87
224 224 224 gray88
224 224 224 grey88
227 227 227 gray89
227 227 227 grey89
229 229 229 gray90
229 229 229 grey90
232 232 232 gray91
232 232 232 grey91
235 235 235 gray92
235 235 235 grey92
237 237 237 gray93
237 237 237 grey93
240 240 240 gray94
240 240 240 grey94
242 242 242 gray95
242 242 242 grey95
245 245 245 gray96
245 245 245 grey96
247 247 247 gray97
247 247 247 grey97
250 250 250 gray98
250 250 250 grey98
252 252 252 gray99
252 252 252 grey99
255 255 255 gray100
255 255 255 grey100
169 169 169 dark grey
169 169 169 DarkGrey
169 169 169 dark gray
169 169 169 DarkGray
0 0 139 dark blue
0 0 139 DarkBlue
0 139 139 dark cyan
0 139 139 DarkCyan
139 0 139 dark magenta
139 0 139 DarkMagenta
139 0 0 dark red
139 0 0 DarkRed
144 238 144 light green
144 238 144 LightGreen
220 20 60 crimson
75 0 130 indigo
128 128 0 olive
102 51 153 rebecca purple
102 51 153 RebeccaPurple
192 192 192 silver
0 128 128 teal
+740
View File
@@ -0,0 +1,740 @@
//! SGR (Select Graphic Rendition) attribute parsing and types
//! (ZIG/src/terminal/sgr.zig).
//!
//! C-ABI helpers of the Zig version (`Attribute.C`, `cval`, `lib.Enum`
//! `Tag`) are intentionally not ported.
use crate::color::{Name, Rgb};
use crate::parser::{MAX_PARAMS, SepList};
/// Attribute type for SGR
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Attribute<'a> {
/// Unset all attributes
Unset,
/// Unknown attribute, the raw CSI command parameters are here.
Unknown(Unknown<'a>),
/// Bold the text.
Bold,
ResetBold,
/// Italic text.
Italic,
ResetItalic,
/// Faint/dim text.
/// Note: reset faint is the same SGR code as reset bold
Faint,
/// Underline the text
Underline(Underline),
UnderlineColor(Rgb),
/// `256_underline_color`
UnderlineColor256(u8),
ResetUnderlineColor,
// Overline the text
Overline,
ResetOverline,
/// Blink the text
Blink,
ResetBlink,
/// Invert fg/bg colors.
Inverse,
ResetInverse,
/// Invisible
Invisible,
ResetInvisible,
/// Strikethrough the text.
Strikethrough,
ResetStrikethrough,
/// Set foreground color as RGB values.
DirectColorFg(Rgb),
/// Set background color as RGB values.
DirectColorBg(Rgb),
/// Set the background/foreground as a named color attribute (`8_bg`).
Bg8(Name),
/// `8_fg`
Fg8(Name),
/// Reset the fg/bg to their default values.
ResetFg,
ResetBg,
/// Set the background/foreground as a named bright color attribute
/// (`8_bright_bg`).
Bg8Bright(Name),
/// `8_bright_fg`
Fg8Bright(Name),
/// Set background color as 256-color palette (`256_bg`).
Bg256(u8),
/// Set foreground color as 256-color palette (`256_fg`).
Fg256(u8),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Unknown<'a> {
/// Full is the full SGR input.
pub full: &'a [u16],
/// Partial is the remaining, where we got hung up.
pub partial: &'a [u16],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Underline {
None = 0,
Single = 1,
Double = 2,
Curly = 3,
Dotted = 4,
Dashed = 5,
}
/// Parser parses the attributes from a list of SGR parameters. Iterating
/// yields attributes until the parameters are exhausted.
#[derive(Debug, Clone, Default)]
pub struct Parser<'a> {
pub params: &'a [u16],
pub params_sep: SepList,
pub idx: usize,
}
impl<'a> Parser<'a> {
/// Empty state parser.
pub const EMPTY: Parser<'static> = Parser {
params: &[],
params_sep: SepList::init_empty(),
idx: 0,
};
pub fn new(params: &'a [u16], params_sep: SepList) -> Self {
Self {
params,
params_sep,
idx: 0,
}
}
/// `SepList::is_set` but tolerant of indices past the end.
fn sep_is_set(&self, idx: usize) -> bool {
idx < MAX_PARAMS && self.params_sep.is_set(idx)
}
/// Next returns the next attribute or None if there are no more attributes.
fn next_attr(&mut self) -> Option<Attribute<'a>> {
if self.idx >= self.params.len() {
// Add one to ensure we don't loop on unset
let was_zero = self.idx == 0;
self.idx += 1;
// If we're at index zero it means we must have an empty list
// and an empty list implicitly means unset, otherwise we're
// done and return None.
return if was_zero {
Some(Attribute::Unset)
} else {
None
};
}
let params = self.params;
let slice = &params[self.idx..];
let colon = self.sep_is_set(self.idx);
self.idx += 1;
// Our last one will have an idx be the last value.
if slice.is_empty() {
return None;
}
// If we have a colon separator then we need to ensure we're
// parsing a value that allows it.
if colon {
match slice[0] {
4 | 38 | 48 | 58 => {}
_ => {
// Consume all the colon separated
// values and return them as unknown.
let start = self.idx;
while self.sep_is_set(self.idx) {
self.idx += 1;
}
self.idx += 1;
return Some(Attribute::Unknown(Unknown {
full: params,
partial: &slice[..(self.idx - start + 1).min(slice.len())],
}));
}
}
}
match slice[0] {
0 => return Some(Attribute::Unset),
1 => return Some(Attribute::Bold),
2 => return Some(Attribute::Faint),
3 => return Some(Attribute::Italic),
4 => {
if colon {
// A trailing colon with no following sub-param
// (e.g. "ESC[58:4:m") leaves the colon separator
// bit set on the last param without adding another
// entry, so we can see param 4 with a colon but
// nothing after it.
if slice.len() < 2 {
// fall through to unknown
} else if self.is_colon() {
self.consume_unknown_colon();
} else {
self.idx += 1;
return Some(Attribute::Underline(match slice[1] {
0 => Underline::None,
1 => Underline::Single,
2 => Underline::Double,
3 => Underline::Curly,
4 => Underline::Dotted,
5 => Underline::Dashed,
// For unknown underline styles,
// just render a single underline.
_ => Underline::Single,
}));
}
} else {
return Some(Attribute::Underline(Underline::Single));
}
}
5 | 6 => return Some(Attribute::Blink),
7 => return Some(Attribute::Inverse),
8 => return Some(Attribute::Invisible),
9 => return Some(Attribute::Strikethrough),
21 => return Some(Attribute::Underline(Underline::Double)),
22 => return Some(Attribute::ResetBold),
23 => return Some(Attribute::ResetItalic),
24 => return Some(Attribute::Underline(Underline::None)),
25 => return Some(Attribute::ResetBlink),
27 => return Some(Attribute::ResetInverse),
28 => return Some(Attribute::ResetInvisible),
29 => return Some(Attribute::ResetStrikethrough),
30..=37 => return Some(Attribute::Fg8(Name((slice[0] - 30) as u8))),
38 => {
if slice.len() >= 2 {
match slice[1] {
// `2` indicates direct-color (r, g, b).
// We need at least 3 more params for this to make sense.
2 => {
if let Some(v) =
self.parse_direct_color(Attribute::DirectColorFg, slice, colon)
{
return Some(v);
}
}
// `5` indicates indexed color.
5 if slice.len() >= 3 => {
self.idx += 2;
return Some(Attribute::Fg256(slice[2] as u8));
}
_ => {}
}
}
}
39 => return Some(Attribute::ResetFg),
40..=47 => return Some(Attribute::Bg8(Name((slice[0] - 40) as u8))),
48 => {
if slice.len() >= 2 {
match slice[1] {
2 => {
if let Some(v) =
self.parse_direct_color(Attribute::DirectColorBg, slice, colon)
{
return Some(v);
}
}
5 if slice.len() >= 3 => {
self.idx += 2;
return Some(Attribute::Bg256(slice[2] as u8));
}
_ => {}
}
}
}
49 => return Some(Attribute::ResetBg),
53 => return Some(Attribute::Overline),
55 => return Some(Attribute::ResetOverline),
58 => {
if slice.len() >= 2 {
match slice[1] {
2 => {
if let Some(v) =
self.parse_direct_color(Attribute::UnderlineColor, slice, colon)
{
return Some(v);
}
}
5 if slice.len() >= 3 => {
self.idx += 2;
return Some(Attribute::UnderlineColor256(slice[2] as u8));
}
_ => {}
}
}
}
59 => return Some(Attribute::ResetUnderlineColor),
// 82 instead of 90 to offset to "bright" colors
90..=97 => return Some(Attribute::Fg8Bright(Name((slice[0] - 82) as u8))),
100..=107 => return Some(Attribute::Bg8Bright(Name((slice[0] - 92) as u8))),
_ => {}
}
Some(Attribute::Unknown(Unknown {
full: params,
partial: slice,
}))
}
fn parse_direct_color(
&mut self,
make: fn(Rgb) -> Attribute<'a>,
slice: &[u16],
colon: bool,
) -> Option<Attribute<'a>> {
// Any direct color style must have at least 5 values.
if slice.len() < 5 {
return None;
}
// Only used for direct color sets (38, 48, 58) and subparam 2.
debug_assert_eq!(slice[1], 2);
// Note: We truncate because the value should be 0 to 255. If
// it isn't, the behavior is undefined so we just... truncate it.
// If we don't have a colon, then we expect exactly 3 semicolon
// separated values.
if !colon {
self.idx += 4;
return Some(make(Rgb {
r: slice[2] as u8,
g: slice[3] as u8,
b: slice[4] as u8,
}));
}
// We have a colon, we might have either 5 or 6 values depending
// on if the colorspace is present.
match self.count_colon() {
3 => {
self.idx += 4;
Some(make(Rgb {
r: slice[2] as u8,
g: slice[3] as u8,
b: slice[4] as u8,
}))
}
4 => {
self.idx += 5;
Some(make(Rgb {
r: slice[3] as u8,
g: slice[4] as u8,
b: slice[5] as u8,
}))
}
_ => {
self.consume_unknown_colon();
None
}
}
}
/// Returns true if the present position has a colon separator.
/// This always returns false for the last value since it has no
/// separator.
fn is_colon(&self) -> bool {
self.sep_is_set(self.idx)
}
fn count_colon(&self) -> usize {
let mut count = 0;
let mut idx = self.idx;
while idx < self.params.len().saturating_sub(1) && self.sep_is_set(idx) {
count += 1;
idx += 1;
}
count
}
/// Consumes all the remaining parameters separated by a colon and
/// returns an unknown attribute.
fn consume_unknown_colon(&mut self) {
let count = self.count_colon();
self.idx += count + 1;
}
}
impl<'a> Iterator for Parser<'a> {
type Item = Attribute<'a>;
fn next(&mut self) -> Option<Attribute<'a>> {
self.next_attr()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use Attribute as A;
fn rgb(r: u8, g: u8, b: u8) -> Rgb {
Rgb { r, g, b }
}
fn test_parse(params: &[u16]) -> Attribute<'_> {
let mut p = Parser::new(params, SepList::init_empty());
p.next().unwrap()
}
fn test_parse_colon(params: &[u16]) -> Attribute<'_> {
let mut p = Parser::new(params, SepList::init_empty());
// Mark all parameters except the last as having a colon after.
for i in 0..params.len() - 1 {
p.params_sep.set(i);
}
p.next().unwrap()
}
fn sep(idxs: &[usize]) -> SepList {
let mut l = SepList::init_empty();
for &i in idxs {
l.set(i);
}
l
}
fn is_unknown(a: Option<Attribute<'_>>) -> bool {
matches!(a, Some(A::Unknown(_)))
}
#[test]
fn sgr_parser() {
assert_eq!(test_parse(&[]), A::Unset);
assert_eq!(test_parse(&[0]), A::Unset);
assert_eq!(
test_parse(&[38, 2, 40, 44, 52]),
A::DirectColorFg(rgb(40, 44, 52))
);
assert!(is_unknown(Some(test_parse(&[38, 2, 44, 52]))));
assert_eq!(
test_parse(&[48, 2, 40, 44, 52]),
A::DirectColorBg(rgb(40, 44, 52))
);
assert!(is_unknown(Some(test_parse(&[48, 2, 44, 52]))));
}
#[test]
fn sgr_parser_multiple() {
let mut p = Parser::new(&[0, 38, 2, 40, 44, 52], SepList::init_empty());
assert_eq!(p.next(), Some(A::Unset));
assert!(matches!(p.next(), Some(A::DirectColorFg(_))));
assert_eq!(p.next(), None);
assert_eq!(p.next(), None);
}
#[test]
fn sgr_unsupported_with_colon() {
let mut p = Parser::new(&[0, 4, 1], sep(&[0]));
assert!(is_unknown(p.next()));
assert_eq!(p.next(), Some(A::Bold));
assert_eq!(p.next(), None);
}
#[test]
fn sgr_unsupported_with_multiple_colon() {
let mut p = Parser::new(&[0, 4, 2, 1], sep(&[0, 1]));
assert!(is_unknown(p.next()));
assert_eq!(p.next(), Some(A::Bold));
assert_eq!(p.next(), None);
}
#[test]
fn sgr_bold() {
assert_eq!(test_parse(&[1]), A::Bold);
assert_eq!(test_parse(&[22]), A::ResetBold);
}
#[test]
fn sgr_italic() {
assert_eq!(test_parse(&[3]), A::Italic);
assert_eq!(test_parse(&[23]), A::ResetItalic);
}
#[test]
fn sgr_underline() {
assert_eq!(test_parse(&[4]), A::Underline(Underline::Single));
assert_eq!(test_parse(&[24]), A::Underline(Underline::None));
}
#[test]
fn sgr_underline_styles() {
assert_eq!(test_parse_colon(&[4, 2]), A::Underline(Underline::Double));
assert_eq!(test_parse_colon(&[4, 0]), A::Underline(Underline::None));
assert_eq!(test_parse_colon(&[4, 1]), A::Underline(Underline::Single));
assert_eq!(test_parse_colon(&[4, 3]), A::Underline(Underline::Curly));
assert_eq!(test_parse_colon(&[4, 4]), A::Underline(Underline::Dotted));
assert_eq!(test_parse_colon(&[4, 5]), A::Underline(Underline::Dashed));
}
#[test]
fn sgr_underline_style_with_more() {
let mut p = Parser::new(&[4, 2, 1], sep(&[0]));
assert!(matches!(p.next(), Some(A::Underline(_))));
assert_eq!(p.next(), Some(A::Bold));
assert_eq!(p.next(), None);
}
#[test]
fn sgr_underline_style_with_too_many_colons() {
let mut p = Parser::new(&[4, 2, 3, 1], sep(&[0, 1]));
assert!(is_unknown(p.next()));
assert_eq!(p.next(), Some(A::Bold));
assert_eq!(p.next(), None);
}
#[test]
fn sgr_blink() {
assert_eq!(test_parse(&[5]), A::Blink);
assert_eq!(test_parse(&[6]), A::Blink);
assert_eq!(test_parse(&[25]), A::ResetBlink);
}
#[test]
fn sgr_inverse() {
assert_eq!(test_parse(&[7]), A::Inverse);
assert_eq!(test_parse(&[27]), A::ResetInverse);
}
#[test]
fn sgr_strikethrough() {
assert_eq!(test_parse(&[9]), A::Strikethrough);
assert_eq!(test_parse(&[29]), A::ResetStrikethrough);
}
#[test]
fn sgr_8_color() {
let mut p = Parser::new(&[31, 43, 90, 103], SepList::init_empty());
assert_eq!(p.next(), Some(A::Fg8(Name::RED)));
assert_eq!(p.next(), Some(A::Bg8(Name::YELLOW)));
assert_eq!(p.next(), Some(A::Fg8Bright(Name::BRIGHT_BLACK)));
assert_eq!(p.next(), Some(A::Bg8Bright(Name::BRIGHT_YELLOW)));
}
#[test]
fn sgr_256_color() {
let mut p = Parser::new(&[38, 5, 161, 48, 5, 236], SepList::init_empty());
assert_eq!(p.next(), Some(A::Fg256(161)));
assert_eq!(p.next(), Some(A::Bg256(236)));
assert_eq!(p.next(), None);
}
#[test]
fn sgr_256_color_underline() {
let mut p = Parser::new(&[58, 5, 9], SepList::init_empty());
assert_eq!(p.next(), Some(A::UnderlineColor256(9)));
assert_eq!(p.next(), None);
}
#[test]
fn sgr_24_bit_bg_color() {
assert_eq!(
test_parse_colon(&[48, 2, 1, 2, 3]),
A::DirectColorBg(rgb(1, 2, 3))
);
}
#[test]
fn sgr_underline_color() {
assert_eq!(
test_parse_colon(&[58, 2, 1, 2, 3]),
A::UnderlineColor(rgb(1, 2, 3))
);
assert_eq!(
test_parse_colon(&[58, 2, 0, 1, 2, 3]),
A::UnderlineColor(rgb(1, 2, 3))
);
}
#[test]
fn sgr_reset_underline_color() {
let mut p = Parser::new(&[59], SepList::init_empty());
assert_eq!(p.next(), Some(A::ResetUnderlineColor));
}
#[test]
fn sgr_invisible() {
let mut p = Parser::new(&[8, 28], SepList::init_empty());
assert_eq!(p.next(), Some(A::Invisible));
assert_eq!(p.next(), Some(A::ResetInvisible));
}
#[test]
fn sgr_underline_bg_and_fg() {
let mut p = Parser::new(
&[4, 38, 2, 255, 247, 219, 48, 2, 242, 93, 147, 4],
SepList::init_empty(),
);
assert_eq!(p.next(), Some(A::Underline(Underline::Single)));
assert_eq!(p.next(), Some(A::DirectColorFg(rgb(255, 247, 219))));
assert_eq!(p.next(), Some(A::DirectColorBg(rgb(242, 93, 147))));
assert_eq!(p.next(), Some(A::Underline(Underline::Single)));
}
#[test]
fn sgr_direct_color_fg_missing_color() {
// This used to crash
let p = Parser::new(&[38, 5], SepList::init_empty());
for _ in p {}
}
#[test]
fn sgr_direct_color_bg_missing_color() {
// This used to crash
let p = Parser::new(&[48, 5], SepList::init_empty());
for _ in p {}
}
#[test]
fn sgr_direct_fg_bg_underline_ignore_optional_color_space() {
// These behaviors have been verified against xterm.
// Colon version should skip the optional color space identifier
assert_eq!(
test_parse_colon(&[38, 2, 0, 1, 2, 3]),
A::DirectColorFg(rgb(1, 2, 3))
);
assert_eq!(
test_parse_colon(&[48, 2, 0, 1, 2, 3]),
A::DirectColorBg(rgb(1, 2, 3))
);
assert_eq!(
test_parse_colon(&[58, 2, 0, 1, 2, 3]),
A::UnderlineColor(rgb(1, 2, 3))
);
// Semicolon version should not parse optional color space identifier
assert_eq!(
test_parse(&[38, 2, 0, 1, 2, 3]),
A::DirectColorFg(rgb(0, 1, 2))
);
assert_eq!(
test_parse(&[48, 2, 0, 1, 2, 3]),
A::DirectColorBg(rgb(0, 1, 2))
);
assert_eq!(
test_parse(&[58, 2, 0, 1, 2, 3]),
A::UnderlineColor(rgb(0, 1, 2))
);
}
#[test]
fn sgr_direct_fg_colon_with_too_many_colons() {
let mut p = Parser::new(&[38, 2, 0, 1, 2, 3, 4, 1], sep(&[0, 1, 2, 3, 4, 5]));
assert!(is_unknown(p.next()));
assert_eq!(p.next(), Some(A::Bold));
assert_eq!(p.next(), None);
}
#[test]
fn sgr_direct_fg_colon_with_colorspace_and_extra_param() {
let mut p = Parser::new(&[38, 2, 0, 1, 2, 3, 1], sep(&[0, 1, 2, 3, 4]));
assert_eq!(p.next(), Some(A::DirectColorFg(rgb(1, 2, 3))));
assert_eq!(p.next(), Some(A::Bold));
assert_eq!(p.next(), None);
}
#[test]
fn sgr_direct_fg_colon_no_colorspace_and_extra_param() {
let mut p = Parser::new(&[38, 2, 1, 2, 3, 1], sep(&[0, 1, 2, 3]));
assert_eq!(p.next(), Some(A::DirectColorFg(rgb(1, 2, 3))));
assert_eq!(p.next(), Some(A::Bold));
assert_eq!(p.next(), None);
}
// Kakoune sent this complex SGR sequence that caused invalid behavior.
#[test]
fn sgr_kakoune_input() {
// This used to crash
let mut p = Parser::new(
&[0, 4, 3, 38, 2, 175, 175, 215, 58, 2, 0, 190, 80, 70],
sep(&[1, 8, 9, 10, 11, 12]),
);
assert_eq!(p.next(), Some(A::Unset));
assert_eq!(p.next(), Some(A::Underline(Underline::Curly)));
assert_eq!(p.next(), Some(A::DirectColorFg(rgb(175, 175, 215))));
assert_eq!(p.next(), Some(A::UnderlineColor(rgb(190, 80, 70))));
}
// Discussion #5930, another input sent by kakoune
#[test]
fn sgr_kakoune_input_issue_underline_fg_and_bg() {
// echo -e "\033[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136mset everything in one sequence, broken\033[m"
let mut p = Parser::new(
&[
4, 3, 38, 2, 51, 51, 51, 48, 2, 170, 170, 170, 58, 2, 255, 97, 136,
],
sep(&[0]),
);
assert_eq!(p.next(), Some(A::Underline(Underline::Curly)));
assert_eq!(p.next(), Some(A::DirectColorFg(rgb(51, 51, 51))));
assert_eq!(p.next(), Some(A::DirectColorBg(rgb(170, 170, 170))));
assert_eq!(p.next(), Some(A::UnderlineColor(rgb(255, 97, 136))));
assert_eq!(p.next(), None);
}
// Fuzz crash: afl-out/stream/default/crashes/id:000021
// Input "ESC [ 5 8 : 4 : m" produces params [58, 4] with colon
// separator bits set at indices 0 and 1.
#[test]
fn sgr_underline_colon_with_trailing_separator_and_short_slice() {
let mut p = Parser::new(&[58, 4], sep(&[0, 1]));
// 58:4 is not a valid underline color (sub-param 4 is not 2 or 5),
// so it falls through as unknown.
assert!(is_unknown(p.next()));
// Param 4 with a trailing colon but no sub-param is malformed,
// so it also falls through as unknown rather than panicking.
assert!(is_unknown(p.next()));
assert_eq!(p.next(), None);
}
}
+679
View File
@@ -0,0 +1,679 @@
//! This file contains the implementation for tmux control mode
//! (ZIG/src/terminal/tmux/control.zig). See tmux(1) for more information on
//! control mode. Some basics are documented here but this is not meant to be
//! a comprehensive source of protocol documentation.
//!
//! Deviations from the Zig version:
//! - Notifications own their data (`Vec<u8>`) instead of borrowing from the
//! parser's buffer, which avoids self-referential lifetimes.
//! - Oniguruma regexes are replaced by hand-written matchers that implement
//! the same (greedy, backtracking) semantics for the specific patterns.
//! - A numeric ID that overflows `usize` is treated as a failed match (Zig
//! would hit `unreachable`).
/// Errors returned by [`Parser::put`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum Error {
#[error("out of memory")]
OutOfMemory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
/// Outside of any active notifications. This should drop any output
/// unless it is '%' on the first byte of a line. The buffer will be
/// cleared when it sees '%', this is so that the previous notification
/// data is valid until we receive/process new data.
Idle,
/// We experienced unexpected input and are in a broken state
/// so we cannot continue processing. When this state is set,
/// the buffer has been freed and must not be accessed.
Broken,
/// Inside an active notification (started with '%').
Notification,
/// Inside a begin/end block.
Block,
}
/// A tmux control mode parser. This takes in output from tmux control
/// mode and parses it into a structured notifications.
///
/// It is up to the caller to establish the connection to the tmux
/// control mode session in some way (e.g. via exec, a network socket,
/// whatever). This is fully agnostic to how the data is received and sent.
#[derive(Debug)]
pub struct Parser {
/// Current state of the client.
state: State,
/// The buffer used to store in-progress notifications, output, etc.
buffer: Vec<u8>,
/// The maximum size in bytes of the buffer. This is used to limit
/// memory usage. If the buffer exceeds this size, the client will
/// enter a broken state (the control mode session will be forcibly
/// exited and future data dropped).
pub max_bytes: usize,
}
impl Default for Parser {
fn default() -> Self {
Self::new()
}
}
impl Parser {
pub fn new() -> Self {
Self {
state: State::Idle,
buffer: Vec::new(),
max_bytes: 1024 * 1024,
}
}
/// Handle a byte of input.
///
/// If we reach our byte limit this will return `OutOfMemory`. It only
/// does this on the first time we exceed the limit; subsequent calls
/// will return None as we drop all input in a broken state.
pub fn put(&mut self, byte: u8) -> Result<Option<Notification>, Error> {
// If we're in a broken state, just do nothing.
if self.state == State::Broken {
return Ok(None);
}
if self.buffer.len() >= self.max_bytes {
self.broken();
return Err(Error::OutOfMemory);
}
match self.state {
State::Broken => return Ok(None),
// Waiting for a notification so if the byte is not '%' then
// we're in a broken state. Control mode output should always
// be wrapped in '%begin/%end' orelse we expect a notification.
// Return an exit notification.
State::Idle => {
if byte == b'%' {
self.buffer.clear();
self.state = State::Notification;
} else {
self.broken();
return Ok(Some(Notification::Exit));
}
}
// If we're in a notification and its not a newline then
// we accumulate. If it is a newline then we have a
// complete notification we need to parse.
State::Notification => {
if byte == b'\n' {
// If parsing failed we do not mark the state as broken
// because we may be able to continue parsing other types
// of notifications.
return Ok(self.parse_notification());
}
}
// If we're in a block then we accumulate until we see a newline
// and then we check to see if that line ended the block.
State::Block => {
if byte == b'\n' {
let idx = self
.buffer
.iter()
.rposition(|&b| b == b'\n')
.map_or(0, |v| v + 1);
let line = &self.buffer[idx..];
if line.starts_with(b"%end") || line.starts_with(b"%error") {
let err = line.starts_with(b"%error");
let output = trim_right_crlf(&self.buffer[..idx]).to_vec();
// If it is an error then log it.
if err {
log::warn!(
"tmux control mode error={}",
String::from_utf8_lossy(&output)
);
}
self.state = State::Idle;
return Ok(Some(if err {
Notification::BlockErr(output)
} else {
Notification::BlockEnd(output)
}));
}
// Didn't end the block, continue accumulating.
}
}
}
self.buffer.push(byte);
Ok(None)
}
fn parse_notification(&mut self) -> Option<Notification> {
debug_assert_eq!(self.state, State::Notification);
let mut line: &[u8] = &self.buffer;
if line.last() == Some(&b'\r') {
line = &line[..line.len() - 1];
}
let cmd_end = line.iter().position(|&b| b == b' ').unwrap_or(line.len());
let cmd = &line[..cmd_end];
// The notification MUST exist because we guard entering the notification
// state on seeing at least a '%'.
let result: Option<Notification> = match cmd {
b"%begin" => {
// We don't use the rest of the tokens for now because tmux
// claims to guarantee that begin/end are always in order and
// never intermixed.
// Move to block state because we expect a corresponding end/error
// and want to accumulate the data.
self.state = State::Block;
self.buffer.clear();
return None;
}
b"%output" => match_output(line),
b"%session-changed" => match_session_changed(line),
b"%sessions-changed" => {
if line == b"%sessions-changed" {
Some(Notification::SessionsChanged)
} else {
log::warn!(
"failed to match notification cmd=%sessions-changed line=\"{}\"",
String::from_utf8_lossy(line)
);
None
}
}
b"%layout-change" => match_layout_change(line),
b"%window-add" => match_window_add(line),
b"%window-renamed" => match_window_renamed(line),
b"%window-pane-changed" => match_window_pane_changed(line),
b"%client-detached" => match_client_detached(line),
b"%client-session-changed" => match_client_session_changed(line),
_ => {
// Unknown notification, log it and return to idle state.
log::warn!(
"unknown tmux control mode notification={}",
String::from_utf8_lossy(cmd)
);
None
}
};
if result.is_none() && cmd_is_known(cmd) && cmd != b"%sessions-changed" {
log::warn!(
"failed to match notification cmd={} line=\"{}\"",
String::from_utf8_lossy(cmd),
String::from_utf8_lossy(line)
);
}
// Whether matched or not, clear the buffer and return to idle. (The
// Zig code avoids clearing when slices point into the buffer, but we
// own our data so clearing is always safe.)
self.buffer.clear();
self.state = State::Idle;
result
}
/// Mark the tmux state as broken.
fn broken(&mut self) {
self.state = State::Broken;
self.buffer = Vec::new();
}
}
fn cmd_is_known(cmd: &[u8]) -> bool {
matches!(
cmd,
b"%output"
| b"%session-changed"
| b"%sessions-changed"
| b"%layout-change"
| b"%window-add"
| b"%window-renamed"
| b"%window-pane-changed"
| b"%client-detached"
| b"%client-session-changed"
)
}
fn trim_right_crlf(mut s: &[u8]) -> &[u8] {
while let Some((&last, rest)) = s.split_last() {
if last == b'\r' || last == b'\n' {
s = rest;
} else {
break;
}
}
s
}
// ---------------------------------------------------------------------
// Hand-written matchers equivalent to the Zig regexes. `.` does not match
// '\n' in Oniguruma, but lines never contain '\n' here.
/// Parse `[0-9]+` at the start of `s`; returns (value, rest).
fn digits(s: &[u8]) -> Option<(usize, &[u8])> {
let n = s.iter().take_while(|b| b.is_ascii_digit()).count();
if n == 0 {
return None;
}
let v = std::str::from_utf8(&s[..n]).ok()?.parse::<usize>().ok()?;
Some((v, &s[n..]))
}
/// Strip an exact literal prefix.
fn lit<'a>(s: &'a [u8], p: &[u8]) -> Option<&'a [u8]> {
s.strip_prefix(p)
}
/// `(.+)` followed by end: non-empty remainder.
fn nonempty(s: &[u8]) -> Option<&[u8]> {
if s.is_empty() { None } else { Some(s) }
}
fn match_output(line: &[u8]) -> Option<Notification> {
// ^%output %([0-9]+) (.+)$
let s = lit(line, b"%output %")?;
let (id, s) = digits(s)?;
let s = lit(s, b" ")?;
let data = nonempty(s)?;
Some(Notification::Output {
pane_id: id,
data: data.to_vec(),
})
}
fn match_session_changed(line: &[u8]) -> Option<Notification> {
// ^%session-changed \$([0-9]+) (.+)$
let s = lit(line, b"%session-changed $")?;
let (id, s) = digits(s)?;
let s = lit(s, b" ")?;
let name = nonempty(s)?;
Some(Notification::SessionChanged {
id,
name: name.to_vec(),
})
}
fn match_layout_change(line: &[u8]) -> Option<Notification> {
// ^%layout-change @([0-9]+) (.+) (.+) (.*)$
let s = lit(line, b"%layout-change @")?;
let (id, s) = digits(s)?;
let rest = lit(s, b" ")?;
// Greedy group 2, then group 3 (non-empty), then group 4 (any).
// The last space starts group 4; group 2 ends at the last space at or
// before `last - 2` (so that group 3 is non-empty) and at index >= 1.
let last = rest.iter().rposition(|&b| b == b' ')?;
if last < 3 {
return None;
}
let p = rest[1..=last - 2].iter().rposition(|&b| b == b' ')? + 1;
let layout = &rest[..p];
let visible = &rest[p + 1..last];
let flags = &rest[last + 1..];
Some(Notification::LayoutChange {
window_id: id,
layout: layout.to_vec(),
visible_layout: visible.to_vec(),
raw_flags: flags.to_vec(),
})
}
fn match_window_add(line: &[u8]) -> Option<Notification> {
// ^%window-add @([0-9]+)$
let s = lit(line, b"%window-add @")?;
let (id, s) = digits(s)?;
if !s.is_empty() {
return None;
}
Some(Notification::WindowAdd { id })
}
fn match_window_renamed(line: &[u8]) -> Option<Notification> {
// ^%window-renamed @([0-9]+) (.+)$
let s = lit(line, b"%window-renamed @")?;
let (id, s) = digits(s)?;
let s = lit(s, b" ")?;
let name = nonempty(s)?;
Some(Notification::WindowRenamed {
id,
name: name.to_vec(),
})
}
fn match_window_pane_changed(line: &[u8]) -> Option<Notification> {
// ^%window-pane-changed @([0-9]+) %([0-9]+)$
let s = lit(line, b"%window-pane-changed @")?;
let (window_id, s) = digits(s)?;
let s = lit(s, b" %")?;
let (pane_id, s) = digits(s)?;
if !s.is_empty() {
return None;
}
Some(Notification::WindowPaneChanged { window_id, pane_id })
}
fn match_client_detached(line: &[u8]) -> Option<Notification> {
// ^%client-detached (.+)$
let s = lit(line, b"%client-detached ")?;
let client = nonempty(s)?;
Some(Notification::ClientDetached {
client: client.to_vec(),
})
}
fn match_client_session_changed(line: &[u8]) -> Option<Notification> {
// ^%client-session-changed (.+) \$([0-9]+) (.+)$
let s = lit(line, b"%client-session-changed ")?;
// Greedy client: try the latest split first.
for p in (1..s.len()).rev() {
if let Some(r) = s[p..].strip_prefix(b" $")
&& let Some((session_id, r)) = digits(r)
&& let Some(r) = lit(r, b" ")
&& let Some(name) = nonempty(r)
{
return Some(Notification::ClientSessionChanged {
client: s[..p].to_vec(),
session_id,
name: name.to_vec(),
});
}
}
None
}
/// Possible notification types from tmux control mode. These are documented
/// in tmux(1). A lot of the simple documentation was copied from that man
/// page here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Notification {
/// Entering tmux control mode. This isn't an actual event sent by
/// tmux but is one sent by us to indicate that we have detected that
/// tmux control mode is starting.
Enter,
/// Exit.
///
/// NOTE: The tmux protocol contains a "reason" string (human friendly)
/// associated with this. We currently drop it because we don't need it
/// but this may be something we want to add later.
Exit,
/// Dispatched at the end of a begin/end block with the raw data.
/// The control mode parser can't parse the data because it is unaware
/// of the command that was sent to trigger this output.
BlockEnd(Vec<u8>),
BlockErr(Vec<u8>),
/// Raw output from a pane.
Output {
pane_id: usize,
/// unescaped
data: Vec<u8>,
},
/// The client is now attached to the session with ID session-id, which is
/// named name.
SessionChanged {
id: usize,
name: Vec<u8>,
},
/// A session was created or destroyed.
SessionsChanged,
/// The layout of the window with ID window-id changed.
LayoutChange {
window_id: usize,
layout: Vec<u8>,
visible_layout: Vec<u8>,
raw_flags: Vec<u8>,
},
/// The window with ID window-id was linked to the current session.
WindowAdd {
id: usize,
},
/// The window with ID window-id was renamed to name.
WindowRenamed {
id: usize,
name: Vec<u8>,
},
/// The active pane in the window with ID window-id changed to the pane
/// with ID pane-id.
WindowPaneChanged {
window_id: usize,
pane_id: usize,
},
/// The client has detached.
ClientDetached {
client: Vec<u8>,
},
/// The client is now attached to the session with ID session-id, which is
/// named name.
ClientSessionChanged {
client: Vec<u8>,
session_id: usize,
name: Vec<u8>,
},
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn feed(c: &mut Parser, s: &str) {
for b in s.bytes() {
assert_eq!(c.put(b).unwrap(), None);
}
}
#[test]
fn tmux_begin_end_empty() {
let mut c = Parser::new();
feed(&mut c, "%begin 1578922740 269 1\n");
feed(&mut c, "%end 1578922740 269 1");
let n = c.put(b'\n').unwrap().unwrap();
assert_eq!(n, Notification::BlockEnd(vec![]));
}
#[test]
fn tmux_begin_error_empty() {
let mut c = Parser::new();
feed(&mut c, "%begin 1578922740 269 1\n");
feed(&mut c, "%error 1578922740 269 1");
let n = c.put(b'\n').unwrap().unwrap();
assert_eq!(n, Notification::BlockErr(vec![]));
}
#[test]
fn tmux_begin_end_data() {
let mut c = Parser::new();
feed(&mut c, "%begin 1578922740 269 1\n");
feed(&mut c, "hello\nworld\n");
feed(&mut c, "%end 1578922740 269 1");
let n = c.put(b'\n').unwrap().unwrap();
assert_eq!(n, Notification::BlockEnd(b"hello\nworld".to_vec()));
}
#[test]
fn tmux_output() {
let mut c = Parser::new();
feed(&mut c, "%output %42 foo bar baz");
let n = c.put(b'\n').unwrap().unwrap();
assert_eq!(
n,
Notification::Output {
pane_id: 42,
data: b"foo bar baz".to_vec()
}
);
}
#[test]
fn tmux_session_changed() {
let mut c = Parser::new();
feed(&mut c, "%session-changed $42 foo");
let n = c.put(b'\n').unwrap().unwrap();
assert_eq!(
n,
Notification::SessionChanged {
id: 42,
name: b"foo".to_vec()
}
);
}
#[test]
fn tmux_sessions_changed() {
let mut c = Parser::new();
feed(&mut c, "%sessions-changed");
let n = c.put(b'\n').unwrap().unwrap();
assert_eq!(n, Notification::SessionsChanged);
}
#[test]
fn tmux_sessions_changed_carriage_return() {
let mut c = Parser::new();
feed(&mut c, "%sessions-changed\r");
let n = c.put(b'\n').unwrap().unwrap();
assert_eq!(n, Notification::SessionsChanged);
}
#[test]
fn tmux_layout_change() {
let mut c = Parser::new();
feed(
&mut c,
"%layout-change @2 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} *-",
);
let n = c.put(b'\n').unwrap().unwrap();
assert_eq!(
n,
Notification::LayoutChange {
window_id: 2,
layout: b"1234x791,0,0{617x791,0,0,0,617x791,618,0,1}".to_vec(),
visible_layout: b"1234x791,0,0{617x791,0,0,0,617x791,618,0,1}".to_vec(),
raw_flags: b"*-".to_vec(),
}
);
}
#[test]
fn tmux_layout_change_empty_flags() {
let mut c = Parser::new();
feed(&mut c, "%layout-change @2 a b ");
let n = c.put(b'\n').unwrap().unwrap();
assert_eq!(
n,
Notification::LayoutChange {
window_id: 2,
layout: b"a".to_vec(),
visible_layout: b"b".to_vec(),
raw_flags: vec![],
}
);
}
#[test]
fn tmux_window_add() {
let mut c = Parser::new();
feed(&mut c, "%window-add @14");
let n = c.put(b'\n').unwrap().unwrap();
assert_eq!(n, Notification::WindowAdd { id: 14 });
}
#[test]
fn tmux_window_renamed() {
let mut c = Parser::new();
feed(&mut c, "%window-renamed @42 bar");
let n = c.put(b'\n').unwrap().unwrap();
assert_eq!(
n,
Notification::WindowRenamed {
id: 42,
name: b"bar".to_vec()
}
);
}
#[test]
fn tmux_window_pane_changed() {
let mut c = Parser::new();
feed(&mut c, "%window-pane-changed @42 %2");
let n = c.put(b'\n').unwrap().unwrap();
assert_eq!(
n,
Notification::WindowPaneChanged {
window_id: 42,
pane_id: 2
}
);
}
#[test]
fn tmux_client_detached() {
let mut c = Parser::new();
feed(&mut c, "%client-detached /dev/pts/1");
let n = c.put(b'\n').unwrap().unwrap();
assert_eq!(
n,
Notification::ClientDetached {
client: b"/dev/pts/1".to_vec()
}
);
}
#[test]
fn tmux_client_session_changed() {
let mut c = Parser::new();
feed(&mut c, "%client-session-changed /dev/pts/1 $2 mysession");
let n = c.put(b'\n').unwrap().unwrap();
assert_eq!(
n,
Notification::ClientSessionChanged {
client: b"/dev/pts/1".to_vec(),
session_id: 2,
name: b"mysession".to_vec(),
}
);
}
#[test]
fn tmux_idle_garbage_is_exit_then_broken() {
let mut c = Parser::new();
assert_eq!(c.put(b'x').unwrap(), Some(Notification::Exit));
assert_eq!(c.put(b'%').unwrap(), None);
}
#[test]
fn tmux_max_bytes() {
let mut c = Parser::new();
c.max_bytes = 4;
for b in b"%out" {
assert_eq!(c.put(*b).unwrap(), None);
}
assert_eq!(c.put(b'p'), Err(Error::OutOfMemory));
assert_eq!(c.put(b'p').unwrap(), None);
}
}
+8
View File
@@ -0,0 +1,8 @@
//! Types and functions related to tmux protocols (ZIG/src/terminal/tmux.zig).
//!
//! Only the pure control-mode parser lives in this crate; layout, output and
//! viewer belong to `ghostty-terminal`.
pub mod control;
pub use control::{Notification as ControlNotification, Parser as ControlParser};
+156
View File
@@ -0,0 +1,156 @@
//! DFA-based non-allocating error-replacing UTF-8 decoder
//! (ZIG/src/terminal/UTF8Decoder.zig).
//!
//! This implementation is based largely on the excellent work of
//! Bjoern Hoehrmann, with slight modifications to support error-
//! replacement.
//!
//! For details on Bjoern's DFA-based UTF-8 decoder, see
//! http://bjoern.hoehrmann.de/utf-8/decoder/dfa (MIT licensed)
#[rustfmt::skip]
const CHAR_CLASSES: [u8; 256] = [
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,
7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,
8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
10,3,3,3,3,3,3,3,3,3,3,3,3,4,3,3, 11,6,6,6,5,8,8,8,8,8,8,8,8,8,8,8,
];
#[rustfmt::skip]
const TRANSITIONS: [u8; 108] = [
0,12,24,36,60,96,84,12,12,12,48,72, 12,12,12,12,12,12,12,12,12,12,12,12,
12, 0,12,12,12,12,12, 0,12, 0,12,12, 12,24,12,12,12,12,12,24,12,24,12,12,
12,12,12,12,12,12,12,24,12,12,12,12, 12,24,12,12,12,12,12,12,12,24,12,12,
12,12,12,12,12,12,12,36,12,36,12,12, 12,36,12,12,12,12,12,36,12,36,12,12,
12,36,12,12,12,12,12,12,12,12,12,12,
];
// DFA states
const ACCEPT_STATE: u8 = 0;
const REJECT_STATE: u8 = 12;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Utf8Decoder {
// This is where we accumulate our current codepoint.
accumulator: u32,
// The internal state of the DFA.
state: u8,
}
impl Utf8Decoder {
pub const fn new() -> Self {
Self {
accumulator: 0,
state: ACCEPT_STATE,
}
}
/// Takes the next byte in the utf-8 sequence and emits a tuple of
/// - The codepoint that was generated, if there is one.
/// - A boolean that indicates whether the provided byte was consumed.
///
/// The only case where the byte is not consumed is if an ill-formed
/// sequence is reached, in which case a replacement character will be
/// emitted and the byte will not be consumed.
///
/// If the byte is not consumed, the caller is responsible for calling
/// again with the same byte before continuing.
#[inline]
pub fn next(&mut self, byte: u8) -> (Option<u32>, bool) {
let char_class = CHAR_CLASSES[byte as usize];
let initial_state = self.state;
if self.state == ACCEPT_STATE {
self.accumulator = (0xFFu32 >> char_class) & (byte as u32);
} else {
self.accumulator <<= 6;
self.accumulator |= (byte & 0x3F) as u32;
}
self.state = TRANSITIONS[(self.state + char_class) as usize];
if self.state == ACCEPT_STATE {
let cp = self.accumulator;
self.accumulator = 0;
// Emit the fully decoded codepoint.
(Some(cp), true)
} else if self.state == REJECT_STATE {
self.accumulator = 0;
self.state = ACCEPT_STATE;
// Emit a replacement character. If we rejected the first byte
// in a sequence, then it was consumed, otherwise it was not.
(Some(0xFFFD), initial_state == ACCEPT_STATE)
} else {
// Emit nothing, we're in the middle of a sequence.
(None, true)
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn ascii() {
let mut d = Utf8Decoder::new();
let mut out = Vec::new();
for &byte in b"Hello, World!" {
let res = d.next(byte);
assert!(res.1);
if let Some(cp) = res.0 {
out.push(cp as u8);
}
}
assert_eq!(out, b"Hello, World!");
}
#[test]
fn well_formed_utf8() {
let mut d = Utf8Decoder::new();
let mut out = Vec::new();
// 4 bytes, 3 bytes, 2 bytes, 1 byte
for &byte in "😄✤ÁA".as_bytes() {
let mut consumed = false;
while !consumed {
let res = d.next(byte);
consumed = res.1;
// There are no errors in this sequence, so
// every byte should be consumed first try.
assert!(consumed);
if let Some(cp) = res.0 {
out.push(cp);
}
}
}
assert_eq!(out, vec![0x1F604, 0x2724, 0xC1, 0x41]);
}
#[test]
fn partially_invalid_utf8() {
let mut d = Utf8Decoder::new();
let mut out = Vec::new();
// Illegally terminated sequence, valid sequence, illegal surrogate pair.
let mut input = vec![0xF0, 0x9F];
input.extend("😄".as_bytes());
input.extend([0xED, 0xA0, 0x80]);
for byte in input {
let mut consumed = false;
while !consumed {
let res = d.next(byte);
consumed = res.1;
if let Some(cp) = res.0 {
out.push(cp);
}
}
}
assert_eq!(out, vec![0xFFFD, 0x1F604, 0xFFFD, 0xFFFD, 0xFFFD]);
}
}
+115
View File
@@ -0,0 +1,115 @@
//! X11 color names (ZIG/src/terminal/x11_color.zig).
use crate::color::Rgb;
use std::collections::HashMap;
use std::sync::OnceLock;
/// This is the rgb.txt file from the X11 project. This was last sourced
/// from this location: https://gitlab.freedesktop.org/xorg/app/rgb
/// This data is licensed under the MIT/X11 license while this file is
/// licensed under the same license as Ghostty.
const DATA: &str = include_str!("res/rgb.txt");
/// The map of all available X11 colors. Keys are lowercase ASCII; lookups
/// are ASCII case-insensitive (see [`get`]).
fn map() -> &'static HashMap<String, Rgb> {
static MAP: OnceLock<HashMap<String, Rgb>> = OnceLock::new();
MAP.get_or_init(|| {
let mut m = HashMap::new();
// Parse the line. This is not very robust parsing, because we expect
// a very exact format for rgb.txt.
for line in DATA.split('\n') {
if line.is_empty() {
continue;
}
let r: u8 = line[0..3].trim_matches(' ').parse().expect("bad rgb.txt");
let g: u8 = line[4..7].trim_matches(' ').parse().expect("bad rgb.txt");
let b: u8 = line[8..11].trim_matches(' ').parse().expect("bad rgb.txt");
let name = line[12..].trim_matches(|c| c == ' ' || c == '\t' || c == '\n');
m.entry(name.to_ascii_lowercase())
.or_insert(Rgb { r, g, b });
}
m
})
}
/// Look up an X11 color by name (ASCII case-insensitive).
pub fn get(name: &str) -> Option<Rgb> {
map().get(&name.to_ascii_lowercase()).copied()
}
/// Byte-slice variant of [`get`].
pub fn get_bytes(name: &[u8]) -> Option<Rgb> {
get(std::str::from_utf8(name).ok()?)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn x11_color_map() {
assert_eq!(None, get("nosuchcolor"));
assert_eq!(
Rgb {
r: 255,
g: 255,
b: 255
},
get("white").unwrap()
);
assert_eq!(
Rgb {
r: 0,
g: 250,
b: 154
},
get("medium spring green").unwrap()
);
assert_eq!(
Rgb {
r: 34,
g: 139,
b: 34
},
get("ForestGreen").unwrap()
);
assert_eq!(
Rgb {
r: 34,
g: 139,
b: 34
},
get("FoReStGReen").unwrap()
);
assert_eq!(Rgb { r: 0, g: 0, b: 0 }, get("black").unwrap());
assert_eq!(Rgb { r: 255, g: 0, b: 0 }, get("red").unwrap());
assert_eq!(Rgb { r: 0, g: 255, b: 0 }, get("green").unwrap());
assert_eq!(Rgb { r: 0, g: 0, b: 255 }, get("blue").unwrap());
assert_eq!(
Rgb {
r: 124,
g: 252,
b: 0
},
get("lawngreen").unwrap()
);
assert_eq!(
Rgb {
r: 0,
g: 250,
b: 154
},
get("mediumspringgreen").unwrap()
);
assert_eq!(
Rgb {
r: 34,
g: 139,
b: 34
},
get("forestgreen").unwrap()
);
}
}
+21
View File
@@ -0,0 +1,21 @@
# ghostty-vt W1 notes
## Ported (Zig -> Rust, under crates/ghostty-vt/src)
- Parser.zig -> parser.rs (`Parser`, `State`, `TransitionAction`, `Action`, `Csi`/`Esc`/`Dcs`, `MAX_PARAMS`, `SepList`)
- parse_table.zig -> parse_table.rs (`TABLE`, built by `const fn`; identical rule order)
- UTF8Decoder.zig -> utf8_decoder.rs (`Utf8Decoder::next -> (Option<u32>, bool)`)
- osc.zig -> osc/mod.rs; osc/encoding.zig -> osc/encoding.rs; osc/parsers/*.zig -> osc/parsers/*.rs (all 15)
- color.zig -> color.rs; x11_color.zig -> x11_color.rs (res/rgb.txt copied, `include_str!`, lazy map); kitty/color.zig -> kitty/color.rs
## Deviations / API notes
- `Action` owns its data (fixed inline arrays) rather than borrowing from the parser: use `csi.params()`, `csi.intermediates()`, `csi.final_`, `csi.params_sep`. `Print(u32)`. `Parser::next` returns `[Option<Action>; 3]`.
- `Parser::new()`; `parser.osc_parser.alloc` is a `bool` ("allocator available", Zig `?Allocator`). Stream must set `osc_parser.alloc = true` as stream.zig does, otherwise OSC 4/5/10-19/104/110-119/133/21 are rejected like in Zig.
- OSC `Command` is a Rust enum (`osc::Command`, `osc::Key` via `Command::key()`); `[:0]const u8` fields are `Vec<u8>` (no NUL, not necessarily UTF-8). `Parser::end` returns an owned `Option<Command>`.
- Fixed buffer (MAX_BUF=2048, one byte reserved for NUL where Zig writes one) vs allocating writer = one `Vec<u8>` with an optional cap. `Parser::data()` (reserves NUL) / `buffered()` (does not) replace `writer.buffered()`. Remaining edge: osc9/iterm2/text_sizing use `data()` even where Zig writes no NUL for some sub-commands (only differs at exactly 2048 payload bytes).
- semantic_prompt: `Option` enum is `OptionKey`; `printf_q_decode`/`url_percent_decode` local copies (TODO(integration): move to/consume from ghostty-os string_encoding). `color::Rgb` (alias `RGB`); `DynamicRgb.override` is `override_`.
- Clippy: test modules `#![allow(clippy::unwrap_used)]`.
## Skipped tests
- `ghostty.h Command.ProgressReport.State` (C ABI), `refAllDecls`/`test { _ = ... }` comptime-reference tests, comptime `@sizeOf(Command)` assert.
- One iterm2 test is `SkipZigTest` in Zig; kept as `#[ignore]`.
- Extra tests added: osc_112 in color parser, printf-q decode in semantic_prompt.
+32
View File
@@ -0,0 +1,32 @@
# ghostty-vt W2 notes
## Ported (Zig -> Rust, all in crates/ghostty-vt/src)
- csi.zig -> csi.rs (EraseDisplay, EraseLine/TabClear with `Other(u8)` for the non-exhaustive Zig enums, SizeReportStyle, TitlePushPop)
- sgr.zig -> sgr.rs (`Attribute<'a>`, `Parser<'a>` implementing `Iterator`; uses `crate::color::{Name, Rgb}` and `crate::parser::{SepList, MAX_PARAMS}`)
- modes.zig -> modes.rs (`Mode` enum, `ENTRIES` table, `ModePacked(u64)`, `ModeState`, `mode_from_int`)
- charsets.zig -> charsets.rs (const-built 256-entry tables)
- ansi.zig -> ansi.rs; device_status.zig -> device_status.rs; mouse_shape.zig -> mouse_shape.rs
- kitty/key.zig: three remaining tests added to existing kitty/key.rs
- kitty/graphics_command.zig -> kitty/graphics_command.rs (parser + Response + all command structs; `pub mod graphics_command` in kitty/mod.rs)
- apc.zig -> apc.rs; dcs.zig -> dcs.rs; tmux/control.zig -> tmux/control.rs (+ tmux/mod.rs, only control)
- Dependencies added: `thiserror`, `base64`.
## Deviations
- Non-exhaustive Zig enums (EraseLine, TabClear, RenditionAspect, StatusLineType) are Rust enums with `Other(raw)`; C0 uses `from_int -> Option`.
- tmux Notification owns its data (`Vec<u8>`) instead of borrowing the parser buffer. Oniguruma regexes replaced by hand-written matchers with the same greedy semantics. IDs overflowing usize are treated as non-match (Zig: `unreachable`).
- DCS/APC handlers drop allocator params; `tmux` state holds `Box<tmux::control::Parser>`.
- graphics_command base64: `simd.base64` replaced by `base64` crate (standard alphabet, padding optional, ASCII whitespace ignored like simdutf). zlib is not decoded in the parser (Zig does not either), so no flate2 dependency.
- `Parser::complete` moves the decoded payload out (Zig `decodeData` also consumes the buffer).
- modes: Zig tests `ModePacked` size == 8; Rust has an equivalent `packed_size` test (u64). Mode bit index = enum discriminant = position in ENTRIES.
- sgr uses a bounds-tolerant `sep_is_set` (Zig's StaticBitSet would assert past MAX_PARAMS).
- `Attribute` variant names: `Fg8/Bg8/Fg8Bright/Bg8Bright/Fg256/Bg256/UnderlineColor256` for Zig `@"8_fg"` etc.
## Skipped (Zig-only)
- sgr "Attribute C compat", `Attribute.C`/`cval`/`lib.Enum`/`lib.TaggedUnion` (C ABI).
- mouse_shape "ghostty.h MouseShape" (C header check) and GObject type registration (belongs to apprt-gtk; `MouseShape` is `repr(i32)` in declaration order).
- modes `test { _ = Mode; _ = ModePacked }` and charsets/tests reflection-only bits (replaced by explicit tests).
- Allocator-failure aspects (`deinit`, arenas) have no Rust equivalent.
## Known gaps / integration
- dcs.rs uses `crate::parser::Dcs` (W1). `sgr`/`dcs` depend on W1 modules compiling.
- tmux layout/output/viewer are NOT here (ghostty-terminal).