From b2b248e003f6fadc4160c755b6f28ef0a19c989b Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 11 Oct 2026 21:52:41 +0200 Subject: [PATCH] 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; 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 --- Cargo.lock | 1504 ++++++++++++++++- crates/ghostty-vt/Cargo.toml | 3 + crates/ghostty-vt/src/ansi.rs | 156 ++ crates/ghostty-vt/src/apc.rs | 138 ++ crates/ghostty-vt/src/charsets.rs | 121 ++ crates/ghostty-vt/src/color.rs | 1220 +++++++++++++ crates/ghostty-vt/src/csi.rs | 91 + crates/ghostty-vt/src/dcs.rs | 371 ++++ crates/ghostty-vt/src/device_status.rs | 76 + crates/ghostty-vt/src/kitty/color.rs | 114 ++ .../ghostty-vt/src/kitty/graphics_command.rs | 1391 +++++++++++++++ crates/ghostty-vt/src/kitty/key.rs | 51 + crates/ghostty-vt/src/kitty/mod.rs | 2 + crates/ghostty-vt/src/lib.rs | 15 + crates/ghostty-vt/src/modes.rs | 387 +++++ crates/ghostty-vt/src/mouse_shape.rs | 125 ++ crates/ghostty-vt/src/osc/encoding.rs | 33 + crates/ghostty-vt/src/osc/mod.rs | 741 ++++++++ .../src/osc/parsers/change_window_icon.rs | 24 + .../src/osc/parsers/change_window_title.rs | 92 + .../src/osc/parsers/clipboard_operation.rs | 91 + crates/ghostty-vt/src/osc/parsers/color.rs | 564 +++++++ .../src/osc/parsers/context_signal.rs | 498 ++++++ .../ghostty-vt/src/osc/parsers/hyperlink.rs | 143 ++ crates/ghostty-vt/src/osc/parsers/iterm2.rs | 284 ++++ .../osc/parsers/kitty_clipboard_protocol.rs | 658 ++++++++ .../ghostty-vt/src/osc/parsers/kitty_color.rs | 197 +++ .../src/osc/parsers/kitty_text_sizing.rs | 266 +++ crates/ghostty-vt/src/osc/parsers/mod.rs | 17 + .../ghostty-vt/src/osc/parsers/mouse_shape.rs | 29 + crates/ghostty-vt/src/osc/parsers/osc9.rs | 557 ++++++ .../ghostty-vt/src/osc/parsers/report_pwd.rs | 40 + .../src/osc/parsers/rxvt_extension.rs | 51 + .../src/osc/parsers/semantic_prompt.rs | 998 +++++++++++ crates/ghostty-vt/src/parse_table.rs | 527 ++++++ crates/ghostty-vt/src/parser.rs | 938 +++++++++- crates/ghostty-vt/src/res/rgb.txt | 782 +++++++++ crates/ghostty-vt/src/sgr.rs | 740 ++++++++ crates/ghostty-vt/src/tmux/control.rs | 679 ++++++++ crates/ghostty-vt/src/tmux/mod.rs | 8 + crates/ghostty-vt/src/utf8_decoder.rs | 156 ++ crates/ghostty-vt/src/x11_color.rs | 115 ++ docs/rewrite/notes/ghostty-vt-W1.md | 21 + docs/rewrite/notes/ghostty-vt-W2.md | 32 + 44 files changed, 15040 insertions(+), 6 deletions(-) create mode 100644 crates/ghostty-vt/src/ansi.rs create mode 100644 crates/ghostty-vt/src/apc.rs create mode 100644 crates/ghostty-vt/src/charsets.rs create mode 100644 crates/ghostty-vt/src/color.rs create mode 100644 crates/ghostty-vt/src/csi.rs create mode 100644 crates/ghostty-vt/src/dcs.rs create mode 100644 crates/ghostty-vt/src/device_status.rs create mode 100644 crates/ghostty-vt/src/kitty/color.rs create mode 100644 crates/ghostty-vt/src/kitty/graphics_command.rs create mode 100644 crates/ghostty-vt/src/modes.rs create mode 100644 crates/ghostty-vt/src/mouse_shape.rs create mode 100644 crates/ghostty-vt/src/osc/encoding.rs create mode 100644 crates/ghostty-vt/src/osc/mod.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/change_window_icon.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/change_window_title.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/clipboard_operation.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/color.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/context_signal.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/hyperlink.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/iterm2.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/kitty_clipboard_protocol.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/kitty_color.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/kitty_text_sizing.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/mod.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/mouse_shape.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/osc9.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/report_pwd.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/rxvt_extension.rs create mode 100644 crates/ghostty-vt/src/osc/parsers/semantic_prompt.rs create mode 100644 crates/ghostty-vt/src/parse_table.rs create mode 100644 crates/ghostty-vt/src/res/rgb.txt create mode 100644 crates/ghostty-vt/src/sgr.rs create mode 100644 crates/ghostty-vt/src/tmux/control.rs create mode 100644 crates/ghostty-vt/src/tmux/mod.rs create mode 100644 crates/ghostty-vt/src/utf8_decoder.rs create mode 100644 crates/ghostty-vt/src/x11_color.rs create mode 100644 docs/rewrite/notes/ghostty-vt-W1.md create mode 100644 docs/rewrite/notes/ghostty-vt-W2.md diff --git a/Cargo.lock b/Cargo.lock index 2a1b859..f3f5cad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,507 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "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]] name = "ghostty-apprt-gtk" version = "0.1.0" @@ -15,9 +516,12 @@ dependencies = [ name = "ghostty-config" version = "0.1.0" dependencies = [ + "const_format", "ghostty-input", "ghostty-os", "ghostty-vt", + "log", + "onig", ] [[package]] @@ -37,14 +541,19 @@ dependencies = [ [[package]] name = "ghostty-datastruct" version = "0.1.0" +dependencies = [ + "half", + "thiserror", +] [[package]] name = "ghostty-font" version = "0.1.0" dependencies = [ - "ghostty-config", - "ghostty-terminal", - "ghostty-unicode", + "log", + "png", + "thiserror", + "tiny-skia", ] [[package]] @@ -52,11 +561,22 @@ name = "ghostty-input" version = "0.1.0" dependencies = [ "ghostty-vt", + "indexmap", + "thiserror", ] [[package]] name = "ghostty-os" version = "0.1.0" +dependencies = [ + "gettext-rs", + "log", + "nix", + "rustix", + "temp-env", + "thiserror", + "zbus", +] [[package]] name = "ghostty-renderer" @@ -101,3 +621,981 @@ version = "0.1.0" [[package]] name = "ghostty-vt" 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", +] diff --git a/crates/ghostty-vt/Cargo.toml b/crates/ghostty-vt/Cargo.toml index 8fcf73d..b25392d 100644 --- a/crates/ghostty-vt/Cargo.toml +++ b/crates/ghostty-vt/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true license.workspace = true [dependencies] +base64 = "0.23.1" +log = "0.4.34" +thiserror = "2.0.21" [lints] diff --git a/crates/ghostty-vt/src/ansi.rs b/crates/ghostty-vt/src/ansi.rs new file mode 100644 index 0000000..6551772 --- /dev/null +++ b/crates/ghostty-vt/src/ansi.rs @@ -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 { + 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, +} diff --git a/crates/ghostty-vt/src/apc.rs b/crates/ghostty-vt/src/apc.rs new file mode 100644 index 0000000..f6d04db --- /dev/null +++ b/crates/ghostty-vt/src/apc.rs @@ -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 { + 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 { + 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)); + } +} diff --git a/crates/ghostty-vt/src/charsets.rs b/crates/ghostty-vt/src/charsets.rs new file mode 100644 index 0000000..ecbb545 --- /dev/null +++ b/crates/ghostty-vt/src/charsets.rs @@ -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); + } +} diff --git a/crates/ghostty-vt/src/color.rs b/crates/ghostty-vt/src/color.rs new file mode 100644 index 0000000..c089763 --- /dev/null +++ b/crates/ghostty-vt/src/color.rs @@ -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 + '_ { + (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, + pub default: Option, +} + +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 { + 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 { + 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 { + 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 { + 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::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 { + 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 { + 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 { + 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:// + /// + /// , , := h | hh | hhh | hhhh + /// + /// where `h` is a single hexadecimal digit. + /// + /// 2. rgbi:// + /// + /// where , , and 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 { + Self::parse_bytes(value.as_bytes()) + } + + /// Byte-slice variant of [`Rgb::parse`]. + pub fn parse_bytes(value: &[u8]) -> Result { + 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); + } + } +} diff --git a/crates/ghostty-vt/src/csi.rs b/crates/ghostty-vt/src/csi.rs new file mode 100644 index 0000000..eede106 --- /dev/null +++ b/crates/ghostty-vt/src/csi.rs @@ -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 { + 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, +} diff --git a/crates/ghostty-vt/src/dcs.rs b/crates/ghostty-vt/src/dcs.rs new file mode 100644 index 0000000..95b9d49 --- /dev/null +++ b/crates/ghostty-vt/src/dcs.rs @@ -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, +} + +impl Handler { + pub fn new() -> Self { + Self::default() + } + + pub fn hook(&mut self, dcs: &Dcs) -> Option { + 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 { + 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 { + 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, ()> { + 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 { + 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, + 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), + + /// DECRQSS + Decrqss(DecrqssBuffer), + + /// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode + Tmux(Box), +} + +#[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)); + } +} diff --git a/crates/ghostty-vt/src/device_status.rs b/crates/ghostty-vt/src/device_status.rs new file mode 100644 index 0000000..352f709 --- /dev/null +++ b/crates/ghostty-vt/src/device_status.rs @@ -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 { + 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()); + } +} diff --git a/crates/ghostty-vt/src/kitty/color.rs b/crates/ghostty-vt/src/kitty/color.rs new file mode 100644 index 0000000..dc9d331 --- /dev/null +++ b/crates/ghostty-vt/src/kitty/color.rs @@ -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, + + /// 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 { + 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 { + 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::().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()); + } +} diff --git a/crates/ghostty-vt/src/kitty/graphics_command.rs b/crates/ghostty-vt/src/kitty/graphics_command.rs new file mode 100644 index 0000000..d3a09be --- /dev/null +++ b/crates/ghostty-vt/src/kitty/graphics_command.rs @@ -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; + +/// 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 { + // 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, 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 = 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, + + 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 { + 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;"). + // + // 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 { + 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, 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, +} + +#[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 { + std::mem::take(&mut self.data) + } + + /// Returns the transmission data if it has any. + pub fn transmission(&self) -> Option { + 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 { + 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 { + kv.get(&k).copied() +} + +impl Transmission { + fn parse(kv: &Kv) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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"); + } +} diff --git a/crates/ghostty-vt/src/kitty/key.rs b/crates/ghostty-vt/src/kitty/key.rs index 1ad94da..8b2f4ad 100644 --- a/crates/ghostty-vt/src/kitty/key.rs +++ b/crates/ghostty-vt/src/kitty/key.rs @@ -170,4 +170,55 @@ mod tests { stack.pop(100); 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); + } } diff --git a/crates/ghostty-vt/src/kitty/mod.rs b/crates/ghostty-vt/src/kitty/mod.rs index 57a31d8..8c8ebde 100644 --- a/crates/ghostty-vt/src/kitty/mod.rs +++ b/crates/ghostty-vt/src/kitty/mod.rs @@ -1,3 +1,5 @@ //! Kitty protocol types (ZIG/src/terminal/kitty.zig). +pub mod color; +pub mod graphics_command; pub mod key; diff --git a/crates/ghostty-vt/src/lib.rs b/crates/ghostty-vt/src/lib.rs index 96ab5d0..77e8049 100644 --- a/crates/ghostty-vt/src/lib.rs +++ b/crates/ghostty-vt/src/lib.rs @@ -1,5 +1,20 @@ //! ghostty-vt: VT parsing layer of Ghostty (parser, OSC/CSI/DCS/APC, SGR, //! 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 modes; +pub mod mouse_shape; +pub mod osc; +pub mod parse_table; pub mod parser; +pub mod sgr; +pub mod tmux; +pub mod utf8_decoder; +pub mod x11_color; diff --git a/crates/ghostty-vt/src/modes.rs b/crates/ghostty-vt/src/modes.rs new file mode 100644 index 0000000..1569490 --- /dev/null +++ b/crates/ghostty-vt/src/modes.rs @@ -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 { + 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::(), 8); + } +} diff --git a/crates/ghostty-vt/src/mouse_shape.rs b/crates/ghostty-vt/src/mouse_shape.rs new file mode 100644 index 0000000..5d15661 --- /dev/null +++ b/crates/ghostty-vt/src/mouse_shape.rs @@ -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 { + 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); + } +} diff --git a/crates/ghostty-vt/src/osc/encoding.rs b/crates/ghostty-vt/src/osc/encoding.rs new file mode 100644 index 0000000..8610a6e --- /dev/null +++ b/crates/ghostty-vt/src/osc/encoding.rs @@ -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")); + } +} diff --git a/crates/ghostty-vt/src/osc/mod.rs b/crates/ghostty-vt/src/osc/mod.rs new file mode 100644 index 0000000..ff4a4fa --- /dev/null +++ b/crates/ghostty-vt/src/osc/mod.rs @@ -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` (no NUL terminator, not +//! necessarily valid UTF-8). +//! - Zig's "fixed buffer vs. allocating writer" is a single `Vec` 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), + + /// 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), + + /// 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, + }, + + /// 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, + }, + + /// 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, + }, + + /// 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, + body: Vec, + }, + + /// Start a hyperlink (OSC 8) + HyperlinkStart { + id: Option>, + uri: Vec, + }, + + /// 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), + + /// 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), + + /// ConEmu run process (OSC 9;7) + ConemuRunProcess(Vec), + + /// ConEmu output environment variable (OSC 9;8) + ConemuOutputEnvironmentVariable(Vec), + + /// 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, + /// None => do not change + /// Some(false) => turn off + /// Some(true) => turn on + output: Option, + }, + + /// ConEmu comment (OSC 9;11) + ConemuComment(Vec), + + /// 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), +} + +/// 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, +} + +#[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) -> 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, + 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> { + 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> { + 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) -> Option { + #[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), + } + } +} diff --git a/crates/ghostty-vt/src/osc/parsers/change_window_icon.rs b/crates/ghostty-vt/src/osc/parsers/change_window_icon.rs new file mode 100644 index 0000000..31aa64d --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/change_window_icon.rs @@ -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) -> Option { + 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())); + } +} diff --git a/crates/ghostty-vt/src/osc/parsers/change_window_title.rs b/crates/ghostty-vt/src/osc/parsers/change_window_title.rs new file mode 100644 index 0000000..a37f544 --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/change_window_title.rs @@ -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) -> Option { + 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())); + } +} diff --git a/crates/ghostty-vt/src/osc/parsers/clipboard_operation.rs b/crates/ghostty-vt/src/osc/parsers/clipboard_operation.rs new file mode 100644 index 0000000..d863d83 --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/clipboard_operation.rs @@ -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) -> Option { + 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 { + 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() + } + ); + } +} diff --git a/crates/ghostty-vt/src/osc/parsers/color.rs b/crates/ghostty-vt/src/osc/parsers/color.rs new file mode 100644 index 0000000..e866be0 --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/color.rs @@ -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) -> Option { + 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 { + 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 { + 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 { + 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) -> 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) -> 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, +) -> 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, +) -> 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; + +/// 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 + ); + } +} diff --git a/crates/ghostty-vt/src/osc/parsers/context_signal.rs b/crates/ghostty-vt/src/osc/parsers/context_signal.rs new file mode 100644 index 0000000..6fbe1eb --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/context_signal.rs @@ -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= - initiates, updates, or returns to a context. + Start, + /// OSC 3008;end= - 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, + /// Raw unparsed metadata fields after the context ID. + /// Fields are semicolon-separated key=value pairs. + /// Parsed lazily via `read_option`. + pub metadata: Vec, +} + +/// 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> { + option.read(&self.metadata) + } + + pub fn context_type(&self) -> Option { + match self.read_option(Field::Type)? { + FieldValue::Type(v) => Some(v), + _ => None, + } + } + + pub fn exit(&self) -> Option { + 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 { + 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 { + 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 { + 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> { + 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::().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=[;=]* +/// end=[;=]* +pub fn parse(parser: &mut Parser, _terminator_ch: Option) -> Option { + 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 { + 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()); + } +} diff --git a/crates/ghostty-vt/src/osc/parsers/hyperlink.rs b/crates/ghostty-vt/src/osc/parsers/hyperlink.rs new file mode 100644 index 0000000..0231cbf --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/hyperlink.rs @@ -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) -> Option { + 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> = 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 { + 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); + } +} diff --git a/crates/ghostty-vt/src/osc/parsers/iterm2.rs b/crates/ghostty-vt/src/osc/parsers/iterm2.rs new file mode 100644 index 0000000..ba0b6f5 --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/iterm2.rs @@ -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 { + 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) -> Option { + 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 { + 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() + }) + ); + } +} diff --git a/crates/ghostty-vt/src/osc/parsers/kitty_clipboard_protocol.rs b/crates/ghostty-vt/src/osc/parsers/kitty_clipboard_protocol.rs new file mode 100644 index 0000000..3234b41 --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/kitty_clipboard_protocol.rs @@ -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, + /// The raw payload. It may be Base64 encoded, check the `e` option. + pub payload: Option>, + /// 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> { + 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 { + 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 { + match self.read_option(Opt::Status)? { + OptionValue::Status(v) => Some(v), + _ => None, + } + } + + pub fn type_(&self) -> Option { + 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 { + 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 { + 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 { + 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 { + 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> { + 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) -> Option { + 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>, + id: Option<&'a str>, + loc: Option, + mime: Option<&'a str>, + name: Option<&'a str>, + password: Option<&'a str>, + pw: Option<&'a str>, + status: Option, + type_: Option, + } + + 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() + }, + ); + } +} diff --git a/crates/ghostty-vt/src/osc/parsers/kitty_color.rs b/crates/ghostty-vt/src/osc/parsers/kitty_color.rs new file mode 100644 index 0000000..1aeedb3 --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/kitty_color.rs @@ -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) -> Option { + 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()); + } +} diff --git a/crates/ghostty-vt/src/osc/parsers/kitty_text_sizing.rs b/crates/ghostty-vt/src/osc/parsers/kitty_text_sizing.rs new file mode 100644 index 0000000..a87331e --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/kitty_text_sizing.rs @@ -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 { + 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 { + 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, +} + +#[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 { + 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) -> Option { + 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 { + 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()); + } +} diff --git a/crates/ghostty-vt/src/osc/parsers/mod.rs b/crates/ghostty-vt/src/osc/parsers/mod.rs new file mode 100644 index 0000000..262fd70 --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/mod.rs @@ -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; diff --git a/crates/ghostty-vt/src/osc/parsers/mouse_shape.rs b/crates/ghostty-vt/src/osc/parsers/mouse_shape.rs new file mode 100644 index 0000000..364f61c --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/mouse_shape.rs @@ -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) -> Option { + 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() + } + ); + } +} diff --git a/crates/ghostty-vt/src/osc/parsers/osc9.rs b/crates/ghostty-vt/src/osc/parsers/osc9.rs new file mode 100644 index 0000000..68d902c --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/osc9.rs @@ -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(bytes: &[u8]) -> Option { + 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) -> Option { + // 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 { + 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::(&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::(&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) { + assert_eq!( + run(input), + Command::ConemuProgressReport(ProgressReport { state, progress }) + ); + } + + fn xterm(input: &str, keyboard: Option, output: Option) { + 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(_))); + } +} diff --git a/crates/ghostty-vt/src/osc/parsers/report_pwd.rs b/crates/ghostty-vt/src/osc/parsers/report_pwd.rs new file mode 100644 index 0000000..f510687 --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/report_pwd.rs @@ -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) -> Option { + 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() } + ); + } +} diff --git a/crates/ghostty-vt/src/osc/parsers/rxvt_extension.rs b/crates/ghostty-vt/src/osc/parsers/rxvt_extension.rs new file mode 100644 index 0000000..8a08ab5 --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/rxvt_extension.rs @@ -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) -> Option { + 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() + } + ); + } +} diff --git a/crates/ghostty-vt/src/osc/parsers/semantic_prompt.rs b/crates/ghostty-vt/src/osc/parsers/semantic_prompt.rs new file mode 100644 index 0000000..0cd44c7 --- /dev/null +++ b/crates/ghostty-vt/src/osc/parsers/semantic_prompt.rs @@ -0,0 +1,998 @@ +//! OSC 133 semantic prompts +//! (ZIG/src/terminal/osc/parsers/semantic_prompt.zig). +//! +//! + +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, +} + +#[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> { + 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 { + match self.read_option(OptionKey::Cl)? { + OptionValue::Click(c) => Some(c), + _ => None, + } + } + + /// Typed convenience: `k` option. + pub fn prompt_kind(&self) -> Option { + 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 { + match self.read_option(OptionKey::Redraw)? { + OptionValue::Redraw(c) => Some(c), + _ => None, + } + } + + /// Typed convenience: `special_key` option. + pub fn special_key(&self) -> Option { + match self.read_option(OptionKey::SpecialKey)? { + OptionValue::Bool(c) => Some(c), + _ => None, + } + } + + /// Typed convenience: `click_events` option. + pub fn click_events(&self) -> Option { + 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 { + 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) -> 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> { + 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::().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 { + 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 { + 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) -> Option { + 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, 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, buf: &[u8]) -> Result<(), DecodeError> { + fn hex(c: u8) -> Option { + 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 { + 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, 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>) -> 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) + ); + } +} diff --git a/crates/ghostty-vt/src/parse_table.rs b/crates/ghostty-vt/src/parse_table.rs new file mode 100644 index 0000000..71b28b9 --- /dev/null +++ b/crates/ghostty-vt/src/parse_table.rs @@ -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; 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) + ); + } +} diff --git a/crates/ghostty-vt/src/parser.rs b/crates/ghostty-vt/src/parser.rs index 26d1904..b47c4e0 100644 --- a/crates/ghostty-vt/src/parser.rs +++ b/crates/ghostty-vt/src/parser.rs @@ -1,9 +1,20 @@ //! VT-series parser for escape and control sequences //! (ZIG/src/terminal/Parser.zig). //! -//! NOTE(W1): this file is owned by work package W1. `MAX_PARAMS` and -//! `SepList` were pre-created by the orchestrator because `sgr` (W2) -//! depends on them; keep their API stable. +//! This is implemented directly as the state machine described on +//! vt100.net: https://vt100.net/emu/dec_ansi_parser +//! +//! `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 /// match it. @@ -50,3 +61,924 @@ impl SepList { 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(¶ms[..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; 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 { + 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; 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()); + } +} diff --git a/crates/ghostty-vt/src/res/rgb.txt b/crates/ghostty-vt/src/res/rgb.txt new file mode 100644 index 0000000..7096643 --- /dev/null +++ b/crates/ghostty-vt/src/res/rgb.txt @@ -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 diff --git a/crates/ghostty-vt/src/sgr.rs b/crates/ghostty-vt/src/sgr.rs new file mode 100644 index 0000000..4253bac --- /dev/null +++ b/crates/ghostty-vt/src/sgr.rs @@ -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> { + 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 = ¶ms[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> { + // 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> { + 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>) -> 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); + } +} diff --git a/crates/ghostty-vt/src/tmux/control.rs b/crates/ghostty-vt/src/tmux/control.rs new file mode 100644 index 0000000..5aabe50 --- /dev/null +++ b/crates/ghostty-vt/src/tmux/control.rs @@ -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`) 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, + + /// 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, 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 { + 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 = 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::().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 { + // ^%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 { + // ^%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 { + // ^%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 { + // ^%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 { + // ^%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 { + // ^%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 { + // ^%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 { + // ^%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), + BlockErr(Vec), + + /// Raw output from a pane. + Output { + pane_id: usize, + /// unescaped + data: Vec, + }, + + /// The client is now attached to the session with ID session-id, which is + /// named name. + SessionChanged { + id: usize, + name: Vec, + }, + + /// A session was created or destroyed. + SessionsChanged, + + /// The layout of the window with ID window-id changed. + LayoutChange { + window_id: usize, + layout: Vec, + visible_layout: Vec, + raw_flags: Vec, + }, + + /// 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, + }, + + /// 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, + }, + + /// The client is now attached to the session with ID session-id, which is + /// named name. + ClientSessionChanged { + client: Vec, + session_id: usize, + name: Vec, + }, +} + +#[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); + } +} diff --git a/crates/ghostty-vt/src/tmux/mod.rs b/crates/ghostty-vt/src/tmux/mod.rs new file mode 100644 index 0000000..d59f6a5 --- /dev/null +++ b/crates/ghostty-vt/src/tmux/mod.rs @@ -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}; diff --git a/crates/ghostty-vt/src/utf8_decoder.rs b/crates/ghostty-vt/src/utf8_decoder.rs new file mode 100644 index 0000000..e4ae2bc --- /dev/null +++ b/crates/ghostty-vt/src/utf8_decoder.rs @@ -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, 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]); + } +} diff --git a/crates/ghostty-vt/src/x11_color.rs b/crates/ghostty-vt/src/x11_color.rs new file mode 100644 index 0000000..4c265e7 --- /dev/null +++ b/crates/ghostty-vt/src/x11_color.rs @@ -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 { + static MAP: OnceLock> = 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 { + map().get(&name.to_ascii_lowercase()).copied() +} + +/// Byte-slice variant of [`get`]. +pub fn get_bytes(name: &[u8]) -> Option { + 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() + ); + } +} diff --git a/docs/rewrite/notes/ghostty-vt-W1.md b/docs/rewrite/notes/ghostty-vt-W1.md new file mode 100644 index 0000000..4bf9654 --- /dev/null +++ b/docs/rewrite/notes/ghostty-vt-W1.md @@ -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, 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; 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` (no NUL, not necessarily UTF-8). `Parser::end` returns an owned `Option`. +- Fixed buffer (MAX_BUF=2048, one byte reserved for NUL where Zig writes one) vs allocating writer = one `Vec` 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. diff --git a/docs/rewrite/notes/ghostty-vt-W2.md b/docs/rewrite/notes/ghostty-vt-W2.md new file mode 100644 index 0000000..c54830b --- /dev/null +++ b/docs/rewrite/notes/ghostty-vt-W2.md @@ -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`) 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`. +- 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).