diff --git a/.claude/reviews/2026-03-14-pr828-cubic-r2-summary.md b/.claude/reviews/2026-03-14-pr828-cubic-r2-summary.md new file mode 100644 index 0000000000..2e26e1642f --- /dev/null +++ b/.claude/reviews/2026-03-14-pr828-cubic-r2-summary.md @@ -0,0 +1,47 @@ +# Review Loop Summary — PR #828 Cubic Issues (Round 2) + +**Date:** 2026-03-14 +**Rounds:** 2/3 +**Status:** Converged (zero new findings in Round 2) +**Reviewers:** Security(opus) + Logic(opus) + Completeness(opus) + +## Issues by Round +| Round | Reviewers | Found | Fixed | Skipped | Cross-validated | +|-------|-----------|-------|-------|---------|-----------------| +| 0 | cubic | 11 | 6 | 5 | - | +| 1 | 3 | 2 | 2 | 0 | 2 | +| 2 | 1 | 0 | - | - | Converged | + +## Cubic Issues Disposition (11 total) + +### Fixed (8): +1. **callbacks.rs** - All 6 FFI trampolines wrapped with `catch_unwind` + panic logging (P1) +2. **v2.rs:560** - `surface.send_input` UUID validation added (P1, cross-validated in R1) +3. **v2.rs:622** - `notification.create` UUID validation added (P2) +4. **window.rs:62** - `lock().unwrap()` → `lock_or_recover` (P2) +5. **window.rs:280** - Additional `lock().unwrap()` found by reviewers (cross-validated, elevated to high) +6. **main.rs:90** - `wrap` bool flag fixed with `action = Set, default_value_t = true` (P2) +7. **demo.sh:66** - Non-socket file check added (P2) +8. **demo.sh:120** - nc timeout `-w 5` added (P2) + +### Skipped with rationale (3): +- **server.rs:70** - Already fixed in prior round (stale socket detection is correct) +- **app.rs:45 RuntimeCallbacks** - False positive (callbacks stored in `state._callbacks`) +- **store.rs:24 dead code** - Intentional MVP scaffolding + +### Not in scope (medium, no fix needed): +- **build.rs:164** - `.flatten()` in build script is acceptable +- **GHOSTTY_APP_PTR lock().unwrap()** - Static mutex for raw pointer; crash-on-poison is safer than recovery + +## Changes Made +``` + linux/cmux-cli/src/main.rs | 6 ++- + linux/cmux/src/socket/v2.rs | 46 ++++++++++++++---- + linux/cmux/src/ui/window.rs | 14 +----- + linux/ghostty-gtk/src/callbacks.rs | 98 ++++++++++++++++++++++++- + scripts/capture-linux-port-demo.sh | 8 +++- + 5 files changed, 110 insertions(+), 62 deletions(-) +``` + +## Build Verification +- `cargo check`: pass (26 pre-existing dead code warnings, no new warnings) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 49de9988fa..38e1b16095 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,7 +12,7 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 12, 2026. +Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 9, 2026. ### 1) OSC 99 (kitty) notification parser @@ -45,10 +45,6 @@ Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 12, 20 ### 4) macOS resize stale-frame mitigation -Sections 3 and 4 are grouped by feature, not by commit order. The section 4 resize commits were -applied earlier than the section 3 copy-mode commit, but they are kept together here because they -touch the same stale-frame mitigation path and tend to conflict in the same files during rebases. - - Commits: - `769bbf7a9` (macos: reduce transient blank/scaled frames during resize) - `9efcdfdf8` (macos: keep top-left gravity for stale-frame replay) @@ -63,44 +59,14 @@ touch the same stale-frame mitigation path and tend to conflict in the same file - Replays the last rendered frame during resize and keeps its geometry anchored correctly. - Reduces transient blank or scaled frames while a macOS window is being resized. -### 5) zsh prompt redraw markers use OSC 133 P - -- Commit: `8ade43ce5` (zsh: use OSC 133 P for prompt redraws) -- Files: - - `src/shell-integration/zsh/ghostty-integration` -- Summary: - - Emits one `OSC 133;A` fresh-prompt mark for real prompt transitions. - - Uses `OSC 133;P` markers for prompt redraws so async zsh themes do not look like extra prompt lines. - -### 6) zsh Pure-style multiline prompt redraws - -- Commits: - - `0cf559581` (zsh: fix Pure-style multiline prompt redraws) - - `312c7b23a` (zsh: avoid extra Pure continuation markers) - - `404a3f175` (Fix Pure prompt redraw markers) -- Files: - - `src/shell-integration/zsh/ghostty-integration` -- Summary: - - Handles multiline prompts that use `\n%{\r%}` to return to column 0 before the visible prompt line. - - Keeps redraw-safe prompt-start markers for async themes. - - Avoids inserting an explicit continuation marker after Pure's hidden carriage return, because Ghostty already tracks the newline as prompt continuation and the extra marker duplicates the preprompt row. - - Restores that prompt-marker behavior on top of the current Ghostty `main` base after the older redraw fix drifted out during later submodule updates. - -The fork branch HEAD is now the section 6 zsh redraw follow-up commit. - -### 7) cmux theme picker helper hooks +### 5) Linux embedded resize stale-frame guard scoping -- Commit: `0c52c987b` (Add cmux theme picker helper hooks) +- Commit: `9b0febb59` (embedded: scope stale-frame guard override to Linux) - Files: - - `build.zig` - - `src/cli/list_themes.zig` - - `src/main_ghostty.zig` + - `src/renderer/generic.zig` - Summary: - - Adds a `zig build cli-helper` step so cmux can bundle Ghostty's CLI helper binary on macOS. - - Lets `+list-themes` switch into a cmux-managed mode via env vars, writing the cmux theme override file and posting the existing cmux reload notification for live app-wide preview. - - Fixes the helper-only `app-runtime=none` stdout path so the Ghostty CLI binary builds with the current Zig toolchain. - -The fork branch HEAD is now the section 7 cmux theme picker helper commit. + - Limits the embedded resize stale-frame override to Linux `libghostty` builds. + - Preserves the existing synchronous resize guard for other embedded hosts such as GhosttyKit on Darwin. ## Upstreamed fork changes @@ -120,14 +86,8 @@ These files change frequently upstream; be careful when rebasing the fork: - `src/terminal/osc.zig` - OSC dispatch logic moves often. Re-check the integration points for the OSC 99 parser. -- `src/shell-integration/zsh/ghostty-integration` - - Prompt marker handling is easy to regress when upstream adjusts zsh redraw behavior. Keep the - `OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes. Pure-style `\n%{\r%}` - prompt newlines should not get an extra explicit continuation marker after the hidden CR. - -- `src/cli/list_themes.zig` - - cmux now relies on the upstream picker UI plus local env-driven hooks for live preview and restore. - If upstream reorganizes the preview loop or key handling, re-check the cmux mode path and keep the - stock Ghostty behavior unchanged when the cmux env vars are absent. +- `src/renderer/generic.zig` + - Upstream already carries resize-flash mitigation logic around the sync display path. + - Keep the Linux embedded override narrow so GhosttyKit/macOS retains the stale-frame replay behavior. If you resolve a conflict, update this doc with what changed. diff --git a/ghostty b/ghostty index bc9be90a21..9b0febb591 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42 +Subproject commit 9b0febb591623d801b51f77566c138ce631ad940 diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000000..f6ddc9e915 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1,6 @@ +/target +/ghostty +*.swp +*.swo +*~ +.DS_Store diff --git a/linux/Cargo.lock b/linux/Cargo.lock new file mode 100644 index 0000000000..c70507c372 --- /dev/null +++ b/linux/Cargo.lock @@ -0,0 +1,1709 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cairo-rs" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e3bd0f4e25afa9cabc157908d14eeef9067d6448c49414d17b3fb55f0eadd0" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059cc746549898cbfd9a47754288e5a958756650ef4652bbb6c5f71a6bda4f8b" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cef5b5a1a6827c7322ae2a636368a573006b27cfa76c7ebd53e834daeaab6a" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "cmux" +version = "0.1.0" +dependencies = [ + "anyhow", + "dirs", + "gdk4", + "ghostty-gtk", + "ghostty-sys", + "gio", + "glib", + "gtk4", + "libadwaita", + "libc", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "cmux-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "libc", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd242894c084f4beed508a56952750bce3e96e85eb68fdc153637daa163e10c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b34f3b580c988bd217e9543a2de59823fafae369d1a055555e5f95a8b130b96" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850c9d9c1aecd1a3eb14fadc1cdb0ac0a2298037e116264c7473e1740a32d60" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6eb95798e2b46f279cf59005daf297d5b69555428f185650d71974a910473a" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghostty-gtk" +version = "0.1.0" +dependencies = [ + "gdk4", + "ghostty-sys", + "glib", + "gtk4", + "tracing", +] + +[[package]] +name = "ghostty-sys" +version = "0.1.0" +dependencies = [ + "cc", +] + +[[package]] +name = "gio" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.59.0", +] + +[[package]] +name = "glib" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b86dfad7d14251c9acaf1de63bc8754b7e3b4e5b16777b6f5a748208fe9519b" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df583a85ba2d5e15e1797e40d666057b28bc2f60a67c9c24145e6db2cc3861ea" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f5e72f931c8c9f65fbfc89fe0ddc7746f147f822f127a53a9854666ac1f855" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755059de55fa6f85a46bde8caf03e2184c96bfda1f6206163c72fb0ea12436dc" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f274dd0102c21c47bbfa8ebcb92d0464fab794a22fad6c3f3d5f165139a326d6" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed1786c4703dd196baf7e103525ce0cf579b3a63a0570fe653b7ee6bac33999" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e03b01e54d77c310e1d98647d73f996d04b2f29b9121fe493ea525a7ec03d6" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libadwaita" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500135d29c16aabf67baafd3e7741d48e8b8978ca98bac39e589165c8dc78191" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "libc", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6680988058c2558baf3f548a370e4e78da3bf7f08469daa822ac414842c912db" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6576b311f6df659397043a5fa8a021da8f72e34af180b44f7d57348de691ab5c" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186909673fc09be354555c302c0b3dcf753cd9fa08dcb8077fa663c80fb243fa" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[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 = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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 = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[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", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/linux/Cargo.toml b/linux/Cargo.toml new file mode 100644 index 0000000000..eaf487aab2 --- /dev/null +++ b/linux/Cargo.toml @@ -0,0 +1,45 @@ +[workspace] +members = [ + "ghostty-sys", + "ghostty-gtk", + "cmux", + "cmux-cli", +] +resolver = "2" + +[workspace.package] +edition = "2021" +license = "MIT" +repository = "https://github.com/manaflow-ai/cmux" + +[workspace.dependencies] +# GTK4 / libadwaita +gtk4 = { version = "0.9", features = ["v4_6"] } +libadwaita = { version = "0.7", features = ["v1_4", "gtk_v4_6"] } +glib = "0.20" +gdk4 = { version = "0.9", features = ["v4_6"] } +gio = "0.20" + +# Async runtime +tokio = { version = "1", features = ["full"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# IDs +uuid = { version = "1", features = ["v4", "serde"] } + +# CLI +clap = { version = "4", features = ["derive"] } + +# Paths +dirs = "6" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Error handling +anyhow = "1" +thiserror = "2" diff --git a/linux/README.md b/linux/README.md new file mode 100644 index 0000000000..72a24b18a5 --- /dev/null +++ b/linux/README.md @@ -0,0 +1,49 @@ +# cmux-linux + +Rust + GTK4/libadwaita port of cmux (terminal multiplexer for AI coding agents). + +## Build + +```bash +cargo check # Type check +cargo test # Run tests +cargo build # Debug build +cargo build --release # Release build +``` + +## Architecture + +- `ghostty-sys/` — Raw FFI bindings to libghostty C API (`ghostty.h`) +- `ghostty-gtk/` — Safe Rust wrapper: GhosttyApp, GhosttyGlSurface, key mapping +- `cmux/` — Main application (GTK4/libadwaita) + - `model/` — TabManager, Workspace, Panel, LayoutNode + - `ui/` — Window, Sidebar, SplitView, TerminalPanel + - `socket/` — Unix socket server, v2 JSON protocol, auth + - `session/` — Session persistence (XDG, JSON compatible with macOS cmux) + - `notifications.rs` — Notification store + desktop notifications +- `cmux-cli/` — CLI client (`cmux workspace list`, `cmux surface send-text`, etc.) + +## Architecture Review + +**Read `docs/architecture-review.md` and `docs/ubuntu-mvp-spec.md` before making structural changes.** +They document the current Ubuntu MVP tradeoffs, Ghostty integration constraints, and review scope. + +## Ghostty Integration + +The `link-ghostty` feature enables actual FFI linking to libghostty. +Without it (default), the crates compile in stub mode for development. + +To build with ghostty: +1. Initialize the ghostty submodule +2. Build with `cargo build --features cmux/link-ghostty` + +## Socket Protocol + +Unix socket at `$XDG_RUNTIME_DIR/cmux.sock` (falls back to `/tmp/cmux-$UID.sock`). +Line-delimited JSON v2 protocol. Compatible with macOS cmux socket API. + +## Reference + +- macOS cmux source: root of this repository (Swift/AppKit) +- ghostty C API: `ghostty.h` in the repo root +- Ghostty GTK runtime: `ghostty/src/apprt/gtk/` (reference for GL/input integration) diff --git a/linux/cmux-cli/Cargo.toml b/linux/cmux-cli/Cargo.toml new file mode 100644 index 0000000000..0b1eb639a9 --- /dev/null +++ b/linux/cmux-cli/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cmux-cli" +version = "0.1.0" +edition.workspace = true +description = "CLI client for cmux terminal multiplexer" + +[[bin]] +name = "cmux" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } +libc = "0.2" diff --git a/linux/cmux-cli/src/main.rs b/linux/cmux-cli/src/main.rs new file mode 100644 index 0000000000..73b5afa676 --- /dev/null +++ b/linux/cmux-cli/src/main.rs @@ -0,0 +1,368 @@ +//! cmux CLI — command-line client for the cmux socket API. + +use clap::{Parser, Subcommand}; +use serde_json::Value; +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::unix::fs::MetadataExt; +use std::os::unix::net::UnixStream; +use std::sync::atomic::{AtomicU64, Ordering}; + +const IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const MAX_RESPONSE_LEN: usize = 1024 * 1024; + +static REQUEST_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Parser)] +#[command(name = "cmux", about = "cmux terminal multiplexer CLI")] +struct Cli { + #[command(subcommand)] + command: Commands, + + /// Socket path override + #[arg(long, default_value_t = default_socket_path(), global = true)] + socket: String, + + /// Output raw JSON + #[arg(long, global = true)] + json: bool, +} + +#[derive(Subcommand)] +enum Commands { + /// Ping the cmux server + Ping, + + /// Workspace management + #[command(subcommand)] + Workspace(WorkspaceCommands), + + /// Surface (terminal) operations + #[command(subcommand)] + Surface(SurfaceCommands), + + /// Pane operations + #[command(subcommand)] + Pane(PaneCommands), + + /// Send a notification + Notify { + /// Notification title + #[arg(long)] + title: String, + /// Notification body + #[arg(long, default_value = "")] + body: String, + /// Target workspace UUID + #[arg(long)] + workspace: Option, + /// Target surface/panel UUID + #[arg(long)] + surface: Option, + /// Suppress desktop notification + #[arg(long)] + no_desktop: bool, + }, + + /// List available API methods + Capabilities, +} + +#[derive(Subcommand)] +enum WorkspaceCommands { + /// List all workspaces + List, + /// Create a new workspace + New { + /// Working directory + #[arg(long)] + directory: Option, + /// Workspace title + #[arg(long)] + title: Option, + }, + /// Select a workspace by index (0-based) + Select { + /// Workspace index + index: usize, + }, + /// Select the next workspace + Next { + /// Wrap around when reaching the end (default: true) + #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] + wrap: bool, + }, + /// Select the previous workspace + Previous { + /// Wrap around when reaching the start (default: true) + #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] + wrap: bool, + }, + /// Select the last workspace + Last, + /// Jump to the newest unread workspace + LatestUnread, + /// Close a workspace + Close { + /// Workspace index (closes selected if not specified) + index: Option, + }, + /// Set status metadata + SetStatus { + /// Status key + #[arg(long)] + key: String, + /// Status value + #[arg(long)] + value: String, + /// Optional icon + #[arg(long)] + icon: Option, + /// Optional color + #[arg(long)] + color: Option, + }, +} + +#[derive(Subcommand)] +enum SurfaceCommands { + /// Send text input to a terminal + SendText { + /// Text to send (supports \n for newline) + text: String, + /// Surface handle + #[arg(long)] + surface: Option, + }, +} + +#[derive(Subcommand)] +enum PaneCommands { + /// Create a new split pane + New { + /// Split orientation: horizontal or vertical + #[arg(long, default_value = "horizontal")] + orientation: String, + }, +} + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + let (method, params) = match &cli.command { + Commands::Ping => ("system.ping", serde_json::json!({})), + Commands::Capabilities => ("system.capabilities", serde_json::json!({})), + + Commands::Workspace(ws) => match ws { + WorkspaceCommands::List => ("workspace.list", serde_json::json!({})), + WorkspaceCommands::New { directory, title } => ( + "workspace.new", + serde_json::json!({ + "directory": directory, + "title": title, + }), + ), + WorkspaceCommands::Select { index } => { + ("workspace.select", serde_json::json!({"index": index})) + } + WorkspaceCommands::Next { wrap } => { + ("workspace.next", serde_json::json!({"wrap": wrap})) + } + WorkspaceCommands::Previous { wrap } => { + ("workspace.previous", serde_json::json!({"wrap": wrap})) + } + WorkspaceCommands::Last => ("workspace.last", serde_json::json!({})), + WorkspaceCommands::LatestUnread => ("workspace.latest_unread", serde_json::json!({})), + WorkspaceCommands::Close { index } => { + let mut params = serde_json::json!({}); + if let Some(idx) = index { + params["index"] = serde_json::json!(idx); + } + ("workspace.close", params) + } + WorkspaceCommands::SetStatus { + key, + value, + icon, + color, + } => ( + "workspace.set_status", + serde_json::json!({ + "key": key, + "value": value, + "icon": icon, + "color": color, + }), + ), + }, + + Commands::Surface(surf) => match surf { + SurfaceCommands::SendText { text, surface } => { + // Unescape \n sequences + let unescaped = text.replace("\\n", "\n"); + ( + "surface.send_input", + serde_json::json!({ + "input": unescaped, + "surface": surface, + }), + ) + } + }, + + Commands::Pane(pane) => match pane { + PaneCommands::New { orientation } => { + ("pane.new", serde_json::json!({"orientation": orientation})) + } + }, + + Commands::Notify { + title, + body, + workspace, + surface, + no_desktop, + } => ( + "notification.create", + serde_json::json!({ + "title": title, + "body": body, + "workspace": workspace, + "surface": surface, + "send_desktop": !no_desktop, + }), + ), + }; + + let response = send_request(&cli.socket, method, params)?; + + if cli.json { + println!("{}", serde_json::to_string_pretty(&response)?); + } else { + format_response(method, &response); + } + + // Exit with error code if the response indicates failure + if response.get("ok").and_then(|v| v.as_bool()) != Some(true) { + std::process::exit(1); + } + + Ok(()) +} + +/// Send a v2 request to the cmux socket and return the response. +fn send_request(socket_path: &str, method: &str, params: Value) -> anyhow::Result { + let mut stream = UnixStream::connect(socket_path) + .map_err(|e| anyhow::anyhow!("Cannot connect to cmux at {}: {}", socket_path, e))?; + stream.set_read_timeout(Some(IO_TIMEOUT))?; + stream.set_write_timeout(Some(IO_TIMEOUT))?; + + let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let request = serde_json::json!({ + "id": id, + "method": method, + "params": params, + }); + + let request_json = serde_json::to_string(&request)?; + stream.write_all(request_json.as_bytes())?; + stream.write_all(b"\n")?; + stream.flush()?; + + let limited = (&stream).take((MAX_RESPONSE_LEN + 1) as u64); + let mut reader = BufReader::new(limited); + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line)?; + if bytes_read == 0 { + anyhow::bail!("cmux closed socket without a response"); + } + if line.len() > MAX_RESPONSE_LEN { + anyhow::bail!("cmux response exceeded {} bytes", MAX_RESPONSE_LEN); + } + + let response: Value = serde_json::from_str(line.trim())?; + Ok(response) +} + +fn default_socket_path() -> String { + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + let path = std::path::Path::new(&dir); + if path.is_absolute() { + if let Ok(meta) = std::fs::metadata(path) { + let my_uid = unsafe { libc::getuid() }; + if meta.is_dir() && meta.uid() == my_uid && (meta.mode() & 0o777) == 0o700 { + return format!("{}/cmux.sock", dir); + } + } + } + } + + format!("/tmp/cmux-{}.sock", unsafe { libc::getuid() }) +} + +/// Pretty-print a response for human consumption. +fn format_response(method: &str, response: &Value) { + let ok = response + .get("ok") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !ok { + if let Some(error) = response.get("error") { + let code = error + .get("code") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let msg = error.get("message").and_then(|v| v.as_str()).unwrap_or(""); + eprintln!("Error [{}]: {}", code, msg); + } + return; + } + + let result = response.get("result"); + + match method { + "system.ping" => println!("pong"), + + "workspace.list" => { + if let Some(workspaces) = result + .and_then(|r| r.get("workspaces")) + .and_then(|w| w.as_array()) + { + for ws in workspaces { + let index = ws.get("index").and_then(|v| v.as_u64()).unwrap_or(0); + let title = ws.get("title").and_then(|v| v.as_str()).unwrap_or("?"); + let selected = ws + .get("selected") + .or_else(|| ws.get("is_selected")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let panels = ws.get("panel_count").and_then(|v| v.as_u64()).unwrap_or(0); + let marker = if selected { "*" } else { " " }; + println!("{}{} {} ({} panels)", marker, index, title, panels); + } + } + } + + "system.capabilities" => { + if let Some(methods) = result + .and_then(|r| r.get("methods")) + .and_then(|m| m.as_array()) + { + for m in methods { + if let Some(s) = m.as_str() { + println!(" {}", s); + } + } + } + } + + _ => { + // Generic: print the result JSON + if let Some(r) = result { + println!("{}", serde_json::to_string_pretty(r).unwrap_or_default()); + } else { + println!("OK"); + } + } + } +} diff --git a/linux/cmux/Cargo.toml b/linux/cmux/Cargo.toml new file mode 100644 index 0000000000..dcf4bdd43a --- /dev/null +++ b/linux/cmux/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "cmux" +version = "0.1.0" +edition.workspace = true +description = "cmux terminal multiplexer for Linux (GTK4/libadwaita)" + +[features] +link-ghostty = ["ghostty-gtk/link-ghostty"] + +[[bin]] +name = "cmux-app" +path = "src/main.rs" + +[dependencies] +ghostty-gtk = { path = "../ghostty-gtk" } +ghostty-sys = { path = "../ghostty-sys" } +gtk4 = { workspace = true } +libadwaita = { workspace = true } +glib = { workspace = true } +gdk4 = { workspace = true } +gio = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +dirs = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +libc = "0.2" diff --git a/linux/cmux/build.rs b/linux/cmux/build.rs new file mode 100644 index 0000000000..91db850ee3 --- /dev/null +++ b/linux/cmux/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rustc-link-arg-bin=cmux-app=-Wl,-rpath,$ORIGIN"); +} diff --git a/linux/cmux/src/app.rs b/linux/cmux/src/app.rs new file mode 100644 index 0000000000..524cc27006 --- /dev/null +++ b/linux/cmux/src/app.rs @@ -0,0 +1,354 @@ +//! Application entry point — creates the AdwApplication and main window. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; + +use ghostty_sys::*; +use gtk4::prelude::*; +use libadwaita as adw; +use tokio::sync::mpsc::UnboundedSender; + +/// Lock a mutex, recovering from poisoning rather than panicking. +/// Prevents cascading panics when one thread panics while holding a lock. +pub fn lock_or_recover(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|poisoned| { + tracing::error!("Mutex was poisoned, recovering"); + poisoned.into_inner() + }) +} + +use crate::model::TabManager; +use crate::notifications::NotificationStore; +use crate::socket; +use crate::ui; +use uuid::Uuid; + +/// Shared application state accessible from UI callbacks (single-threaded, GTK main thread). +pub struct AppState { + pub shared: Arc, + pub ghostty_app: RefCell>, + pub terminal_cache: RefCell>, + /// Stored to keep the callbacks alive for the lifetime of the app. + _callbacks: RefCell>, +} + +impl AppState { + pub fn new(shared: Arc) -> Self { + Self { + shared, + ghostty_app: RefCell::new(None), + terminal_cache: RefCell::new(HashMap::new()), + _callbacks: RefCell::new(None), + } + } + + pub fn terminal_surface_for( + &self, + panel_id: Uuid, + working_directory: Option<&str>, + ) -> ghostty_gtk::surface::GhosttyGlSurface { + if let Some(surface) = self.terminal_cache.borrow().get(&panel_id) { + return surface.clone(); + } + + let gl_surface = ghostty_gtk::surface::GhosttyGlSurface::new(); + gl_surface.set_hexpand(true); + gl_surface.set_vexpand(true); + + if let Some(app) = self.ghostty_app.borrow().as_ref() { + gl_surface.initialize(app.raw(), working_directory, None); + } + + self.terminal_cache + .borrow_mut() + .insert(panel_id, gl_surface.clone()); + gl_surface + } + + pub fn send_input_to_panel(&self, panel_id: Uuid, text: &str) -> bool { + let surface = if let Some(surface) = self.terminal_cache.borrow().get(&panel_id).cloned() { + surface + } else { + let working_directory = { + let tab_manager = lock_or_recover(&self.shared.tab_manager); + let Some(workspace) = tab_manager.find_workspace_with_panel(panel_id) else { + return false; + }; + let Some(panel) = workspace.panel(panel_id) else { + return false; + }; + if panel.panel_type != crate::model::PanelType::Terminal { + return false; + } + panel.directory.clone() + }; + self.terminal_surface_for(panel_id, working_directory.as_deref()) + }; + + surface.send_text(text) + } + + pub fn close_panel(&self, panel_id: Uuid, process_alive: bool) -> bool { + { + let mut tab_manager = lock_or_recover(&self.shared.tab_manager); + let Some(workspace) = tab_manager.find_workspace_with_panel_mut(panel_id) else { + return false; + }; + if !workspace.remove_panel(panel_id) { + return false; + } + let empty_workspace_id = workspace.is_empty().then_some(workspace.id); + if let Some(workspace_id) = empty_workspace_id { + tab_manager.remove_by_id(workspace_id); + } + } + + self.terminal_cache.borrow_mut().remove(&panel_id); + self.shared.notify_ui_refresh(); + tracing::debug!(%panel_id, process_alive, "closed terminal panel"); + true + } + + pub fn prune_terminal_cache(&self) { + let live_panels: HashSet = { + let tab_manager = lock_or_recover(&self.shared.tab_manager); + tab_manager + .iter() + .flat_map(|workspace| workspace.panels.values()) + .filter(|panel| panel.panel_type == crate::model::PanelType::Terminal) + .map(|panel| panel.id) + .collect() + }; + + self.terminal_cache + .borrow_mut() + .retain(|panel_id, _| live_panels.contains(panel_id)); + } +} + +/// Messages from background tasks that require a UI refresh. +#[derive(Clone, Debug)] +pub enum UiEvent { + Refresh, + SendInput { panel_id: Uuid, text: String }, +} + +/// Thread-safe state shared between GTK main thread and socket server. +/// The socket server reads/writes through this, then signals the GTK main thread +/// via glib channels for UI updates. +pub struct SharedState { + pub tab_manager: Mutex, + pub notifications: Mutex, + ui_event_tx: Mutex>>, +} + +impl SharedState { + pub fn new() -> Self { + Self { + tab_manager: Mutex::new(TabManager::new()), + notifications: Mutex::new(NotificationStore::new()), + ui_event_tx: Mutex::new(None), + } + } + + pub fn install_ui_event_sender(&self, sender: UnboundedSender) { + *lock_or_recover(&self.ui_event_tx) = Some(sender); + } + + pub fn send_ui_event(&self, event: UiEvent) -> bool { + lock_or_recover(&self.ui_event_tx) + .as_ref() + .is_some_and(|sender| sender.send(event).is_ok()) + } + + pub fn notify_ui_refresh(&self) { + let _ = self.send_ui_event(UiEvent::Refresh); + } +} + +/// Run the GTK application. Returns the exit code. +pub fn run() -> i32 { + let app = adw::Application::builder() + .application_id("ai.manaflow.cmux") + .build(); + + let shared = Arc::new(SharedState::new()); + let state = Rc::new(AppState::new(shared.clone())); + + { + let shared_for_socket = shared.clone(); + app.connect_startup(move |_app| { + let shared = shared_for_socket.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + rt.block_on(async { + if let Err(e) = socket::server::run_socket_server(shared).await { + tracing::error!("Socket server error: {}", e); + } + }); + }); + }); + } + + let state_clone = state.clone(); + app.connect_activate(move |app| { + activate(app, &state_clone); + }); + + app.connect_shutdown(|_app| { + *GHOSTTY_APP_PTR.lock().unwrap() = SendAppPtr(std::ptr::null_mut()); + GHOSTTY_TICK_PENDING.store(false, Ordering::Release); + socket::server::cleanup(); + tracing::info!("Application shutdown"); + }); + + app.run().into() +} + +fn activate(app: &adw::Application, state: &Rc) { + if let Some(window) = app.active_window() { + window.present(); + return; + } + + let (ui_event_tx, ui_event_rx) = tokio::sync::mpsc::unbounded_channel(); + state.shared.install_ui_event_sender(ui_event_tx); + + init_ghostty(state); + + // Create the main window + let window = ui::window::create_window(app, state, ui_event_rx); + window.present(); +} + +/// Initialize the ghostty embedded runtime and store it in AppState. +fn init_ghostty(state: &Rc) { + if state.ghostty_app.borrow().is_some() { + return; + } + + if let Err(e) = ghostty_gtk::app::GhosttyApp::init() { + tracing::error!("Failed to init ghostty: {}", e); + return; + } + + let handler = CmuxCallbackHandler; + + let callbacks = ghostty_gtk::callbacks::RuntimeCallbacks::new(Box::new(handler)); + + match ghostty_gtk::app::GhosttyApp::new(&callbacks) { + Ok(ghostty_app) => { + tracing::info!("Ghostty app initialized successfully"); + *GHOSTTY_APP_PTR.lock().unwrap() = SendAppPtr(ghostty_app.raw()); + *state.ghostty_app.borrow_mut() = Some(ghostty_app); + *state._callbacks.borrow_mut() = Some(callbacks); + } + Err(e) => { + tracing::error!("Failed to create GhosttyApp: {}", e); + } + } +} + +/// Callback handler that bridges ghostty events to the GTK main loop. +struct CmuxCallbackHandler; + +impl ghostty_gtk::callbacks::GhosttyCallbackHandler for CmuxCallbackHandler { + fn on_wakeup(&self) { + if (*GHOSTTY_APP_PTR.lock().unwrap()).is_null() { + return; + } + + if GHOSTTY_TICK_PENDING.swap(true, Ordering::AcqRel) { + return; + } + + glib::MainContext::default().invoke_with_priority(glib::Priority::DEFAULT, move || { + GHOSTTY_TICK_PENDING.store(false, Ordering::Release); + let app_ptr = *GHOSTTY_APP_PTR.lock().unwrap(); + if app_ptr.is_null() { + return; + } + + #[cfg(feature = "link-ghostty")] + unsafe { + ghostty_app_tick(app_ptr.get()); + } + #[cfg(not(feature = "link-ghostty"))] + let _ = (); + }); + } + + fn on_action(&self, target: ghostty_target_s, action: ghostty_action_s) -> bool { + match action.tag { + ghostty_action_tag_e::GHOSTTY_ACTION_RENDER => { + // The target surface wants a re-render. + if target.tag == ghostty_target_tag_e::GHOSTTY_TARGET_SURFACE { + let surface_ptr = unsafe { target.target.surface }; + if !surface_ptr.is_null() { + #[cfg(feature = "link-ghostty")] + unsafe { + let userdata = ghostty_surface_userdata(surface_ptr); + let _ = ghostty_gtk::callbacks::queue_render_from_userdata(userdata); + } + } + } + true + } + ghostty_action_tag_e::GHOSTTY_ACTION_SET_TITLE => true, + _ => { + tracing::trace!("Unhandled ghostty action: {:?}", action.tag as u32); + false + } + } + } +} + +#[derive(Clone, Copy)] +struct SendAppPtr(ghostty_app_t); + +unsafe impl Send for SendAppPtr {} +unsafe impl Sync for SendAppPtr {} + +impl SendAppPtr { + #[cfg(feature = "link-ghostty")] + fn get(self) -> ghostty_app_t { + self.0 + } + + fn is_null(self) -> bool { + self.0.is_null() + } +} + +static GHOSTTY_APP_PTR: Mutex = Mutex::new(SendAppPtr(std::ptr::null_mut())); +static GHOSTTY_TICK_PENDING: AtomicBool = AtomicBool::new(false); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn close_panel_removes_last_workspace() { + let shared = Arc::new(SharedState::new()); + let state = AppState::new(shared.clone()); + let panel_id = shared + .tab_manager + .lock() + .unwrap() + .selected() + .and_then(|workspace| workspace.focused_panel_id) + .expect("workspace should have a focused panel"); + + assert!(state.close_panel(panel_id, false)); + assert!(shared.tab_manager.lock().unwrap().is_empty()); + } + + #[test] + fn close_panel_returns_false_for_unknown_panel() { + let state = AppState::new(Arc::new(SharedState::new())); + assert!(!state.close_panel(Uuid::new_v4(), true)); + } +} diff --git a/linux/cmux/src/main.rs b/linux/cmux/src/main.rs new file mode 100644 index 0000000000..a235c16b0a --- /dev/null +++ b/linux/cmux/src/main.rs @@ -0,0 +1,35 @@ +mod app; +mod model; +mod notifications; +mod session; +mod socket; +mod ui; + +use tracing_subscriber::EnvFilter; + +fn main() { + prefer_desktop_opengl(); + + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .init(); + + tracing::info!("cmux starting"); + + // Run the GTK application + let exit_code = app::run(); + std::process::exit(exit_code); +} + +fn prefer_desktop_opengl() { + const FLAG: &str = "gl-prefer-gl"; + match std::env::var("GDK_DEBUG") { + Ok(existing) if existing.split(',').any(|flag| flag.trim() == FLAG) => {} + Ok(existing) if existing.trim().is_empty() => std::env::set_var("GDK_DEBUG", FLAG), + Ok(existing) => std::env::set_var("GDK_DEBUG", format!("{existing},{FLAG}")), + Err(_) => std::env::set_var("GDK_DEBUG", FLAG), + } +} diff --git a/linux/cmux/src/model/mod.rs b/linux/cmux/src/model/mod.rs new file mode 100644 index 0000000000..869f055e19 --- /dev/null +++ b/linux/cmux/src/model/mod.rs @@ -0,0 +1,7 @@ +pub mod panel; +pub mod tab_manager; +pub mod workspace; + +pub use panel::{Panel, PanelType}; +pub use tab_manager::TabManager; +pub use workspace::Workspace; diff --git a/linux/cmux/src/model/panel.rs b/linux/cmux/src/model/panel.rs new file mode 100644 index 0000000000..b2f8801862 --- /dev/null +++ b/linux/cmux/src/model/panel.rs @@ -0,0 +1,382 @@ +//! Panel model — represents a terminal or browser panel within a workspace. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Panel type discriminator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PanelType { + Terminal, + Browser, +} + +/// A panel within a workspace pane. +/// +/// Panels are the leaf nodes of the layout tree. Each panel is either a +/// terminal (backed by a ghostty surface) or a browser (WebKit2GTK). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Panel { + pub id: Uuid, + pub panel_type: PanelType, + pub title: Option, + pub custom_title: Option, + pub directory: Option, + pub is_pinned: bool, + pub is_manually_unread: bool, + pub git_branch: Option, + pub listening_ports: Vec, + pub tty_name: Option, +} + +impl Panel { + /// Create a new terminal panel. + pub fn new_terminal() -> Self { + Self { + id: Uuid::new_v4(), + panel_type: PanelType::Terminal, + title: None, + custom_title: None, + directory: None, + is_pinned: false, + is_manually_unread: false, + git_branch: None, + listening_ports: Vec::new(), + tty_name: None, + } + } + + /// Create a new browser panel. + pub fn new_browser() -> Self { + Self { + id: Uuid::new_v4(), + panel_type: PanelType::Browser, + title: None, + custom_title: None, + directory: None, + is_pinned: false, + is_manually_unread: false, + git_branch: None, + listening_ports: Vec::new(), + tty_name: None, + } + } + + /// Display title: custom title if set, otherwise process title, otherwise "Terminal"/"Browser". + pub fn display_title(&self) -> &str { + if let Some(ref t) = self.custom_title { + return t; + } + if let Some(ref t) = self.title { + return t; + } + match self.panel_type { + PanelType::Terminal => "Terminal", + PanelType::Browser => "Browser", + } + } +} + +/// Git branch info for a panel or workspace. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitBranch { + pub branch: String, + pub is_dirty: bool, +} + +/// Recursive layout tree for workspace pane arrangement. +/// +/// A workspace's content area is described by a `LayoutNode`: +/// - `Pane`: a leaf containing one or more panels (tabs within a pane) +/// - `Split`: a binary split (horizontal or vertical) with two children +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum LayoutNode { + #[serde(rename = "pane")] + Pane { + /// Panel IDs in tab order within this pane. + panel_ids: Vec, + /// Currently selected panel in this pane. + selected_panel_id: Option, + }, + #[serde(rename = "split")] + Split { + orientation: SplitOrientation, + /// Normalized divider position (0.0 to 1.0). + divider_position: f64, + first: Box, + second: Box, + }, +} + +/// Split orientation for layout. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SplitOrientation { + Horizontal, + Vertical, +} + +impl LayoutNode { + /// Create a simple single-pane layout with one panel. + pub fn single_pane(panel_id: Uuid) -> Self { + LayoutNode::Pane { + panel_ids: vec![panel_id], + selected_panel_id: Some(panel_id), + } + } + + /// Split this node, placing the existing content in the first half + /// and a new panel in the second half. + pub fn split(self, orientation: SplitOrientation, new_panel_id: Uuid) -> Self { + LayoutNode::Split { + orientation, + divider_position: 0.5, + first: Box::new(self), + second: Box::new(LayoutNode::Pane { + panel_ids: vec![new_panel_id], + selected_panel_id: Some(new_panel_id), + }), + } + } + + /// Collect all panel IDs in this layout tree. + pub fn all_panel_ids(&self) -> Vec { + match self { + LayoutNode::Pane { panel_ids, .. } => panel_ids.clone(), + LayoutNode::Split { first, second, .. } => { + let mut ids = first.all_panel_ids(); + ids.extend(second.all_panel_ids()); + ids + } + } + } + + /// Find the pane containing the given panel ID and return a mutable reference. + pub fn find_pane_with_panel(&mut self, panel_id: Uuid) -> Option<&mut LayoutNode> { + match self { + LayoutNode::Pane { panel_ids, .. } => { + if panel_ids.contains(&panel_id) { + Some(self) + } else { + None + } + } + LayoutNode::Split { first, second, .. } => first + .find_pane_with_panel(panel_id) + .or_else(|| second.find_pane_with_panel(panel_id)), + } + } + + /// Select the given panel if it exists in this layout tree. + pub fn select_panel(&mut self, panel_id: Uuid) -> bool { + match self { + LayoutNode::Pane { + panel_ids, + selected_panel_id, + } => { + if panel_ids.contains(&panel_id) { + *selected_panel_id = Some(panel_id); + true + } else { + false + } + } + LayoutNode::Split { first, second, .. } => { + first.select_panel(panel_id) || second.select_panel(panel_id) + } + } + } + + /// Remove a panel from the layout. If a pane becomes empty, the split + /// is collapsed. Returns true if the panel was found and removed. + pub fn remove_panel(&mut self, panel_id: Uuid) -> bool { + match self { + LayoutNode::Pane { + panel_ids, + selected_panel_id, + } => { + if let Some(pos) = panel_ids.iter().position(|&id| id == panel_id) { + panel_ids.remove(pos); + if *selected_panel_id == Some(panel_id) { + *selected_panel_id = panel_ids.first().copied(); + } + true + } else { + false + } + } + LayoutNode::Split { first, second, .. } => { + let removed = first.remove_panel(panel_id) || second.remove_panel(panel_id); + if removed { + // Collapse if either side is now empty + if first.is_empty() { + *self = *second.clone(); + } else if second.is_empty() { + *self = *first.clone(); + } + } + removed + } + } + } + + /// Update the divider position for the split identified by its child panel sets. + pub fn set_divider_position_for_split( + &mut self, + first_panel_ids: &[Uuid], + second_panel_ids: &[Uuid], + divider_position: f64, + ) -> bool { + match self { + LayoutNode::Pane { .. } => false, + LayoutNode::Split { + divider_position: current, + first, + second, + .. + } => { + let is_target = same_panel_set(first, first_panel_ids) + && same_panel_set(second, second_panel_ids); + if is_target { + *current = divider_position.clamp(0.0, 1.0); + true + } else { + first.set_divider_position_for_split( + first_panel_ids, + second_panel_ids, + divider_position, + ) || second.set_divider_position_for_split( + first_panel_ids, + second_panel_ids, + divider_position, + ) + } + } + } + } + + /// Check if this node contains no panels. + pub fn is_empty(&self) -> bool { + match self { + LayoutNode::Pane { panel_ids, .. } => panel_ids.is_empty(), + LayoutNode::Split { first, second, .. } => first.is_empty() && second.is_empty(), + } + } +} + +fn same_panel_set(node: &LayoutNode, expected: &[Uuid]) -> bool { + let mut actual = node.all_panel_ids(); + let mut expected = expected.to_vec(); + actual.sort_unstable(); + expected.sort_unstable(); + actual == expected +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_single_pane() { + let id = Uuid::new_v4(); + let node = LayoutNode::single_pane(id); + assert_eq!(node.all_panel_ids(), vec![id]); + } + + #[test] + fn test_split() { + let id1 = Uuid::new_v4(); + let id2 = Uuid::new_v4(); + let node = LayoutNode::single_pane(id1).split(SplitOrientation::Horizontal, id2); + let ids = node.all_panel_ids(); + assert_eq!(ids.len(), 2); + assert!(ids.contains(&id1)); + assert!(ids.contains(&id2)); + } + + #[test] + fn test_remove_panel_collapses_split() { + let id1 = Uuid::new_v4(); + let id2 = Uuid::new_v4(); + let mut node = LayoutNode::single_pane(id1).split(SplitOrientation::Horizontal, id2); + assert!(node.remove_panel(id2)); + assert_eq!(node.all_panel_ids(), vec![id1]); + // Should have collapsed back to a single pane + assert!(matches!(node, LayoutNode::Pane { .. })); + } + + #[test] + fn test_set_divider_position_for_split_updates_matching_split() { + let id1 = Uuid::new_v4(); + let id2 = Uuid::new_v4(); + let id3 = Uuid::new_v4(); + let mut node = LayoutNode::single_pane(id1).split(SplitOrientation::Horizontal, id2); + node = node.split(SplitOrientation::Vertical, id3); + + assert!(node.set_divider_position_for_split(&[id1, id2], &[id3], 0.75)); + + match node { + LayoutNode::Split { + divider_position, .. + } => assert_eq!(divider_position, 0.75), + _ => panic!("expected split layout"), + } + } + + #[test] + fn test_set_divider_position_for_split_updates_nested_split() { + let id1 = Uuid::new_v4(); + let id2 = Uuid::new_v4(); + let id3 = Uuid::new_v4(); + + let mut node = LayoutNode::Split { + orientation: SplitOrientation::Horizontal, + divider_position: 0.5, + first: Box::new(LayoutNode::single_pane(id1).split(SplitOrientation::Vertical, id2)), + second: Box::new(LayoutNode::single_pane(id3)), + }; + + assert!(node.set_divider_position_for_split(&[id1], &[id2], 0.2)); + + match node { + LayoutNode::Split { first, .. } => match *first { + LayoutNode::Split { + divider_position, .. + } => assert_eq!(divider_position, 0.2), + _ => panic!("expected nested split"), + }, + _ => panic!("expected outer split"), + } + } + + #[test] + fn test_layout_serialization_roundtrip() { + let id1 = Uuid::new_v4(); + let id2 = Uuid::new_v4(); + let node = LayoutNode::single_pane(id1).split(SplitOrientation::Vertical, id2); + let json = serde_json::to_string(&node).unwrap(); + let restored: LayoutNode = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.all_panel_ids().len(), 2); + } + + #[test] + fn test_select_panel_in_split() { + let id1 = Uuid::new_v4(); + let id2 = Uuid::new_v4(); + let mut node = LayoutNode::single_pane(id1).split(SplitOrientation::Vertical, id2); + assert!(node.select_panel(id2)); + + let mut selected = None; + if let LayoutNode::Split { second, .. } = &node { + if let LayoutNode::Pane { + selected_panel_id, .. + } = second.as_ref() + { + selected = *selected_panel_id; + } + } + + assert_eq!(selected, Some(id2)); + } +} diff --git a/linux/cmux/src/model/tab_manager.rs b/linux/cmux/src/model/tab_manager.rs new file mode 100644 index 0000000000..2727eefa22 --- /dev/null +++ b/linux/cmux/src/model/tab_manager.rs @@ -0,0 +1,373 @@ +//! TabManager — manages the collection of workspaces. + +use uuid::Uuid; + +use super::workspace::Workspace; + +/// Manages all workspaces and tracks the currently selected one. +/// +/// This is the top-level model for the sidebar workspace list. +#[derive(Debug)] +pub struct TabManager { + workspaces: Vec, + selected_index: Option, +} + +impl TabManager { + /// Create a new TabManager with a single default workspace. + pub fn new() -> Self { + let ws = Workspace::new(); + Self { + workspaces: vec![ws], + selected_index: Some(0), + } + } + + /// Create an empty TabManager (for restoring from session). + pub fn empty() -> Self { + Self { + workspaces: Vec::new(), + selected_index: None, + } + } + + /// Number of workspaces. + pub fn len(&self) -> usize { + self.workspaces.len() + } + + pub fn is_empty(&self) -> bool { + self.workspaces.is_empty() + } + + /// Get the currently selected workspace index. + pub fn selected_index(&self) -> Option { + self.selected_index + } + + /// Get the currently selected workspace. + pub fn selected(&self) -> Option<&Workspace> { + self.selected_index.and_then(|i| self.workspaces.get(i)) + } + + /// Get the currently selected workspace ID. + pub fn selected_id(&self) -> Option { + self.selected().map(|ws| ws.id) + } + + /// Get the currently selected workspace mutably. + pub fn selected_mut(&mut self) -> Option<&mut Workspace> { + self.selected_index.and_then(|i| self.workspaces.get_mut(i)) + } + + /// Select a workspace by index. + pub fn select(&mut self, index: usize) -> bool { + if index < self.workspaces.len() { + self.selected_index = Some(index); + true + } else { + false + } + } + + /// Select workspace by ID. + pub fn select_by_id(&mut self, id: Uuid) -> bool { + if let Some(index) = self.workspaces.iter().position(|w| w.id == id) { + self.selected_index = Some(index); + true + } else { + false + } + } + + /// Select the next workspace (wrapping around). + pub fn select_next(&mut self, wrap: bool) { + if self.workspaces.is_empty() { + return; + } + match self.selected_index { + Some(i) if i + 1 < self.workspaces.len() => { + self.selected_index = Some(i + 1); + } + Some(_) if wrap => { + self.selected_index = Some(0); + } + None => { + self.selected_index = Some(0); + } + _ => {} + } + } + + /// Select the previous workspace (wrapping around). + pub fn select_previous(&mut self, wrap: bool) { + if self.workspaces.is_empty() { + return; + } + match self.selected_index { + Some(0) if wrap => { + self.selected_index = Some(self.workspaces.len() - 1); + } + Some(i) if i > 0 => { + self.selected_index = Some(i - 1); + } + None => { + self.selected_index = Some(self.workspaces.len() - 1); + } + _ => {} + } + } + + /// Select the last workspace. + pub fn select_last(&mut self) { + if !self.workspaces.is_empty() { + self.selected_index = Some(self.workspaces.len() - 1); + } + } + + /// Add a new workspace. Returns the new workspace's ID. + pub fn add_workspace(&mut self, workspace: Workspace) -> Uuid { + let id = workspace.id; + self.workspaces.push(workspace); + self.selected_index = Some(self.workspaces.len() - 1); + id + } + + /// Add a new workspace after the current one. + pub fn add_workspace_after_current(&mut self, workspace: Workspace) -> Uuid { + let id = workspace.id; + let insert_at = self.selected_index.map(|i| i + 1).unwrap_or(0); + self.workspaces.insert(insert_at, workspace); + self.selected_index = Some(insert_at); + id + } + + /// Remove a workspace by index. Returns the removed workspace. + pub fn remove(&mut self, index: usize) -> Option { + if index >= self.workspaces.len() { + return None; + } + let ws = self.workspaces.remove(index); + + // Adjust selection + if self.workspaces.is_empty() { + self.selected_index = None; + } else if let Some(sel) = self.selected_index { + if sel >= self.workspaces.len() { + self.selected_index = Some(self.workspaces.len() - 1); + } else if sel > index { + self.selected_index = Some(sel - 1); + } + } + + Some(ws) + } + + /// Remove a workspace by ID. Returns the removed workspace. + pub fn remove_by_id(&mut self, id: Uuid) -> Option { + let index = self.workspaces.iter().position(|w| w.id == id)?; + self.remove(index) + } + + /// Get a workspace by ID. + pub fn workspace(&self, id: Uuid) -> Option<&Workspace> { + self.workspaces.iter().find(|w| w.id == id) + } + + /// Get a workspace by ID mutably. + pub fn workspace_mut(&mut self, id: Uuid) -> Option<&mut Workspace> { + self.workspaces.iter_mut().find(|w| w.id == id) + } + + /// Get a workspace by index. + pub fn get(&self, index: usize) -> Option<&Workspace> { + self.workspaces.get(index) + } + + /// Get a workspace by index mutably. + pub fn get_mut(&mut self, index: usize) -> Option<&mut Workspace> { + self.workspaces.get_mut(index) + } + + /// Iterate over all workspaces. + pub fn iter(&self) -> impl Iterator { + self.workspaces.iter() + } + + /// Select the workspace with the newest unread notification. + pub fn select_latest_unread(&mut self) -> Option { + let index = self.latest_unread_index()?; + self.selected_index = Some(index); + self.workspaces.get(index).map(|ws| ws.id) + } + + /// Index of the workspace with the newest unread notification. + pub fn latest_unread_index(&self) -> Option { + self.workspaces + .iter() + .enumerate() + .filter(|(_, ws)| ws.unread_count > 0) + .max_by(|(_, a), (_, b)| { + let a_ts = a.latest_notification_at.unwrap_or(0.0); + let b_ts = b.latest_notification_at.unwrap_or(0.0); + a_ts.total_cmp(&b_ts) + }) + .map(|(index, _)| index) + } + + /// Move a workspace from one index to another. + pub fn move_workspace(&mut self, from: usize, to: usize) -> bool { + if from >= self.workspaces.len() || to >= self.workspaces.len() || from == to { + return from == to && from < self.workspaces.len(); + } + let previous_selection = self.selected_index; + let ws = self.workspaces.remove(from); + self.workspaces.insert(to, ws); + + // Adjust selection to follow the moved workspace + if let Some(selected) = previous_selection { + self.selected_index = if selected == from { + Some(to) + } else if from < to && selected > from && selected <= to { + Some(selected - 1) + } else if from > to && selected >= to && selected < from { + Some(selected + 1) + } else { + Some(selected) + }; + } + true + } + + /// Find the workspace containing a panel with the given UUID. + pub fn find_workspace_with_panel(&self, panel_id: Uuid) -> Option<&Workspace> { + self.workspaces + .iter() + .find(|w| w.panels.contains_key(&panel_id)) + } + + /// Find the workspace containing a panel with the given UUID, mutably. + pub fn find_workspace_with_panel_mut(&mut self, panel_id: Uuid) -> Option<&mut Workspace> { + self.workspaces + .iter_mut() + .find(|w| w.panels.contains_key(&panel_id)) + } +} + +impl Default for TabManager { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_tab_manager() { + let tm = TabManager::new(); + assert_eq!(tm.len(), 1); + assert_eq!(tm.selected_index(), Some(0)); + } + + #[test] + fn test_add_and_select() { + let mut tm = TabManager::new(); + let ws2 = Workspace::new(); + let id2 = tm.add_workspace(ws2); + assert_eq!(tm.len(), 2); + assert_eq!(tm.selected_index(), Some(1)); + + tm.select(0); + assert_eq!(tm.selected_index(), Some(0)); + + tm.select_by_id(id2); + assert_eq!(tm.selected_index(), Some(1)); + } + + #[test] + fn test_remove() { + let mut tm = TabManager::new(); + tm.add_workspace(Workspace::new()); + tm.add_workspace(Workspace::new()); + assert_eq!(tm.len(), 3); + + tm.select(1); + tm.remove(0); + assert_eq!(tm.len(), 2); + // Selection should adjust + assert_eq!(tm.selected_index(), Some(0)); + } + + #[test] + fn test_navigation() { + let mut tm = TabManager::new(); + tm.add_workspace(Workspace::new()); + tm.add_workspace(Workspace::new()); + tm.select(0); + + tm.select_next(false); + assert_eq!(tm.selected_index(), Some(1)); + + tm.select_next(true); + assert_eq!(tm.selected_index(), Some(2)); + + tm.select_next(true); + assert_eq!(tm.selected_index(), Some(0)); + + tm.select_previous(true); + assert_eq!(tm.selected_index(), Some(2)); + + tm.select_last(); + assert_eq!(tm.selected_index(), Some(2)); + } + + #[test] + fn test_select_latest_unread_prefers_newest_notification() { + let mut tm = TabManager::empty(); + + let mut ws1 = Workspace::new(); + ws1.record_notification("Claude Code", "Waiting for input", None); + let ws1_id = ws1.id; + tm.add_workspace(ws1); + + std::thread::sleep(std::time::Duration::from_millis(1)); + + let mut ws2 = Workspace::new(); + ws2.record_notification("Codex", "Approval needed", None); + let ws2_id = ws2.id; + tm.add_workspace(ws2); + + let selected = tm.select_latest_unread(); + assert_eq!(selected, Some(ws2_id)); + assert_ne!(selected, Some(ws1_id)); + } + + #[test] + fn test_move_workspace_remaps_shifted_selection() { + let mut tm = TabManager::new(); + tm.add_workspace(Workspace::new()); + tm.add_workspace(Workspace::new()); + tm.add_workspace(Workspace::new()); + + tm.select(2); + assert!(tm.move_workspace(0, 3)); + assert_eq!(tm.selected_index(), Some(1)); + + tm.select(1); + assert!(tm.move_workspace(3, 0)); + assert_eq!(tm.selected_index(), Some(2)); + } + + #[test] + fn test_move_workspace_is_noop_when_from_equals_to() { + let mut tm = TabManager::new(); + tm.add_workspace(Workspace::new()); + + tm.select(1); + assert!(tm.move_workspace(1, 1)); + assert_eq!(tm.selected_index(), Some(1)); + assert!(!tm.move_workspace(3, 3)); + } +} diff --git a/linux/cmux/src/model/workspace.rs b/linux/cmux/src/model/workspace.rs new file mode 100644 index 0000000000..bcaf533891 --- /dev/null +++ b/linux/cmux/src/model/workspace.rs @@ -0,0 +1,509 @@ +//! Workspace model — a named collection of panels with layout and metadata. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use uuid::Uuid; + +use super::panel::{GitBranch, LayoutNode, Panel, PanelType, SplitOrientation}; + +/// A workspace contains one or more panels arranged in a split layout. +/// +/// Each workspace appears as a tab in the sidebar. +#[derive(Debug, Clone)] +pub struct Workspace { + pub id: Uuid, + pub process_title: String, + pub custom_title: Option, + pub custom_color: Option, + pub is_pinned: bool, + pub current_directory: String, + pub focused_panel_id: Option, + + /// The layout tree describing pane arrangement. + pub layout: LayoutNode, + + /// All panels in this workspace, keyed by UUID. + pub panels: HashMap, + + /// Status entries (agent metadata, key-value pairs). + pub status_entries: Vec, + + /// Log entries from agents/tools. + pub log_entries: Vec, + + /// Progress indicator. + pub progress: Option, + + /// Git branch for the workspace root. + pub git_branch: Option, + + /// Unread notification count. + pub unread_count: u32, + /// Sidebar summary for the latest notification in this workspace. + pub latest_notification: Option, + /// Timestamp of the latest notification, used for latest-unread routing. + pub latest_notification_at: Option, + /// Panel that most recently requested attention, if known. + pub attention_panel_id: Option, +} + +/// Status entry (agent metadata key-value pairs shown in sidebar). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatusEntry { + pub key: String, + pub value: String, + pub icon: Option, + pub color: Option, + pub timestamp: f64, +} + +/// Log entry from agents/tools. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogEntry { + pub message: String, + pub level: String, + pub source: Option, + pub timestamp: f64, +} + +/// Progress indicator for a workspace. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Progress { + pub value: f64, + pub label: Option, +} + +/// Truncate a string to at most `max_bytes` bytes without splitting UTF-8. +pub fn truncate_str(s: &str, max_bytes: usize) -> &str { + if s.len() <= max_bytes { + return s; + } + + let mut end = max_bytes; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] +} + +impl Workspace { + /// Create a new workspace with a single terminal panel. + pub fn new() -> Self { + let current_directory = std::env::var("HOME").unwrap_or_else(|_| "/".to_string()); + let panel = Panel::new_terminal(); + let panel_id = panel.id; + let mut panels = HashMap::new(); + panels.insert(panel_id, panel); + + Self { + id: Uuid::new_v4(), + process_title: "Terminal".to_string(), + custom_title: None, + custom_color: None, + is_pinned: false, + current_directory, + focused_panel_id: Some(panel_id), + layout: LayoutNode::single_pane(panel_id), + panels, + status_entries: Vec::new(), + log_entries: Vec::new(), + progress: None, + git_branch: None, + unread_count: 0, + latest_notification: None, + latest_notification_at: None, + attention_panel_id: None, + } + } + + /// Create a new workspace with a specific working directory. + pub fn with_directory(directory: &str) -> Self { + let mut ws = Self::new(); + ws.current_directory = directory.to_string(); + if let Some(panel_id) = ws.focused_panel_id { + if let Some(panel) = ws.panels.get_mut(&panel_id) { + panel.directory = Some(directory.to_string()); + } + } + ws + } + + /// Display title: custom title if set, otherwise process title. + pub fn display_title(&self) -> &str { + self.custom_title.as_deref().unwrap_or(&self.process_title) + } + + /// Add a new panel by splitting the focused pane. + pub fn split(&mut self, orientation: SplitOrientation, panel_type: PanelType) -> Uuid { + let new_panel = match panel_type { + PanelType::Terminal => Panel::new_terminal(), + PanelType::Browser => Panel::new_browser(), + }; + let new_id = new_panel.id; + self.panels.insert(new_id, new_panel); + + // Find the focused pane and split it + let mut split_done = false; + if let Some(focused_id) = self.focused_panel_id { + if let Some(pane) = self.layout.find_pane_with_panel(focused_id) { + let old = std::mem::replace( + pane, + LayoutNode::Pane { + panel_ids: vec![], + selected_panel_id: None, + }, + ); + *pane = old.split(orientation, new_id); + split_done = true; + } + } + + if !split_done { + // No focused panel — just split the root + let old = std::mem::replace( + &mut self.layout, + LayoutNode::Pane { + panel_ids: vec![], + selected_panel_id: None, + }, + ); + self.layout = old.split(orientation, new_id); + } + + self.focused_panel_id = Some(new_id); + new_id + } + + /// Remove a panel by ID. Returns true if the panel existed. + pub fn remove_panel(&mut self, panel_id: Uuid) -> bool { + if self.panels.remove(&panel_id).is_none() { + return false; + } + self.layout.remove_panel(panel_id); + + // Update focused panel if needed + if self.focused_panel_id == Some(panel_id) { + self.focused_panel_id = self.layout.all_panel_ids().into_iter().next(); + } + + true + } + + /// Get a reference to a panel by ID. + pub fn panel(&self, id: Uuid) -> Option<&Panel> { + self.panels.get(&id) + } + + /// Get a mutable reference to a panel by ID. + pub fn panel_mut(&mut self, id: Uuid) -> Option<&mut Panel> { + self.panels.get_mut(&id) + } + + /// Get all panel IDs in layout order. + pub fn panel_ids(&self) -> Vec { + self.layout.all_panel_ids() + } + + /// Check if the workspace has no panels. + pub fn is_empty(&self) -> bool { + self.panels.is_empty() + } + + const MAX_STATUS_ENTRIES: usize = 100; + const MAX_STATUS_KEY_LEN: usize = 256; + const MAX_STATUS_VALUE_LEN: usize = 4096; + + /// Update the status entry for a key, creating it if it doesn't exist. + pub fn set_status(&mut self, key: &str, value: &str, icon: Option<&str>, color: Option<&str>) { + let key = truncate_str(key, Self::MAX_STATUS_KEY_LEN); + let value = truncate_str(value, Self::MAX_STATUS_VALUE_LEN); + let icon = icon.map(|s| truncate_str(s, 256)); + let color = color.map(|s| truncate_str(s, 64)); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + + if let Some(entry) = self.status_entries.iter_mut().find(|e| e.key == key) { + entry.value = value.to_string(); + entry.icon = icon.map(|s| s.to_string()); + entry.color = color.map(|s| s.to_string()); + entry.timestamp = now; + } else { + if self.status_entries.len() >= Self::MAX_STATUS_ENTRIES { + if let Some(oldest_idx) = self + .status_entries + .iter() + .enumerate() + .min_by(|a, b| { + a.1.timestamp + .partial_cmp(&b.1.timestamp) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(idx, _)| idx) + { + self.status_entries.remove(oldest_idx); + } + } + self.status_entries.push(StatusEntry { + key: key.to_string(), + value: value.to_string(), + icon: icon.map(|s| s.to_string()), + color: color.map(|s| s.to_string()), + timestamp: now, + }); + } + } + + const MAX_LOG_ENTRIES: usize = 1000; + const MAX_LOG_MESSAGE_LEN: usize = 8192; + + /// Append a log entry. + pub fn append_log(&mut self, message: &str, level: &str, source: Option<&str>) { + let message = truncate_str(message, Self::MAX_LOG_MESSAGE_LEN); + let level = truncate_str(level, 64); + let source = source.map(|s| truncate_str(s, 256)); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + + if self.log_entries.len() >= Self::MAX_LOG_ENTRIES { + self.log_entries.drain(..self.log_entries.len() / 4); + } + + self.log_entries.push(LogEntry { + message: message.to_string(), + level: level.to_string(), + source: source.map(|s| s.to_string()), + timestamp: now, + }); + } + + /// Most relevant status label for the sidebar. + pub fn sidebar_status_label(&self) -> Option<&str> { + self.status_entries + .iter() + .rev() + .find(|entry| entry.key == "agent") + .or_else(|| self.status_entries.last()) + .map(|entry| entry.value.as_str()) + } + + /// Record an attention event from a notification. + pub fn record_notification(&mut self, title: &str, body: &str, panel_id: Option) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + + self.unread_count = self.unread_count.saturating_add(1); + self.latest_notification = Some(notification_summary(title, body)); + self.latest_notification_at = Some(now); + self.attention_panel_id = panel_id.filter(|id| self.panels.contains_key(id)); + } + + /// Mark all workspace notifications as read. + pub fn mark_notifications_read(&mut self) { + self.unread_count = 0; + } + + /// Focus a specific panel and reveal its tab. + pub fn focus_panel(&mut self, panel_id: Uuid) -> bool { + if !self.panels.contains_key(&panel_id) { + return false; + } + + if self.layout.select_panel(panel_id) { + self.focused_panel_id = Some(panel_id); + true + } else { + false + } + } +} + +fn notification_summary(title: &str, body: &str) -> String { + let title = title.trim(); + let body = body.trim(); + let summary = match (title.is_empty(), body.is_empty()) { + (false, false) if body == title => title.to_string(), + (false, false) => format!("{title}: {body}"), + (false, true) => title.to_string(), + (true, false) => body.to_string(), + (true, true) => "Notification".to_string(), + }; + + let single_line = summary.split_whitespace().collect::>().join(" "); + truncate_for_sidebar(&single_line, 120) +} + +fn truncate_for_sidebar(text: &str, max_chars: usize) -> String { + let mut truncated = text.chars().take(max_chars).collect::(); + if text.chars().count() > max_chars { + truncated.push_str("..."); + } + truncated +} + +impl Default for Workspace { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_workspace() { + let ws = Workspace::new(); + assert_eq!(ws.panels.len(), 1); + assert!(ws.focused_panel_id.is_some()); + assert_eq!(ws.display_title(), "Terminal"); + let panel_id = ws.focused_panel_id.expect("workspace should have a panel"); + assert_eq!( + ws.panels + .get(&panel_id) + .and_then(|panel| panel.directory.as_deref()), + None + ); + } + + #[test] + fn test_split_workspace() { + let mut ws = Workspace::new(); + let new_id = ws.split(SplitOrientation::Horizontal, PanelType::Terminal); + assert_eq!(ws.panels.len(), 2); + assert_eq!(ws.focused_panel_id, Some(new_id)); + assert_eq!( + ws.panels + .get(&new_id) + .and_then(|panel| panel.directory.as_deref()), + None + ); + } + + #[test] + fn test_with_directory_updates_initial_terminal_panel() { + let ws = Workspace::with_directory("/tmp/cmux-test"); + let panel_id = ws.focused_panel_id.expect("workspace should have a panel"); + assert_eq!(ws.current_directory, "/tmp/cmux-test"); + assert_eq!( + ws.panels + .get(&panel_id) + .and_then(|panel| panel.directory.as_deref()), + Some("/tmp/cmux-test") + ); + } + + #[test] + fn test_remove_panel() { + let mut ws = Workspace::new(); + let new_id = ws.split(SplitOrientation::Horizontal, PanelType::Terminal); + assert!(ws.remove_panel(new_id)); + assert_eq!(ws.panels.len(), 1); + } + + #[test] + fn test_status_entries() { + let mut ws = Workspace::new(); + ws.set_status("agent", "claude-code", Some("robot"), None); + assert_eq!(ws.status_entries.len(), 1); + ws.set_status("agent", "claude-code v2", None, None); + assert_eq!(ws.status_entries.len(), 1); + assert_eq!(ws.status_entries[0].value, "claude-code v2"); + } + + #[test] + fn test_status_entry_eviction_preserves_remaining_order() { + let mut ws = Workspace::new(); + + for i in 0..100 { + ws.set_status(&format!("key-{i}"), &format!("value-{i}"), None, None); + } + + ws.set_status("key-100", "value-100", None, None); + + assert_eq!(ws.status_entries.len(), 100); + assert_eq!( + ws.status_entries.first().map(|entry| entry.key.as_str()), + Some("key-1") + ); + assert_eq!( + ws.status_entries.last().map(|entry| entry.key.as_str()), + Some("key-100") + ); + } + + #[test] + fn test_record_notification_updates_unread_and_summary() { + let mut ws = Workspace::new(); + let panel_id = ws.focused_panel_id; + ws.record_notification("Codex", "Waiting for input", panel_id); + + assert_eq!(ws.unread_count, 1); + assert_eq!( + ws.latest_notification.as_deref(), + Some("Codex: Waiting for input") + ); + assert_eq!(ws.attention_panel_id, panel_id); + assert!(ws.latest_notification_at.is_some()); + } + + #[test] + fn test_record_notification_does_not_steal_focus() { + let mut ws = Workspace::new(); + let original_focus = ws + .focused_panel_id + .expect("workspace should have a focused panel"); + let other_panel_id = ws.split(SplitOrientation::Horizontal, PanelType::Terminal); + assert_eq!(ws.focused_panel_id, Some(other_panel_id)); + + ws.focus_panel(original_focus); + ws.record_notification("Codex", "Waiting for input", Some(other_panel_id)); + + assert_eq!(ws.focused_panel_id, Some(original_focus)); + assert_eq!(ws.attention_panel_id, Some(other_panel_id)); + } + + #[test] + fn test_mark_notifications_read_clears_unread_count() { + let mut ws = Workspace::new(); + ws.record_notification("Claude Code", "Approval needed", None); + assert_eq!(ws.unread_count, 1); + + ws.mark_notifications_read(); + assert_eq!(ws.unread_count, 0); + } + + #[test] + fn test_split_falls_back_to_root_when_focused_panel_is_stale() { + let mut ws = Workspace::new(); + ws.focused_panel_id = Some(uuid::Uuid::new_v4()); + + let new_id = ws.split(SplitOrientation::Horizontal, PanelType::Terminal); + + assert_eq!(ws.focused_panel_id, Some(new_id)); + assert!(ws.layout.all_panel_ids().contains(&new_id)); + } + + #[test] + fn test_focus_panel_does_not_update_focus_when_layout_select_fails() { + let mut ws = Workspace::new(); + let original_focus = ws.focused_panel_id; + let panel_id = original_focus.expect("workspace should have a focused panel"); + + ws.layout = LayoutNode::single_pane(uuid::Uuid::new_v4()); + + assert!(!ws.focus_panel(panel_id)); + assert_eq!(ws.focused_panel_id, original_focus); + } + + #[test] + fn test_truncate_str_preserves_utf8_boundaries() { + assert_eq!(truncate_str("abcdef", 4), "abcd"); + assert_eq!(truncate_str("あいう", 4), "あ"); + } +} diff --git a/linux/cmux/src/notifications.rs b/linux/cmux/src/notifications.rs new file mode 100644 index 0000000000..ed052540c3 --- /dev/null +++ b/linux/cmux/src/notifications.rs @@ -0,0 +1,139 @@ +//! Notification store and desktop notification integration. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A notification from a terminal or agent. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Notification { + pub id: Uuid, + pub title: String, + pub body: String, + pub source_workspace_id: Option, + pub source_panel_id: Option, + pub timestamp: f64, + pub is_read: bool, +} + +/// Notification store — keeps track of all notifications. +#[derive(Debug, Default)] +pub struct NotificationStore { + notifications: Vec, +} + +const MAX_NOTIFICATIONS: usize = 500; + +impl NotificationStore { + pub fn new() -> Self { + Self { + notifications: Vec::new(), + } + } + + /// Add a notification and optionally send a desktop notification. + pub fn add( + &mut self, + title: &str, + body: &str, + workspace_id: Option, + panel_id: Option, + send_desktop: bool, + ) -> Uuid { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + let title = crate::model::workspace::truncate_str(title, 1024); + let body = crate::model::workspace::truncate_str(body, 8192); + + let notification = Notification { + id: Uuid::new_v4(), + title: title.to_string(), + body: body.to_string(), + source_workspace_id: workspace_id, + source_panel_id: panel_id, + timestamp: now, + is_read: false, + }; + + let id = notification.id; + + if send_desktop { + send_desktop_notification(title, body); + } + + if self.notifications.len() >= MAX_NOTIFICATIONS { + self.notifications.drain(..self.notifications.len() / 4); + } + + self.notifications.push(notification); + id + } + + /// Get all notifications. + pub fn all(&self) -> &[Notification] { + &self.notifications + } + + /// Get unread count. + pub fn unread_count(&self) -> usize { + self.notifications.iter().filter(|n| !n.is_read).count() + } + + /// Get unread count for a specific workspace. + pub fn unread_count_for_workspace(&self, workspace_id: Uuid) -> usize { + self.notifications + .iter() + .filter(|n| !n.is_read && n.source_workspace_id == Some(workspace_id)) + .count() + } + + /// Mark a notification as read. + pub fn mark_read(&mut self, id: Uuid) { + if let Some(n) = self.notifications.iter_mut().find(|n| n.id == id) { + n.is_read = true; + } + } + + /// Mark all notifications for a workspace as read. + pub fn mark_workspace_read(&mut self, workspace_id: Uuid) { + for notification in &mut self.notifications { + if notification.source_workspace_id == Some(workspace_id) { + notification.is_read = true; + } + } + } + + /// Mark all notifications as read. + pub fn mark_all_read(&mut self) { + for n in &mut self.notifications { + n.is_read = true; + } + } + + /// Clear all notifications. + pub fn clear(&mut self) { + self.notifications.clear(); + } +} + +/// Send a desktop notification using gio::Notification. +fn send_desktop_notification(title: &str, body: &str) { + let title = title.to_string(); + let body = body.to_string(); + + glib::MainContext::default().invoke(move || { + let notification = gio::Notification::new(&title); + notification.set_body(Some(&body)); + + if let Some(app) = gio::Application::default() { + use gio::prelude::ApplicationExt; + app.send_notification(None, ¬ification); + } else { + tracing::debug!( + title = %title, + "Desktop notification unavailable; body omitted" + ); + } + }); +} diff --git a/linux/cmux/src/session/mod.rs b/linux/cmux/src/session/mod.rs new file mode 100644 index 0000000000..408547e28b --- /dev/null +++ b/linux/cmux/src/session/mod.rs @@ -0,0 +1,2 @@ +pub mod snapshot; +pub mod store; diff --git a/linux/cmux/src/session/snapshot.rs b/linux/cmux/src/session/snapshot.rs new file mode 100644 index 0000000000..d37e4d9ddb --- /dev/null +++ b/linux/cmux/src/session/snapshot.rs @@ -0,0 +1,226 @@ +//! Session snapshot types — JSON-compatible with the macOS cmux format. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::model::panel::{GitBranch, LayoutNode, SplitOrientation}; +use crate::model::workspace::{LogEntry, Progress, StatusEntry}; + +/// Root session snapshot. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSessionSnapshot { + pub version: u32, + pub created_at: f64, + pub windows: Vec, +} + +/// Window snapshot (Linux has one window typically, but supports multiple). +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionWindowSnapshot { + pub frame: Option, + pub tab_manager: SessionTabManagerSnapshot, + pub sidebar: SessionSidebarSnapshot, +} + +/// Tab manager snapshot. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionTabManagerSnapshot { + pub selected_workspace_index: Option, + pub workspaces: Vec, +} + +/// Workspace snapshot. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionWorkspaceSnapshot { + pub process_title: String, + pub custom_title: Option, + pub custom_color: Option, + pub is_pinned: bool, + pub current_directory: String, + pub focused_panel_id: Option, + pub layout: SessionWorkspaceLayoutSnapshot, + pub panels: Vec, + pub status_entries: Vec, + pub log_entries: Vec, + pub progress: Option, + pub git_branch: Option, +} + +/// Recursive layout snapshot (matches macOS JSON format). +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum SessionWorkspaceLayoutSnapshot { + #[serde(rename = "pane")] + Pane { pane: SessionPaneLayoutSnapshot }, + #[serde(rename = "split")] + Split { split: SessionSplitLayoutSnapshot }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionPaneLayoutSnapshot { + pub panel_ids: Vec, + pub selected_panel_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionSplitLayoutSnapshot { + pub orientation: SplitOrientation, + pub divider_position: f64, + pub first: Box, + pub second: Box, +} + +/// Panel snapshot. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionPanelSnapshot { + pub id: Uuid, + #[serde(rename = "type")] + pub panel_type: String, + pub title: Option, + pub custom_title: Option, + pub directory: Option, + pub is_pinned: bool, + pub is_manually_unread: bool, + pub git_branch: Option, + pub listening_ports: Vec, + pub tty_name: Option, + pub terminal: Option, + pub browser: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionTerminalPanelSnapshot { + pub working_directory: Option, + pub scrollback: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionBrowserPanelSnapshot { + pub url_string: Option, + pub should_render_web_view: bool, + pub page_zoom: f64, + pub developer_tools_visible: bool, +} + +/// Window geometry. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionRectSnapshot { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// Sidebar state. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionSidebarSnapshot { + pub is_visible: bool, + pub selection: String, + pub width: Option, +} + +// ----------------------------------------------------------------------- +// Conversion helpers +// ----------------------------------------------------------------------- + +impl SessionWorkspaceLayoutSnapshot { + /// Convert from a model LayoutNode. + pub fn from_layout(node: &LayoutNode) -> Self { + match node { + LayoutNode::Pane { + panel_ids, + selected_panel_id, + } => SessionWorkspaceLayoutSnapshot::Pane { + pane: SessionPaneLayoutSnapshot { + panel_ids: panel_ids.clone(), + selected_panel_id: *selected_panel_id, + }, + }, + LayoutNode::Split { + orientation, + divider_position, + first, + second, + } => SessionWorkspaceLayoutSnapshot::Split { + split: SessionSplitLayoutSnapshot { + orientation: *orientation, + divider_position: *divider_position, + first: Box::new(Self::from_layout(first)), + second: Box::new(Self::from_layout(second)), + }, + }, + } + } + + /// Convert to a model LayoutNode. + pub fn to_layout(&self) -> LayoutNode { + match self { + SessionWorkspaceLayoutSnapshot::Pane { pane: p } => LayoutNode::Pane { + panel_ids: p.panel_ids.clone(), + selected_panel_id: p.selected_panel_id, + }, + SessionWorkspaceLayoutSnapshot::Split { split: s } => LayoutNode::Split { + orientation: s.orientation, + divider_position: if s.divider_position.is_finite() { + s.divider_position.clamp(0.0, 1.0) + } else { + 0.5 + }, + first: Box::new(s.first.to_layout()), + second: Box::new(s.second.to_layout()), + }, + } + } +} + +impl SessionPanelSnapshot { + /// Convert from a model Panel. + pub fn from_panel(panel: &crate::model::panel::Panel) -> Self { + let panel_type = match panel.panel_type { + crate::model::PanelType::Terminal => "terminal".to_string(), + crate::model::PanelType::Browser => "browser".to_string(), + }; + + Self { + id: panel.id, + panel_type, + title: panel.title.clone(), + custom_title: panel.custom_title.clone(), + directory: panel.directory.clone(), + is_pinned: panel.is_pinned, + is_manually_unread: panel.is_manually_unread, + git_branch: panel.git_branch.clone(), + listening_ports: panel.listening_ports.clone(), + tty_name: panel.tty_name.clone(), + terminal: if panel.panel_type == crate::model::PanelType::Terminal { + Some(SessionTerminalPanelSnapshot { + working_directory: panel.directory.clone(), + scrollback: None, // TODO: capture scrollback + }) + } else { + None + }, + browser: if panel.panel_type == crate::model::PanelType::Browser { + Some(SessionBrowserPanelSnapshot { + url_string: None, + should_render_web_view: true, + page_zoom: 1.0, + developer_tools_visible: false, + }) + } else { + None + }, + } + } +} diff --git a/linux/cmux/src/session/store.rs b/linux/cmux/src/session/store.rs new file mode 100644 index 0000000000..fc2539c88b --- /dev/null +++ b/linux/cmux/src/session/store.rs @@ -0,0 +1,131 @@ +//! Session store — reads and writes session snapshots to XDG_DATA_HOME. + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; + +use std::os::unix::fs::OpenOptionsExt; +use std::os::unix::fs::PermissionsExt; + +use crate::app::lock_or_recover; +use crate::session::snapshot::*; + +/// Get the session file path: ~/.local/share/cmux/session.json +fn session_path() -> PathBuf { + let data_dir = dirs::data_dir() + .or_else(|| dirs::home_dir().map(|home| home.join(".local/share"))) + .unwrap_or_else(|| std::env::temp_dir().join(format!("cmux-{}", unsafe { libc::getuid() }))) + .join("cmux"); + data_dir.join("session.json") +} + +/// Save a session snapshot to disk. +pub fn save_session(snapshot: &AppSessionSnapshot) -> anyhow::Result<()> { + let path = session_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?; + } + + let json = serde_json::to_string_pretty(snapshot)?; + write_atomic(&path, json.as_bytes())?; + + tracing::debug!("Session saved to {}", path.display()); + Ok(()) +} + +/// Load a session snapshot from disk. +pub fn load_session() -> anyhow::Result> { + let path = session_path(); + if !path.exists() { + return Ok(None); + } + + let json = std::fs::read_to_string(&path)?; + let snapshot: AppSessionSnapshot = match serde_json::from_str(&json) { + Ok(snapshot) => snapshot, + Err(error) => { + tracing::warn!( + "Corrupt session file at {}, ignoring: {}", + path.display(), + error + ); + let backup = path.with_extension("json.corrupt"); + let _ = std::fs::rename(&path, &backup); + return Ok(None); + } + }; + + tracing::debug!("Session loaded from {}", path.display()); + Ok(Some(snapshot)) +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { + let tmp_path = path.with_extension(format!("json.tmp.{}", std::process::id())); + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(&tmp_path)?; + file.write_all(bytes)?; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + file.sync_all()?; + std::fs::rename(&tmp_path, path).inspect_err(|_| { + let _ = std::fs::remove_file(&tmp_path); + })?; + Ok(()) +} + +/// Create a snapshot from the current application state. +pub fn create_snapshot(state: &crate::app::AppState) -> AppSessionSnapshot { + let tm = lock_or_recover(&state.shared.tab_manager); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + + let workspaces: Vec = tm + .iter() + .map(|ws| { + let panels: Vec = ws + .panels + .values() + .map(SessionPanelSnapshot::from_panel) + .collect(); + + SessionWorkspaceSnapshot { + process_title: ws.process_title.clone(), + custom_title: ws.custom_title.clone(), + custom_color: ws.custom_color.clone(), + is_pinned: ws.is_pinned, + current_directory: ws.current_directory.clone(), + focused_panel_id: ws.focused_panel_id, + layout: SessionWorkspaceLayoutSnapshot::from_layout(&ws.layout), + panels, + status_entries: ws.status_entries.clone(), + log_entries: ws.log_entries.clone(), + progress: ws.progress.clone(), + git_branch: ws.git_branch.clone(), + } + }) + .collect(); + + AppSessionSnapshot { + version: 1, + created_at: now, + windows: vec![SessionWindowSnapshot { + frame: None, + tab_manager: SessionTabManagerSnapshot { + selected_workspace_index: tm.selected_index(), + workspaces, + }, + sidebar: SessionSidebarSnapshot { + is_visible: true, + selection: "tabs".to_string(), + width: None, + }, + }], + } +} diff --git a/linux/cmux/src/socket/auth.rs b/linux/cmux/src/socket/auth.rs new file mode 100644 index 0000000000..590f5bb043 --- /dev/null +++ b/linux/cmux/src/socket/auth.rs @@ -0,0 +1,97 @@ +//! Socket authentication using SO_PEERCRED. + +use std::io; + +/// Information about the connected peer process. +#[derive(Debug)] +pub struct PeerInfo { + pub pid: u32, + pub uid: u32, + pub gid: u32, +} + +/// Authenticate a connected peer using SO_PEERCRED. +/// +/// On Linux, this retrieves the PID, UID, and GID of the connected process +/// from the kernel. +pub fn authenticate_peer(stream: &tokio::net::UnixStream) -> io::Result { + let cred = stream.peer_cred()?; + + Ok(PeerInfo { + pid: cred.pid().and_then(|p| u32::try_from(p).ok()).unwrap_or(0), + uid: cred.uid(), + gid: cred.gid(), + }) +} + +/// Check if the peer is the same user as the cmux process. +pub fn is_same_user(peer: &PeerInfo) -> bool { + peer.uid == unsafe { libc::getuid() } +} + +/// Socket control mode matching macOS cmux. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SocketControlMode { + /// Only allow connections from cmux child processes (same UID + descendant PID). + CmuxOnly, + /// Allow any connection from the same local user (same UID). + LocalUser, + /// Allow any local connection (no auth check). + AllowAll, +} + +impl SocketControlMode { + /// Parse from environment variable or config. + pub fn from_env() -> Self { + match std::env::var("CMUX_SOCKET_MODE").as_deref() { + Ok("allowAll") => Self::AllowAll, + Ok("localUser") => Self::LocalUser, + _ => Self::CmuxOnly, + } + } +} + +/// Check whether a peer is authorized under the given control mode. +/// `server_pid` should be the cmux server process ID (used for CmuxOnly descendant check). +pub fn is_authorized(peer: &PeerInfo, mode: SocketControlMode, server_pid: u32) -> bool { + match mode { + SocketControlMode::AllowAll => true, + SocketControlMode::LocalUser => is_same_user(peer), + SocketControlMode::CmuxOnly => { + // Same UID + peer must be a descendant of the cmux process. + is_same_user(peer) && is_descendant(peer.pid, server_pid) + } + } +} + +/// Check if `pid` is a descendant of `ancestor_pid` by walking /proc/PID/status. +fn is_descendant(pid: u32, ancestor_pid: u32) -> bool { + if pid == 0 { + return false; + } + let mut current = pid; + // Walk up the process tree (bounded to prevent infinite loops) + for _ in 0..64 { + if current == ancestor_pid { + return true; + } + if current <= 1 { + return false; + } + match read_ppid(current) { + Some(ppid) if ppid != current => current = ppid, + _ => return false, + } + } + false +} + +fn read_ppid(pid: u32) -> Option { + let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?; + for line in status.lines() { + if let Some(rest) = line.strip_prefix("PPid:") { + return rest.trim().parse().ok(); + } + } + None +} diff --git a/linux/cmux/src/socket/mod.rs b/linux/cmux/src/socket/mod.rs new file mode 100644 index 0000000000..1df70b347f --- /dev/null +++ b/linux/cmux/src/socket/mod.rs @@ -0,0 +1,3 @@ +pub mod auth; +pub mod server; +pub mod v2; diff --git a/linux/cmux/src/socket/server.rs b/linux/cmux/src/socket/server.rs new file mode 100644 index 0000000000..dae4b186e2 --- /dev/null +++ b/linux/cmux/src/socket/server.rs @@ -0,0 +1,238 @@ +//! Unix socket server for the cmux control API. +//! +//! Listens on a Unix socket and handles line-delimited JSON v2 protocol. +//! Each client connection is handled in a separate tokio task. + +use std::os::unix::fs::FileTypeExt; +use std::sync::Arc; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixListener; +use tokio::sync::Semaphore; +use tokio::time::{timeout, Duration}; + +use crate::app::SharedState; +use crate::socket::auth; +use crate::socket::v2; + +/// Maximum request line size (1 MB). Lines exceeding this limit cause disconnection. +const MAX_REQUEST_LEN: usize = 1024 * 1024; +/// Maximum concurrent client connections. +const MAX_CONNECTIONS: usize = 64; +/// Idle timeout per client connection. Clients that send no data within this +/// window are disconnected to free resources. +const CLIENT_IDLE_TIMEOUT: Duration = Duration::from_secs(300); + +/// Determine the socket path. Prefers `XDG_RUNTIME_DIR` (user-private) over `/tmp`. +/// +/// Validates that `XDG_RUNTIME_DIR` is owned by the current user and not +/// world-writable, per the XDG Base Directory Specification. +pub fn socket_path() -> String { + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + let path = std::path::Path::new(&dir); + if path.is_absolute() { + if let Ok(meta) = std::fs::metadata(path) { + use std::os::unix::fs::MetadataExt; + let my_uid = unsafe { libc::getuid() }; + if meta.is_dir() && meta.uid() == my_uid && (meta.mode() & 0o777) == 0o700 { + return format!("{}/cmux.sock", dir); + } + } + tracing::warn!( + "XDG_RUNTIME_DIR ({}) failed validation, falling back to /tmp", + dir + ); + } + } + format!("/tmp/cmux-{}.sock", unsafe { libc::getuid() }) +} + +/// Run the socket server. This should be called from a tokio runtime +/// on a background thread. +pub async fn run_socket_server(state: Arc) -> anyhow::Result<()> { + let control_mode = auth::SocketControlMode::from_env(); + let server_pid = std::process::id(); + tracing::info!("Socket control mode: {:?}", control_mode); + if control_mode == auth::SocketControlMode::CmuxOnly { + tracing::info!( + "CmuxOnly mode: same-UID + descendant-PID check via /proc enabled" + ); + } + + let path = socket_path(); + + // Check if an existing socket is live before removing + let socket_path = std::path::Path::new(&path); + if socket_path.exists() { + // Only remove if it's actually a Unix socket, not a regular file + let metadata = std::fs::symlink_metadata(socket_path)?; + if metadata.file_type().is_socket() { + if std::os::unix::net::UnixStream::connect(&path).is_ok() { + anyhow::bail!("Another cmux instance is already running on {}", path); + } + // Socket is stale — safe to remove + let _ = std::fs::remove_file(&path); + } else { + anyhow::bail!( + "Path {} exists but is not a socket — refusing to overwrite", + path + ); + } + } + + // Restrict socket permissions: set umask before bind so the socket is + // created with 0o600 from the start, then restore the original umask. + // The umask window is brief (just the bind syscall) and the only side + // effect on concurrent file creates is MORE restrictive permissions. + let listener = { + let old_umask = unsafe { libc::umask(0o177) }; + let result = UnixListener::bind(&path); + unsafe { libc::umask(old_umask) }; + result? + }; + tracing::info!("Socket server listening on {}", path); + + let semaphore = Arc::new(Semaphore::new(MAX_CONNECTIONS)); + + loop { + match listener.accept().await { + Ok((stream, _addr)) => { + // Authenticate the client + match auth::authenticate_peer(&stream) { + Ok(peer_info) => { + if !auth::is_authorized(&peer_info, control_mode, server_pid) { + tracing::warn!( + "Client rejected: pid={}, uid={} (mode={:?})", + peer_info.pid, + peer_info.uid, + control_mode, + ); + continue; + } + tracing::debug!( + "Client connected: pid={}, uid={}", + peer_info.pid, + peer_info.uid + ); + // Acquire permit before spawning to bound both tasks and connections + let permit = match semaphore.clone().acquire_owned().await { + Ok(permit) => permit, + Err(_) => continue, + }; + let state = state.clone(); + tokio::spawn(async move { + let _permit = permit; + if let Err(e) = handle_client(stream, state).await { + tracing::debug!("Client disconnected: {}", e); + } + }); + } + Err(e) => { + tracing::warn!("Client authentication failed: {}", e); + } + } + } + Err(e) => { + tracing::error!("Accept error: {}", e); + } + } + } +} + +/// Handle a single client connection. +async fn handle_client( + stream: tokio::net::UnixStream, + state: Arc, +) -> anyhow::Result<()> { + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + let mut line_buf: Vec = Vec::with_capacity(4096); + + loop { + line_buf.clear(); + + // Bounded line read: consume from BufReader in chunks, enforcing MAX_REQUEST_LEN + // before the full line is assembled in memory. + let eof = loop { + let available = match timeout(CLIENT_IDLE_TIMEOUT, reader.fill_buf()).await { + Ok(r) => r?, + Err(_) => { + tracing::debug!("Client idle timeout, disconnecting"); + return Ok(()); + } + }; + if available.is_empty() { + break true; + } + match available.iter().position(|&b| b == b'\n') { + Some(pos) => { + if line_buf.len() + pos > MAX_REQUEST_LEN { + tracing::warn!( + "Client sent oversized request ({} bytes), disconnecting", + line_buf.len() + pos + ); + return Ok(()); + } + line_buf.extend_from_slice(&available[..pos]); + reader.consume(pos + 1); + break false; + } + None => { + let len = available.len(); + line_buf.extend_from_slice(available); + reader.consume(len); + if line_buf.len() > MAX_REQUEST_LEN { + tracing::warn!( + "Client sent oversized request ({} bytes), disconnecting", + line_buf.len() + ); + return Ok(()); + } + } + } + }; + + if eof && line_buf.is_empty() { + break; // Client disconnected + } + + if line_buf.len() > MAX_REQUEST_LEN { + tracing::warn!( + "Client sent oversized request ({} bytes), disconnecting", + line_buf.len() + ); + break; + } + + let trimmed = std::str::from_utf8(&line_buf) + .map(|s| s.trim()) + .unwrap_or(""); + if trimmed.is_empty() { + if eof { + break; + } + continue; + } + + // Dispatch on a blocking thread to avoid holding std::sync::Mutex on async runtime + let state_clone = state.clone(); + let trimmed_owned = trimmed.to_string(); + let response = + tokio::task::spawn_blocking(move || v2::dispatch(&trimmed_owned, &state_clone)).await?; + let mut response_json = serde_json::to_string(&response)?; + response_json.push('\n'); + writer.write_all(response_json.as_bytes()).await?; + writer.flush().await?; + + if eof { + break; + } + } + + Ok(()) +} + +/// Clean up the socket file on shutdown. +pub fn cleanup() { + let _ = std::fs::remove_file(socket_path()); +} diff --git a/linux/cmux/src/socket/v2.rs b/linux/cmux/src/socket/v2.rs new file mode 100644 index 0000000000..ba082a7c58 --- /dev/null +++ b/linux/cmux/src/socket/v2.rs @@ -0,0 +1,978 @@ +//! v2 JSON protocol dispatch. +//! +//! Request format: +//! ```json +//! {"id": "1", "method": "workspace.list", "params": {}} +//! ``` +//! +//! Response format: +//! ```json +//! {"id": "1", "ok": true, "result": {...}} +//! ``` + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::app::{lock_or_recover, SharedState, UiEvent}; +use crate::model::panel::SplitOrientation; +use crate::model::PanelType; +use crate::model::Workspace; + +/// V2 protocol request. +#[derive(Debug, Deserialize)] +pub struct Request { + pub id: Value, + pub method: String, + #[serde(default)] + pub params: Value, +} + +/// V2 protocol response. +#[derive(Debug, Serialize)] +pub struct Response { + pub id: Value, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize)] +pub struct ErrorInfo { + pub code: String, + pub message: String, +} + +impl Response { + fn success(id: Value, result: Value) -> Self { + Self { + id, + ok: true, + result: Some(result), + error: None, + } + } + + fn error(id: Value, code: &str, message: &str) -> Self { + Self { + id, + ok: false, + result: None, + error: Some(ErrorInfo { + code: code.to_string(), + message: message.to_string(), + }), + } + } +} + +/// Parse and dispatch a v2 request. Returns the response. +pub fn dispatch(json_line: &str, state: &Arc) -> Response { + let req: Request = match serde_json::from_str(json_line) { + Ok(r) => r, + Err(e) => { + return Response::error(Value::Null, "parse_error", &format!("Invalid JSON: {}", e)); + } + }; + + let id = req.id.clone(); + + match req.method.as_str() { + // System + "system.ping" => Response::success(id, serde_json::json!({"pong": true})), + "system.capabilities" => handle_capabilities(id), + + // Workspace commands + "workspace.list" => handle_workspace_list(id, state), + "workspace.new" => handle_workspace_new(id, &req.params, state), + "workspace.create" => handle_workspace_create(id, &req.params, state), + "workspace.select" => handle_workspace_select(id, &req.params, state), + "workspace.next" => handle_workspace_next(id, &req.params, state), + "workspace.previous" => handle_workspace_previous(id, &req.params, state), + "workspace.last" => handle_workspace_last(id, state), + "workspace.latest_unread" => handle_workspace_latest_unread(id, state), + "workspace.close" => handle_workspace_close(id, &req.params, state), + "workspace.set_status" => handle_workspace_set_status(id, &req.params, state), + "workspace.report_git_branch" => handle_workspace_report_git(id, &req.params, state), + "workspace.set_progress" => handle_workspace_set_progress(id, &req.params, state), + "workspace.append_log" => handle_workspace_append_log(id, &req.params, state), + + // Pane commands + "pane.new" => handle_pane_new(id, &req.params, state), + + // Surface commands + "surface.send_input" => handle_surface_send_input(id, &req.params, state), + + // Notification commands + "notification.create" => handle_notification_create(id, &req.params, state), + + _ => Response::error( + id, + "unknown_method", + &format!( + "Unknown method: {}", + crate::model::workspace::truncate_str(&req.method, 200) + ), + ), + } +} + +// ----------------------------------------------------------------------- +// System handlers +// ----------------------------------------------------------------------- + +fn handle_capabilities(id: Value) -> Response { + let methods = vec![ + "system.ping", + "system.capabilities", + "workspace.list", + "workspace.new", + "workspace.create", + "workspace.select", + "workspace.next", + "workspace.previous", + "workspace.last", + "workspace.latest_unread", + "workspace.close", + "workspace.set_status", + "workspace.report_git_branch", + "workspace.set_progress", + "workspace.append_log", + "pane.new", + "surface.send_input", + "notification.create", + ]; + Response::success(id, serde_json::json!({"methods": methods})) +} + +// ----------------------------------------------------------------------- +// Workspace handlers +// ----------------------------------------------------------------------- + +fn handle_workspace_list(id: Value, state: &Arc) -> Response { + let tm = lock_or_recover(&state.tab_manager); + let workspaces: Vec = tm + .iter() + .enumerate() + .map(|(i, ws)| { + let selected = tm.selected_index() == Some(i); + serde_json::json!({ + "index": i, + "id": ws.id.to_string(), + "title": ws.display_title(), + "directory": ws.current_directory, + "panel_count": ws.panels.len(), + "unread_count": ws.unread_count, + "latest_notification": ws.latest_notification, + "attention_panel_id": ws.attention_panel_id.map(|id| id.to_string()), + "selected": selected, + "is_selected": selected, + }) + }) + .collect(); + + Response::success(id, serde_json::json!({"workspaces": workspaces})) +} + +fn handle_workspace_new(id: Value, params: &Value, state: &Arc) -> Response { + create_workspace(id, params, state, false) +} + +fn handle_workspace_create(id: Value, params: &Value, state: &Arc) -> Response { + create_workspace(id, params, state, true) +} + +fn create_workspace( + id: Value, + params: &Value, + state: &Arc, + preserve_selection: bool, +) -> Response { + let directory = params + .get("directory") + .or_else(|| params.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| crate::model::workspace::truncate_str(s, 4096)); + let title = params + .get("title") + .and_then(|v| v.as_str()) + .map(|s| crate::model::workspace::truncate_str(s, 1024)); + + let mut ws = if let Some(dir) = directory { + Workspace::with_directory(dir) + } else { + Workspace::new() + }; + + if let Some(t) = title { + ws.custom_title = Some(t.to_string()); + } + + let ws_id = ws.id; + let mut tab_manager = lock_or_recover(&state.tab_manager); + let previously_selected = if preserve_selection { + tab_manager.selected_id() + } else { + None + }; + tab_manager.add_workspace(ws); + if let Some(selected_id) = previously_selected { + let _ = tab_manager.select_by_id(selected_id); + } + drop(tab_manager); + state.notify_ui_refresh(); + + Response::success( + id, + serde_json::json!({ + "workspace_id": ws_id.to_string(), + "workspace": ws_id.to_string() + }), + ) +} + +fn handle_workspace_select(id: Value, params: &Value, state: &Arc) -> Response { + let index = match parse_usize_param(&id, params, "index") { + Ok(index) => index, + Err(response) => return response, + }; + let ws_id = match parse_workspace_param(params) { + Ok(v) => v, + Err(()) => return Response::error(id, "invalid_params", "Invalid workspace UUID"), + }; + + let mut tm = lock_or_recover(&state.tab_manager); + + let selected = if let Some(idx) = index { + tm.select(idx) + } else if let Some(wid) = ws_id { + tm.select_by_id(wid) + } else { + return Response::error( + id, + "invalid_params", + "Provide 'index' or 'workspace'/'workspace_id'", + ); + }; + + if selected { + let selected_workspace = tm.selected_id(); + drop(tm); + if let Some(workspace_id) = selected_workspace { + mark_workspace_read(state, workspace_id); + } + state.notify_ui_refresh(); + Response::success(id, serde_json::json!({"selected": true})) + } else { + Response::error(id, "not_found", "Workspace not found") + } +} + +fn handle_workspace_next(id: Value, params: &Value, state: &Arc) -> Response { + let wrap = params.get("wrap").and_then(|v| v.as_bool()).unwrap_or(true); + let selected_workspace = { + let mut tm = lock_or_recover(&state.tab_manager); + tm.select_next(wrap); + tm.selected_id() + }; + if let Some(workspace_id) = selected_workspace { + mark_workspace_read(state, workspace_id); + } + state.notify_ui_refresh(); + Response::success(id, serde_json::json!({"ok": true})) +} + +fn handle_workspace_previous(id: Value, params: &Value, state: &Arc) -> Response { + let wrap = params.get("wrap").and_then(|v| v.as_bool()).unwrap_or(true); + let selected_workspace = { + let mut tm = lock_or_recover(&state.tab_manager); + tm.select_previous(wrap); + tm.selected_id() + }; + if let Some(workspace_id) = selected_workspace { + mark_workspace_read(state, workspace_id); + } + state.notify_ui_refresh(); + Response::success(id, serde_json::json!({"ok": true})) +} + +fn handle_workspace_last(id: Value, state: &Arc) -> Response { + let selected_workspace = { + let mut tm = lock_or_recover(&state.tab_manager); + tm.select_last(); + tm.selected_id() + }; + if let Some(workspace_id) = selected_workspace { + mark_workspace_read(state, workspace_id); + } + state.notify_ui_refresh(); + Response::success(id, serde_json::json!({"ok": true})) +} + +fn handle_workspace_latest_unread(id: Value, state: &Arc) -> Response { + let selected_workspace = { + let mut tm = lock_or_recover(&state.tab_manager); + tm.select_latest_unread() + }; + + if let Some(workspace_id) = selected_workspace { + mark_workspace_read(state, workspace_id); + state.notify_ui_refresh(); + Response::success( + id, + serde_json::json!({ + "workspace_id": workspace_id.to_string(), + "workspace": workspace_id.to_string(), + "selected": true + }), + ) + } else { + Response::error(id, "not_found", "No unread workspace") + } +} + +fn handle_workspace_close(id: Value, params: &Value, state: &Arc) -> Response { + let index = match parse_usize_param(&id, params, "index") { + Ok(index) => index, + Err(response) => return response, + }; + let ws_id = match parse_workspace_param(params) { + Ok(v) => v, + Err(()) => return Response::error(id, "invalid_params", "Invalid workspace UUID"), + }; + + let removed = { + let mut tm = lock_or_recover(&state.tab_manager); + if let Some(idx) = index { + tm.remove(idx).is_some() + } else if let Some(wid) = ws_id { + tm.remove_by_id(wid).is_some() + } else if let Some(idx) = tm.selected_index() { + tm.remove(idx).is_some() + } else { + false + } + }; + + if removed { + state.notify_ui_refresh(); + Response::success(id, serde_json::json!({"closed": true})) + } else { + Response::error(id, "not_found", "Workspace not found") + } +} + +fn handle_workspace_set_status(id: Value, params: &Value, state: &Arc) -> Response { + let ws_id = match parse_workspace_param(params) { + Ok(v) => v, + Err(()) => return Response::error(id, "invalid_params", "Invalid workspace UUID"), + }; + let key = params.get("key").and_then(|v| v.as_str()); + let value = params.get("value").and_then(|v| v.as_str()); + let icon = params.get("icon").and_then(|v| v.as_str()); + let color = params.get("color").and_then(|v| v.as_str()); + + let (Some(key), Some(value)) = (key, value) else { + return Response::error(id, "invalid_params", "Provide 'key' and 'value'"); + }; + + let updated = { + let mut tm = lock_or_recover(&state.tab_manager); + let ws = if let Some(wid) = ws_id { + tm.workspace_mut(wid) + } else { + tm.selected_mut() + }; + + if let Some(ws) = ws { + ws.set_status(key, value, icon, color); + true + } else { + false + } + }; + + if updated { + state.notify_ui_refresh(); + Response::success(id, serde_json::json!({"ok": true})) + } else { + Response::error(id, "not_found", "Workspace not found") + } +} + +fn handle_workspace_report_git(id: Value, params: &Value, state: &Arc) -> Response { + let ws_id = match parse_workspace_param(params) { + Ok(v) => v, + Err(()) => return Response::error(id, "invalid_params", "Invalid workspace UUID"), + }; + let branch = params.get("branch").and_then(|v| v.as_str()); + let is_dirty = params + .get("is_dirty") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let Some(branch) = branch else { + return Response::error(id, "invalid_params", "Provide 'branch'"); + }; + + let updated = { + let mut tm = lock_or_recover(&state.tab_manager); + let ws = if let Some(wid) = ws_id { + tm.workspace_mut(wid) + } else { + tm.selected_mut() + }; + + if let Some(ws) = ws { + ws.git_branch = Some(crate::model::panel::GitBranch { + branch: crate::model::workspace::truncate_str(branch, 256).to_string(), + is_dirty, + }); + true + } else { + false + } + }; + + if updated { + state.notify_ui_refresh(); + Response::success(id, serde_json::json!({"ok": true})) + } else { + Response::error(id, "not_found", "Workspace not found") + } +} + +fn handle_workspace_set_progress(id: Value, params: &Value, state: &Arc) -> Response { + let ws_id = match parse_workspace_param(params) { + Ok(v) => v, + Err(()) => return Response::error(id, "invalid_params", "Invalid workspace UUID"), + }; + let value = params.get("value").and_then(|v| v.as_f64()); + let label = params.get("label").and_then(|v| v.as_str()); + + let updated = { + let mut tm = lock_or_recover(&state.tab_manager); + let ws = if let Some(wid) = ws_id { + tm.workspace_mut(wid) + } else { + tm.selected_mut() + }; + + if let Some(ws) = ws { + if let Some(value) = value { + ws.progress = Some(crate::model::workspace::Progress { + value, + label: label.map(|s| s.to_string()), + }); + } else { + ws.progress = None; + } + true + } else { + false + } + }; + + if updated { + state.notify_ui_refresh(); + Response::success(id, serde_json::json!({"ok": true})) + } else { + Response::error(id, "not_found", "Workspace not found") + } +} + +fn handle_workspace_append_log(id: Value, params: &Value, state: &Arc) -> Response { + let ws_id = match parse_workspace_param(params) { + Ok(v) => v, + Err(()) => return Response::error(id, "invalid_params", "Invalid workspace UUID"), + }; + let message = params.get("message").and_then(|v| v.as_str()); + let level = params + .get("level") + .and_then(|v| v.as_str()) + .unwrap_or("info"); + let source = params.get("source").and_then(|v| v.as_str()); + + let Some(message) = message else { + return Response::error(id, "invalid_params", "Provide 'message'"); + }; + + let updated = { + let mut tm = lock_or_recover(&state.tab_manager); + let ws = if let Some(wid) = ws_id { + tm.workspace_mut(wid) + } else { + tm.selected_mut() + }; + + if let Some(ws) = ws { + ws.append_log(message, level, source); + true + } else { + false + } + }; + + if updated { + state.notify_ui_refresh(); + Response::success(id, serde_json::json!({"ok": true})) + } else { + Response::error(id, "not_found", "Workspace not found") + } +} + +// ----------------------------------------------------------------------- +// Pane handlers +// ----------------------------------------------------------------------- + +fn handle_pane_new(id: Value, params: &Value, state: &Arc) -> Response { + let orientation = match params.get("orientation").and_then(|v| v.as_str()) { + Some("horizontal") => SplitOrientation::Horizontal, + Some("vertical") => SplitOrientation::Vertical, + _ => SplitOrientation::Horizontal, + }; + + let mut tm = lock_or_recover(&state.tab_manager); + if let Some(ws) = tm.selected_mut() { + let panel_id = ws.split(orientation, PanelType::Terminal); + drop(tm); + state.notify_ui_refresh(); + Response::success(id, serde_json::json!({"panel_id": panel_id.to_string()})) + } else { + Response::error(id, "not_found", "No workspace selected") + } +} + +// ----------------------------------------------------------------------- +// Surface handlers +// ----------------------------------------------------------------------- + +fn handle_surface_send_input(id: Value, params: &Value, state: &Arc) -> Response { + let Some(input) = params.get("input").and_then(|v| v.as_str()) else { + return Response::error(id, "invalid_params", "Provide 'input'"); + }; + // Limit input size to prevent unbounded memory growth via the channel + let input = crate::model::workspace::truncate_str(input, 128 * 1024); + + let explicit_panel_id = match params.get("surface").or_else(|| params.get("panel")) { + Some(v) => { + let Some(s) = v.as_str() else { + return Response::error(id, "invalid_params", "surface/panel must be a string"); + }; + match uuid::Uuid::parse_str(s) { + Ok(uuid) => Some(uuid), + Err(_) => { + return Response::error( + id, + "invalid_params", + "Invalid surface/panel UUID format", + ) + } + } + } + None => None, + }; + + let panel_id = { + let tab_manager = lock_or_recover(&state.tab_manager); + if let Some(panel_id) = explicit_panel_id { + if tab_manager.find_workspace_with_panel(panel_id).is_none() { + return Response::error(id, "not_found", "Surface not found"); + } + panel_id + } else if let Some(workspace) = tab_manager.selected() { + let Some(panel_id) = workspace + .focused_panel_id + .or_else(|| workspace.panel_ids().into_iter().next()) + else { + return Response::error(id, "not_found", "No focused surface"); + }; + panel_id + } else { + return Response::error(id, "not_found", "No workspace selected"); + } + }; + + if !state.send_ui_event(UiEvent::SendInput { + panel_id, + text: input.to_string(), + }) { + return Response::error(id, "not_ready", "UI is not ready"); + } + + Response::success( + id, + serde_json::json!({ + "sent": true, + "surface": panel_id.to_string(), + }), + ) +} + +// ----------------------------------------------------------------------- +// Notification handlers +// ----------------------------------------------------------------------- + +fn handle_notification_create(id: Value, params: &Value, state: &Arc) -> Response { + let title = crate::model::workspace::truncate_str( + params + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("cmux"), + 1024, + ); + let body = crate::model::workspace::truncate_str( + params.get("body").and_then(|v| v.as_str()).unwrap_or(""), + 8192, + ); + let workspace_id = match parse_workspace_param(params) { + Ok(v) => v, + Err(()) => return Response::error(id, "invalid_params", "Invalid workspace UUID"), + }; + let panel_id = match params.get("surface").or_else(|| params.get("panel")) { + Some(v) => { + let Some(s) = v.as_str() else { + return Response::error(id, "invalid_params", "surface/panel must be a string"); + }; + match uuid::Uuid::parse_str(s) { + Ok(uuid) => Some(uuid), + Err(_) => { + return Response::error( + id, + "invalid_params", + "Invalid surface/panel UUID format", + ) + } + } + } + None => None, + }; + let send_desktop = params + .get("send_desktop") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + let target = { + let mut tm = lock_or_recover(&state.tab_manager); + let target_workspace_id = if let Some(workspace_id) = workspace_id { + if tm.workspace(workspace_id).is_some() { + Some(workspace_id) + } else { + return Response::error(id, "not_found", "Workspace not found"); + } + } else if let Some(panel_id) = panel_id { + tm.find_workspace_with_panel(panel_id).map(|ws| ws.id) + } else { + tm.selected_id() + }; + + let Some(target_workspace_id) = target_workspace_id else { + return Response::error(id, "not_found", "No workspace selected"); + }; + + let workspace = tm.workspace_mut(target_workspace_id).unwrap(); + let resolved_panel_id = panel_id.filter(|id| workspace.panels.contains_key(id)); + workspace.record_notification(title, body, resolved_panel_id); + (target_workspace_id, resolved_panel_id) + }; + + let (target_workspace_id, resolved_panel_id) = target; + lock_or_recover(&state.notifications).add( + title, + body, + Some(target_workspace_id), + resolved_panel_id, + send_desktop, + ); + state.notify_ui_refresh(); + + Response::success( + id, + serde_json::json!({ + "notified": true, + "workspace": target_workspace_id.to_string(), + "workspace_id": target_workspace_id.to_string(), + "surface": resolved_panel_id.map(|panel_id| panel_id.to_string()), + }), + ) +} + +fn mark_workspace_read(state: &Arc, workspace_id: uuid::Uuid) { + lock_or_recover(&state.notifications).mark_workspace_read(workspace_id); + + if let Some(workspace) = lock_or_recover(&state.tab_manager).workspace_mut(workspace_id) { + workspace.mark_notifications_read(); + } +} + +/// Parse a workspace UUID from `workspace` or `workspace_id` params. +/// Returns `Err(())` if the key exists but the value is not a valid UUID. +/// Returns `Ok(None)` if neither key is present. +fn parse_workspace_param(params: &Value) -> Result, ()> { + let val = params + .get("workspace") + .or_else(|| params.get("workspace_id")); + match val { + Some(v) => match v.as_str().map(uuid::Uuid::parse_str) { + Some(Ok(id)) => Ok(Some(id)), + _ => Err(()), + }, + None => Ok(None), + } +} + +fn parse_usize_param(id: &Value, params: &Value, key: &str) -> Result, Response> { + match params.get(key) { + Some(v) => match v.as_u64() { + Some(value) => usize::try_from(value).map(Some).map_err(|_| { + Response::error( + id.clone(), + "invalid_params", + &format!("'{key}' is out of range"), + ) + }), + None => Err(Response::error( + id.clone(), + "invalid_params", + &format!("'{key}' must be a non-negative integer"), + )), + }, + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_notification_create_updates_workspace_attention() { + let state = Arc::new(SharedState::new()); + let (workspace_id, panel_id) = { + let tab_manager = lock_or_recover(&state.tab_manager); + let workspace = tab_manager.selected().unwrap(); + (workspace.id, workspace.focused_panel_id.unwrap()) + }; + + let request = serde_json::json!({ + "id": 1, + "method": "notification.create", + "params": { + "title": "Codex", + "body": "Waiting for input", + "workspace": workspace_id.to_string(), + "surface": panel_id.to_string(), + "send_desktop": false + } + }); + + let response = dispatch(&request.to_string(), &state); + assert!(response.ok); + + let tab_manager = lock_or_recover(&state.tab_manager); + let workspace = tab_manager.workspace(workspace_id).unwrap(); + assert_eq!(workspace.unread_count, 1); + assert_eq!( + workspace.latest_notification.as_deref(), + Some("Codex: Waiting for input") + ); + assert_eq!(workspace.attention_panel_id, Some(panel_id)); + } + + #[test] + fn test_workspace_latest_unread_selects_newest_workspace() { + let state = Arc::new(SharedState::new()); + let workspace_one_id = lock_or_recover(&state.tab_manager).selected_id().unwrap(); + + let new_workspace_request = serde_json::json!({ + "id": 1, + "method": "workspace.new", + "params": { + "title": "Second" + } + }); + let response = dispatch(&new_workspace_request.to_string(), &state); + assert!(response.ok); + + let workspace_two_id = lock_or_recover(&state.tab_manager).selected_id().unwrap(); + + let first_notification = serde_json::json!({ + "id": 2, + "method": "notification.create", + "params": { + "title": "Claude Code", + "body": "Needs approval", + "workspace": workspace_one_id.to_string(), + "send_desktop": false + } + }); + assert!(dispatch(&first_notification.to_string(), &state).ok); + + std::thread::sleep(std::time::Duration::from_millis(1)); + + let second_notification = serde_json::json!({ + "id": 3, + "method": "notification.create", + "params": { + "title": "Codex", + "body": "Waiting for input", + "workspace": workspace_two_id.to_string(), + "send_desktop": false + } + }); + assert!(dispatch(&second_notification.to_string(), &state).ok); + + let latest_unread = serde_json::json!({ + "id": 4, + "method": "workspace.latest_unread", + "params": {} + }); + let response = dispatch(&latest_unread.to_string(), &state); + assert!(response.ok); + + let tab_manager = lock_or_recover(&state.tab_manager); + assert_eq!(tab_manager.selected_id(), Some(workspace_two_id)); + assert_eq!( + tab_manager + .workspace(workspace_two_id) + .unwrap() + .unread_count, + 0 + ); + assert_eq!( + tab_manager + .workspace(workspace_one_id) + .unwrap() + .unread_count, + 1 + ); + } + + #[test] + fn test_surface_send_input_dispatches_ui_event() { + let state = Arc::new(SharedState::new()); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + state.install_ui_event_sender(tx); + + let panel_id = { + let tab_manager = lock_or_recover(&state.tab_manager); + tab_manager.selected().unwrap().focused_panel_id.unwrap() + }; + + let request = serde_json::json!({ + "id": 1, + "method": "surface.send_input", + "params": { + "surface": panel_id.to_string(), + "input": "ls\n" + } + }); + + let response = dispatch(&request.to_string(), &state); + assert!(response.ok); + + let event = rx.try_recv().expect("expected a UI event"); + match event { + UiEvent::SendInput { + panel_id: actual, + text, + } => { + assert_eq!(actual, panel_id); + assert_eq!(text, "ls\n"); + } + other => panic!("unexpected event: {other:?}"), + } + } + + #[test] + fn test_workspace_create_alias_and_legacy_response_field() { + let state = Arc::new(SharedState::new()); + let selected_before = lock_or_recover(&state.tab_manager).selected_id(); + + let response = dispatch( + r#"{"id":1,"method":"workspace.create","params":{"title":"Legacy"}}"#, + &state, + ); + + assert!(response.ok); + let result = response.result.unwrap(); + let workspace_id = result + .get("workspace_id") + .and_then(|v| v.as_str()) + .expect("legacy workspace_id should be present"); + assert_eq!( + result.get("workspace").and_then(|v| v.as_str()), + Some(workspace_id) + ); + assert_eq!( + lock_or_recover(&state.tab_manager).selected_id(), + selected_before + ); + } + + #[test] + fn test_workspace_list_keeps_selected_alias() { + let state = Arc::new(SharedState::new()); + + let response = dispatch(r#"{"id":1,"method":"workspace.list","params":{}}"#, &state); + + assert!(response.ok); + let result = response.result.unwrap(); + let workspaces = result["workspaces"].as_array().expect("workspaces array"); + let first = &workspaces[0]; + assert_eq!(first.get("selected").and_then(|v| v.as_bool()), Some(true)); + assert_eq!( + first.get("is_selected").and_then(|v| v.as_bool()), + Some(true) + ); + } + + #[test] + fn test_workspace_select_accepts_legacy_workspace_id_param() { + let state = Arc::new(SharedState::new()); + let workspace_id = lock_or_recover(&state.tab_manager).selected_id().unwrap(); + + let response = dispatch( + &serde_json::json!({ + "id": 1, + "method": "workspace.select", + "params": { + "workspace_id": workspace_id.to_string() + } + }) + .to_string(), + &state, + ); + + assert!(response.ok); + assert_eq!( + lock_or_recover(&state.tab_manager).selected_id(), + Some(workspace_id) + ); + } + + #[test] + fn test_workspace_create_accepts_legacy_cwd_param() { + let state = Arc::new(SharedState::new()); + + let response = dispatch( + r#"{"id":1,"method":"workspace.create","params":{"cwd":"/tmp/cmux-legacy"}}"#, + &state, + ); + + assert!(response.ok); + let workspace_id = response.result.as_ref().unwrap()["workspace_id"] + .as_str() + .expect("workspace_id should be present"); + let workspace_id = uuid::Uuid::parse_str(workspace_id).expect("valid uuid"); + + let tab_manager = lock_or_recover(&state.tab_manager); + let workspace = tab_manager + .workspace(workspace_id) + .expect("workspace should exist"); + assert_eq!(workspace.current_directory, "/tmp/cmux-legacy"); + } +} diff --git a/linux/cmux/src/ui/mod.rs b/linux/cmux/src/ui/mod.rs new file mode 100644 index 0000000000..5fab3c221d --- /dev/null +++ b/linux/cmux/src/ui/mod.rs @@ -0,0 +1,4 @@ +pub mod sidebar; +pub mod split_view; +pub mod terminal_panel; +pub mod window; diff --git a/linux/cmux/src/ui/sidebar.rs b/linux/cmux/src/ui/sidebar.rs new file mode 100644 index 0000000000..30b1bf715a --- /dev/null +++ b/linux/cmux/src/ui/sidebar.rs @@ -0,0 +1,175 @@ +//! Sidebar — workspace list using GtkListBox. + +use std::path::Path; +use std::rc::Rc; + +use gtk4::prelude::*; + +use crate::app::{lock_or_recover, AppState}; +use crate::model::Workspace; + +pub struct SidebarWidgets { + pub root: gtk4::Box, + pub list_box: gtk4::ListBox, +} + +/// Create the sidebar widget containing the workspace list. +pub fn create_sidebar(state: &Rc) -> SidebarWidgets { + let sidebar_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + sidebar_box.add_css_class("sidebar"); + + let scrolled = gtk4::ScrolledWindow::new(); + scrolled.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); + scrolled.set_vexpand(true); + + let list_box = gtk4::ListBox::new(); + list_box.set_selection_mode(gtk4::SelectionMode::Single); + list_box.add_css_class("navigation-sidebar"); + + refresh_sidebar(&list_box, state); + + scrolled.set_child(Some(&list_box)); + sidebar_box.append(&scrolled); + + SidebarWidgets { + root: sidebar_box, + list_box, + } +} + +/// Refresh the workspace list from shared state. +pub fn refresh_sidebar(list_box: >k4::ListBox, state: &Rc) { + while let Some(child) = list_box.first_child() { + list_box.remove(&child); + } + + // Build rows and capture selection index while holding the lock, then + // release the lock before calling list_box.select_row. select_row emits + // `row-selected` synchronously; the connected handler tries to acquire + // the same tab_manager lock, which would deadlock on std::sync::Mutex. + let (rows, selected_index): (Vec, Option) = { + let tab_manager = lock_or_recover(&state.shared.tab_manager); + let selected_index = tab_manager.selected_index(); + let rows = tab_manager + .iter() + .enumerate() + .map(|(index, workspace)| create_workspace_row(workspace, index)) + .collect(); + (rows, selected_index) + }; + + for (index, row) in rows.iter().enumerate() { + list_box.append(row); + if selected_index == Some(index) { + list_box.select_row(Some(row)); + } + } +} + +fn create_workspace_row(workspace: &Workspace, index: usize) -> gtk4::ListBoxRow { + let row = gtk4::ListBoxRow::new(); + row.add_css_class("workspace-row"); + + let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + outer.set_margin_start(10); + outer.set_margin_end(10); + outer.set_margin_top(8); + outer.set_margin_bottom(8); + + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + + let index_label = gtk4::Label::new(Some(&format!("{}", index + 1))); + index_label.add_css_class("dim-label"); + index_label.add_css_class("caption"); + header.append(&index_label); + + let title_label = gtk4::Label::new(Some(workspace.display_title())); + title_label.set_hexpand(true); + title_label.set_halign(gtk4::Align::Start); + title_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); + header.append(&title_label); + + if workspace.unread_count > 0 { + let badge = gtk4::Label::new(Some(&workspace.unread_count.to_string())); + badge.add_css_class("badge"); + badge.add_css_class("accent"); + header.append(&badge); + } + + outer.append(&header); + + let meta_label = gtk4::Label::new(Some(&workspace_meta_text(workspace))); + meta_label.set_halign(gtk4::Align::Start); + meta_label.set_wrap(false); + meta_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); + meta_label.add_css_class("caption"); + meta_label.add_css_class("dim-label"); + outer.append(&meta_label); + + let notification_text = workspace + .latest_notification + .clone() + .unwrap_or_else(|| compact_path(&workspace.current_directory)); + let notification_label = gtk4::Label::new(Some(¬ification_text)); + notification_label.set_halign(gtk4::Align::Start); + notification_label.set_wrap(false); + notification_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); + notification_label.add_css_class("caption"); + if workspace.unread_count > 0 { + notification_label.add_css_class("sidebar-notification"); + } else { + notification_label.add_css_class("dim-label"); + } + outer.append(¬ification_label); + + row.set_child(Some(&outer)); + row +} + +fn workspace_meta_text(workspace: &Workspace) -> String { + let mut parts = Vec::new(); + + if let Some(status) = workspace.sidebar_status_label() { + parts.push(status.to_string()); + } + + if let Some(git_branch) = &workspace.git_branch { + parts.push(if git_branch.is_dirty { + format!("git {} *", git_branch.branch) + } else { + format!("git {}", git_branch.branch) + }); + } else { + parts.push(compact_path(&workspace.current_directory)); + } + + parts.join(" | ") +} + +fn compact_path(path: &str) -> String { + if path.is_empty() { + return "~".to_string(); + } + + if let Ok(home) = std::env::var("HOME") { + // Guard against HOME="/" where strip_prefix would match any absolute path + if home != "/" { + let p = Path::new(path); + if let Ok(stripped) = p.strip_prefix(&home) { + let s = stripped.display(); + return if stripped.as_os_str().is_empty() { + "~".to_string() + } else { + format!("~/{s}") + }; + } + } + } + + let path = Path::new(path); + if let Some(name) = path.file_name().and_then(|name| name.to_str()) { + return name.to_string(); + } + + path.to_string_lossy().into_owned() +} diff --git a/linux/cmux/src/ui/split_view.rs b/linux/cmux/src/ui/split_view.rs new file mode 100644 index 0000000000..51c71b080f --- /dev/null +++ b/linux/cmux/src/ui/split_view.rs @@ -0,0 +1,185 @@ +//! Split view — recursive GtkPaned tree from LayoutNode. + +use std::cell::Cell; +use std::collections::HashMap; +use std::rc::Rc; + +use gtk4::prelude::*; +use uuid::Uuid; + +use crate::app::{lock_or_recover, AppState}; +use crate::model::panel::{LayoutNode, Panel, SplitOrientation}; +use crate::ui::terminal_panel; + +/// Build a GTK widget tree from a LayoutNode. +/// +/// - `LayoutNode::Pane` → GtkStack (with tabs if multiple panels) wrapping terminal widgets +/// - `LayoutNode::Split` → GtkPaned with recursive children +pub fn build_layout( + workspace_id: Uuid, + node: &LayoutNode, + panels: &HashMap, + attention_panel_id: Option, + state: &Rc, +) -> gtk4::Widget { + match node { + LayoutNode::Pane { + panel_ids, + selected_panel_id, + } => build_pane( + panel_ids, + *selected_panel_id, + panels, + attention_panel_id, + state, + ), + + LayoutNode::Split { + orientation, + divider_position, + first, + second, + } => build_split( + workspace_id, + *orientation, + *divider_position, + first, + second, + panels, + attention_panel_id, + state, + ), + } +} + +/// Build a pane widget (single or tabbed panels). +fn build_pane( + panel_ids: &[Uuid], + selected_id: Option, + panels: &HashMap, + attention_panel_id: Option, + state: &Rc, +) -> gtk4::Widget { + if panel_ids.is_empty() { + // Empty pane — show placeholder + let label = gtk4::Label::new(Some("Empty pane")); + label.set_hexpand(true); + label.set_vexpand(true); + return label.upcast(); + } + + if panel_ids.len() == 1 { + // Single panel — no tabs needed + let panel_id = panel_ids[0]; + if let Some(panel) = panels.get(&panel_id) { + return terminal_panel::create_panel_widget( + panel, + attention_panel_id == Some(panel_id), + state, + ); + } + let label = gtk4::Label::new(Some("Panel not found")); + return label.upcast(); + } + + // Multiple panels — use GtkStack with switcher + let stack = gtk4::Stack::new(); + stack.set_hexpand(true); + stack.set_vexpand(true); + + for &panel_id in panel_ids { + if let Some(panel) = panels.get(&panel_id) { + let widget = terminal_panel::create_panel_widget( + panel, + attention_panel_id == Some(panel_id), + state, + ); + let page = stack.add_child(&widget); + page.set_title(panel.display_title()); + page.set_name(&panel_id.to_string()); + } + } + + // Select the active panel + if let Some(sel_id) = selected_id { + stack.set_visible_child_name(&sel_id.to_string()); + } + + // If there are tabs, add a tab switcher + let vbox = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + if panel_ids.len() > 1 { + let switcher = gtk4::StackSwitcher::new(); + switcher.set_stack(Some(&stack)); + vbox.append(&switcher); + } + vbox.append(&stack); + vbox.set_hexpand(true); + vbox.set_vexpand(true); + vbox.upcast() +} + +/// Build a split widget (GtkPaned with two children). +fn build_split( + workspace_id: Uuid, + orientation: SplitOrientation, + divider_position: f64, + first: &LayoutNode, + second: &LayoutNode, + panels: &HashMap, + attention_panel_id: Option, + state: &Rc, +) -> gtk4::Widget { + let gtk_orientation = match orientation { + SplitOrientation::Horizontal => gtk4::Orientation::Horizontal, + SplitOrientation::Vertical => gtk4::Orientation::Vertical, + }; + + let paned = gtk4::Paned::new(gtk_orientation); + paned.set_wide_handle(true); + paned.set_hexpand(true); + paned.set_vexpand(true); + + let first_panel_ids = first.all_panel_ids(); + let second_panel_ids = second.all_panel_ids(); + let first_widget = build_layout(workspace_id, first, panels, attention_panel_id, state); + let second_widget = build_layout(workspace_id, second, panels, attention_panel_id, state); + + paned.set_start_child(Some(&first_widget)); + paned.set_end_child(Some(&second_widget)); + + let pos = divider_position; + let initial_position_applied = Rc::new(Cell::new(false)); + let state = Rc::clone(state); + let initial_position_applied_for_notify = Rc::clone(&initial_position_applied); + paned.connect_position_notify(move |paned| { + let size = match paned.orientation() { + gtk4::Orientation::Horizontal => paned.width(), + _ => paned.height(), + }; + if size <= 0 { + return; + } + + if !initial_position_applied_for_notify.replace(true) { + let desired_position = (size as f64 * pos) as i32; + if paned.position() != desired_position { + paned.set_position(desired_position); + } + return; + } + + let divider_position = (paned.position() as f64 / size as f64).clamp(0.0, 1.0); + { + let mut tm = lock_or_recover(&state.shared.tab_manager); + if let Some(workspace) = tm.workspace_mut(workspace_id) { + let _ = workspace.layout.set_divider_position_for_split( + &first_panel_ids, + &second_panel_ids, + divider_position, + ); + } + } + }); + + paned.upcast() +} diff --git a/linux/cmux/src/ui/terminal_panel.rs b/linux/cmux/src/ui/terminal_panel.rs new file mode 100644 index 0000000000..f8d31be3c0 --- /dev/null +++ b/linux/cmux/src/ui/terminal_panel.rs @@ -0,0 +1,76 @@ +//! Terminal panel — wraps a GhosttyGlSurface in a panel container. + +use std::rc::Rc; + +use gtk4::prelude::*; + +use crate::app::AppState; +use crate::model::panel::{Panel, PanelType}; + +/// Create a GTK widget for a panel. +pub fn create_panel_widget( + panel: &Panel, + is_attention_source: bool, + state: &Rc, +) -> gtk4::Widget { + match panel.panel_type { + PanelType::Terminal => create_terminal_widget(panel, is_attention_source, state), + PanelType::Browser => create_browser_placeholder(panel, is_attention_source), + } +} + +/// Create a terminal panel widget backed by GhosttyGlSurface. +fn create_terminal_widget( + panel: &Panel, + is_attention_source: bool, + state: &Rc, +) -> gtk4::Widget { + let container = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + container.set_hexpand(true); + container.set_vexpand(true); + container.add_css_class("panel-shell"); + if is_attention_source { + container.add_css_class("attention-panel"); + } + + let gl_surface = state.terminal_surface_for(panel.id, panel.directory.as_deref()); + { + let state = Rc::clone(state); + let panel_id = panel.id; + gl_surface.set_close_handler(move |process_alive| { + let _ = state.close_panel(panel_id, process_alive); + }); + } + if let Some(parent) = gl_surface.parent() { + if let Ok(parent_box) = parent.downcast::() { + parent_box.remove(&gl_surface); + } + } + + container.append(&gl_surface); + + // Store the panel ID for later lookup + container.set_widget_name(&panel.id.to_string()); + + container.upcast() +} + +/// Create a placeholder for the browser panel (Phase 4). +fn create_browser_placeholder(panel: &Panel, is_attention_source: bool) -> gtk4::Widget { + let container = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + container.set_hexpand(true); + container.set_vexpand(true); + container.add_css_class("panel-shell"); + if is_attention_source { + container.add_css_class("attention-panel"); + } + + let label = gtk4::Label::new(Some("Browser panel (coming in Phase 4)")); + label.set_hexpand(true); + label.set_vexpand(true); + label.add_css_class("dim-label"); + container.append(&label); + + container.set_widget_name(&panel.id.to_string()); + container.upcast() +} diff --git a/linux/cmux/src/ui/window.rs b/linux/cmux/src/ui/window.rs new file mode 100644 index 0000000000..95cebb27d8 --- /dev/null +++ b/linux/cmux/src/ui/window.rs @@ -0,0 +1,349 @@ +//! Main application window using AdwNavigationSplitView. + +use std::rc::Rc; + +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tokio::sync::mpsc::UnboundedReceiver; + +use crate::app::{lock_or_recover, AppState, UiEvent}; +use crate::model::panel::SplitOrientation; +use crate::model::{PanelType, Workspace}; +use crate::ui::{sidebar, split_view}; + +/// Create the main application window. +pub fn create_window( + app: &adw::Application, + state: &Rc, + ui_events: UnboundedReceiver, +) -> adw::ApplicationWindow { + install_css(); + + let window = adw::ApplicationWindow::builder() + .application(app) + .title("cmux") + .default_width(1280) + .default_height(860) + .build(); + + let split_view = adw::NavigationSplitView::new(); + split_view.set_min_sidebar_width(220.0); + split_view.set_max_sidebar_width(360.0); + split_view.set_vexpand(true); + split_view.set_hexpand(true); + + let sidebar_widgets = sidebar::create_sidebar(state); + let list_box = sidebar_widgets.list_box.clone(); + let sidebar_page = adw::NavigationPage::new(&sidebar_widgets.root, "Workspaces"); + split_view.set_sidebar(Some(&sidebar_page)); + + let content_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + content_box.set_hexpand(true); + content_box.set_vexpand(true); + rebuild_content(&content_box, state); + + let content_page = adw::NavigationPage::new(&content_box, "Terminal"); + split_view.set_content(Some(&content_page)); + + bind_sidebar_selection(&list_box, &content_box, state); + bind_shared_state_updates(&list_box, &content_box, state, ui_events); + + let header = adw::HeaderBar::new(); + + let new_ws_btn = gtk4::Button::from_icon_name("tab-new-symbolic"); + new_ws_btn.set_tooltip_text(Some("New Workspace")); + { + let state = state.clone(); + let list_box = list_box.clone(); + let content_box = content_box.clone(); + new_ws_btn.connect_clicked(move |_| { + let workspace = Workspace::new(); + lock_or_recover(&state.shared.tab_manager).add_workspace(workspace); + refresh_ui(&list_box, &content_box, &state); + }); + } + header.pack_start(&new_ws_btn); + + let split_h_btn = gtk4::Button::from_icon_name("view-dual-symbolic"); + split_h_btn.set_tooltip_text(Some("Split Horizontal")); + { + let state = state.clone(); + let list_box = list_box.clone(); + let content_box = content_box.clone(); + split_h_btn.connect_clicked(move |_| { + if let Some(workspace) = lock_or_recover(&state.shared.tab_manager).selected_mut() { + workspace.split(SplitOrientation::Horizontal, PanelType::Terminal); + } + refresh_ui(&list_box, &content_box, &state); + }); + } + header.pack_start(&split_h_btn); + + let split_v_btn = gtk4::Button::from_icon_name("view-paged-symbolic"); + split_v_btn.set_tooltip_text(Some("Split Vertical")); + { + let state = state.clone(); + let list_box = list_box.clone(); + let content_box = content_box.clone(); + split_v_btn.connect_clicked(move |_| { + if let Some(workspace) = lock_or_recover(&state.shared.tab_manager).selected_mut() { + workspace.split(SplitOrientation::Vertical, PanelType::Terminal); + } + refresh_ui(&list_box, &content_box, &state); + }); + } + header.pack_start(&split_v_btn); + + let outer_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + outer_box.append(&header); + outer_box.append(&split_view); + + window.set_content(Some(&outer_box)); + setup_shortcuts(&window, state, &list_box, &content_box); + + { + let state = state.clone(); + window.connect_is_active_notify(move |window| { + let active = window.is_active(); + if let Some(app) = state.ghostty_app.borrow().as_ref() { + app.set_focus(active); + } + }); + } + + window +} + +/// Rebuild the content area from the current workspace layout. +pub fn rebuild_content(content_box: >k4::Box, state: &Rc) { + while let Some(child) = content_box.first_child() { + content_box.remove(&child); + } + + // Clone workspace data out of the lock so we don't hold it during + // GTK widget construction (build_layout callbacks may re-acquire it). + let workspace_data = { + let tab_manager = lock_or_recover(&state.shared.tab_manager); + tab_manager.selected().map(|ws| { + (ws.id, ws.layout.clone(), ws.panels.clone(), ws.attention_panel_id) + }) + }; + + if let Some((id, layout, panels, attention_panel_id)) = workspace_data { + let widget = split_view::build_layout(id, &layout, &panels, attention_panel_id, state); + content_box.append(&widget); + } else { + let label = gtk4::Label::new(Some("No workspace selected")); + label.add_css_class("dim-label"); + content_box.append(&label); + } +} + +fn refresh_ui(list_box: >k4::ListBox, content_box: >k4::Box, state: &Rc) { + state.prune_terminal_cache(); + sidebar::refresh_sidebar(list_box, state); + rebuild_content(content_box, state); +} + +fn bind_sidebar_selection(list_box: >k4::ListBox, content_box: >k4::Box, state: &Rc) { + let state = state.clone(); + let lb = list_box.clone(); + let content_box = content_box.clone(); + + list_box.connect_row_selected(move |_list_box, row| { + let Some(row) = row else { + return; + }; + + let index = row.index(); + if index < 0 { + return; + } + if select_workspace_by_index(&state, index as usize) { + refresh_ui(&lb, &content_box, &state); + } + }); +} + +fn bind_shared_state_updates( + list_box: >k4::ListBox, + content_box: >k4::Box, + state: &Rc, + mut ui_events: UnboundedReceiver, +) { + let state = state.clone(); + let list_box = list_box.clone(); + let content_box = content_box.clone(); + + glib::MainContext::default().spawn_local(async move { + while let Some(event) = ui_events.recv().await { + let mut pending = Some(event); + let mut needs_refresh = false; + loop { + let event = match pending.take() { + Some(event) => event, + None => match ui_events.try_recv() { + Ok(event) => event, + Err(_) => break, + }, + }; + + match event { + UiEvent::Refresh => needs_refresh = true, + UiEvent::SendInput { panel_id, text } => { + let sent = state.send_input_to_panel(panel_id, &text); + if !sent { + tracing::warn!( + %panel_id, + "surface.send_input dropped because panel is not ready" + ); + } + } + } + } + + if needs_refresh { + refresh_ui(&list_box, &content_box, &state); + } + } + }); +} + +fn select_workspace_by_index(state: &Rc, index: usize) -> bool { + let (selected, already_selected, workspace_id) = { + let mut tab_manager = lock_or_recover(&state.shared.tab_manager); + let already_selected = tab_manager.selected_index() == Some(index); + let selected = tab_manager.select(index); + let workspace_id = tab_manager.get(index).map(|workspace| workspace.id); + (selected, already_selected, workspace_id) + }; + + if !selected || already_selected { + return false; + } + + if let Some(workspace_id) = workspace_id { + mark_workspace_read(state, workspace_id); + } + + true +} + +fn select_latest_unread(state: &Rc) -> bool { + let workspace_id = { + let mut tab_manager = lock_or_recover(&state.shared.tab_manager); + tab_manager.select_latest_unread() + }; + + let Some(workspace_id) = workspace_id else { + return false; + }; + + mark_workspace_read(state, workspace_id); + true +} + +fn mark_workspace_read(state: &Rc, workspace_id: uuid::Uuid) { + lock_or_recover(&state.shared.notifications).mark_workspace_read(workspace_id); + + if let Some(workspace) = + lock_or_recover(&state.shared.tab_manager).workspace_mut(workspace_id) + { + workspace.mark_notifications_read(); + } +} + +fn setup_shortcuts( + window: &adw::ApplicationWindow, + state: &Rc, + list_box: >k4::ListBox, + content_box: >k4::Box, +) { + let controller = gtk4::EventControllerKey::new(); + + let state = state.clone(); + let list_box = list_box.clone(); + let content_box = content_box.clone(); + + controller.connect_key_pressed(move |_controller, keyval, _keycode, modifier| { + let ctrl = modifier.contains(gdk4::ModifierType::CONTROL_MASK); + let shift = modifier.contains(gdk4::ModifierType::SHIFT_MASK); + + match (keyval, ctrl, shift) { + (gdk4::Key::T, true, true) => { + let workspace = Workspace::new(); + lock_or_recover(&state.shared.tab_manager).add_workspace(workspace); + refresh_ui(&list_box, &content_box, &state); + glib::Propagation::Stop + } + (gdk4::Key::W, true, true) => { + let mut tab_manager = lock_or_recover(&state.shared.tab_manager); + if let Some(index) = tab_manager.selected_index() { + tab_manager.remove(index); + } + drop(tab_manager); + refresh_ui(&list_box, &content_box, &state); + glib::Propagation::Stop + } + (gdk4::Key::D, true, true) => { + if let Some(workspace) = lock_or_recover(&state.shared.tab_manager).selected_mut() { + workspace.split(SplitOrientation::Horizontal, PanelType::Terminal); + } + refresh_ui(&list_box, &content_box, &state); + glib::Propagation::Stop + } + (gdk4::Key::E, true, true) => { + if let Some(workspace) = lock_or_recover(&state.shared.tab_manager).selected_mut() { + workspace.split(SplitOrientation::Vertical, PanelType::Terminal); + } + refresh_ui(&list_box, &content_box, &state); + glib::Propagation::Stop + } + (gdk4::Key::U, true, true) => { + if select_latest_unread(&state) { + refresh_ui(&list_box, &content_box, &state); + } + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, + } + }); + + window.add_controller(controller); +} + +fn install_css() { + let provider = gtk4::CssProvider::new(); + provider.load_from_data( + " + .workspace-row { + border-radius: 10px; + } + + .sidebar-notification { + color: @accent_color; + font-weight: 600; + } + + .panel-shell { + border: 1px solid rgba(127, 127, 127, 0.18); + border-radius: 10px; + padding: 3px; + } + + .attention-panel { + border: 2px solid #3584e4; + background-color: rgba(53, 132, 228, 0.08); + } + ", + ); + + if let Some(display) = gdk4::Display::default() { + gtk4::style_context_add_provider_for_display( + &display, + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } +} diff --git a/linux/docs/architecture-review.md b/linux/docs/architecture-review.md new file mode 100644 index 0000000000..2c28bfea75 --- /dev/null +++ b/linux/docs/architecture-review.md @@ -0,0 +1,237 @@ +# Architecture Review + +Reviewed: 2026-03-10 + +This review is for the current Ubuntu MVP implementation only. +It focuses on architecture, state ownership, integration boundaries, and release risk. + +## Executive Summary + +The current direction is correct. + +- `cmux` owns attention flow, workspace state, unread routing, and terminal widget identity. +- `ghostty-gtk` owns GTK adaptation: GL context, viewport, resize propagation, IME, focus, scroll, and input forwarding. +- `ghostty` core owns renderer policy and PTY/render lifecycle. + +The most important design conclusion from this round is: + +> The resize freeze was not a `cmux` layout bug. It was an embedded renderer policy bug in Ghostty. + +That matters because it validates the current layer boundaries. We should keep the fix in the renderer layer, not paper over it in the GTK host. + +## System Boundaries + +```mermaid +flowchart LR + Socket["Socket server\n(tokio thread)"] + Shared["SharedState\nArc>\nArc>"] + UI["GTK main thread\nAppState + window"] + Cache["terminal_cache\nGhosttyGlSurface by panel_id"] + Surface["ghostty-gtk\nGhosttyGlSurface"] + Core["libghostty embedded runtime"] + PTY["PTY / shell / agent TUI"] + + Socket --> Shared + Shared -->|UiEvent::Refresh / SendInput| UI + UI --> Cache + Cache --> Surface + Surface -->|FFI: key, text, resize, focus, draw| Core + Core --> PTY + PTY --> Core + Core -->|wakeup / render actions| UI +``` + +## Attention Loop + +```mermaid +sequenceDiagram + participant Agent as Codex / Claude Code + participant Socket as cmux socket API + participant Model as SharedState + participant UI as GTK UI + participant User as Operator + + Agent->>Socket: notification.create / workspace.set_status + Socket->>Model: update workspace attention state + Socket->>UI: UiEvent::Refresh + UI->>UI: refresh sidebar context + User->>UI: select unread / Ctrl+Shift+U + UI->>Model: select latest unread workspace + UI->>UI: focus attention panel +``` + +This is the correct MVP core. + +The product value is not "many terminal tabs." +The product value is "notice -> identify -> jump" under multi-agent load. + +## Resize Root Cause + +```mermaid +flowchart TD + A["GTK resize"] --> B["ghostty_surface_set_size(...)"] + B --> C["Renderer sees new surface size"] + C --> D{"embedded runtime\nuses stale-frame guard?"} + D -->|yes| E["presentLastTarget()"] + E --> F["terminal body looks frozen\nwhile UI keeps repainting"] + D -->|no| G["rebuild cells / draw fresh frame"] + G --> H["terminal follows resize correctly"] +``` + +The actual fix lives in [generic.zig](../../ghostty/src/renderer/generic.zig). + +That is the right layer for the fix because the behavior is a renderer presentation policy, not a GTK event-dispatch problem. + +## What Is Architecturally Sound + +### 1. Terminal widget caching belongs in `cmux` + +`cmux` now preserves terminal widget identity by `panel_id` in [app.rs](../cmux/src/app.rs). + +That is the correct ownership model: + +- workspace/panel identity is a `cmux` concern +- surface reuse policy is a `cmux` concern +- `ghostty` should not know about workspace switching + +The cache also prevents state loss across sidebar refreshes and workspace switches. + +### 2. GTK adaptation belongs in `ghostty-gtk` + +The following are correctly implemented in [surface.rs](../ghostty-gtk/src/surface.rs): + +- explicit desktop GL 4.3 context creation +- `glViewport(...)` before draw +- size and content-scale propagation on resize +- IME preedit/commit integration +- focus handoff and resize-time focus recovery +- scroll direction normalization +- click-to-focus + +All of those are host integration concerns, so `ghostty-gtk` is the right place for them. + +### 3. Renderer policy belongs in `ghostty` + +The stale-frame guard change in [generic.zig](../../ghostty/src/renderer/generic.zig) is principled. + +The old behavior was reasonable for native host runtimes that prefer avoiding blank flashes during synchronous resize redraws. +It is not reasonable for the embedded runtime, where the host is already tightly driving resize and redraw. + +The embedded runtime should not inherit native-host presentation heuristics blindly. + +## Findings + +### P1: Push risk because the required resize fix is not on upstream `ghostty` main yet + +As of 2026-03-10, this branch depends on the root `ghostty` submodule being pinned to a commit from `fork/draft/linux-embedded-host-support`, not `origin/main`. + +That means the verified resize fix is still not self-contained in an upstream-reviewable Ghostty base. + +Impact: + +- pushing only the `cmux-linux` PR does not fully reproduce the working behavior +- review becomes misleading if the Ghostty dependency is not called out explicitly + +Recommendation: + +- do not hide this dependency +- split the Ghostty renderer fix into its own branch/PR if it is not already isolated +- make the `cmux-linux` PR explicitly depend on that Ghostty change + +### P2: UI event delivery still uses 33ms polling + +[window.rs](../cmux/src/ui/window.rs) still uses: + +- `std::sync::mpsc` +- `try_recv()` +- `glib::timeout_add_local(Duration::from_millis(33), ...)` + +This is acceptable for MVP because the product value is already visible. +It is still architectural debt: + +- avoidable idle polling +- avoidable latency +- extra glue around the GTK main loop + +Recommendation: + +- move to `glib::MainContext::channel()` or `gio`-native socket integration later + +### P2: Focus recovery is intentionally heuristic + +[surface.rs](../ghostty-gtk/src/surface.rs) uses delayed focus disarm and delayed resize-time focus restore. + +This was a practical fix for GTK resize/focus churn and it works. +It is still policy, not a guaranteed GTK invariant. + +Risk: + +- future toolbar/sidebar interactions may expose over-eager focus return + +Recommendation: + +- acceptable for MVP +- keep an eye on explicit focus ownership once more non-terminal controls are added + +### P3: Distribution story is still development-grade + +[build.rs](../ghostty-sys/build.rs) now builds and links Ghostty for local development, but packaging is not final. + +Open questions remain: + +- dynamic library search path +- bundling of `libghostty` +- reproducible install layout + +This is not blocking the MVP review, but it is not release-finished. + +## Recommended Branch Strategy + +Because this work spans `cmux-linux` and external `ghostty`, the branch strategy should make that explicit. + +```mermaid +flowchart TD + Base["main / upstream base"] + CmuxPR["cmux-linux PR branch\nreviewable branch"] + Dev["cmux-linux dev branch\nunsafe / iterative work"] + GhosttyDev["ghostty dev branch\nembedded renderer fixes"] + GhosttyPR["ghostty PR branch"] + + Base --> CmuxPR + CmuxPR --> Dev + Base --> GhosttyDev + GhosttyDev --> GhosttyPR + Dev -->|cherry-pick stable commits| CmuxPR +``` + +Recommended workflow: + +1. keep `#828` as the clean review branch +2. create a separate `cmux-linux` dev branch for ongoing experiments +3. create a separate Ghostty branch for embedded renderer changes +4. cherry-pick only stable commits back to the review branch +5. link the `cmux-linux` PR to the Ghostty PR explicitly + +## Review Conclusion + +The current MVP architecture is directionally correct. + +The key positive result is that the layers are finally telling the truth: + +- `cmux` handles attention and workspace semantics +- `ghostty-gtk` handles GTK adaptation +- `ghostty` handles rendering policy + +The main release risk is not bad architecture inside `cmux`. +The main release risk is cross-repo coupling: a required runtime fix currently lives in Ghostty, outside the PR branch that would be reviewed first. + +## Push Recommendation + +Do not push the current `cmux-linux` branch as if it were self-contained. + +Push plan: + +1. split the Ghostty embedded resize fix into its own branch +2. keep the `cmux-linux` PR branch review-clean +3. mention the cross-repo dependency in the PR description +4. only then push the PR branch diff --git a/linux/docs/ubuntu-mvp-spec.md b/linux/docs/ubuntu-mvp-spec.md new file mode 100644 index 0000000000..67af5e7260 --- /dev/null +++ b/linux/docs/ubuntu-mvp-spec.md @@ -0,0 +1,122 @@ +# Ubuntu MVP Specification + +## Status + +This document defines the target for the `release/phase-1-mvp` PR stream. +Until this MVP lands, changes should be evaluated against this scope first. + +## Product Thesis + +The Ubuntu version of `cmux` should not be treated as "a terminal with a sidebar full of tabs." +Its defining value is that it helps users run several AI coding sessions in parallel and immediately understand: + +1. which workspace needs attention, +2. why it needs attention, +3. how to jump back to the exact place that needs action. + +The MVP succeeds if it preserves that value with the smallest possible feature set. + +## North Star + +When 4 to 8 Claude Code or Codex sessions are running at the same time, the user can identify the workspace that needs attention within one second and jump to it with a single action. + +## Core UX Model + +- The left sidebar answers: "Where should I go?" +- The top surface tabs answer: "Where inside this workspace should I look?" +- Notifications must add context, not just noise. +- Attention cues must be visible without stealing focus. + +## MVP User Stories + +### 1. Attention routing + +As a user running multiple agent sessions, I can see which workspace needs me without reading every terminal. + +### 2. Context at a glance + +As a user, I can tell why a workspace needs attention from the sidebar alone. + +### 3. Exact return target + +As a user, I can jump to the latest unread workspace and see which pane or surface triggered the alert. + +## In Scope + +### Workspace model + +- Multiple workspaces +- One or more terminal surfaces per workspace +- Vertical and horizontal splits +- Top tabs within a pane when multiple surfaces exist in that pane + +### Notification flow + +- Accept notifications from an external control path such as `cmux notify` +- Associate a notification with a workspace, and with a surface when available +- Track unread state +- Track the latest notification text for display in the sidebar +- Support a "jump to latest unread" action + +### Sidebar information density + +Each workspace row should show the minimum context needed to make routing decisions: + +- workspace title, +- agent or status label when available, +- git branch or working directory, +- latest notification text, +- unread indicator. + +### Attention highlighting + +- Clear unread badge or equivalent state in the sidebar +- Strong visual emphasis for the selected workspace +- Visible pane or surface highlight for the source of the latest unread notification + +## Non-Functional Requirements + +- Low latency: notification-to-UI update should feel immediate +- No focus stealing: alerts must not switch workspaces automatically +- Keyboard-first: core flows must be accessible without the mouse +- Scanability: the sidebar must remain readable with at least 8 workspaces +- Native feel: the app should stay lightweight and terminal-first + +## Explicit Non-Goals For MVP + +The following are valuable, but not required for the MVP: + +- in-app browser, +- pull request metadata, +- listening ports, +- advanced progress visualizations, +- rich notification history UI, +- drag-and-drop workspace management, +- deep customization or theming, +- fully automatic terminal escape-sequence notification capture. + +If a feature does not improve the "notice -> identify -> jump" loop, it is probably out of scope for this phase. + +## Acceptance Criteria + +The MVP is complete when all of the following are true: + +1. A user can create and switch between multiple workspaces. +2. A user can split terminals and use surface tabs inside a workspace. +3. An external notification can target a workspace and update unread state. +4. The sidebar shows enough context to distinguish active and waiting workspaces. +5. The user can jump to the latest unread workspace with one command or shortcut. +6. The pane or surface that triggered the alert is visually identifiable after the jump. +7. The interaction works without requiring desktop notifications to be the only signal. + +## PR Guidance + +For the current PR stream, preferred work is: + +1. notification state and plumbing, +2. sidebar information architecture, +3. unread navigation, +4. pane or surface attention highlighting, +5. keyboard shortcuts for the core attention workflow. + +Changes that mainly add breadth should wait until the loop above is solid. diff --git a/linux/ghostty-gtk/Cargo.toml b/linux/ghostty-gtk/Cargo.toml new file mode 100644 index 0000000000..e706df16ca --- /dev/null +++ b/linux/ghostty-gtk/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ghostty-gtk" +version = "0.1.0" +edition.workspace = true +description = "Safe Rust wrapper around libghostty for GTK4 integration" + +[features] +link-ghostty = ["ghostty-sys/link-ghostty"] + +[dependencies] +ghostty-sys = { path = "../ghostty-sys" } +gtk4 = { workspace = true } +glib = { workspace = true } +gdk4 = { workspace = true } +tracing = { workspace = true } diff --git a/linux/ghostty-gtk/src/app.rs b/linux/ghostty-gtk/src/app.rs new file mode 100644 index 0000000000..411cf0ee56 --- /dev/null +++ b/linux/ghostty-gtk/src/app.rs @@ -0,0 +1,174 @@ +//! Safe wrapper around ghostty_app_t lifecycle. + +use ghostty_sys::*; +use std::ptr; + +use crate::callbacks::RuntimeCallbacks; + +/// Manages the lifecycle of a ghostty application instance. +/// +/// The GhosttyApp owns the `ghostty_app_t` and `ghostty_config_t` and ensures +/// they are properly freed on drop. +pub struct GhosttyApp { + app: ghostty_app_t, + config: ghostty_config_t, + /// Prevent Send — ghostty_app_t is not thread-safe + _not_send: std::marker::PhantomData<*mut ()>, +} + +impl GhosttyApp { + /// Initialize the ghostty runtime. Must be called once before any other API. + /// + /// # Safety + /// This calls into the C FFI. Should only be called once per process. + #[cfg(feature = "link-ghostty")] + pub fn init() -> Result<(), String> { + configure_bundled_resources_dir(); + let ret = unsafe { ghostty_init(0, ptr::null_mut()) }; + if ret != GHOSTTY_SUCCESS { + return Err(format!("ghostty_init failed with code {}", ret)); + } + Ok(()) + } + + #[cfg(not(feature = "link-ghostty"))] + pub fn init() -> Result<(), String> { + tracing::warn!("ghostty not linked — running in stub mode"); + Ok(()) + } + + /// Create a new GhosttyApp with the given runtime callbacks. + /// + /// # Safety + /// The `callbacks` must remain valid for the lifetime of this app. + #[cfg(feature = "link-ghostty")] + pub fn new(callbacks: &RuntimeCallbacks) -> Result { + let config = unsafe { ghostty_config_new() }; + if config.is_null() { + return Err("ghostty_config_new returned null".into()); + } + + unsafe { + ghostty_config_load_default_files(config); + ghostty_config_load_recursive_files(config); + ghostty_config_finalize(config); + } + + // Check for config diagnostics + let diag_count = unsafe { ghostty_config_diagnostics_count(config) }; + for i in 0..diag_count { + let diag = unsafe { ghostty_config_get_diagnostic(config, i) }; + if diag.message.is_null() { + tracing::warn!("ghostty config diagnostic: (null message)"); + continue; + } + let msg = unsafe { std::ffi::CStr::from_ptr(diag.message) }; + tracing::warn!("ghostty config diagnostic: {:?}", msg); + } + + let runtime_config = callbacks.as_raw(); + let app = unsafe { ghostty_app_new(&runtime_config, config) }; + if app.is_null() { + unsafe { ghostty_config_free(config) }; + return Err("ghostty_app_new returned null".into()); + } + + Ok(Self { + app, + config, + _not_send: std::marker::PhantomData, + }) + } + + #[cfg(not(feature = "link-ghostty"))] + pub fn new(_callbacks: &RuntimeCallbacks) -> Result { + Ok(Self { + app: ptr::null_mut(), + config: ptr::null_mut(), + _not_send: std::marker::PhantomData, + }) + } + + /// Process pending events. Should be called from `glib::idle_add` wakeup. + #[cfg(feature = "link-ghostty")] + pub fn tick(&self) { + unsafe { ghostty_app_tick(self.app) }; + } + + #[cfg(not(feature = "link-ghostty"))] + pub fn tick(&self) {} + + /// Get the raw app pointer for FFI calls. + pub fn raw(&self) -> ghostty_app_t { + self.app + } + + /// Notify ghostty that the app focus state changed. + #[cfg(feature = "link-ghostty")] + pub fn set_focus(&self, focused: bool) { + unsafe { ghostty_app_set_focus(self.app, focused) }; + } + + #[cfg(not(feature = "link-ghostty"))] + pub fn set_focus(&self, _focused: bool) {} + + /// Set the system color scheme (light/dark). + #[cfg(feature = "link-ghostty")] + pub fn set_color_scheme(&self, scheme: ghostty_color_scheme_e) { + unsafe { ghostty_app_set_color_scheme(self.app, scheme) }; + } + + #[cfg(not(feature = "link-ghostty"))] + pub fn set_color_scheme(&self, _scheme: ghostty_color_scheme_e) {} + + /// Check if any surfaces need confirmation before quitting. + #[cfg(feature = "link-ghostty")] + pub fn needs_confirm_quit(&self) -> bool { + unsafe { ghostty_app_needs_confirm_quit(self.app) } + } + + #[cfg(not(feature = "link-ghostty"))] + pub fn needs_confirm_quit(&self) -> bool { + false + } + + /// Get the config handle for creating surfaces with inherited config. + pub fn config(&self) -> ghostty_config_t { + self.config + } +} + +#[cfg(feature = "link-ghostty")] +fn configure_bundled_resources_dir() { + const KEY: &str = "GHOSTTY_RESOURCES_DIR"; + + if std::env::var_os(KEY).is_some() { + return; + } + + let Some(dir) = ghostty_sys::bundled_resources_dir() else { + return; + }; + + if std::path::Path::new(dir).exists() { + std::env::set_var(KEY, dir); + tracing::info!( + resources_dir = dir, + "Configured bundled Ghostty resources dir" + ); + } +} + +impl Drop for GhosttyApp { + fn drop(&mut self) { + #[cfg(feature = "link-ghostty")] + unsafe { + if !self.app.is_null() { + ghostty_app_free(self.app); + } + if !self.config.is_null() { + ghostty_config_free(self.config); + } + } + } +} diff --git a/linux/ghostty-gtk/src/callbacks.rs b/linux/ghostty-gtk/src/callbacks.rs new file mode 100644 index 0000000000..902e8d4a74 --- /dev/null +++ b/linux/ghostty-gtk/src/callbacks.rs @@ -0,0 +1,314 @@ +//! Runtime callback infrastructure for ghostty embedded runtime. +//! +//! The host application provides callbacks that ghostty invokes for: +//! - Wakeup: ghostty needs the host to call `tick()` on the main thread +//! - Action: ghostty wants the host to perform an action (new split, title change, etc.) +//! +//! Clipboard and close-surface callbacks are different: ghostty passes the +//! surface userdata for those, not the application userdata. We therefore +//! dispatch them directly to `GhosttyGlSurface` instead of routing them +//! through the application-level handler trait. + +use ghostty_sys::*; +use gtk4::glib; +use gtk4::prelude::GLAreaExt; +use gtk4::prelude::ObjectExt; +use std::os::raw::{c_char, c_void}; + +use crate::surface::GhosttyGlSurface; + +/// Trait for handling ghostty runtime events. +/// +/// Implement this trait in the cmux application to receive callbacks from ghostty. +pub trait GhosttyCallbackHandler: 'static { + /// Called when ghostty needs the host to call `app.tick()`. + /// The host should schedule this on the GTK main loop via `glib::idle_add_once`. + fn on_wakeup(&self); + + /// Called when ghostty wants the host to perform an action. + /// Returns `true` if the action was handled. + fn on_action(&self, target: ghostty_target_s, action: ghostty_action_s) -> bool; +} + +/// Stores the callback configuration for the ghostty runtime. +/// +/// We use double-indirection: the `userdata` pointer points to a +/// `*mut dyn GhosttyCallbackHandler` (a raw fat pointer stored on the heap). +pub struct RuntimeCallbacks { + /// Pointer to a heap-allocated raw fat pointer to the handler. + /// This is `Box<*mut dyn GhosttyCallbackHandler>`. + handler_ptr: *mut *mut dyn GhosttyCallbackHandler, +} + +/// Stable userdata stored on each ghostty surface. +/// +/// We keep only a weak reference so callbacks can safely noop if the GTK +/// widget has already been destroyed before the main-loop handoff runs. +pub struct SurfaceUserdata { + surface: glib::SendWeakRef, +} + +impl SurfaceUserdata { + pub fn new(surface: &GhosttyGlSurface) -> Self { + Self { + surface: surface.downgrade().into(), + } + } + + fn weak_surface(&self) -> glib::SendWeakRef { + self.surface.clone() + } +} + +impl RuntimeCallbacks { + /// Create runtime callbacks wrapping the given handler. + /// + /// # Safety + /// The handler must remain valid for the lifetime of the ghostty app. + pub fn new(handler: Box) -> Self { + let raw: *mut dyn GhosttyCallbackHandler = Box::into_raw(handler); + let handler_ptr = Box::into_raw(Box::new(raw)); + Self { handler_ptr } + } + + /// Build the raw C runtime config struct. + pub fn as_raw(&self) -> ghostty_runtime_config_s { + ghostty_runtime_config_s { + userdata: self.handler_ptr as *mut c_void, + supports_selection_clipboard: true, // Linux supports X11 selection + wakeup_cb: Some(wakeup_trampoline), + action_cb: Some(action_trampoline), + read_clipboard_cb: Some(read_clipboard_trampoline), + confirm_read_clipboard_cb: Some(confirm_read_clipboard_trampoline), + write_clipboard_cb: Some(write_clipboard_trampoline), + close_surface_cb: Some(close_surface_trampoline), + } + } +} + +impl Drop for RuntimeCallbacks { + fn drop(&mut self) { + unsafe { + // Reconstruct the handler box and drop it + let fat_ptr = Box::from_raw(self.handler_ptr); + let _ = Box::from_raw(*fat_ptr); + } + } +} + +// ----------------------------------------------------------------------- +// Helper to recover the handler from userdata +// ----------------------------------------------------------------------- + +unsafe fn handler_from_userdata<'a>( + userdata: *mut c_void, +) -> Option<&'a dyn GhosttyCallbackHandler> { + if userdata.is_null() { + return None; + } + let fat_ptr = userdata as *const *mut dyn GhosttyCallbackHandler; + let inner = *fat_ptr; + if inner.is_null() { + return None; + } + Some(&*inner) +} + +unsafe fn surface_userdata_from_ptr<'a>(userdata: *mut c_void) -> Option<&'a SurfaceUserdata> { + if userdata.is_null() { + return None; + } + + Some(&*(userdata as *const SurfaceUserdata)) +} + +unsafe fn weak_surface_from_userdata( + userdata: *mut c_void, +) -> Option> { + surface_userdata_from_ptr(userdata).map(SurfaceUserdata::weak_surface) +} + +pub unsafe fn surface_from_callback_userdata(userdata: *mut c_void) -> Option { + surface_userdata_from_ptr(userdata).and_then(|userdata| userdata.surface.upgrade()) +} + +pub unsafe fn queue_render_from_userdata(userdata: *mut c_void) -> bool { + let Some(surface) = weak_surface_from_userdata(userdata) else { + return false; + }; + + glib::MainContext::default().invoke(move || { + let Some(surface) = surface.upgrade() else { + return; + }; + surface.queue_render(); + }); + true +} + +fn invoke_surface_callback(userdata: *mut c_void, callback: F) +where + F: FnOnce(GhosttyGlSurface) + Send + 'static, +{ + let Some(surface) = (unsafe { weak_surface_from_userdata(userdata) }) else { + return; + }; + + glib::MainContext::default().invoke(move || { + let Some(surface) = surface.upgrade() else { + return; + }; + callback(surface); + }); +} + +// ----------------------------------------------------------------------- +// C callback trampolines +// ----------------------------------------------------------------------- + +unsafe extern "C" fn wakeup_trampoline(userdata: *mut c_void) { + if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if let Some(handler) = handler_from_userdata(userdata) { + handler.on_wakeup(); + } + })) { + tracing::error!("Panic in wakeup trampoline: {:?}", e); + } +} + +unsafe extern "C" fn action_trampoline( + _app: ghostty_app_t, + target: ghostty_target_s, + action: ghostty_action_s, +) -> bool { + // The userdata is stored in the app; retrieve it + #[cfg(feature = "link-ghostty")] + { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let userdata = ghostty_app_userdata(_app); + handler_from_userdata(userdata).is_some_and(|handler| handler.on_action(target, action)) + })) + .unwrap_or_else(|e| { + tracing::error!("Panic in action trampoline: {:?}", e); + false + }) + } + #[cfg(not(feature = "link-ghostty"))] + { + let _ = (target, action); + false + } +} + +unsafe extern "C" fn read_clipboard_trampoline( + userdata: *mut c_void, + clipboard: ghostty_clipboard_e, + context: *mut c_void, +) { + if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let context = context as usize; + invoke_surface_callback(userdata, move |surface| { + surface.read_clipboard_request(clipboard, context as *mut c_void); + }); + })) { + tracing::error!("Panic in read_clipboard trampoline: {:?}", e); + } +} + +unsafe extern "C" fn confirm_read_clipboard_trampoline( + userdata: *mut c_void, + content: *const c_char, + context: *mut c_void, + request: ghostty_clipboard_request_e, +) { + if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let context = context as usize; + let content = if content.is_null() { + String::new() + } else { + std::ffi::CStr::from_ptr(content) + .to_string_lossy() + .into_owned() + }; + invoke_surface_callback(userdata, move |surface| { + surface.confirm_clipboard_read(&content, context as *mut c_void, request); + }); + })) { + tracing::error!("Panic in confirm_read_clipboard trampoline: {:?}", e); + } +} + +unsafe extern "C" fn write_clipboard_trampoline( + userdata: *mut c_void, + clipboard: ghostty_clipboard_e, + content: *const ghostty_clipboard_content_s, + content_len: usize, + confirm: bool, +) { + if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let entries = if content.is_null() || content_len == 0 { + Vec::new() + } else { + std::slice::from_raw_parts(content, content_len) + .iter() + .map(|entry| ClipboardContent { + mime: c_string(entry.mime), + data: c_string(entry.data), + }) + .collect() + }; + invoke_surface_callback(userdata, move |surface| { + surface.write_clipboard(clipboard, &entries, confirm); + }); + })) { + tracing::error!("Panic in write_clipboard trampoline: {:?}", e); + } +} + +unsafe extern "C" fn close_surface_trampoline(userdata: *mut c_void, process_alive: bool) { + if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + invoke_surface_callback(userdata, move |surface| { + surface.close_requested(process_alive); + }); + })) { + tracing::error!("Panic in close_surface trampoline: {:?}", e); + } +} + +fn c_string(ptr: *const c_char) -> Option { + if ptr.is_null() { + None + } else { + Some( + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(), + ) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ClipboardContent { + pub mime: Option, + pub data: Option, +} + +#[cfg(test)] +mod tests { + use super::{c_string, handler_from_userdata, surface_from_callback_userdata}; + + #[test] + fn c_string_returns_none_for_null() { + assert_eq!(c_string(std::ptr::null()), None); + } + + #[test] + fn handler_from_userdata_returns_none_for_null() { + assert!(unsafe { handler_from_userdata(std::ptr::null_mut()) }.is_none()); + } + + #[test] + fn surface_from_callback_userdata_returns_none_for_null() { + assert!(unsafe { surface_from_callback_userdata(std::ptr::null_mut()) }.is_none()); + } +} diff --git a/linux/ghostty-gtk/src/keys.rs b/linux/ghostty-gtk/src/keys.rs new file mode 100644 index 0000000000..ad4457ccd3 --- /dev/null +++ b/linux/ghostty-gtk/src/keys.rs @@ -0,0 +1,311 @@ +//! GDK keyval → ghostty_input_key_e conversion table. +//! +//! This is a port of ghostty's `src/apprt/gtk/key.zig` mapping table. + +use ghostty_sys::ghostty_input_key_e::{self, *}; + +/// Convert a GDK keyval (u32) to a ghostty key code. +/// +/// Returns `None` if the keyval has no ghostty equivalent. +pub fn gdk_keyval_to_ghostty(keyval: u32) -> Option { + // GDK key constants (from gdk/gdkkeysyms.h) + // We use raw u32 values to avoid API differences between gtk4-rs versions. + let ghostty_key = match keyval { + // Writing System Keys + 0x0060 | 0xfe50 => GHOSTTY_KEY_BACKQUOTE, // grave | dead_grave + 0x005c => GHOSTTY_KEY_BACKSLASH, + 0x005b => GHOSTTY_KEY_BRACKET_LEFT, + 0x005d => GHOSTTY_KEY_BRACKET_RIGHT, + 0x002c => GHOSTTY_KEY_COMMA, + 0x0030 => GHOSTTY_KEY_DIGIT_0, + 0x0031 => GHOSTTY_KEY_DIGIT_1, + 0x0032 => GHOSTTY_KEY_DIGIT_2, + 0x0033 => GHOSTTY_KEY_DIGIT_3, + 0x0034 => GHOSTTY_KEY_DIGIT_4, + 0x0035 => GHOSTTY_KEY_DIGIT_5, + 0x0036 => GHOSTTY_KEY_DIGIT_6, + 0x0037 => GHOSTTY_KEY_DIGIT_7, + 0x0038 => GHOSTTY_KEY_DIGIT_8, + 0x0039 => GHOSTTY_KEY_DIGIT_9, + 0x003d => GHOSTTY_KEY_EQUAL, + 0x0061 | 0x0041 => GHOSTTY_KEY_A, + 0x0062 | 0x0042 => GHOSTTY_KEY_B, + 0x0063 | 0x0043 => GHOSTTY_KEY_C, + 0x0064 | 0x0044 => GHOSTTY_KEY_D, + 0x0065 | 0x0045 => GHOSTTY_KEY_E, + 0x0066 | 0x0046 => GHOSTTY_KEY_F, + 0x0067 | 0x0047 => GHOSTTY_KEY_G, + 0x0068 | 0x0048 => GHOSTTY_KEY_H, + 0x0069 | 0x0049 => GHOSTTY_KEY_I, + 0x006a | 0x004a => GHOSTTY_KEY_J, + 0x006b | 0x004b => GHOSTTY_KEY_K, + 0x006c | 0x004c => GHOSTTY_KEY_L, + 0x006d | 0x004d => GHOSTTY_KEY_M, + 0x006e | 0x004e => GHOSTTY_KEY_N, + 0x006f | 0x004f => GHOSTTY_KEY_O, + 0x0070 | 0x0050 => GHOSTTY_KEY_P, + 0x0071 | 0x0051 => GHOSTTY_KEY_Q, + 0x0072 | 0x0052 => GHOSTTY_KEY_R, + 0x0073 | 0x0053 => GHOSTTY_KEY_S, + 0x0074 | 0x0054 => GHOSTTY_KEY_T, + 0x0075 | 0x0055 => GHOSTTY_KEY_U, + 0x0076 | 0x0056 => GHOSTTY_KEY_V, + 0x0077 | 0x0057 => GHOSTTY_KEY_W, + 0x0078 | 0x0058 => GHOSTTY_KEY_X, + 0x0079 | 0x0059 => GHOSTTY_KEY_Y, + 0x007a | 0x005a => GHOSTTY_KEY_Z, + 0x002d => GHOSTTY_KEY_MINUS, + 0x002e => GHOSTTY_KEY_PERIOD, + 0x0027 => GHOSTTY_KEY_QUOTE, // apostrophe + 0x003b => GHOSTTY_KEY_SEMICOLON, + 0x002f => GHOSTTY_KEY_SLASH, + + // Functional Keys + 0xffe9 => GHOSTTY_KEY_ALT_LEFT, // Alt_L + 0xffea => GHOSTTY_KEY_ALT_RIGHT, // Alt_R + 0xff08 => GHOSTTY_KEY_BACKSPACE, // BackSpace + 0xffe5 => GHOSTTY_KEY_CAPS_LOCK, // Caps_Lock + 0xff67 => GHOSTTY_KEY_CONTEXT_MENU, // Menu + 0xffe3 => GHOSTTY_KEY_CONTROL_LEFT, // Control_L + 0xffe4 => GHOSTTY_KEY_CONTROL_RIGHT, // Control_R + 0xff0d => GHOSTTY_KEY_ENTER, // Return + 0xffe7 | 0xffeb => GHOSTTY_KEY_META_LEFT, // Meta_L | Super_L + 0xffe8 | 0xffec => GHOSTTY_KEY_META_RIGHT, // Meta_R | Super_R + 0xffe1 => GHOSTTY_KEY_SHIFT_LEFT, // Shift_L + 0xffe2 => GHOSTTY_KEY_SHIFT_RIGHT, // Shift_R + 0x0020 => GHOSTTY_KEY_SPACE, // space + 0xff09 | 0xfe20 => GHOSTTY_KEY_TAB, // Tab | ISO_Left_Tab + + // Control Pad Section + 0xffff => GHOSTTY_KEY_DELETE, + 0xff57 => GHOSTTY_KEY_END, + 0xff6a => GHOSTTY_KEY_HELP, + 0xff50 => GHOSTTY_KEY_HOME, + 0xff63 => GHOSTTY_KEY_INSERT, + 0xff56 => GHOSTTY_KEY_PAGE_DOWN, + 0xff55 => GHOSTTY_KEY_PAGE_UP, + + // Arrow Pad Section + 0xff54 => GHOSTTY_KEY_ARROW_DOWN, + 0xff51 => GHOSTTY_KEY_ARROW_LEFT, + 0xff53 => GHOSTTY_KEY_ARROW_RIGHT, + 0xff52 => GHOSTTY_KEY_ARROW_UP, + + // Numpad Section + 0xff7f => GHOSTTY_KEY_NUM_LOCK, + 0xffb0 => GHOSTTY_KEY_NUMPAD_0, + 0xffb1 => GHOSTTY_KEY_NUMPAD_1, + 0xffb2 => GHOSTTY_KEY_NUMPAD_2, + 0xffb3 => GHOSTTY_KEY_NUMPAD_3, + 0xffb4 => GHOSTTY_KEY_NUMPAD_4, + 0xffb5 => GHOSTTY_KEY_NUMPAD_5, + 0xffb6 => GHOSTTY_KEY_NUMPAD_6, + 0xffb7 => GHOSTTY_KEY_NUMPAD_7, + 0xffb8 => GHOSTTY_KEY_NUMPAD_8, + 0xffb9 => GHOSTTY_KEY_NUMPAD_9, + 0xffab => GHOSTTY_KEY_NUMPAD_ADD, + 0xffac => GHOSTTY_KEY_NUMPAD_COMMA, // KP_Separator + 0xffae => GHOSTTY_KEY_NUMPAD_DECIMAL, + 0xffaf => GHOSTTY_KEY_NUMPAD_DIVIDE, + 0xff8d => GHOSTTY_KEY_NUMPAD_ENTER, + 0xffbd => GHOSTTY_KEY_NUMPAD_EQUAL, + 0xffaa => GHOSTTY_KEY_NUMPAD_MULTIPLY, + 0xffad => GHOSTTY_KEY_NUMPAD_SUBTRACT, + + // Function Keys + 0xff1b => GHOSTTY_KEY_ESCAPE, + 0xffbe => GHOSTTY_KEY_F1, + 0xffbf => GHOSTTY_KEY_F2, + 0xffc0 => GHOSTTY_KEY_F3, + 0xffc1 => GHOSTTY_KEY_F4, + 0xffc2 => GHOSTTY_KEY_F5, + 0xffc3 => GHOSTTY_KEY_F6, + 0xffc4 => GHOSTTY_KEY_F7, + 0xffc5 => GHOSTTY_KEY_F8, + 0xffc6 => GHOSTTY_KEY_F9, + 0xffc7 => GHOSTTY_KEY_F10, + 0xffc8 => GHOSTTY_KEY_F11, + 0xffc9 => GHOSTTY_KEY_F12, + 0xffca => GHOSTTY_KEY_F13, + 0xffcb => GHOSTTY_KEY_F14, + 0xffcc => GHOSTTY_KEY_F15, + 0xffcd => GHOSTTY_KEY_F16, + 0xffce => GHOSTTY_KEY_F17, + 0xffcf => GHOSTTY_KEY_F18, + 0xffd0 => GHOSTTY_KEY_F19, + 0xffd1 => GHOSTTY_KEY_F20, + 0xffd2 => GHOSTTY_KEY_F21, + 0xffd3 => GHOSTTY_KEY_F22, + 0xffd4 => GHOSTTY_KEY_F23, + 0xffd5 => GHOSTTY_KEY_F24, + 0xffd6 => GHOSTTY_KEY_F25, + 0xff61 => GHOSTTY_KEY_PRINT_SCREEN, + 0xff14 => GHOSTTY_KEY_SCROLL_LOCK, + 0xff13 => GHOSTTY_KEY_PAUSE, + + _ => return None, + }; + + Some(ghostty_key) +} + +/// Convert GDK modifier state to ghostty modifier flags. +pub fn gdk_mods_to_ghostty(state: gdk4::ModifierType) -> u32 { + let mut mods = 0u32; + + if state.contains(gdk4::ModifierType::SHIFT_MASK) { + mods |= ghostty_sys::ghostty_input_mods_e::GHOSTTY_MODS_SHIFT as u32; + } + if state.contains(gdk4::ModifierType::CONTROL_MASK) { + mods |= ghostty_sys::ghostty_input_mods_e::GHOSTTY_MODS_CTRL as u32; + } + if state.contains(gdk4::ModifierType::ALT_MASK) { + mods |= ghostty_sys::ghostty_input_mods_e::GHOSTTY_MODS_ALT as u32; + } + if state.contains(gdk4::ModifierType::SUPER_MASK) { + mods |= ghostty_sys::ghostty_input_mods_e::GHOSTTY_MODS_SUPER as u32; + } + if state.contains(gdk4::ModifierType::LOCK_MASK) { + mods |= ghostty_sys::ghostty_input_mods_e::GHOSTTY_MODS_CAPS as u32; + } + + mods +} + +/// Convert a GDK mouse button number to ghostty mouse button. +pub fn gdk_button_to_ghostty(button: u32) -> ghostty_sys::ghostty_input_mouse_button_e { + use ghostty_sys::ghostty_input_mouse_button_e::*; + match button { + 1 => GHOSTTY_MOUSE_LEFT, + 2 => GHOSTTY_MOUSE_MIDDLE, + 3 => GHOSTTY_MOUSE_RIGHT, + 4 => GHOSTTY_MOUSE_FOUR, + 5 => GHOSTTY_MOUSE_FIVE, + 6 => GHOSTTY_MOUSE_SIX, + 7 => GHOSTTY_MOUSE_SEVEN, + 8 => GHOSTTY_MOUSE_EIGHT, + _ => GHOSTTY_MOUSE_UNKNOWN, + } +} + +/// Get the hardware keycode mapping for physical key translation. +/// This maps X11/evdev keycodes to ghostty physical keys. +pub fn hardware_keycode_to_ghostty(keycode: u32) -> Option { + // evdev keycodes (X11 keycode = evdev + 8) + let evdev_code = if keycode >= 8 { + keycode - 8 + } else { + return None; + }; + + let key = match evdev_code { + 1 => GHOSTTY_KEY_ESCAPE, + 2 => GHOSTTY_KEY_DIGIT_1, + 3 => GHOSTTY_KEY_DIGIT_2, + 4 => GHOSTTY_KEY_DIGIT_3, + 5 => GHOSTTY_KEY_DIGIT_4, + 6 => GHOSTTY_KEY_DIGIT_5, + 7 => GHOSTTY_KEY_DIGIT_6, + 8 => GHOSTTY_KEY_DIGIT_7, + 9 => GHOSTTY_KEY_DIGIT_8, + 10 => GHOSTTY_KEY_DIGIT_9, + 11 => GHOSTTY_KEY_DIGIT_0, + 12 => GHOSTTY_KEY_MINUS, + 13 => GHOSTTY_KEY_EQUAL, + 14 => GHOSTTY_KEY_BACKSPACE, + 15 => GHOSTTY_KEY_TAB, + 16 => GHOSTTY_KEY_Q, + 17 => GHOSTTY_KEY_W, + 18 => GHOSTTY_KEY_E, + 19 => GHOSTTY_KEY_R, + 20 => GHOSTTY_KEY_T, + 21 => GHOSTTY_KEY_Y, + 22 => GHOSTTY_KEY_U, + 23 => GHOSTTY_KEY_I, + 24 => GHOSTTY_KEY_O, + 25 => GHOSTTY_KEY_P, + 26 => GHOSTTY_KEY_BRACKET_LEFT, + 27 => GHOSTTY_KEY_BRACKET_RIGHT, + 28 => GHOSTTY_KEY_ENTER, + 29 => GHOSTTY_KEY_CONTROL_LEFT, + 30 => GHOSTTY_KEY_A, + 31 => GHOSTTY_KEY_S, + 32 => GHOSTTY_KEY_D, + 33 => GHOSTTY_KEY_F, + 34 => GHOSTTY_KEY_G, + 35 => GHOSTTY_KEY_H, + 36 => GHOSTTY_KEY_J, + 37 => GHOSTTY_KEY_K, + 38 => GHOSTTY_KEY_L, + 39 => GHOSTTY_KEY_SEMICOLON, + 40 => GHOSTTY_KEY_QUOTE, + 41 => GHOSTTY_KEY_BACKQUOTE, + 42 => GHOSTTY_KEY_SHIFT_LEFT, + 43 => GHOSTTY_KEY_BACKSLASH, + 44 => GHOSTTY_KEY_Z, + 45 => GHOSTTY_KEY_X, + 46 => GHOSTTY_KEY_C, + 47 => GHOSTTY_KEY_V, + 48 => GHOSTTY_KEY_B, + 49 => GHOSTTY_KEY_N, + 50 => GHOSTTY_KEY_M, + 51 => GHOSTTY_KEY_COMMA, + 52 => GHOSTTY_KEY_PERIOD, + 53 => GHOSTTY_KEY_SLASH, + 54 => GHOSTTY_KEY_SHIFT_RIGHT, + 55 => GHOSTTY_KEY_NUMPAD_MULTIPLY, + 56 => GHOSTTY_KEY_ALT_LEFT, + 57 => GHOSTTY_KEY_SPACE, + 58 => GHOSTTY_KEY_CAPS_LOCK, + 59 => GHOSTTY_KEY_F1, + 60 => GHOSTTY_KEY_F2, + 61 => GHOSTTY_KEY_F3, + 62 => GHOSTTY_KEY_F4, + 63 => GHOSTTY_KEY_F5, + 64 => GHOSTTY_KEY_F6, + 65 => GHOSTTY_KEY_F7, + 66 => GHOSTTY_KEY_F8, + 67 => GHOSTTY_KEY_F9, + 68 => GHOSTTY_KEY_F10, + 69 => GHOSTTY_KEY_NUM_LOCK, + 70 => GHOSTTY_KEY_SCROLL_LOCK, + 71 => GHOSTTY_KEY_NUMPAD_7, + 72 => GHOSTTY_KEY_NUMPAD_8, + 73 => GHOSTTY_KEY_NUMPAD_9, + 74 => GHOSTTY_KEY_NUMPAD_SUBTRACT, + 75 => GHOSTTY_KEY_NUMPAD_4, + 76 => GHOSTTY_KEY_NUMPAD_5, + 77 => GHOSTTY_KEY_NUMPAD_6, + 78 => GHOSTTY_KEY_NUMPAD_ADD, + 79 => GHOSTTY_KEY_NUMPAD_1, + 80 => GHOSTTY_KEY_NUMPAD_2, + 81 => GHOSTTY_KEY_NUMPAD_3, + 82 => GHOSTTY_KEY_NUMPAD_0, + 83 => GHOSTTY_KEY_NUMPAD_DECIMAL, + 86 => GHOSTTY_KEY_INTL_BACKSLASH, + 87 => GHOSTTY_KEY_F11, + 88 => GHOSTTY_KEY_F12, + 96 => GHOSTTY_KEY_NUMPAD_ENTER, + 97 => GHOSTTY_KEY_CONTROL_RIGHT, + 98 => GHOSTTY_KEY_NUMPAD_DIVIDE, + 99 => GHOSTTY_KEY_PRINT_SCREEN, + 100 => GHOSTTY_KEY_ALT_RIGHT, + 102 => GHOSTTY_KEY_HOME, + 103 => GHOSTTY_KEY_ARROW_UP, + 104 => GHOSTTY_KEY_PAGE_UP, + 105 => GHOSTTY_KEY_ARROW_LEFT, + 106 => GHOSTTY_KEY_ARROW_RIGHT, + 107 => GHOSTTY_KEY_END, + 108 => GHOSTTY_KEY_ARROW_DOWN, + 109 => GHOSTTY_KEY_PAGE_DOWN, + 110 => GHOSTTY_KEY_INSERT, + 111 => GHOSTTY_KEY_DELETE, + 119 => GHOSTTY_KEY_PAUSE, + 125 => GHOSTTY_KEY_META_LEFT, + 126 => GHOSTTY_KEY_META_RIGHT, + 127 => GHOSTTY_KEY_CONTEXT_MENU, + _ => return None, + }; + + Some(key) +} diff --git a/linux/ghostty-gtk/src/lib.rs b/linux/ghostty-gtk/src/lib.rs new file mode 100644 index 0000000000..0437a69ab9 --- /dev/null +++ b/linux/ghostty-gtk/src/lib.rs @@ -0,0 +1,4 @@ +pub mod app; +pub mod callbacks; +pub mod keys; +pub mod surface; diff --git a/linux/ghostty-gtk/src/surface.rs b/linux/ghostty-gtk/src/surface.rs new file mode 100644 index 0000000000..6f1f934111 --- /dev/null +++ b/linux/ghostty-gtk/src/surface.rs @@ -0,0 +1,1033 @@ +//! GhosttyGlSurface — a GtkGLArea-based widget that hosts a ghostty terminal. +//! +//! This is the core rendering widget. It: +//! - Creates a GtkGLArea for OpenGL rendering +//! - Connects keyboard, mouse, scroll, and IME event controllers +//! - Forwards all events to the ghostty surface via FFI +//! - Manages the ghostty_surface_t lifecycle + +use ghostty_sys::*; +use glib::translate::IntoGlib; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use std::cell::{Cell, RefCell}; +use std::os::raw::c_char; +use std::os::raw::c_void; +use std::ptr; +use std::rc::Rc; + +use crate::callbacks::ClipboardContent; +use crate::keys; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum ImeKeyEventState { + #[default] + Idle, + NotComposing, + Composing, +} + +fn cstring_input(text: &str, context: &'static str) -> Option { + match std::ffi::CString::new(text) { + Ok(cstr) => Some(cstr), + Err(_) => { + tracing::warn!("Ignoring {} containing interior NUL", context); + None + } + } +} + +// Minimal GL bindings for viewport setup. +// GtkGLArea does NOT set glViewport before emitting the render signal, +// but ghostty's renderer reads GL_VIEWPORT to determine the surface size. +#[cfg(feature = "link-ghostty")] +mod gl_raw { + pub type GLint = i32; + pub type GLsizei = i32; + + #[link(name = "GL")] + extern "C" { + pub fn glViewport(x: GLint, y: GLint, width: GLsizei, height: GLsizei); + } +} + +// ----------------------------------------------------------------------- +// GObject subclass for the GL surface widget +// ----------------------------------------------------------------------- + +mod imp { + use super::*; + + #[derive(Default)] + pub struct GhosttyGlSurface { + pub(super) surface: Cell, + pub(super) app: Cell, + pub(super) callback_userdata: RefCell>>, + pub(super) pending_text: RefCell>, + pub(super) title: RefCell, + pub(super) im_context: RefCell>, + pub(super) im_composing: Cell, + pub(super) in_keyevent: Cell, + pub(super) im_commit_text: RefCell>, + pub(super) close_handler: RefCell>>, + pub(super) focused: Cell, + pub(super) focus_idle_queued: Cell, + pub(super) focus_restore_armed: Cell, + pub(super) focus_disarm_source: RefCell>, + pub(super) resize_focus_restore_source: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for GhosttyGlSurface { + const NAME: &'static str = "GhosttyGlSurface"; + type Type = super::GhosttyGlSurface; + type ParentType = gtk4::GLArea; + } + + impl ObjectImpl for GhosttyGlSurface { + fn constructed(&self) { + self.parent_constructed(); + + let gl_area = self.obj(); + // Match Ghostty's GTK surface behavior so resizes and renderer-driven + // invalidations can produce fresh frames without our own manual loop. + gl_area.set_auto_render(true); + gl_area.set_has_depth_buffer(false); + gl_area.set_has_stencil_buffer(false); + // Request OpenGL 4.3 (required by ghostty renderer) + gl_area.set_required_version(4, 3); + gl_area.set_focusable(true); + gl_area.set_can_focus(true); + + // Set up IME context + let im_context = gtk4::IMMulticontext::new(); + *self.im_context.borrow_mut() = Some(im_context); + gl_area.setup_ime(); + } + + fn dispose(&self) { + if let Some(source) = self.focus_disarm_source.borrow_mut().take() { + source.remove(); + } + if let Some(source) = self.resize_focus_restore_source.borrow_mut().take() { + source.remove(); + } + if let Some(im_context) = self.im_context.borrow().as_ref() { + im_context.set_client_widget(Option::<>k4::Widget>::None); + } + + let surface = self.surface.get(); + if !surface.is_null() { + #[cfg(feature = "link-ghostty")] + unsafe { + ghostty_surface_free(surface); + } + self.surface.set(ptr::null_mut()); + } + self.callback_userdata.borrow_mut().take(); + self.close_handler.borrow_mut().take(); + } + } + + impl WidgetImpl for GhosttyGlSurface { + fn realize(&self) { + self.parent_realize(); + let widget = self.obj(); + widget.make_current(); + if widget.error().is_some() { + tracing::error!("Failed to make GL context current"); + return; + } + } + + fn unrealize(&self) { + self.parent_unrealize(); + } + } + + impl GLAreaImpl for GhosttyGlSurface { + fn create_context(&self) -> Option { + use gdk4::prelude::GLContextExt; + use gtk4::prelude::NativeExt; + let widget = self.obj(); + let native = widget.native()?; + let surface = native.surface()?; + match surface.create_gl_context() { + Ok(ctx) => { + // Force desktop OpenGL (not GLES) and require 4.3 core profile + ctx.set_use_es(0); // 0 = desktop GL, not GLES + ctx.set_required_version(4, 3); + // Do NOT call ctx.realize() here — GtkGLArea handles that + // during its own realize phase with proper FBO setup. + Some(ctx) + } + Err(e) => { + tracing::error!("Failed to create GL context: {}", e); + None + } + } + } + + fn render(&self, _context: &gdk4::GLContext) -> glib::Propagation { + let surface = self.surface.get(); + if !surface.is_null() { + #[cfg(feature = "link-ghostty")] + unsafe { + // GtkGLArea does NOT set glViewport before the render signal. + // Ghostty's renderer reads GL_VIEWPORT via surfaceSize() to + // determine the render area. We must set it here. + let widget = self.obj(); + let scale = widget.scale_factor(); + let w = widget.width() * scale; + let h = widget.height() * scale; + gl_raw::glViewport(0, 0, w, h); + + ghostty_surface_draw(surface); + } + } + glib::Propagation::Stop + } + + fn resize(&self, width: i32, height: i32) { + let surface = self.surface.get(); + if !surface.is_null() && width > 0 && height > 0 { + #[cfg(feature = "link-ghostty")] + unsafe { + let scale = self.obj().scale_factor(); + let width_px = width.saturating_mul(scale) as u32; + let height_px = height.saturating_mul(scale) as u32; + let scale = scale as f64; + ghostty_surface_set_content_scale(surface, scale, scale); + ghostty_surface_set_size(surface, width_px, height_px); + } + + self.obj().schedule_resize_focus_restore(); + } + } + } +} + +glib::wrapper! { + /// A GtkGLArea that renders a ghostty terminal surface. + pub struct GhosttyGlSurface(ObjectSubclass) + @extends gtk4::GLArea, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget; +} + +impl GhosttyGlSurface { + /// Create a new terminal surface widget. + pub fn new() -> Self { + glib::Object::builder().build() + } + + /// Initialize the ghostty surface with the given app. + /// + /// This creates the underlying `ghostty_surface_t` and connects all + /// input event controllers. + /// + /// # Safety + /// The `app` must be a valid ghostty_app_t that outlives this surface. + pub fn initialize( + &self, + app: ghostty_app_t, + working_directory: Option<&str>, + command: Option<&str>, + ) { + let imp = self.imp(); + imp.app.set(app); + + self.setup_event_controllers(); + + // Create the surface after the widget is realized + let widget = self.clone(); + let wd = working_directory.map(|s| s.to_string()); + let cmd = command.map(|s| s.to_string()); + + self.connect_realize(move |w| { + widget.create_surface(app, wd.as_deref(), cmd.as_deref()); + // Grab focus so keyboard events go to this terminal + w.grab_focus(); + }); + } + + fn create_surface( + &self, + app: ghostty_app_t, + _working_directory: Option<&str>, + _command: Option<&str>, + ) { + if app.is_null() { + tracing::warn!("Cannot create surface: app is null (stub mode)"); + return; + } + + if !self.imp().surface.get().is_null() { + return; + } + + #[cfg(feature = "link-ghostty")] + { + let mut config = unsafe { ghostty_surface_config_new() }; + let callback_userdata = Box::new(crate::callbacks::SurfaceUserdata::new(self)); + + // Set platform to Linux with our GtkGLArea + config.platform_tag = ghostty_platform_e::GHOSTTY_PLATFORM_LINUX; + config.platform = ghostty_platform_u { + linux: ghostty_platform_linux_s { + gl_area: self.as_ptr() as *mut c_void, + }, + }; + + // Set scale factor + config.scale_factor = self.scale_factor() as f64; + + // Set working directory + let wd_cstr; + if let Some(wd) = _working_directory { + wd_cstr = std::ffi::CString::new(wd).ok(); + config.working_directory = wd_cstr.as_ref().map_or(ptr::null(), |c| c.as_ptr()); + } + + // Set command + let cmd_cstr; + if let Some(cmd) = _command { + cmd_cstr = std::ffi::CString::new(cmd).ok(); + config.command = cmd_cstr.as_ref().map_or(ptr::null(), |c| c.as_ptr()); + } + + config.context = ghostty_surface_context_e::GHOSTTY_SURFACE_CONTEXT_SPLIT; + config.userdata = + (&*callback_userdata as *const crate::callbacks::SurfaceUserdata) as *mut c_void; + + let surface = unsafe { ghostty_surface_new(app, &config) }; + if surface.is_null() { + tracing::error!("ghostty_surface_new returned null"); + return; + } + + *self.imp().callback_userdata.borrow_mut() = Some(callback_userdata); + self.imp().surface.set(surface); + self.flush_pending_text(); + } + } + + fn setup_event_controllers(&self) { + // Keyboard events + let key_controller = gtk4::EventControllerKey::new(); + { + let surface_widget = self.clone(); + key_controller.connect_key_pressed(move |controller, keyval, keycode, state| { + surface_widget.on_key_event( + controller, + keyval.into_glib(), + keycode, + state, + ghostty_input_action_e::GHOSTTY_ACTION_PRESS, + ) + }); + } + { + let surface_widget = self.clone(); + key_controller.connect_key_released(move |controller, keyval, keycode, state| { + surface_widget.on_key_event( + controller, + keyval.into_glib(), + keycode, + state, + ghostty_input_action_e::GHOSTTY_ACTION_RELEASE, + ); + }); + } + self.add_controller(key_controller); + + // Mouse click events + let click = gtk4::GestureClick::new(); + click.set_button(0); // All buttons + { + let surface_widget = self.clone(); + click.connect_pressed(move |gesture, _n_press, x, y| { + // Grab focus on click so key events go to this widget + surface_widget.grab_focus(); + let button = gesture.current_button(); + surface_widget.on_mouse_button( + button, + x, + y, + ghostty_input_mouse_state_e::GHOSTTY_MOUSE_PRESS, + ); + }); + } + { + let surface_widget = self.clone(); + click.connect_released(move |gesture, _n_press, x, y| { + let button = gesture.current_button(); + surface_widget.on_mouse_button( + button, + x, + y, + ghostty_input_mouse_state_e::GHOSTTY_MOUSE_RELEASE, + ); + }); + } + self.add_controller(click); + + // Mouse motion events + let motion = gtk4::EventControllerMotion::new(); + { + let surface_widget = self.clone(); + motion.connect_motion(move |_controller, x, y| { + surface_widget.on_mouse_motion(x, y); + }); + } + self.add_controller(motion); + + // Scroll events + let scroll = gtk4::EventControllerScroll::new( + gtk4::EventControllerScrollFlags::BOTH_AXES + | gtk4::EventControllerScrollFlags::DISCRETE, + ); + { + let surface_widget = self.clone(); + scroll.connect_scroll(move |_controller, dx, dy| { + surface_widget.on_scroll(dx, dy); + glib::Propagation::Stop + }); + } + self.add_controller(scroll); + + // Focus events + let focus = gtk4::EventControllerFocus::new(); + { + let surface_widget = self.clone(); + focus.connect_enter(move |_| { + surface_widget.on_focus_change(true); + }); + } + { + let surface_widget = self.clone(); + focus.connect_leave(move |_| { + surface_widget.on_focus_change(false); + }); + } + self.add_controller(focus); + } + + fn on_key_event( + &self, + controller: >k4::EventControllerKey, + keyval: u32, + keycode: u32, + state: gdk4::ModifierType, + action: ghostty_input_action_e, + ) -> glib::Propagation { + let surface = self.imp().surface.get(); + if surface.is_null() { + return glib::Propagation::Proceed; + } + + let was_composing = self.imp().im_composing.get(); + if action == ghostty_input_action_e::GHOSTTY_ACTION_PRESS { + if let Some(im_context) = self.imp().im_context.borrow().as_ref() { + if let Some(event) = controller.current_event() { + self.update_ime_cursor_location(); + self.imp().in_keyevent.set(if was_composing { + ImeKeyEventState::Composing + } else { + ImeKeyEventState::NotComposing + }); + let ime_handled = im_context.filter_keypress(&event); + self.imp().in_keyevent.set(ImeKeyEventState::Idle); + + if ime_handled { + let is_composing = self.imp().im_composing.get(); + let has_committed_text = !self.imp().im_commit_text.borrow().is_empty(); + if is_composing || was_composing || !has_committed_text { + return glib::Propagation::Stop; + } + } + } + } + } + + let mods = keys::gdk_mods_to_ghostty(state); + + // Convert keyval to a GDK Key for unicode conversion + let key: gdk4::Key = unsafe { glib::translate::from_glib(keyval) }; + + let committed_text = { + let mut text = self.imp().im_commit_text.borrow_mut(); + std::mem::take(&mut *text) + }; + + let mut text_buf = [0u8; 8]; + let text_cstr; + let committed_text_cstr; + let text_ptr = if !committed_text.is_empty() { + match std::ffi::CString::new(committed_text) { + Ok(cstr) => { + committed_text_cstr = cstr; + committed_text_cstr.as_ptr() + } + Err(_) => { + tracing::warn!("Ignoring IME commit containing interior NUL"); + ptr::null() + } + } + } else if action == ghostty_input_action_e::GHOSTTY_ACTION_PRESS { + if let Some(ch) = key.to_unicode() { + if ch >= '\x20' { + let len = ch.encode_utf8(&mut text_buf).len(); + text_buf[len] = 0; + text_cstr = &text_buf[..=len]; + text_cstr.as_ptr() as *const c_char + } else { + ptr::null() + } + } else { + ptr::null() + } + } else { + ptr::null() + }; + + // Unshifted codepoint: the unicode value of the key without Shift. + // Translate the hardware keycode with no modifiers but preserving the + // keyboard group (layout) from the current event. + let unshifted_codepoint = { + let display = self.display(); + let group = controller + .current_event() + .and_then(|ev| ev.downcast_ref::().map(|ke| ke.layout() as i32)) + .unwrap_or(0); + if let Some((unshifted_key, _, _, _)) = + display.translate_key(keycode, gdk4::ModifierType::empty(), group) + { + unshifted_key.to_unicode().map(|c| c as u32).unwrap_or(0) + } else { + key.to_lower().to_unicode().map(|c| c as u32).unwrap_or(0) + } + }; + + let key_event = ghostty_input_key_s { + action, + mods, + consumed_mods: 0, + keycode, + text: text_ptr, + unshifted_codepoint, + composing: self.imp().im_composing.get(), + }; + + #[cfg(feature = "link-ghostty")] + { + let handled = unsafe { ghostty_surface_key(surface, key_event) }; + if handled { + return glib::Propagation::Stop; + } + } + let _ = key_event; + + glib::Propagation::Proceed + } + + fn on_mouse_button(&self, button: u32, _x: f64, _y: f64, state: ghostty_input_mouse_state_e) { + let surface = self.imp().surface.get(); + if surface.is_null() { + return; + } + + let ghostty_button = keys::gdk_button_to_ghostty(button); + + #[cfg(feature = "link-ghostty")] + unsafe { + ghostty_surface_mouse_button(surface, state, ghostty_button, 0); + } + let _ = (state, ghostty_button); + } + + fn on_mouse_motion(&self, x: f64, y: f64) { + let surface = self.imp().surface.get(); + if surface.is_null() { + return; + } + + #[cfg(feature = "link-ghostty")] + unsafe { + ghostty_surface_mouse_pos(surface, x, y, 0); + } + let _ = (x, y); + } + + fn on_scroll(&self, dx: f64, dy: f64) { + let surface = self.imp().surface.get(); + if surface.is_null() { + return; + } + + #[cfg(feature = "link-ghostty")] + unsafe { + // Ghostty expects positive deltas for up/right and negative for + // down/left. GTK delivers the inverse "natural scrolling" sign. + ghostty_surface_mouse_scroll(surface, -dx, -dy, 0); + } + let _ = (dx, dy); + } + + fn on_focus_change(&self, focused: bool) { + self.imp().focused.set(focused); + let surface = self.imp().surface.get(); + if let Some(im_context) = self.imp().im_context.borrow().as_ref() { + if focused { + im_context.focus_in(); + self.update_ime_cursor_location(); + } else { + self.imp().im_composing.set(false); + self.imp().im_commit_text.borrow_mut().clear(); + im_context.focus_out(); + im_context.reset(); + self.update_preedit(""); + } + } + + if focused { + self.cancel_focus_disarm(); + self.imp().focus_restore_armed.set(true); + } else { + self.schedule_focus_disarm(); + } + + if surface.is_null() || self.imp().focus_idle_queued.replace(true) { + return; + } + + let surface_widget = self.clone(); + glib::idle_add_local_once(move || { + let imp = surface_widget.imp(); + imp.focus_idle_queued.set(false); + + let surface = imp.surface.get(); + if surface.is_null() { + return; + } + + #[cfg(feature = "link-ghostty")] + unsafe { + ghostty_surface_set_focus(surface, imp.focused.get()); + } + }); + } + + fn schedule_focus_disarm(&self) { + self.cancel_focus_disarm(); + + let surface_widget = self.clone(); + let source = + glib::timeout_add_local_once(std::time::Duration::from_millis(250), move || { + surface_widget.imp().focus_disarm_source.borrow_mut().take(); + if !surface_widget.imp().focused.get() { + surface_widget.imp().focus_restore_armed.set(false); + } + }); + *self.imp().focus_disarm_source.borrow_mut() = Some(source); + } + + fn cancel_focus_disarm(&self) { + if let Some(source) = self.imp().focus_disarm_source.borrow_mut().take() { + source.remove(); + } + } + + fn schedule_resize_focus_restore(&self) { + if !self.imp().focus_restore_armed.get() { + return; + } + + self.cancel_focus_disarm(); + + if let Some(source) = self.imp().resize_focus_restore_source.borrow_mut().take() { + source.remove(); + } + + let surface_widget = self.clone(); + let source = + glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || { + surface_widget + .imp() + .resize_focus_restore_source + .borrow_mut() + .take(); + + if !surface_widget.imp().focused.get() { + let _ = surface_widget.grab_focus(); + } + }); + *self.imp().resize_focus_restore_source.borrow_mut() = Some(source); + } + + /// Get the raw ghostty surface pointer. + pub fn raw_surface(&self) -> ghostty_surface_t { + self.imp().surface.get() + } + + /// Request the surface to refresh its rendering. + pub fn refresh(&self) { + let surface = self.imp().surface.get(); + if !surface.is_null() { + #[cfg(feature = "link-ghostty")] + unsafe { + ghostty_surface_refresh(surface); + } + } + self.queue_render(); + } + + fn write_text(&self, surface: ghostty_surface_t, text: &str) -> bool { + #[cfg(feature = "link-ghostty")] + { + let Some(cstr) = cstring_input(text, "terminal text input") else { + return false; + }; + unsafe { + ghostty_surface_text(surface, cstr.as_ptr(), text.len()); + } + } + let _ = (surface, text); + true + } + + fn flush_pending_text(&self) { + let surface = self.imp().surface.get(); + if surface.is_null() { + return; + } + + let pending = std::mem::take(&mut *self.imp().pending_text.borrow_mut()); + for text in pending { + let _ = self.write_text(surface, &text); + } + } + + /// Send text input to the terminal (e.g., from IME commit). + pub fn send_text(&self, text: &str) -> bool { + let surface = self.imp().surface.get(); + if surface.is_null() { + self.imp().pending_text.borrow_mut().push(text.to_string()); + return true; + } + + self.write_text(surface, text) + } + + pub fn read_clipboard_request(&self, clipboard: ghostty_clipboard_e, context: *mut c_void) { + let clipboard = self.clipboard_for_kind(clipboard); + let surface = self.clone(); + let context = SendPtr(context); + clipboard.read_text_async(None::<>k4::gio::Cancellable>, move |result| { + let text = match result { + Ok(Some(text)) => text.to_string(), + Ok(None) => String::new(), + Err(err) => { + tracing::warn!("Failed to read clipboard text: {}", err); + String::new() + } + }; + + surface.complete_clipboard_request(&text, context.0, false); + }); + } + + pub fn confirm_clipboard_read( + &self, + content: &str, + context: *mut c_void, + request: ghostty_clipboard_request_e, + ) { + tracing::warn!( + ?request, + "Auto-confirming Ghostty clipboard request in embedded host" + ); + self.complete_clipboard_request(content, context, true); + } + + pub fn write_clipboard( + &self, + clipboard: ghostty_clipboard_e, + content: &[ClipboardContent], + _confirm: bool, + ) { + let clipboard = self.clipboard_for_kind(clipboard); + if let Some(text) = content + .iter() + .find_map( + |entry| match (entry.mime.as_deref(), entry.data.as_deref()) { + (Some("text/plain"), Some(text)) => Some(text), + _ => None, + }, + ) + .or_else(|| content.iter().find_map(|entry| entry.data.as_deref())) + { + clipboard.set_text(text); + } + } + + pub fn set_close_handler(&self, handler: F) + where + F: Fn(bool) + 'static, + { + *self.imp().close_handler.borrow_mut() = Some(Rc::new(handler)); + } + + pub fn close_requested(&self, process_alive: bool) { + tracing::debug!(process_alive, "ghostty requested surface close"); + let handler = self.imp().close_handler.borrow().clone(); + if let Some(handler) = handler { + handler(process_alive); + } + } + + fn setup_ime(&self) { + let Some(im_context) = self.imp().im_context.borrow().as_ref().cloned() else { + return; + }; + + im_context.set_client_widget(Some(self)); + im_context.set_use_preedit(true); + + let surface_widget = self.clone(); + im_context.connect_preedit_start(move |_context| { + surface_widget.im_preedit_start(); + }); + + let surface_widget = self.clone(); + im_context.connect_commit(move |_context, text| { + surface_widget.im_commit(text); + }); + + let surface_widget = self.clone(); + im_context.connect_preedit_changed(move |context| { + surface_widget.im_preedit_changed(context); + }); + + let surface_widget = self.clone(); + im_context.connect_preedit_end(move |_context| { + surface_widget.im_preedit_end(); + }); + } + + fn im_preedit_start(&self) { + self.imp().im_composing.set(true); + self.imp().im_commit_text.borrow_mut().clear(); + } + + fn im_preedit_changed(&self, context: >k4::IMMulticontext) { + self.imp().im_composing.set(true); + let (text, _attrs, _cursor_pos) = context.preedit_string(); + self.update_preedit(text.as_str()); + self.update_ime_cursor_location(); + } + + fn im_preedit_end(&self) { + self.imp().im_composing.set(false); + self.update_preedit(""); + } + + fn im_commit(&self, text: &str) { + match self.imp().in_keyevent.get() { + ImeKeyEventState::NotComposing => { + let mut committed = self.imp().im_commit_text.borrow_mut(); + committed.clear(); + committed.extend_from_slice(text.as_bytes()); + } + ImeKeyEventState::Composing | ImeKeyEventState::Idle => { + self.imp().im_composing.set(false); + self.update_preedit(""); + self.send_text_as_key(text); + } + } + } + + fn send_text_as_key(&self, text: &str) { + let surface = self.imp().surface.get(); + if surface.is_null() { + return; + } + + #[cfg(not(feature = "link-ghostty"))] + let _ = text; + + let Some(cstr) = cstring_input(text, "IME commit") else { + return; + }; + + #[cfg(feature = "link-ghostty")] + unsafe { + let event = ghostty_input_key_s { + action: ghostty_input_action_e::GHOSTTY_ACTION_PRESS, + mods: 0, + consumed_mods: 0, + keycode: 0, + text: cstr.as_ptr(), + unshifted_codepoint: 0, + composing: false, + }; + let _ = ghostty_surface_key(surface, event); + } + + #[cfg(not(feature = "link-ghostty"))] + let _ = cstr; + } + + fn update_preedit(&self, text: &str) { + let surface = self.imp().surface.get(); + if surface.is_null() { + return; + } + + #[cfg(feature = "link-ghostty")] + { + let Some(cstr) = cstring_input(text, "IME preedit") else { + return; + }; + + unsafe { + ghostty_surface_preedit(surface, cstr.as_ptr(), text.len()); + } + } + let _ = text; + } + + fn update_ime_cursor_location(&self) { + let surface = self.imp().surface.get(); + if surface.is_null() { + return; + } + + #[cfg(feature = "link-ghostty")] + unsafe { + let Some(im_context) = self.imp().im_context.borrow().as_ref().cloned() else { + return; + }; + + let mut x = 0.0; + let mut y = 0.0; + let mut w = 0.0; + let mut h = 0.0; + ghostty_surface_ime_point(surface, &mut x, &mut y, &mut w, &mut h); + let rect = gdk4::Rectangle::new( + x.round() as i32, + y.round() as i32, + w.max(1.0).round() as i32, + h.max(1.0).round() as i32, + ); + im_context.set_cursor_location(&rect); + } + } + + /// Set the current title (called from action callback). + pub fn set_title(&self, title: &str) { + *self.imp().title.borrow_mut() = title.to_string(); + } + + /// Get the current title. + pub fn title(&self) -> String { + self.imp().title.borrow().clone() + } + + /// Request the surface to close. + pub fn request_close(&self) { + let surface = self.imp().surface.get(); + if !surface.is_null() { + #[cfg(feature = "link-ghostty")] + unsafe { + ghostty_surface_request_close(surface); + } + } + } + + /// Check if the process has exited. + pub fn process_exited(&self) -> bool { + let surface = self.imp().surface.get(); + if surface.is_null() { + return true; + } + #[cfg(feature = "link-ghostty")] + { + unsafe { ghostty_surface_process_exited(surface) } + } + #[cfg(not(feature = "link-ghostty"))] + false + } + + /// Get the surface size info. + pub fn surface_size(&self) -> Option { + let surface = self.imp().surface.get(); + if surface.is_null() { + return None; + } + #[cfg(feature = "link-ghostty")] + { + Some(unsafe { ghostty_surface_size(surface) }) + } + #[cfg(not(feature = "link-ghostty"))] + None + } + + fn clipboard_for_kind(&self, clipboard: ghostty_clipboard_e) -> gdk4::Clipboard { + match clipboard { + ghostty_clipboard_e::GHOSTTY_CLIPBOARD_SELECTION => self.primary_clipboard(), + _ => self.clipboard(), + } + } + + fn complete_clipboard_request(&self, text: &str, context: *mut c_void, confirmed: bool) { + let surface = self.imp().surface.get(); + if surface.is_null() { + return; + } + + #[cfg(feature = "link-ghostty")] + { + let Some(cstr) = cstring_input(text, "clipboard request") else { + return; + }; + + unsafe { + ghostty_surface_complete_clipboard_request( + surface, + cstr.as_ptr(), + context, + confirmed, + ); + } + } + #[cfg(not(feature = "link-ghostty"))] + let _ = (text, context, confirmed); + } +} + +#[derive(Clone, Copy)] +struct SendPtr(*mut c_void); + +unsafe impl Send for SendPtr {} + +#[cfg(test)] +mod tests { + use super::cstring_input; + + #[test] + fn cstring_input_accepts_valid_text() { + assert!(cstring_input("hello", "test").is_some()); + } + + #[test] + fn cstring_input_rejects_interior_nul() { + assert!(cstring_input("hel\0lo", "test").is_none()); + } +} + +impl Default for GhosttyGlSurface { + fn default() -> Self { + Self::new() + } +} diff --git a/linux/ghostty-sys/Cargo.toml b/linux/ghostty-sys/Cargo.toml new file mode 100644 index 0000000000..8ff6950db0 --- /dev/null +++ b/linux/ghostty-sys/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ghostty-sys" +version = "0.1.0" +edition.workspace = true +description = "Raw FFI bindings to libghostty (embedded runtime)" + +[features] +link-ghostty = [] + +[build-dependencies] +cc = "1" +# bindgen = "0.70" # TODO: enable when building against actual libghostty diff --git a/linux/ghostty-sys/build.rs b/linux/ghostty-sys/build.rs new file mode 100644 index 0000000000..35c7480bab --- /dev/null +++ b/linux/ghostty-sys/build.rs @@ -0,0 +1,186 @@ +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +fn main() { + // Without the link-ghostty feature, compile in stub mode — no zig build needed. + if env::var("CARGO_FEATURE_LINK_GHOSTTY").is_err() { + return; + } + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let workspace_dir = manifest_dir.parent().unwrap(); + let candidate_dirs = [ + workspace_dir.join("ghostty"), + workspace_dir + .parent() + .map(|parent| parent.join("ghostty")) + .unwrap_or_else(|| workspace_dir.join("ghostty")), + ]; + let ghostty_dir = candidate_dirs + .into_iter() + .find(|path| path.join("build.zig").exists()) + .unwrap_or_else(|| { + panic!( + "ghostty submodule not found. Checked: {} and {}", + workspace_dir.join("ghostty").display(), + workspace_dir + .parent() + .map(|parent| parent.join("ghostty")) + .unwrap_or_else(|| workspace_dir.join("ghostty")) + .display() + ) + }); + + // Build libghostty as a static library using zig build + let output_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + + let install_dir = output_dir.join("ghostty-install"); + + let status = Command::new("zig") + .arg("build") + .arg("-Dapp-runtime=none") // none = libghostty (embedded runtime) + .arg("-Doptimize=ReleaseFast") + .arg("-Demit-terminfo=true") + .arg("--prefix") + .arg(install_dir.as_os_str()) + .current_dir(&ghostty_dir) + .status() + .expect("Failed to run zig build. Is zig installed?"); + + if !status.success() { + panic!("zig build failed with status: {}", status); + } + + // `app-runtime=none` does not install resources, so generate the + // Ghostty terminfo bundle ourselves for embedded hosts. + let share_dir = install_dir.join("share"); + let resources_dir = share_dir.join("ghostty"); + let terminfo_dir = share_dir.join("terminfo"); + fs::create_dir_all(&resources_dir).expect("failed to create ghostty resources dir"); + fs::create_dir_all(&terminfo_dir).expect("failed to create ghostty terminfo dir"); + + let terminfo_helper_src = output_dir.join("ghostty-terminfo.zig"); + fs::write( + &terminfo_helper_src, + r#"const std = @import("std"); +const ghostty = @import("ghostty_terminfo").ghostty; + +pub fn main() !void { + var buffer: [1024]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&buffer); + const writer = &stdout_writer.interface; + try ghostty.encode(writer); + try stdout_writer.end(); +} +"#, + ) + .expect("failed to write ghostty terminfo helper"); + + let build_data_exe = output_dir.join("ghostty-terminfo"); + let ghostty_terminfo_module = ghostty_dir.join("src").join("terminfo").join("ghostty.zig"); + let status = Command::new("zig") + .arg("build-exe") + .arg("--dep") + .arg("ghostty_terminfo") + .arg(format!("-Mroot={}", terminfo_helper_src.display())) + .arg(format!( + "-Mghostty_terminfo={}", + ghostty_terminfo_module.display() + )) + .arg("-O") + .arg("ReleaseFast") + .arg(format!("-femit-bin={}", build_data_exe.display())) + .status() + .expect("Failed to build ghostty-build-data helper"); + + if !status.success() { + panic!("zig build-exe failed with status: {}", status); + } + + let terminfo_source = output_dir.join("ghostty.terminfo"); + let output = Command::new(&build_data_exe) + .arg("+terminfo") + .output() + .expect("Failed to generate ghostty terminfo source"); + + if !output.status.success() { + panic!("ghostty-build-data failed with status: {}", output.status); + } + + fs::write(&terminfo_source, &output.stdout).expect("failed to write ghostty terminfo source"); + fs::write(terminfo_dir.join("ghostty.terminfo"), &output.stdout) + .expect("failed to install ghostty terminfo source"); + + let status = Command::new("tic") + .arg("-x") + .arg("-o") + .arg(&terminfo_dir) + .arg(&terminfo_source) + .status() + .expect("Failed to compile ghostty terminfo database with tic"); + + if !status.success() { + panic!("tic failed with status: {}", status); + } + + // Compile GLAD (OpenGL loader) — ghostty's build excludes it from libghostty, + // expecting the host application to provide it. + let glad_dir = ghostty_dir.join("vendor").join("glad"); + cc::Build::new() + .file(glad_dir.join("src").join("gl.c")) + .include(glad_dir.join("include")) + .compile("glad"); + + // Link libghostty as a shared library (includes all vendored deps) + let lib_dir = install_dir.join("lib"); + let profile_dir = output_dir + .ancestors() + .nth(3) + .expect("OUT_DIR should be nested under target//build") + .to_path_buf(); + let profile_deps_dir = profile_dir.join("deps"); + fs::create_dir_all(&profile_deps_dir).expect("failed to create target deps dir"); + copy_runtime_libraries( + &lib_dir, + &[profile_dir.as_path(), profile_deps_dir.as_path()], + ); + + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + println!("cargo:rustc-link-lib=dylib=ghostty"); + println!( + "cargo:rustc-env=GHOSTTY_BUNDLED_RESOURCES_DIR={}", + resources_dir.display() + ); + + // Rerun if ghostty source changes or feature flag changes + println!("cargo:rerun-if-changed={}", ghostty_dir.display()); + println!("cargo:rerun-if-env-changed=CARGO_FEATURE_LINK_GHOSTTY"); +} + +fn copy_runtime_libraries(lib_dir: &std::path::Path, destinations: &[&std::path::Path]) { + let entries = fs::read_dir(lib_dir).expect("failed to list built Ghostty libs"); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + + let Some(file_name) = path.file_name() else { + continue; + }; + + for destination in destinations { + let target = destination.join(file_name); + fs::copy(&path, &target).unwrap_or_else(|error| { + panic!( + "failed to copy {} to {}: {}", + path.display(), + target.display(), + error + ) + }); + } + } +} diff --git a/linux/ghostty-sys/src/lib.rs b/linux/ghostty-sys/src/lib.rs new file mode 100644 index 0000000000..20a6094740 --- /dev/null +++ b/linux/ghostty-sys/src/lib.rs @@ -0,0 +1,1359 @@ +//! Raw FFI bindings to libghostty (embedded runtime). +//! +//! These types mirror the definitions in `ghostty.h` and must be kept in sync. +//! All types here are `repr(C)` to match the C ABI. + +#![allow(non_camel_case_types, non_upper_case_globals, dead_code)] + +use std::os::raw::{c_char, c_double, c_int, c_void}; + +// ----------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------- + +pub const GHOSTTY_SUCCESS: c_int = 0; + +pub fn bundled_resources_dir() -> Option<&'static str> { + option_env!("GHOSTTY_BUNDLED_RESOURCES_DIR") +} + +// ----------------------------------------------------------------------- +// Opaque types +// ----------------------------------------------------------------------- + +pub type ghostty_app_t = *mut c_void; +pub type ghostty_config_t = *mut c_void; +pub type ghostty_surface_t = *mut c_void; +pub type ghostty_inspector_t = *mut c_void; + +// ----------------------------------------------------------------------- +// Platform +// ----------------------------------------------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_platform_e { + GHOSTTY_PLATFORM_INVALID = 0, + GHOSTTY_PLATFORM_MACOS = 1, + GHOSTTY_PLATFORM_IOS = 2, + GHOSTTY_PLATFORM_LINUX = 3, // Added for cmux-linux +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_platform_macos_s { + pub nsview: *mut c_void, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_platform_ios_s { + pub uiview: *mut c_void, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_platform_linux_s { + pub gl_area: *mut c_void, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub union ghostty_platform_u { + pub macos: ghostty_platform_macos_s, + pub ios: ghostty_platform_ios_s, + pub linux: ghostty_platform_linux_s, +} + +// ----------------------------------------------------------------------- +// Clipboard +// ----------------------------------------------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_clipboard_e { + GHOSTTY_CLIPBOARD_STANDARD = 0, + GHOSTTY_CLIPBOARD_SELECTION = 1, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_clipboard_content_s { + pub mime: *const c_char, + pub data: *const c_char, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_clipboard_request_e { + GHOSTTY_CLIPBOARD_REQUEST_PASTE = 0, + GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ = 1, + GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE = 2, +} + +// ----------------------------------------------------------------------- +// Mouse input +// ----------------------------------------------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_input_mouse_state_e { + GHOSTTY_MOUSE_RELEASE = 0, + GHOSTTY_MOUSE_PRESS = 1, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_input_mouse_button_e { + GHOSTTY_MOUSE_UNKNOWN = 0, + GHOSTTY_MOUSE_LEFT = 1, + GHOSTTY_MOUSE_RIGHT = 2, + GHOSTTY_MOUSE_MIDDLE = 3, + GHOSTTY_MOUSE_FOUR = 4, + GHOSTTY_MOUSE_FIVE = 5, + GHOSTTY_MOUSE_SIX = 6, + GHOSTTY_MOUSE_SEVEN = 7, + GHOSTTY_MOUSE_EIGHT = 8, + GHOSTTY_MOUSE_NINE = 9, + GHOSTTY_MOUSE_TEN = 10, + GHOSTTY_MOUSE_ELEVEN = 11, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_input_mouse_momentum_e { + GHOSTTY_MOUSE_MOMENTUM_NONE = 0, + GHOSTTY_MOUSE_MOMENTUM_BEGAN = 1, + GHOSTTY_MOUSE_MOMENTUM_STATIONARY = 2, + GHOSTTY_MOUSE_MOMENTUM_CHANGED = 3, + GHOSTTY_MOUSE_MOMENTUM_ENDED = 4, + GHOSTTY_MOUSE_MOMENTUM_CANCELLED = 5, + GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN = 6, +} + +pub type ghostty_input_scroll_mods_t = c_int; + +// ----------------------------------------------------------------------- +// Color scheme +// ----------------------------------------------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_color_scheme_e { + GHOSTTY_COLOR_SCHEME_LIGHT = 0, + GHOSTTY_COLOR_SCHEME_DARK = 1, +} + +// ----------------------------------------------------------------------- +// Input modifiers & actions +// ----------------------------------------------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_input_mods_e { + GHOSTTY_MODS_NONE = 0, + GHOSTTY_MODS_SHIFT = 1 << 0, + GHOSTTY_MODS_CTRL = 1 << 1, + GHOSTTY_MODS_ALT = 1 << 2, + GHOSTTY_MODS_SUPER = 1 << 3, + GHOSTTY_MODS_CAPS = 1 << 4, + GHOSTTY_MODS_NUM = 1 << 5, + GHOSTTY_MODS_SHIFT_RIGHT = 1 << 6, + GHOSTTY_MODS_CTRL_RIGHT = 1 << 7, + GHOSTTY_MODS_ALT_RIGHT = 1 << 8, + GHOSTTY_MODS_SUPER_RIGHT = 1 << 9, +} + +// Use a type alias for modifier flags (can combine multiple values) +pub type GhosttyMods = u32; + +// Binding flags are bitflags that can be OR'd together in C, +// so we use a type alias instead of an enum to avoid UB. +pub type GhosttyBindingFlags = u32; +pub const GHOSTTY_BINDING_FLAGS_CONSUMED: GhosttyBindingFlags = 1 << 0; +pub const GHOSTTY_BINDING_FLAGS_ALL: GhosttyBindingFlags = 1 << 1; +pub const GHOSTTY_BINDING_FLAGS_GLOBAL: GhosttyBindingFlags = 1 << 2; +pub const GHOSTTY_BINDING_FLAGS_PERFORMABLE: GhosttyBindingFlags = 1 << 3; + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_input_action_e { + GHOSTTY_ACTION_RELEASE = 0, + GHOSTTY_ACTION_PRESS = 1, + GHOSTTY_ACTION_REPEAT = 2, +} + +// ----------------------------------------------------------------------- +// Key codes (based on W3C UIEvents) +// ----------------------------------------------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ghostty_input_key_e { + GHOSTTY_KEY_UNIDENTIFIED = 0, + + // Writing System Keys § 3.1.1 + GHOSTTY_KEY_BACKQUOTE, + GHOSTTY_KEY_BACKSLASH, + GHOSTTY_KEY_BRACKET_LEFT, + GHOSTTY_KEY_BRACKET_RIGHT, + GHOSTTY_KEY_COMMA, + GHOSTTY_KEY_DIGIT_0, + GHOSTTY_KEY_DIGIT_1, + GHOSTTY_KEY_DIGIT_2, + GHOSTTY_KEY_DIGIT_3, + GHOSTTY_KEY_DIGIT_4, + GHOSTTY_KEY_DIGIT_5, + GHOSTTY_KEY_DIGIT_6, + GHOSTTY_KEY_DIGIT_7, + GHOSTTY_KEY_DIGIT_8, + GHOSTTY_KEY_DIGIT_9, + GHOSTTY_KEY_EQUAL, + GHOSTTY_KEY_INTL_BACKSLASH, + GHOSTTY_KEY_INTL_RO, + GHOSTTY_KEY_INTL_YEN, + GHOSTTY_KEY_A, + GHOSTTY_KEY_B, + GHOSTTY_KEY_C, + GHOSTTY_KEY_D, + GHOSTTY_KEY_E, + GHOSTTY_KEY_F, + GHOSTTY_KEY_G, + GHOSTTY_KEY_H, + GHOSTTY_KEY_I, + GHOSTTY_KEY_J, + GHOSTTY_KEY_K, + GHOSTTY_KEY_L, + GHOSTTY_KEY_M, + GHOSTTY_KEY_N, + GHOSTTY_KEY_O, + GHOSTTY_KEY_P, + GHOSTTY_KEY_Q, + GHOSTTY_KEY_R, + GHOSTTY_KEY_S, + GHOSTTY_KEY_T, + GHOSTTY_KEY_U, + GHOSTTY_KEY_V, + GHOSTTY_KEY_W, + GHOSTTY_KEY_X, + GHOSTTY_KEY_Y, + GHOSTTY_KEY_Z, + GHOSTTY_KEY_MINUS, + GHOSTTY_KEY_PERIOD, + GHOSTTY_KEY_QUOTE, + GHOSTTY_KEY_SEMICOLON, + GHOSTTY_KEY_SLASH, + + // Functional Keys § 3.1.2 + GHOSTTY_KEY_ALT_LEFT, + GHOSTTY_KEY_ALT_RIGHT, + GHOSTTY_KEY_BACKSPACE, + GHOSTTY_KEY_CAPS_LOCK, + GHOSTTY_KEY_CONTEXT_MENU, + GHOSTTY_KEY_CONTROL_LEFT, + GHOSTTY_KEY_CONTROL_RIGHT, + GHOSTTY_KEY_ENTER, + GHOSTTY_KEY_META_LEFT, + GHOSTTY_KEY_META_RIGHT, + GHOSTTY_KEY_SHIFT_LEFT, + GHOSTTY_KEY_SHIFT_RIGHT, + GHOSTTY_KEY_SPACE, + GHOSTTY_KEY_TAB, + GHOSTTY_KEY_CONVERT, + GHOSTTY_KEY_KANA_MODE, + GHOSTTY_KEY_NON_CONVERT, + + // Control Pad Section § 3.2 + GHOSTTY_KEY_DELETE, + GHOSTTY_KEY_END, + GHOSTTY_KEY_HELP, + GHOSTTY_KEY_HOME, + GHOSTTY_KEY_INSERT, + GHOSTTY_KEY_PAGE_DOWN, + GHOSTTY_KEY_PAGE_UP, + + // Arrow Pad Section § 3.3 + GHOSTTY_KEY_ARROW_DOWN, + GHOSTTY_KEY_ARROW_LEFT, + GHOSTTY_KEY_ARROW_RIGHT, + GHOSTTY_KEY_ARROW_UP, + + // Numpad Section § 3.4 + GHOSTTY_KEY_NUM_LOCK, + GHOSTTY_KEY_NUMPAD_0, + GHOSTTY_KEY_NUMPAD_1, + GHOSTTY_KEY_NUMPAD_2, + GHOSTTY_KEY_NUMPAD_3, + GHOSTTY_KEY_NUMPAD_4, + GHOSTTY_KEY_NUMPAD_5, + GHOSTTY_KEY_NUMPAD_6, + GHOSTTY_KEY_NUMPAD_7, + GHOSTTY_KEY_NUMPAD_8, + GHOSTTY_KEY_NUMPAD_9, + GHOSTTY_KEY_NUMPAD_ADD, + GHOSTTY_KEY_NUMPAD_BACKSPACE, + GHOSTTY_KEY_NUMPAD_CLEAR, + GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, + GHOSTTY_KEY_NUMPAD_COMMA, + GHOSTTY_KEY_NUMPAD_DECIMAL, + GHOSTTY_KEY_NUMPAD_DIVIDE, + GHOSTTY_KEY_NUMPAD_ENTER, + GHOSTTY_KEY_NUMPAD_EQUAL, + GHOSTTY_KEY_NUMPAD_MEMORY_ADD, + GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, + GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, + GHOSTTY_KEY_NUMPAD_MEMORY_STORE, + GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, + GHOSTTY_KEY_NUMPAD_MULTIPLY, + GHOSTTY_KEY_NUMPAD_PAREN_LEFT, + GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, + GHOSTTY_KEY_NUMPAD_SUBTRACT, + GHOSTTY_KEY_NUMPAD_SEPARATOR, + GHOSTTY_KEY_NUMPAD_UP, + GHOSTTY_KEY_NUMPAD_DOWN, + GHOSTTY_KEY_NUMPAD_RIGHT, + GHOSTTY_KEY_NUMPAD_LEFT, + GHOSTTY_KEY_NUMPAD_BEGIN, + GHOSTTY_KEY_NUMPAD_HOME, + GHOSTTY_KEY_NUMPAD_END, + GHOSTTY_KEY_NUMPAD_INSERT, + GHOSTTY_KEY_NUMPAD_DELETE, + GHOSTTY_KEY_NUMPAD_PAGE_UP, + GHOSTTY_KEY_NUMPAD_PAGE_DOWN, + + // Function Section § 3.5 + GHOSTTY_KEY_ESCAPE, + GHOSTTY_KEY_F1, + GHOSTTY_KEY_F2, + GHOSTTY_KEY_F3, + GHOSTTY_KEY_F4, + GHOSTTY_KEY_F5, + GHOSTTY_KEY_F6, + GHOSTTY_KEY_F7, + GHOSTTY_KEY_F8, + GHOSTTY_KEY_F9, + GHOSTTY_KEY_F10, + GHOSTTY_KEY_F11, + GHOSTTY_KEY_F12, + GHOSTTY_KEY_F13, + GHOSTTY_KEY_F14, + GHOSTTY_KEY_F15, + GHOSTTY_KEY_F16, + GHOSTTY_KEY_F17, + GHOSTTY_KEY_F18, + GHOSTTY_KEY_F19, + GHOSTTY_KEY_F20, + GHOSTTY_KEY_F21, + GHOSTTY_KEY_F22, + GHOSTTY_KEY_F23, + GHOSTTY_KEY_F24, + GHOSTTY_KEY_F25, + GHOSTTY_KEY_FN, + GHOSTTY_KEY_FN_LOCK, + GHOSTTY_KEY_PRINT_SCREEN, + GHOSTTY_KEY_SCROLL_LOCK, + GHOSTTY_KEY_PAUSE, + + // Media Keys § 3.6 + GHOSTTY_KEY_BROWSER_BACK, + GHOSTTY_KEY_BROWSER_FAVORITES, + GHOSTTY_KEY_BROWSER_FORWARD, + GHOSTTY_KEY_BROWSER_HOME, + GHOSTTY_KEY_BROWSER_REFRESH, + GHOSTTY_KEY_BROWSER_SEARCH, + GHOSTTY_KEY_BROWSER_STOP, + GHOSTTY_KEY_EJECT, + GHOSTTY_KEY_LAUNCH_APP_1, + GHOSTTY_KEY_LAUNCH_APP_2, + GHOSTTY_KEY_LAUNCH_MAIL, + GHOSTTY_KEY_MEDIA_PLAY_PAUSE, + GHOSTTY_KEY_MEDIA_SELECT, + GHOSTTY_KEY_MEDIA_STOP, + GHOSTTY_KEY_MEDIA_TRACK_NEXT, + GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, + GHOSTTY_KEY_POWER, + GHOSTTY_KEY_SLEEP, + GHOSTTY_KEY_AUDIO_VOLUME_DOWN, + GHOSTTY_KEY_AUDIO_VOLUME_MUTE, + GHOSTTY_KEY_AUDIO_VOLUME_UP, + GHOSTTY_KEY_WAKE_UP, + + // Legacy, Non-standard § 3.7 + GHOSTTY_KEY_COPY, + GHOSTTY_KEY_CUT, + GHOSTTY_KEY_PASTE, +} + +// ----------------------------------------------------------------------- +// Key input +// ----------------------------------------------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_input_key_s { + pub action: ghostty_input_action_e, + pub mods: GhosttyMods, + pub consumed_mods: GhosttyMods, + pub keycode: u32, + pub text: *const c_char, + pub unshifted_codepoint: u32, + pub composing: bool, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_input_trigger_tag_e { + GHOSTTY_TRIGGER_PHYSICAL = 0, + GHOSTTY_TRIGGER_UNICODE = 1, + GHOSTTY_TRIGGER_CATCH_ALL = 2, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub union ghostty_input_trigger_key_u { + pub translated: ghostty_input_key_e, + pub physical: ghostty_input_key_e, + pub unicode: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_input_trigger_s { + pub tag: ghostty_input_trigger_tag_e, + pub key: ghostty_input_trigger_key_u, + pub mods: GhosttyMods, +} + +// ----------------------------------------------------------------------- +// Build info, diagnostics, strings +// ----------------------------------------------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_build_mode_e { + GHOSTTY_BUILD_MODE_DEBUG = 0, + GHOSTTY_BUILD_MODE_RELEASE_SAFE = 1, + GHOSTTY_BUILD_MODE_RELEASE_FAST = 2, + GHOSTTY_BUILD_MODE_RELEASE_SMALL = 3, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_info_s { + pub build_mode: ghostty_build_mode_e, + pub version: *const c_char, + pub version_len: usize, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_diagnostic_s { + pub message: *const c_char, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_string_s { + pub ptr: *const c_char, + pub len: usize, + pub sentinel: bool, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_text_s { + pub tl_px_x: c_double, + pub tl_px_y: c_double, + pub offset_start: u32, + pub offset_len: u32, + pub text: *const c_char, + pub text_len: usize, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_command_s { + pub action_key: *const c_char, + pub action: *const c_char, + pub title: *const c_char, + pub description: *const c_char, +} + +// ----------------------------------------------------------------------- +// Points & selections +// ----------------------------------------------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_point_tag_e { + GHOSTTY_POINT_ACTIVE = 0, + GHOSTTY_POINT_VIEWPORT = 1, + GHOSTTY_POINT_SCREEN = 2, + GHOSTTY_POINT_SURFACE = 3, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_point_coord_e { + GHOSTTY_POINT_COORD_EXACT = 0, + GHOSTTY_POINT_COORD_TOP_LEFT = 1, + GHOSTTY_POINT_COORD_BOTTOM_RIGHT = 2, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_point_s { + pub tag: ghostty_point_tag_e, + pub coord: ghostty_point_coord_e, + pub x: u32, + pub y: u32, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_selection_s { + pub top_left: ghostty_point_s, + pub bottom_right: ghostty_point_s, + pub rectangle: bool, +} + +// ----------------------------------------------------------------------- +// Environment variables +// ----------------------------------------------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_env_var_s { + pub key: *const c_char, + pub value: *const c_char, +} + +// ----------------------------------------------------------------------- +// Surface config +// ----------------------------------------------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_surface_context_e { + GHOSTTY_SURFACE_CONTEXT_WINDOW = 0, + GHOSTTY_SURFACE_CONTEXT_TAB = 1, + GHOSTTY_SURFACE_CONTEXT_SPLIT = 2, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_surface_config_s { + pub platform_tag: ghostty_platform_e, + pub platform: ghostty_platform_u, + pub userdata: *mut c_void, + pub scale_factor: c_double, + pub font_size: f32, + pub working_directory: *const c_char, + pub command: *const c_char, + pub env_vars: *mut ghostty_env_var_s, + pub env_var_count: usize, + pub initial_input: *const c_char, + pub wait_after_command: bool, + pub context: ghostty_surface_context_e, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_surface_size_s { + pub columns: u16, + pub rows: u16, + pub width_px: u32, + pub height_px: u32, + pub cell_width_px: u32, + pub cell_height_px: u32, +} + +// ----------------------------------------------------------------------- +// Config types +// ----------------------------------------------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_config_color_s { + pub r: u8, + pub g: u8, + pub b: u8, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_config_color_list_s { + pub colors: *const ghostty_config_color_s, + pub len: usize, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_config_command_list_s { + pub commands: *const ghostty_command_s, + pub len: usize, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_config_palette_s { + pub colors: [ghostty_config_color_s; 256], +} + +// ----------------------------------------------------------------------- +// Target +// ----------------------------------------------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_target_tag_e { + GHOSTTY_TARGET_APP = 0, + GHOSTTY_TARGET_SURFACE = 1, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub union ghostty_target_u { + pub surface: ghostty_surface_t, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_target_s { + pub tag: ghostty_target_tag_e, + pub target: ghostty_target_u, +} + +// ----------------------------------------------------------------------- +// Actions +// ----------------------------------------------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_split_direction_e { + GHOSTTY_SPLIT_DIRECTION_RIGHT = 0, + GHOSTTY_SPLIT_DIRECTION_DOWN = 1, + GHOSTTY_SPLIT_DIRECTION_LEFT = 2, + GHOSTTY_SPLIT_DIRECTION_UP = 3, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_goto_split_e { + GHOSTTY_GOTO_SPLIT_PREVIOUS = 0, + GHOSTTY_GOTO_SPLIT_NEXT = 1, + GHOSTTY_GOTO_SPLIT_UP = 2, + GHOSTTY_GOTO_SPLIT_LEFT = 3, + GHOSTTY_GOTO_SPLIT_DOWN = 4, + GHOSTTY_GOTO_SPLIT_RIGHT = 5, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_goto_window_e { + GHOSTTY_GOTO_WINDOW_PREVIOUS = 0, + GHOSTTY_GOTO_WINDOW_NEXT = 1, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_resize_split_direction_e { + GHOSTTY_RESIZE_SPLIT_UP = 0, + GHOSTTY_RESIZE_SPLIT_DOWN = 1, + GHOSTTY_RESIZE_SPLIT_LEFT = 2, + GHOSTTY_RESIZE_SPLIT_RIGHT = 3, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_resize_split_s { + pub amount: u16, + pub direction: ghostty_action_resize_split_direction_e, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_move_tab_s { + pub amount: isize, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_goto_tab_e { + GHOSTTY_GOTO_TAB_PREVIOUS = -1_isize, + GHOSTTY_GOTO_TAB_NEXT = -2_isize, + GHOSTTY_GOTO_TAB_LAST = -3_isize, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_fullscreen_e { + GHOSTTY_FULLSCREEN_NATIVE = 0, + GHOSTTY_FULLSCREEN_NON_NATIVE = 1, + GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU = 2, + GHOSTTY_FULLSCREEN_NON_NATIVE_PADDED_NOTCH = 3, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_float_window_e { + GHOSTTY_FLOAT_WINDOW_ON = 0, + GHOSTTY_FLOAT_WINDOW_OFF = 1, + GHOSTTY_FLOAT_WINDOW_TOGGLE = 2, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_secure_input_e { + GHOSTTY_SECURE_INPUT_ON = 0, + GHOSTTY_SECURE_INPUT_OFF = 1, + GHOSTTY_SECURE_INPUT_TOGGLE = 2, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_inspector_e { + GHOSTTY_INSPECTOR_TOGGLE = 0, + GHOSTTY_INSPECTOR_SHOW = 1, + GHOSTTY_INSPECTOR_HIDE = 2, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_quit_timer_e { + GHOSTTY_QUIT_TIMER_START = 0, + GHOSTTY_QUIT_TIMER_STOP = 1, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_readonly_e { + GHOSTTY_READONLY_OFF = 0, + GHOSTTY_READONLY_ON = 1, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_desktop_notification_s { + pub title: *const c_char, + pub body: *const c_char, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_set_title_s { + pub title: *const c_char, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_prompt_title_e { + GHOSTTY_PROMPT_TITLE_SURFACE = 0, + GHOSTTY_PROMPT_TITLE_TAB = 1, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_pwd_s { + pub pwd: *const c_char, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_mouse_shape_e { + GHOSTTY_MOUSE_SHAPE_DEFAULT = 0, + GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU, + GHOSTTY_MOUSE_SHAPE_HELP, + GHOSTTY_MOUSE_SHAPE_POINTER, + GHOSTTY_MOUSE_SHAPE_PROGRESS, + GHOSTTY_MOUSE_SHAPE_WAIT, + GHOSTTY_MOUSE_SHAPE_CELL, + GHOSTTY_MOUSE_SHAPE_CROSSHAIR, + GHOSTTY_MOUSE_SHAPE_TEXT, + GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT, + GHOSTTY_MOUSE_SHAPE_ALIAS, + GHOSTTY_MOUSE_SHAPE_COPY, + GHOSTTY_MOUSE_SHAPE_MOVE, + GHOSTTY_MOUSE_SHAPE_NO_DROP, + GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED, + GHOSTTY_MOUSE_SHAPE_GRAB, + GHOSTTY_MOUSE_SHAPE_GRABBING, + GHOSTTY_MOUSE_SHAPE_ALL_SCROLL, + GHOSTTY_MOUSE_SHAPE_COL_RESIZE, + GHOSTTY_MOUSE_SHAPE_ROW_RESIZE, + GHOSTTY_MOUSE_SHAPE_N_RESIZE, + GHOSTTY_MOUSE_SHAPE_E_RESIZE, + GHOSTTY_MOUSE_SHAPE_S_RESIZE, + GHOSTTY_MOUSE_SHAPE_W_RESIZE, + GHOSTTY_MOUSE_SHAPE_NE_RESIZE, + GHOSTTY_MOUSE_SHAPE_NW_RESIZE, + GHOSTTY_MOUSE_SHAPE_SE_RESIZE, + GHOSTTY_MOUSE_SHAPE_SW_RESIZE, + GHOSTTY_MOUSE_SHAPE_EW_RESIZE, + GHOSTTY_MOUSE_SHAPE_NS_RESIZE, + GHOSTTY_MOUSE_SHAPE_NESW_RESIZE, + GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE, + GHOSTTY_MOUSE_SHAPE_ZOOM_IN, + GHOSTTY_MOUSE_SHAPE_ZOOM_OUT, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_mouse_visibility_e { + GHOSTTY_MOUSE_VISIBLE = 0, + GHOSTTY_MOUSE_HIDDEN = 1, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_mouse_over_link_s { + pub url: *const c_char, + pub len: usize, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_size_limit_s { + pub min_width: u32, + pub min_height: u32, + pub max_width: u32, + pub max_height: u32, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_initial_size_s { + pub width: u32, + pub height: u32, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_cell_size_s { + pub width: u32, + pub height: u32, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_renderer_health_e { + GHOSTTY_RENDERER_HEALTH_OK = 0, + GHOSTTY_RENDERER_HEALTH_UNHEALTHY = 1, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_key_sequence_s { + pub active: bool, + pub trigger: ghostty_input_trigger_s, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_key_table_tag_e { + GHOSTTY_KEY_TABLE_ACTIVATE = 0, + GHOSTTY_KEY_TABLE_DEACTIVATE = 1, + GHOSTTY_KEY_TABLE_DEACTIVATE_ALL = 2, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_key_table_activate_s { + pub name: *const c_char, + pub len: usize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub union ghostty_action_key_table_u { + pub activate: ghostty_action_key_table_activate_s, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_key_table_s { + pub tag: ghostty_action_key_table_tag_e, + pub value: ghostty_action_key_table_u, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_color_change_s { + pub kind: i32, // ghostty_action_color_kind_e values: -1=fg, -2=bg, -3=cursor + pub r: u8, + pub g: u8, + pub b: u8, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_config_change_s { + pub config: ghostty_config_t, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_reload_config_s { + pub soft: bool, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_open_url_kind_e { + GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN = 0, + GHOSTTY_ACTION_OPEN_URL_KIND_TEXT = 1, + GHOSTTY_ACTION_OPEN_URL_KIND_HTML = 2, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_open_url_s { + pub kind: ghostty_action_open_url_kind_e, + pub url: *const c_char, + pub len: usize, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_close_tab_mode_e { + GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS = 0, + GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER = 1, + GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT = 2, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_surface_message_childexited_s { + pub exit_code: u32, + pub runtime_ms: u64, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_progress_report_state_e { + GHOSTTY_PROGRESS_STATE_REMOVE = 0, + GHOSTTY_PROGRESS_STATE_SET = 1, + GHOSTTY_PROGRESS_STATE_ERROR = 2, + GHOSTTY_PROGRESS_STATE_INDETERMINATE = 3, + GHOSTTY_PROGRESS_STATE_PAUSE = 4, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_progress_report_s { + pub state: ghostty_action_progress_report_state_e, + pub progress: i8, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_command_finished_s { + pub exit_code: i16, + pub duration: u64, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_start_search_s { + pub needle: *const c_char, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_search_total_s { + pub total: isize, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_search_selected_s { + pub selected: isize, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ghostty_action_scrollbar_s { + pub total: u64, + pub offset: u64, + pub len: u64, +} + +// ----------------------------------------------------------------------- +// Action tag + union +// ----------------------------------------------------------------------- + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ghostty_action_tag_e { + GHOSTTY_ACTION_QUIT = 0, + GHOSTTY_ACTION_NEW_WINDOW, + GHOSTTY_ACTION_NEW_TAB, + GHOSTTY_ACTION_CLOSE_TAB, + GHOSTTY_ACTION_NEW_SPLIT, + GHOSTTY_ACTION_CLOSE_ALL_WINDOWS, + GHOSTTY_ACTION_TOGGLE_MAXIMIZE, + GHOSTTY_ACTION_TOGGLE_FULLSCREEN, + GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW, + GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, + GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL, + GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE, + GHOSTTY_ACTION_TOGGLE_VISIBILITY, + GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY, + GHOSTTY_ACTION_MOVE_TAB, + GHOSTTY_ACTION_GOTO_TAB, + GHOSTTY_ACTION_GOTO_SPLIT, + GHOSTTY_ACTION_GOTO_WINDOW, + GHOSTTY_ACTION_RESIZE_SPLIT, + GHOSTTY_ACTION_EQUALIZE_SPLITS, + GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, + GHOSTTY_ACTION_PRESENT_TERMINAL, + GHOSTTY_ACTION_SIZE_LIMIT, + GHOSTTY_ACTION_RESET_WINDOW_SIZE, + GHOSTTY_ACTION_INITIAL_SIZE, + GHOSTTY_ACTION_CELL_SIZE, + GHOSTTY_ACTION_SCROLLBAR, + GHOSTTY_ACTION_RENDER, + GHOSTTY_ACTION_INSPECTOR, + GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, + GHOSTTY_ACTION_RENDER_INSPECTOR, + GHOSTTY_ACTION_DESKTOP_NOTIFICATION, + GHOSTTY_ACTION_SET_TITLE, + GHOSTTY_ACTION_PROMPT_TITLE, + GHOSTTY_ACTION_PWD, + GHOSTTY_ACTION_MOUSE_SHAPE, + GHOSTTY_ACTION_MOUSE_VISIBILITY, + GHOSTTY_ACTION_MOUSE_OVER_LINK, + GHOSTTY_ACTION_RENDERER_HEALTH, + GHOSTTY_ACTION_OPEN_CONFIG, + GHOSTTY_ACTION_QUIT_TIMER, + GHOSTTY_ACTION_FLOAT_WINDOW, + GHOSTTY_ACTION_SECURE_INPUT, + GHOSTTY_ACTION_KEY_SEQUENCE, + GHOSTTY_ACTION_KEY_TABLE, + GHOSTTY_ACTION_COLOR_CHANGE, + GHOSTTY_ACTION_RELOAD_CONFIG, + GHOSTTY_ACTION_CONFIG_CHANGE, + GHOSTTY_ACTION_CLOSE_WINDOW, + GHOSTTY_ACTION_RING_BELL, + GHOSTTY_ACTION_UNDO, + GHOSTTY_ACTION_REDO, + GHOSTTY_ACTION_CHECK_FOR_UPDATES, + GHOSTTY_ACTION_OPEN_URL, + GHOSTTY_ACTION_SHOW_CHILD_EXITED, + GHOSTTY_ACTION_PROGRESS_REPORT, + GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, + GHOSTTY_ACTION_COMMAND_FINISHED, + GHOSTTY_ACTION_START_SEARCH, + GHOSTTY_ACTION_END_SEARCH, + GHOSTTY_ACTION_SEARCH_TOTAL, + GHOSTTY_ACTION_SEARCH_SELECTED, + GHOSTTY_ACTION_READONLY, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub union ghostty_action_u { + pub new_split: ghostty_action_split_direction_e, + pub toggle_fullscreen: ghostty_action_fullscreen_e, + pub move_tab: ghostty_action_move_tab_s, + pub goto_tab: i32, // ghostty_action_goto_tab_e + pub goto_split: ghostty_action_goto_split_e, + pub goto_window: ghostty_action_goto_window_e, + pub resize_split: ghostty_action_resize_split_s, + pub size_limit: ghostty_action_size_limit_s, + pub initial_size: ghostty_action_initial_size_s, + pub cell_size: ghostty_action_cell_size_s, + pub scrollbar: ghostty_action_scrollbar_s, + pub inspector: ghostty_action_inspector_e, + pub desktop_notification: ghostty_action_desktop_notification_s, + pub set_title: ghostty_action_set_title_s, + pub prompt_title: ghostty_action_prompt_title_e, + pub pwd: ghostty_action_pwd_s, + pub mouse_shape: ghostty_action_mouse_shape_e, + pub mouse_visibility: ghostty_action_mouse_visibility_e, + pub mouse_over_link: ghostty_action_mouse_over_link_s, + pub renderer_health: ghostty_action_renderer_health_e, + pub quit_timer: ghostty_action_quit_timer_e, + pub float_window: ghostty_action_float_window_e, + pub secure_input: ghostty_action_secure_input_e, + pub key_sequence: ghostty_action_key_sequence_s, + pub key_table: ghostty_action_key_table_s, + pub color_change: ghostty_action_color_change_s, + pub reload_config: ghostty_action_reload_config_s, + pub config_change: ghostty_action_config_change_s, + pub open_url: ghostty_action_open_url_s, + pub close_tab_mode: ghostty_action_close_tab_mode_e, + pub child_exited: ghostty_surface_message_childexited_s, + pub progress_report: ghostty_action_progress_report_s, + pub command_finished: ghostty_action_command_finished_s, + pub start_search: ghostty_action_start_search_s, + pub search_total: ghostty_action_search_total_s, + pub search_selected: ghostty_action_search_selected_s, + pub readonly: ghostty_action_readonly_e, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_s { + pub tag: ghostty_action_tag_e, + pub action: ghostty_action_u, +} + +// ----------------------------------------------------------------------- +// Runtime callbacks +// ----------------------------------------------------------------------- + +pub type ghostty_runtime_wakeup_cb = Option; + +pub type ghostty_runtime_read_clipboard_cb = Option< + unsafe extern "C" fn( + userdata: *mut c_void, + clipboard: ghostty_clipboard_e, + context: *mut c_void, + ), +>; + +pub type ghostty_runtime_confirm_read_clipboard_cb = Option< + unsafe extern "C" fn( + userdata: *mut c_void, + content: *const c_char, + context: *mut c_void, + request: ghostty_clipboard_request_e, + ), +>; + +pub type ghostty_runtime_write_clipboard_cb = Option< + unsafe extern "C" fn( + userdata: *mut c_void, + clipboard: ghostty_clipboard_e, + content: *const ghostty_clipboard_content_s, + content_len: usize, + confirm: bool, + ), +>; + +pub type ghostty_runtime_close_surface_cb = + Option; + +pub type ghostty_runtime_action_cb = Option< + unsafe extern "C" fn( + app: ghostty_app_t, + target: ghostty_target_s, + action: ghostty_action_s, + ) -> bool, +>; + +#[repr(C)] +pub struct ghostty_runtime_config_s { + pub userdata: *mut c_void, + pub supports_selection_clipboard: bool, + pub wakeup_cb: ghostty_runtime_wakeup_cb, + pub action_cb: ghostty_runtime_action_cb, + pub read_clipboard_cb: ghostty_runtime_read_clipboard_cb, + pub confirm_read_clipboard_cb: ghostty_runtime_confirm_read_clipboard_cb, + pub write_clipboard_cb: ghostty_runtime_write_clipboard_cb, + pub close_surface_cb: ghostty_runtime_close_surface_cb, +} + +// ----------------------------------------------------------------------- +// Published API (extern "C" functions) +// ----------------------------------------------------------------------- + +// When building without libghostty (stub mode), these are declared but not linked. +// The actual linking happens when the ghostty submodule is built. +#[cfg(feature = "link-ghostty")] +extern "C" { + pub fn ghostty_init(argc: usize, argv: *mut *mut c_char) -> c_int; + pub fn ghostty_info() -> ghostty_info_s; + pub fn ghostty_translate(key: *const c_char) -> *const c_char; + pub fn ghostty_string_free(s: ghostty_string_s); + + // Config + pub fn ghostty_config_new() -> ghostty_config_t; + pub fn ghostty_config_free(config: ghostty_config_t); + pub fn ghostty_config_clone(config: ghostty_config_t) -> ghostty_config_t; + pub fn ghostty_config_load_cli_args(config: ghostty_config_t); + pub fn ghostty_config_load_file(config: ghostty_config_t, path: *const c_char); + pub fn ghostty_config_load_default_files(config: ghostty_config_t); + pub fn ghostty_config_load_recursive_files(config: ghostty_config_t); + pub fn ghostty_config_finalize(config: ghostty_config_t); + pub fn ghostty_config_get( + config: ghostty_config_t, + out: *mut c_void, + key: *const c_char, + key_len: usize, + ) -> bool; + pub fn ghostty_config_trigger( + config: ghostty_config_t, + action: *const c_char, + action_len: usize, + ) -> ghostty_input_trigger_s; + pub fn ghostty_config_diagnostics_count(config: ghostty_config_t) -> u32; + pub fn ghostty_config_get_diagnostic( + config: ghostty_config_t, + index: u32, + ) -> ghostty_diagnostic_s; + + // App + pub fn ghostty_app_new( + runtime_config: *const ghostty_runtime_config_s, + config: ghostty_config_t, + ) -> ghostty_app_t; + pub fn ghostty_app_free(app: ghostty_app_t); + pub fn ghostty_app_tick(app: ghostty_app_t); + pub fn ghostty_app_userdata(app: ghostty_app_t) -> *mut c_void; + pub fn ghostty_app_set_focus(app: ghostty_app_t, focused: bool); + pub fn ghostty_app_key(app: ghostty_app_t, key: ghostty_input_key_s) -> bool; + pub fn ghostty_app_key_is_binding(app: ghostty_app_t, key: ghostty_input_key_s) -> bool; + pub fn ghostty_app_keyboard_changed(app: ghostty_app_t); + pub fn ghostty_app_open_config(app: ghostty_app_t); + pub fn ghostty_app_update_config(app: ghostty_app_t, config: ghostty_config_t); + pub fn ghostty_app_needs_confirm_quit(app: ghostty_app_t) -> bool; + pub fn ghostty_app_has_global_keybinds(app: ghostty_app_t) -> bool; + pub fn ghostty_app_set_color_scheme(app: ghostty_app_t, scheme: ghostty_color_scheme_e); + + // Surface config + pub fn ghostty_surface_config_new() -> ghostty_surface_config_s; + + // Surface + pub fn ghostty_surface_new( + app: ghostty_app_t, + config: *const ghostty_surface_config_s, + ) -> ghostty_surface_t; + pub fn ghostty_surface_free(surface: ghostty_surface_t); + pub fn ghostty_surface_userdata(surface: ghostty_surface_t) -> *mut c_void; + pub fn ghostty_surface_app(surface: ghostty_surface_t) -> ghostty_app_t; + pub fn ghostty_surface_inherited_config( + surface: ghostty_surface_t, + context: ghostty_surface_context_e, + ) -> ghostty_surface_config_s; + pub fn ghostty_surface_update_config(surface: ghostty_surface_t, config: ghostty_config_t); + pub fn ghostty_surface_needs_confirm_quit(surface: ghostty_surface_t) -> bool; + pub fn ghostty_surface_process_exited(surface: ghostty_surface_t) -> bool; + pub fn ghostty_surface_refresh(surface: ghostty_surface_t); + pub fn ghostty_surface_draw(surface: ghostty_surface_t); + pub fn ghostty_surface_set_content_scale( + surface: ghostty_surface_t, + x_scale: c_double, + y_scale: c_double, + ); + pub fn ghostty_surface_set_focus(surface: ghostty_surface_t, focused: bool); + pub fn ghostty_surface_set_occlusion(surface: ghostty_surface_t, occluded: bool); + pub fn ghostty_surface_set_size(surface: ghostty_surface_t, width: u32, height: u32); + pub fn ghostty_surface_size(surface: ghostty_surface_t) -> ghostty_surface_size_s; + pub fn ghostty_surface_set_color_scheme( + surface: ghostty_surface_t, + scheme: ghostty_color_scheme_e, + ); + pub fn ghostty_surface_key_translation_mods( + surface: ghostty_surface_t, + mods: GhosttyMods, + ) -> GhosttyMods; + pub fn ghostty_surface_key(surface: ghostty_surface_t, key: ghostty_input_key_s) -> bool; + pub fn ghostty_surface_key_is_binding( + surface: ghostty_surface_t, + key: ghostty_input_key_s, + flags: *mut GhosttyBindingFlags, + ) -> bool; + pub fn ghostty_surface_text(surface: ghostty_surface_t, text: *const c_char, len: usize); + pub fn ghostty_surface_preedit(surface: ghostty_surface_t, text: *const c_char, len: usize); + pub fn ghostty_surface_mouse_captured(surface: ghostty_surface_t) -> bool; + pub fn ghostty_surface_mouse_button( + surface: ghostty_surface_t, + state: ghostty_input_mouse_state_e, + button: ghostty_input_mouse_button_e, + mods: GhosttyMods, + ) -> bool; + pub fn ghostty_surface_mouse_pos( + surface: ghostty_surface_t, + x: c_double, + y: c_double, + mods: GhosttyMods, + ); + pub fn ghostty_surface_mouse_scroll( + surface: ghostty_surface_t, + x: c_double, + y: c_double, + scroll_mods: ghostty_input_scroll_mods_t, + ); + pub fn ghostty_surface_mouse_pressure( + surface: ghostty_surface_t, + stage: u32, + pressure: c_double, + ); + pub fn ghostty_surface_ime_point( + surface: ghostty_surface_t, + x: *mut c_double, + y: *mut c_double, + w: *mut c_double, + h: *mut c_double, + ); + pub fn ghostty_surface_request_close(surface: ghostty_surface_t); + pub fn ghostty_surface_split( + surface: ghostty_surface_t, + direction: ghostty_action_split_direction_e, + ); + pub fn ghostty_surface_split_focus( + surface: ghostty_surface_t, + direction: ghostty_action_goto_split_e, + ); + pub fn ghostty_surface_split_resize( + surface: ghostty_surface_t, + direction: ghostty_action_resize_split_direction_e, + amount: u16, + ); + pub fn ghostty_surface_split_equalize(surface: ghostty_surface_t); + pub fn ghostty_surface_binding_action( + surface: ghostty_surface_t, + action: *const c_char, + len: usize, + ) -> bool; + pub fn ghostty_surface_complete_clipboard_request( + surface: ghostty_surface_t, + data: *const c_char, + context: *mut c_void, + confirmed: bool, + ); + pub fn ghostty_surface_has_selection(surface: ghostty_surface_t) -> bool; + pub fn ghostty_surface_read_selection( + surface: ghostty_surface_t, + out: *mut ghostty_text_s, + ) -> bool; + pub fn ghostty_surface_read_text( + surface: ghostty_surface_t, + selection: ghostty_selection_s, + out: *mut ghostty_text_s, + ) -> bool; + pub fn ghostty_surface_free_text(surface: ghostty_surface_t, text: *mut ghostty_text_s); + + // Inspector + pub fn ghostty_surface_inspector(surface: ghostty_surface_t) -> ghostty_inspector_t; + pub fn ghostty_inspector_free(surface: ghostty_surface_t); + pub fn ghostty_inspector_set_focus(inspector: ghostty_inspector_t, focused: bool); + pub fn ghostty_inspector_set_content_scale( + inspector: ghostty_inspector_t, + x_scale: c_double, + y_scale: c_double, + ); + pub fn ghostty_inspector_set_size(inspector: ghostty_inspector_t, width: u32, height: u32); + pub fn ghostty_inspector_mouse_button( + inspector: ghostty_inspector_t, + state: ghostty_input_mouse_state_e, + button: ghostty_input_mouse_button_e, + mods: GhosttyMods, + ); + pub fn ghostty_inspector_mouse_pos(inspector: ghostty_inspector_t, x: c_double, y: c_double); + pub fn ghostty_inspector_mouse_scroll( + inspector: ghostty_inspector_t, + x: c_double, + y: c_double, + scroll_mods: ghostty_input_scroll_mods_t, + ); + pub fn ghostty_inspector_key( + inspector: ghostty_inspector_t, + action: ghostty_input_action_e, + key: ghostty_input_key_e, + mods: GhosttyMods, + ); + pub fn ghostty_inspector_text(inspector: ghostty_inspector_t, text: *const c_char); +} diff --git a/screenshots/linux_port_ghostty_sidebar.png b/screenshots/linux_port_ghostty_sidebar.png new file mode 100644 index 0000000000..b8e5305d8e Binary files /dev/null and b/screenshots/linux_port_ghostty_sidebar.png differ diff --git a/screenshots/linux_port_ghostty_splits.png b/screenshots/linux_port_ghostty_splits.png new file mode 100644 index 0000000000..4f8ee9211e Binary files /dev/null and b/screenshots/linux_port_ghostty_splits.png differ diff --git a/screenshots/linux_port_ghostty_splits_annotated.png b/screenshots/linux_port_ghostty_splits_annotated.png new file mode 100644 index 0000000000..b29e91e8ad Binary files /dev/null and b/screenshots/linux_port_ghostty_splits_annotated.png differ diff --git a/screenshots/linux_port_ghostty_terminal.png b/screenshots/linux_port_ghostty_terminal.png new file mode 100644 index 0000000000..353db5aaa5 Binary files /dev/null and b/screenshots/linux_port_ghostty_terminal.png differ diff --git a/screenshots/linux_port_ghostty_terminal_demo.mp4 b/screenshots/linux_port_ghostty_terminal_demo.mp4 new file mode 100644 index 0000000000..7fef58e8b0 Binary files /dev/null and b/screenshots/linux_port_ghostty_terminal_demo.mp4 differ diff --git a/screenshots/linux_port_ghostty_workspace_demo.mp4 b/screenshots/linux_port_ghostty_workspace_demo.mp4 new file mode 100644 index 0000000000..977cefb06e Binary files /dev/null and b/screenshots/linux_port_ghostty_workspace_demo.mp4 differ diff --git a/scripts/capture-linux-port-demo.sh b/scripts/capture-linux-port-demo.sh new file mode 100755 index 0000000000..e84b2a4357 --- /dev/null +++ b/scripts/capture-linux-port-demo.sh @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +LINUX_DIR="$REPO_ROOT/linux" +TARGET_DIR="$LINUX_DIR/target/debug" +OUT_DIR="${1:-$REPO_ROOT/screenshots}" +DISPLAY_NUM="${DISPLAY_NUM:-106}" +DISPLAY=":$DISPLAY_NUM" +WINDOW_SIZE="${WINDOW_SIZE:-1400x900}" +SCREENSHOT_KIT_DIR="${SCREENSHOT_KIT_DIR:-$HOME/bin/screenshot-kit}" +RUNTIME_DIR="${CAPTURE_RUNTIME_DIR:-$REPO_ROOT/tmp/capture-runtime-$DISPLAY_NUM}" +SOCKET_PATH="$RUNTIME_DIR/cmux.sock" +APP_LOG="$REPO_ROOT/tmp/linux_port_capture.log" +APP_PID="" +REQUEST_ID=1 +RECORDING_PID="" +SOCKET_OURS=0 +DEMO_ROOT_DIR="${DEMO_ROOT_DIR:-$REPO_ROOT}" +DEMO_REVIEW_DIR="${DEMO_REVIEW_DIR:-$REPO_ROOT/linux}" +DEMO_DOCS_DIR="${DEMO_DOCS_DIR:-$REPO_ROOT/docs}" + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Error: required command not found: $1" >&2 + exit 1 + fi +} + +cleanup() { + stop_app + + if [ -x "$SCREENSHOT_KIT_DIR/display-setup.sh" ]; then + "$SCREENSHOT_KIT_DIR/display-setup.sh" "$DISPLAY_NUM" "1920x1080x24" stop >/dev/null 2>&1 || true + fi +} + +wait_for_socket() { + for _ in $(seq 1 80); do + if [ -S "$SOCKET_PATH" ]; then + return 0 + fi + sleep 0.25 + done + + echo "Error: cmux socket did not appear at $SOCKET_PATH" >&2 + exit 1 +} + +socket_is_live() { + local probe="" + + probe="$( + printf '{"id":0,"method":"system.ping","params":{}}\n' | + nc -w 1 -N -U "$SOCKET_PATH" 2>/dev/null || true + )" + + echo "$probe" | jq -e '.ok == true' >/dev/null 2>&1 +} + +ensure_socket_available() { + mkdir -p "$RUNTIME_DIR" + chmod 700 "$RUNTIME_DIR" + + if [ ! -e "$SOCKET_PATH" ]; then + return 0 + fi + if [ ! -S "$SOCKET_PATH" ]; then + echo "Error: $SOCKET_PATH exists but is not a socket — refusing to overwrite" >&2 + exit 1 + fi + + if socket_is_live; then + echo "Error: another cmux instance is already responding on $SOCKET_PATH" >&2 + echo "Use a different CAPTURE_RUNTIME_DIR or stop the other instance first." >&2 + exit 1 + fi + + if [ -S "$SOCKET_PATH" ]; then + rm -f "$SOCKET_PATH" + fi +} + +wait_for_window() { + local window_id="" + + for _ in $(seq 1 80); do + window_id="$(DISPLAY="$DISPLAY" xdotool search --onlyvisible --name cmux 2>/dev/null | tail -n1 || true)" + if [ -n "$window_id" ]; then + echo "$window_id" + return 0 + fi + sleep 0.25 + done + + echo "Error: cmux window did not appear on $DISPLAY" >&2 + exit 1 +} + +wait_for_no_window() { + for _ in $(seq 1 40); do + if ! DISPLAY="$DISPLAY" xdotool search --onlyvisible --name cmux >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done +} + +cmux_cli() { + XDG_RUNTIME_DIR="$RUNTIME_DIR" \ + LD_LIBRARY_PATH="$TARGET_DIR" \ + "$TARGET_DIR/cmux" "$@" +} + +socket_call() { + local method="$1" + local params="${2:-{}}" + local response="" + + response="$( + printf '{"id":%s,"method":"%s","params":%s}\n' \ + "$REQUEST_ID" "$method" "$params" | + nc -w 5 -N -U "$SOCKET_PATH" + )" + REQUEST_ID=$((REQUEST_ID + 1)) + + echo "$response" +} + +window_screenshot() { + local output="$1" + DISPLAY="$DISPLAY" "$SCREENSHOT_KIT_DIR/screenshot.sh" "$output" --window cmux --delay 1 >/dev/null +} + +start_recording() { + local output="$1" + local duration="$2" + + DISPLAY="$DISPLAY" "$SCREENSHOT_KIT_DIR/record.sh" "$output" \ + --duration "$duration" \ + --fps 24 \ + --size 1920x1080 >/dev/null & + RECORDING_PID=$! +} + +stop_app() { + if [ -n "$APP_PID" ] && kill -0 "$APP_PID" 2>/dev/null; then + kill "$APP_PID" 2>/dev/null || true + wait "$APP_PID" 2>/dev/null || true + fi + APP_PID="" + if [ "$SOCKET_OURS" -eq 1 ] && [ -S "$SOCKET_PATH" ]; then + rm -f "$SOCKET_PATH" + fi + SOCKET_OURS=0 + wait_for_no_window || true +} + +start_app() { + ensure_socket_available + + LD_LIBRARY_PATH="$TARGET_DIR" \ + XDG_RUNTIME_DIR="$RUNTIME_DIR" \ + GDK_BACKEND=x11 \ + DISPLAY="$DISPLAY" \ + "$TARGET_DIR/cmux-app" >"$APP_LOG" 2>&1 & + APP_PID=$! + + wait_for_socket + SOCKET_OURS=1 + WINDOW_ID="$(wait_for_window)" + DISPLAY="$DISPLAY" xdotool windowsize "$WINDOW_ID" "${WINDOW_SIZE%x*}" "${WINDOW_SIZE#*x}" >/dev/null 2>&1 || true + sleep 1 +} + +type_line() { + local window_id="$1" + local text="$2" + + DISPLAY="$DISPLAY" xdotool type --delay 18 --window "$window_id" "$text" + DISPLAY="$DISPLAY" xdotool key --window "$window_id" Return +} + +display_quoted_path() { + local path="$1" + local suffix + + if [ "$path" = "$HOME" ]; then + printf '"$HOME"' + return + elif [[ "$path" == "$HOME/"* ]]; then + suffix="${path#"$HOME"/}" + # Escape shell-special characters in the suffix + suffix="${suffix//\\/\\\\}" + suffix="${suffix//\$/\\\$}" + suffix="${suffix//\`/\\\`}" + suffix="${suffix//\"/\\\"}" + printf '"$HOME/%s"' "$suffix" + else + local display_path="$path" + display_path="${display_path//\\/\\\\}" + display_path="${display_path//\$/\\\$}" + display_path="${display_path//\`/\\\`}" + display_path="${display_path//\"/\\\"}" + printf '"%s"' "$display_path" + fi +} + +main() { + trap cleanup EXIT + + require_cmd cargo + require_cmd jq + require_cmd nc + require_cmd xdotool + + if [ ! -x "$SCREENSHOT_KIT_DIR/display-setup.sh" ]; then + echo "Error: screenshot-kit not found at $SCREENSHOT_KIT_DIR" >&2 + exit 1 + fi + + mkdir -p "$OUT_DIR" "$REPO_ROOT/tmp" + rm -f "$OUT_DIR"/linux_port_ghostty_*.png "$OUT_DIR"/linux_port_ghostty_*.mp4 "$APP_LOG" + cargo build --features cmux/link-ghostty --manifest-path "$LINUX_DIR/Cargo.toml" >/dev/null + + "$SCREENSHOT_KIT_DIR/display-setup.sh" "$DISPLAY_NUM" "1920x1080x24" start >/dev/null + start_app + + local terminal_demo_video="$OUT_DIR/linux_port_ghostty_terminal_demo.mp4" + local terminal_hero_png="$OUT_DIR/linux_port_ghostty_terminal.png" + local sidebar_png="$OUT_DIR/linux_port_ghostty_sidebar.png" + local splits_png="$OUT_DIR/linux_port_ghostty_splits.png" + local splits_annotated_png="$OUT_DIR/linux_port_ghostty_splits_annotated.png" + local workspace_video="$OUT_DIR/linux_port_ghostty_workspace_demo.mp4" + local display_demo_root + + display_demo_root="$(display_quoted_path "$DEMO_ROOT_DIR")" + + start_recording "$terminal_demo_video" 5 + sleep 0.5 + type_line "$WINDOW_ID" "clear" + type_line "$WINDOW_ID" "cd $display_demo_root" + type_line "$WINDOW_ID" "printf 'ghostty linked demo\n'" + type_line "$WINDOW_ID" "git status --short --branch | head -5" + type_line "$WINDOW_ID" "printf 'workspace: %s\n' cmux" + wait "$RECORDING_PID" + window_screenshot "$terminal_hero_png" + + stop_app + start_app + + local workspace_one workspace_two workspace_three + + workspace_one="$( + socket_call "workspace.create" \ + "$(jq -nc --arg title "Claude Code" --arg directory "$DEMO_ROOT_DIR" '{title:$title,directory:$directory}')" | + jq -r '.result.workspace_id' + )" + workspace_two="$( + socket_call "workspace.new" \ + "$(jq -nc --arg title "Codex Review" --arg directory "$DEMO_REVIEW_DIR" '{title:$title,directory:$directory}')" | + jq -r '.result.workspace_id' + )" + workspace_three="$( + socket_call "workspace.new" \ + "$(jq -nc --arg title "Release Notes" --arg directory "$DEMO_DOCS_DIR" '{title:$title,directory:$directory}')" | + jq -r '.result.workspace_id' + )" + + socket_call "workspace.select" '{"index":0}' >/dev/null + socket_call "workspace.report_git_branch" '{"branch":"linux-port","is_dirty":true}' >/dev/null + socket_call "workspace.set_status" '{"key":"agent","value":"Claude","icon":"robot"}' >/dev/null + socket_call "workspace.set_progress" '{"value":0.72,"label":"validation"}' >/dev/null + socket_call "notification.create" '{"title":"Claude Code","body":"Ready for review","send_desktop":false}' >/dev/null + + socket_call "workspace.select" "$(jq -nc --arg workspace "$workspace_one" '{workspace:$workspace}')" >/dev/null + socket_call "workspace.report_git_branch" '{"branch":"pr-828","is_dirty":false}' >/dev/null + socket_call "workspace.set_status" '{"key":"agent","value":"Codex","icon":"terminal"}' >/dev/null + socket_call "workspace.set_progress" '{"value":0.45,"label":"capture"}' >/dev/null + socket_call "notification.create" '{"title":"Codex","body":"Need screenshot approval","send_desktop":false}' >/dev/null + + socket_call "workspace.select" "$(jq -nc --arg workspace "$workspace_three" '{workspace:$workspace}')" >/dev/null + socket_call "workspace.report_git_branch" '{"branch":"docs/pr-assets","is_dirty":false}' >/dev/null + socket_call "workspace.set_status" '{"key":"agent","value":"Drafting","icon":"note"}' >/dev/null + socket_call "notification.create" '{"title":"Release","body":"Assets ready to attach","send_desktop":false}' >/dev/null + + socket_call "workspace.select" "$(jq -nc --arg workspace "$workspace_one" '{workspace:$workspace}')" >/dev/null + sleep 1 + window_screenshot "$sidebar_png" + + start_recording "$workspace_video" 5 + sleep 0.5 + cmux_cli --json pane new --orientation horizontal >/dev/null + sleep 0.8 + cmux_cli --json pane new --orientation vertical >/dev/null + sleep 1.2 + socket_call "workspace.select" '{"index":2}' >/dev/null + sleep 0.8 + socket_call "workspace.select" '{"index":1}' >/dev/null + sleep 0.8 + wait "$RECORDING_PID" + + window_screenshot "$splits_png" + "$SCREENSHOT_KIT_DIR/annotate.sh" "$splits_png" "$splits_annotated_png" \ + --color '#5ec8ff' \ + --font-size 26 \ + --text '860,120,"Vertical split"' \ + --line '862,42,862,894' \ + --text '1080,470,"Horizontal split"' \ + --line '862,451,1398,451' >/dev/null + + printf 'Created assets in %s\n' "$OUT_DIR" + printf '%s\n' "$OUT_DIR"/linux_port_ghostty_* +} + +main "$@"