From 3fc9fdc8a2caebce2bcabf033190492f87264265 Mon Sep 17 00:00:00 2001 From: Jeremy Anderson Date: Sun, 27 Sep 2026 02:35:52 -0400 Subject: [PATCH] rs-mrxvt - feature updates, copy/paste --- ARCHITECTURE.md | 83 +- Cargo.lock | 4294 +++++++++++++++++++++++++++++++++++++++ README.md | 89 +- examples/config.toml | 19 +- src/app.rs | 671 +++++- src/cli.rs | 64 +- src/clipboard.rs | 571 ++++++ src/command.rs | 36 + src/config.rs | 72 +- src/config_lua.rs | 13 + src/config_watch.rs | 5 +- src/images.rs | 16 +- src/input/bindings.rs | 53 + src/lib.rs | 1 + src/mouse.rs | 254 ++- src/session.rs | 68 +- src/sixel.rs | 4 +- src/terminal/manager.rs | 114 +- src/terminal/pty.rs | 179 +- src/terminal/tab.rs | 446 +++- src/ui/tui.rs | 144 +- tests/broadcasting.rs | 1 + tests/integration.rs | 1 + 23 files changed, 7000 insertions(+), 198 deletions(-) create mode 100644 Cargo.lock create mode 100644 src/clipboard.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fcb4781..2d5ace3 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,14 +13,18 @@ src/ ├── command.rs — the Command enum (single source of truth for actions) ├── config.rs — TOML config + ConfigSource trait (future: Lua) ├── app.rs — App struct: owns TerminalManager + InputRouter + PaletteState +│ + SessionState + the clipboard backend +├── clipboard.rs — copy/paste: external tools + OSC 52 + MockClipboard ├── input/ │ ├── mod.rs │ ├── bindings.rs — KeyChord + KeyBindingTable (default mrxvt chords) │ └── router.rs — translate crossterm KeyEvents to bytes OR Commands +├── session.rs — SessionState: mouse/selection/clicks/hyperlinks/images +├── mouse.rs — mouse modes, SGR/X11 encoders, line-based Selection ├── terminal/ │ ├── mod.rs -│ ├── pty.rs — PtySession: spawn child + read/write/resize -│ ├── tab.rs — TerminalTab: PtySession + Term + reader thread +│ ├── pty.rs — PtySession: spawn child (login-shell aware) + read/write/resize +│ ├── tab.rs — TerminalTab: PtySession + Term + reader thread + VT-event channel │ └── manager.rs — TerminalManager: tabs vec, active idx, broadcast router └── ui/ ├── mod.rs — Renderer trait (the swap point for backends) @@ -243,12 +247,74 @@ pub trait ConfigSource { `--config-format lua` flag or by file extension. The rest of the codebase won't need to change. -## 11. Testing strategy +## 11. Clipboard, selection, and VT events -- **Unit tests** (`cargo test --lib`): 76 tests covering input routing, +### Clipboard + +`ClipboardBackend` is the swap point (`src/clipboard.rs`): + +```rust +pub trait ClipboardBackend: Send { + fn store(&mut self, kind: ClipboardKind, text: &str) -> Result<()>; + fn load(&mut self, kind: ClipboardKind) -> Result; +} +``` + +Production uses `SystemClipboard`: writes go to the first available +external tool (`xclip`/`xsel`/`wl-copy`/`pbcopy`/`termux-clipboard-set`, +auto-detected and cached) **and** are mirrored to the host terminal as an +OSC 52 escape sequence — which is what makes copy work over SSH. Reads +come from the external tool. Tests use `MockClipboard` (with an `as_any` +downcast hook so tests can inspect stores). + +Paste flows through `sanitize_paste` (strip C0/C1 injection, normalize +CRLF, drop one trailing newline) and is wrapped in bracketed-paste +markers when the child enabled DECSET 2004. + +### Selection + +`Selection` (`src/mouse.rs`) stores grid coordinates (`SelPoint`), so a +selection survives scrolling. It is **line-based** like every mainstream +terminal: `line_span(line, cols)` gives the half-open column range for a +row; interior rows are fully selected. `kind` distinguishes +Simple/Word/Line gestures (single/double/triple click via +`SessionState.clicks`), and `TerminalTab::selection_text` extracts the +text (trailing whitespace trimmed per line, `\n`-joined). + +### VT events + +`TerminalTab`'s `EventForwarder` (the `Term`'s `EventListener`) pushes +alacritty events into a channel during `poll_pty`. Each tick, +`TerminalManager::drain_events` collects them and `App::handle_tab_event` +acts: + +| Event | Behavior | +|-------|----------| +| `Title(s)` / `ResetTitle` | rename the tab / restore default title | +| `ClipboardStore(ty, s)` | OSC 52 write → clipboard backend (size-capped) | +| `ClipboardLoad(ty, fmt)` | OSC 52 read → gated on `clipboard.osc52_read` | +| `PtyWrite(s)` | write to that tab's PTY | +| `Bell` | status-bar notice | + +### Login shells and the child environment + +The child environment is inherited in full (portable-pty re-applies the +parent env). Default-shell tabs run as **login shells** +(`CommandBuilder::new_default_prog`, argv0 `-bash`, `profile.login_shell += false` to opt out), because `/etc/profile` is where distros export the +GUI session environment — without it, some GUI apps launched from tabs +can't find their display. When neither `DISPLAY` nor `WAYLAND_DISPLAY` +reaches the child, `PtySession::spawn` logs a one-time warning. + +## 12. Testing strategy + +- **Unit tests** (`cargo test --lib`): 200+ tests covering input routing, config parsing, tab manager state transitions, palette filtering, keybinding resolution, key-to-bytes translation, event abstraction, - backend selection logic, and app event handling. + backend selection logic, app event handling, clipboard (base64/OSC 52, + paste sanitation, mock round-trips), selection (line spans, word/line + kinds, text extraction over real PTYs), scrollback, mouse-mode + bridging, OSC 52/title event forwarding, and login-shell spawning. - **Integration tests** (`tests/integration.rs`): 10 end-to-end tests spawning real subprocesses via PTY and verifying the alacritty_terminal emulator renders the output. @@ -258,10 +324,11 @@ won't need to change. spawns N PTYs (default 50), broadcasts a marker, and verifies all N received it within the timeout. -Total: 93 Rust tests + 1 Python harness, all passing. The same suite -passes with `--features gpu` enabled. +Total: 220+ Rust tests + 1 Python harness, all passing. The same suite +passes with `--features gpu`, `--features lua`, `--features images`, and +combinations thereof. -## 12. Build configurations +## 13. Build configurations | Config | Cargo command | Backends available | |-----------------------|------------------------------------|-------------------------| diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c45cecf --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4294 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alacritty_terminal" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" +dependencies = [ + "base64", + "bitflags 2.13.2", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix 1.1.5", + "rustix-openpty", + "serde", + "signal-hook 0.4.4", + "unicode-width 0.2.0", + "vte", + "windows-sys 0.59.0", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee91c0c2905bae44f84bfa4e044536541df26b7703fd0888deeb9060fcc44289" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "cesu8", + "jni", + "jni-sys 0.3.1", + "libc", + "log", + "ndk 0.8.0", + "ndk-context", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.21", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0481a0e032742109b1133a095184ee93d88f3dc9e0d28a5d033dc77a073f44f" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae85a0696e7ea3b835a453750bf002770776609115e6d25c6d2ff28a8200f7e7" +dependencies = [ + "objc-sys", +] + +[[package]] +name = "block2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b55663a85f33501257357e6421bb33e769d5c9ffb5ba0921c975a123e35e68" +dependencies = [ + "block-sys", + "objc2 0.4.1", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba7adb4dd5aa98e5553510223000e7148f621165ec5f9acd7113f6ca4995298" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0ea9b9476c7fad82841a8dbb380e2eae480c21910feba80725b46931ed8f02" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width 0.1.14", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook 0.3.18", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "d3d12" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdbd1f579714e3c809ebd822c81ef148b1ceaeb3d535352afc73fd0c4c6a0017" +dependencies = [ + "bitflags 2.13.2", + "libloading", + "winapi", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "drm" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +dependencies = [ + "drm-sys", + "rustix 1.1.5", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" +dependencies = [ + "libc", + "linux-raw-sys 0.9.4", +] + +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +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 = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide 0.8.9", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.2", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "gpu-allocator" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd4240fc91d3433d5e5b0fc5b67672d771850dc19bbee03c1381e19322803d7" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.2", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.13.2", + "com", + "libc", + "libloading", + "thiserror 1.0.69", + "widestring", + "winapi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "icrate" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d3aaff8a54577104bafdf686ff18565c3b6903ca5782a2026ef06e2c7aa319" +dependencies = [ + "block2", + "dispatch", + "objc2 0.4.1", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lua-src" +version = "547.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edaf29e3517b49b8b746701e5648ccb5785cde1c119062cbabbc5d5cd115e42" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.5.12+a4f56a4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a8e7962a5368d5f264d045a5a255e90f9aa3fc1941ae15a8d2940d42cac671" +dependencies = [ + "cc", + "which", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.13.2", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "miow" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "mlua" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1f5f8fbebc7db5f671671134b9321c4b9aa9adeafccfd9a8c020ae45c6a35d0" +dependencies = [ + "bstr", + "either", + "mlua-sys", + "num-traits", + "parking_lot", + "rustc-hash 2.1.3", + "rustversion", +] + +[[package]] +name = "mlua-sys" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380c1f7e2099cafcf40e51d3a9f20a346977587aa4d012eae1f043149a728a93" +dependencies = [ + "cc", + "cfg-if", + "lua-src", + "luajit-src", + "pkg-config", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg_aliases", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d" +dependencies = [ + "objc-sys", + "objc2-encode 3.0.0", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode 4.1.0", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.15+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.13.2", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.21", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "rs-mrxvt" +version = "0.1.0" +dependencies = [ + "ab_glyph", + "alacritty_terminal", + "anyhow", + "bytemuck", + "clap", + "crossbeam-channel", + "crossterm", + "env_logger", + "fuzzy-matcher", + "image", + "libc", + "log", + "mlua", + "pollster", + "portable-pty", + "pretty_assertions", + "ratatui", + "raw-window-handle", + "serde", + "shellexpand", + "softbuffer", + "tempfile", + "thiserror 1.0.69", + "tiny-skia", + "tokio", + "toml", + "unicode-width 0.2.0", + "wgpu", + "winit", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-openpty" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" +dependencies = [ + "errno", + "libc", + "rustix 1.1.5", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70b31447ca297092c5a9916fc3b955203157b37c19ca8edde4f52e9843e602c7" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook 0.3.18", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "smithay-client-toolkit" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "922fd3eeab3bd820d76537ce8f582b1cf951eceb5475c28500c7457d9d17f53a" +dependencies = [ + "bitflags 2.13.2", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "drm", + "fastrand", + "js-sys", + "memmap2", + "ndk 0.9.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.5", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor", + "libloading", + "pkg-config", + "tracing", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[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 = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "cursor-icon", + "log", + "memchr", + "serde", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa30049b1c872b72c89866d458eae9f20380ab280ffd1b1e18df2d3e2d98cfe0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433" +dependencies = [ + "arrayvec", + "cfg_aliases", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.13.2", + "cfg_aliases", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block", + "cfg_aliases", + "core-graphics-types", + "d3d12", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9d91f0e2c4b51434dfa6db77846f2793149d8e73f800fa2e41f52b8eac3c5d" +dependencies = [ + "bitflags 2.13.2", + "js-sys", + "web-sys", +] + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix 1.1.5", + "winsafe", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.29.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d59ad965a635657faf09c8f062badd885748428933dad8e8bdd64064d92e5ca" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "bytemuck", + "calloop", + "cfg_aliases", + "core-foundation", + "core-graphics", + "cursor-icon", + "icrate", + "js-sys", + "libc", + "log", + "memmap2", + "ndk 0.8.0", + "ndk-sys 0.5.0+25.2.9519653", + "objc2 0.4.1", + "once_cell", + "orbclient", + "percent-encoding", + "raw-window-handle", + "redox_syscall 0.3.5", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.48.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/README.md b/README.md index e13583d..6930df3 100755 --- a/README.md +++ b/README.md @@ -28,6 +28,29 @@ Three opt-in feature flags extend the terminal in orthogonal directions: - **Multi-tab PTY** — each tab runs an independent shell via `portable-pty`, with VT emulation by `alacritty_terminal`. Tabs are managed by a single `TerminalManager`; one tab's heavy output never blocks the others. +- **Copy & paste** — click-drag select (line-based, like xterm/Ghostty), + double-click word, triple-click line. `Ctrl+Shift+C`/`Ctrl+Shift+V`, + classic `Ctrl+Ins`/`Shift+Ins`, middle-click pastes the primary + selection, right-click pastes the clipboard, and copy-on-select follows + the classic mrxvt/xterm behavior. Copies are also mirrored to the host + terminal via **OSC 52**, so copy works over SSH. +- **Scrollback navigation** — mouse wheel, `Shift+PageUp/PageDown`, + `Shift+Home/End`, and palette commands. The wheel does the right thing + on the alternate screen (sends arrows to `less`/`vim`); typing snaps the + viewport back to the live bottom. +- **Mouse reporting** — DECSET 9/1000/1002/1003/1006 tracked from the + child program; SGR-1006 and legacy X11 encodings both supported. + `Shift+click` always bypasses reporting so you can select text in + `vim`/`htop`. +- **OSC 52 in and out** — programs (tmux, nvim) can set the clipboard; + reads are off by default for security (`clipboard.osc52_read = true` + to enable). +- **Dynamic tab titles** — OSC 0/2 title sequences from the child rename + the tab (e.g. `vim` shows the filename, `ssh` shows the host). +- **Login shells by default** — default-shell tabs run as login shells + (classic mrxvt `loginShell: True`), so `/etc/profile` and its distro + GUI-session exports (D-Bus, flatpak/snap paths) reach every app you + launch — including GUI apps. - **Input Broadcasting** — the classic mrxvt killer feature. Toggle broadcast-to-all with `Ctrl+Shift+I`, or broadcast only to tagged groups via `--tag` on the CLI or the `ToggleBroadcastGroup` command in the @@ -42,7 +65,7 @@ Three opt-in feature flags extend the terminal in orthogonal directions: - **TOML config** — `~/.config/rs-mrxvt/config.toml` with profiles, macros, keybindings, and transparency settings. - **Per-tab fading** — inactive tabs dim smoothly via a lerp animation. -- **Stress-tested** — 89+ unit + integration tests, plus a 50-instance +- **Stress-tested** — 200+ unit + integration tests, plus a 50-instance Python stress harness that broadcasts a marker to 50 shells in ~30ms. ### With `--features lua` @@ -87,15 +110,19 @@ Three opt-in feature flags extend the terminal in orthogonal directions: | Config hot-reload | ✅ shipped | Polling watcher, survives bad configs | | iTerm2 image protocol | ✅ shipped | PNG/JPEG/GIF/WebP/BMP inline | | Sixel image protocol | ✅ shipped | Pure-Rust parser, color registers, repeats | -| Mouse + SGR-1006 | ✅ shipped | X10/X11/SGR encoders, selection state machine | +| Mouse + SGR-1006 | ✅ shipped | X10/X11/SGR encoders, shift-bypass selection | | OSC 8 hyperlinks | ✅ shipped | Parser + scanner + cell-indexed store | | Alt+N / Alt+Arrows | ✅ shipped | New tab, shuffle forward/back | | Alt+Shift+X close | ✅ shipped | Closes focused tab | | Alt+Z zsh tab | ✅ shipped | Secondary shell on a hotkey | | True-color themes | ✅ shipped | Tokyo Night, Gruvbox, Dracula, Solarized | | Multi-distro sysprep | ✅ shipped | pacman/apt/dnf/zypper/xbps/apk/cast/emerge | -| Renderer integration | 🚧 planned | Wire mouse/selection/links/images into UI loop | -| Clipboard support | 🚧 planned | Copy selection, paste on right-click | +| Clipboard | ✅ shipped | Select/copy/paste, OSC 52 mirror, tools+mock | +| Scrollback navigation | ✅ shipped | Wheel, Shift+PgUp/PgDn/Home/End, palette cmds | +| Selection modes | ✅ shipped | Drag/word/line, copy-on-select, highlight | +| Tab title sync | ✅ shipped | OSC 0/2 from child renames the tab | +| Login shells | ✅ shipped | GUI-session env reaches apps (mrxvt default) | +| GUI backend polish | 🚧 planned | Selection/cursor highlight in wgpu+soft UIs | ## 🚀 Quick start @@ -147,15 +174,25 @@ sudo PREFIX=/usr make install # installs to /usr | Shortcut | Action | |-----------------------|-----------------------------------------------| -| `Ctrl+Shift+T` / `Alt+N` | New tab (bash, the default shell) | +| `Ctrl+Shift+T` / `Alt+N` | New tab (login shell) | | `Alt+Z` | New tab running zsh (secondary shell) | | `Alt+Shift+X` | Close the currently focused tab | | `Ctrl+Shift+W` | Close tab (classic mrxvt binding) | +| `Ctrl+Shift+C` / `Ctrl+Insert` | Copy selection to clipboard | +| `Ctrl+Shift+V` / `Shift+Insert` | Paste from clipboard | | `Ctrl+Shift+I` | Toggle broadcast (all tabs) | | `Ctrl+Shift+P` | Open command palette | | `Ctrl+Tab` / `Alt+Right` | Next tab (wraps) | | `Ctrl+Shift+Tab` / `Alt+Left` | Previous tab (wraps) | | `Alt+1` … `Alt+0` | Go to tab N (1..10) | +| `Shift+PageUp` / `Shift+PageDown` | Scroll scrollback a page | +| `Shift+Home` / `Shift+End` | Jump to top / bottom of scrollback | + +Mouse: drag selects, double-click selects a word, triple-click selects a +line, release copies (copy-on-select), middle-click pastes the primary +selection, right-click pastes the clipboard, and the wheel scrolls the +scrollback (or the alternate-screen program). `Shift+click` bypasses +application mouse reporting so you can always select text. All bindings are rebindable in `config.toml` or `config.lua`. @@ -176,6 +213,29 @@ opacity = 0.85 # 1.0 = opaque, 0.0 = fully transparent # background_image = "/path/to/wallpaper.png" ``` +### Clipboard + +```toml +[clipboard] +copy_on_select = true # selecting text copies it (classic mrxvt/xterm) +osc52 = true # mirror copies to the host terminal — works over SSH +osc52_read = false # let programs read the clipboard via OSC 52 (security) +# tool = "xclip" # force a tool; otherwise auto-detected +``` + +rs-mrxvt talks to the system clipboard through the standard tools +(`xclip`/`xsel` on X11, `wl-clipboard` on Wayland, `pbcopy`/`pbpaste` on +macOS, `termux-clipboard-*` on Android) — no clipboard library is linked. +Every copy is *additionally* emitted as an OSC 52 escape sequence, so the +**host terminal** performs the clipboard write too; over SSH with no +local clipboard tool, that mirror is what makes copy work. + +Paste sanitation matches mainstream terminals: control characters that +could inject escape sequences are stripped, CRLF is normalized to LF, and +a single trailing newline is dropped so a paste doesn't auto-submit your +command. When the child enabled bracketed paste (DECSET 2004), pastes are +wrapped in `ESC[200~ … ESC[201~`. + ### Time-based theme (Lua) ```lua @@ -207,6 +267,25 @@ Built-in true-color themes (set `ui.theme` in your config): Custom themes can be defined in TOML — see `examples/config.toml` for the full color list (16 ANSI colors + bg/fg/cursor). +## 🛠 Troubleshooting + +**GUI apps refuse to start from a tab** (`cannot open display`, D-Bus +errors): default-shell tabs run **login shells**, which source +`/etc/profile` — that's where distros export the GUI session environment +(`DBUS_SESSION_BUS_ADDRESS`, flatpak/snap paths). If tabs still can't +launch GUI apps, rs-mrxvt itself was probably started from a +non-graphical context (plain TTY, `sudo`, a stripped service): the child +environment is inherited, so there is no `DISPLAY`/`WAYLAND_DISPLAY` to +inherit. Start rs-mrxvt from your graphical session, or set the variables +yourself. rs-mrxvt logs a warning at spawn when this is the case. If your +`~/.bash_profile` is noisy, per-profile `login_shell = false` restores +plain non-login shells. + +**Copy works but paste doesn't (over SSH):** paste reads the *local* +system clipboard, which needs a local tool (`xclip`, `xsel`, +`wl-paste`). OSC 52 can only *write* the client's clipboard, not read it +synchronously — that's a protocol limit, not a bug. + ## 🧪 Testing ```bash diff --git a/examples/config.toml b/examples/config.toml index a170df5..44b2310 100755 --- a/examples/config.toml +++ b/examples/config.toml @@ -12,6 +12,18 @@ rows = 40 scrollback = 10000 # lines of scrollback history per tab shell = "/bin/bash" # default shell (bash); override per-profile +# ────────────────────────────────────────────────────────────────────────── +# Clipboard (copy / paste) +# ────────────────────────────────────────────────────────────────────────── +[clipboard] +copy_on_select = true # classic xterm/mrxvt: selecting text copies it +osc52 = true # also mirror copies to the host terminal (OSC 52); + # this is what makes copy work over SSH +osc52_read = false # allow programs (tmux/nvim) to READ the clipboard + # via OSC 52 queries — off by default for safety +# tool = "xclip" # force a clipboard tool; auto-detects otherwise + # (xclip / xsel / wl-clipboard / pbcopy / termux) + # ────────────────────────────────────────────────────────────────────────── # UI behavior # ────────────────────────────────────────────────────────────────────────── @@ -28,7 +40,12 @@ focus = "click" # "click" (default) | "follow" (focus follows mouse) default_profile = "default" [profiles.default] -command = ["bash"] # bash is the primary shell +# Empty command = run the user's default shell ($SHELL, then /etc/passwd) +# as a LOGIN shell — it sources /etc/profile, so distro GUI session +# variables (D-Bus, flatpak/snap paths) reach apps launched from the tab. +# Set login_shell = false if you don't want that. +# command = ["bash"] +login_shell = true # cwd = "/home/user/projects" # tag = "local" diff --git a/src/app.rs b/src/app.rs index 38b2b0a..62dfb98 100644 --- a/src/app.rs +++ b/src/app.rs @@ -32,21 +32,35 @@ //! [`AppEvent`], a backend-agnostic enum — every renderer translates its //! native events into this type at the boundary. +use std::time::{Duration, Instant}; + +use alacritty_terminal::event::Event as TermEvent; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::cli::Cli; +use crate::clipboard::{bracket, sanitize_paste, ClipboardBackend, ClipboardKind, SystemClipboard}; use crate::command::Command; use crate::config::Config; use crate::input::router::{InputAction, InputRouter}; use crate::input::bindings::KeyBindingTable; -use crate::mouse::{MouseButton, MouseEvent, MouseEventKind}; +use crate::mouse::{MouseButton, MouseEvent, MouseEventKind, SelPoint}; use crate::session::SessionState; use crate::terminal::manager::{Action, BroadcastTarget, TerminalManager}; -use crate::ui::event::{AppEvent, AppKey, AppKeyEvent}; +use crate::ui::event::{AppEvent, AppKey, AppKeyEvent, AppMouseEvent}; #[cfg(test)] use crate::ui::event::AppModifiers; use crate::ui::palette::PaletteState; +/// How long a status-bar notice stays visible. +pub const NOTICE_TTL: Duration = Duration::from_millis(2500); + +/// A transient status-bar message (e.g. "Copied 42 chars"). +#[derive(Debug, Clone)] +pub struct Notice { + pub text: String, + pub expires: Instant, +} + /// The app. Construct with [`App::new`], then run with a [`Renderer`]. pub struct App { pub manager: TerminalManager, @@ -56,10 +70,25 @@ pub struct App { pub config: Config, pub cli: Cli, pub quit: bool, + /// Clipboard backend (system tools + OSC 52 in production, mock in tests). + pub clipboard: Box, + /// Transient status-bar message, if any. + pub notice: Option, } impl App { pub fn new(cli: Cli, config: Config) -> anyhow::Result { + let clipboard: Box = + Box::new(SystemClipboard::from_config(&config.clipboard)); + Self::with_clipboard(cli, config, clipboard) + } + + /// Constructor with an explicit clipboard backend (tests use a mock). + pub fn with_clipboard( + cli: Cli, + config: Config, + clipboard: Box, + ) -> anyhow::Result { let bindings = KeyBindingTable::defaults(); let router = InputRouter::new(bindings); @@ -111,9 +140,86 @@ impl App { config, cli, quit: false, + clipboard, + notice: None, }) } + /// Show a transient message in the status bar. + pub fn notify(&mut self, text: impl Into) { + self.notice = Some(Notice { + text: text.into(), + expires: Instant::now() + NOTICE_TTL, + }); + } + + /// The active notice, if it hasn't expired. + pub fn active_notice(&self) -> Option<&str> { + self.notice + .as_ref() + .filter(|n| Instant::now() < n.expires) + .map(|n| n.text.as_str()) + } + + // ─── clipboard ─────────────────────────────────────────────────────────── + + /// Copy the active selection to the system clipboard. + pub fn copy_selection(&mut self) { + let Some(tab) = self.manager.active_tab() else { + return; + }; + if !self.session.selection.is_active() { + self.notify("No selection"); + return; + } + let Some(text) = tab.selection_text(&self.session.selection) else { + self.notify("No selection"); + return; + }; + if text.is_empty() { + self.notify("Selection is empty"); + return; + } + let n = text.chars().count(); + match self.clipboard.store(ClipboardKind::Clipboard, &text) { + Ok(()) => self.notify(format!("Copied {n} chars")), + Err(e) => self.notify(format!("Copy failed: {e}")), + } + } + + /// Load the given clipboard and paste it into the terminal(s). + pub fn paste(&mut self, kind: ClipboardKind) { + match self.clipboard.load(kind) { + Ok(text) => self.deliver_paste(&text), + Err(e) => self.notify(format!("Paste failed: {e}")), + } + } + + /// Sanitize, optionally bracket, and route already-loaded paste text. + /// + /// Shared by the clipboard-tool path, OSC 52 paste requests, and host + /// terminal paste events. + pub fn deliver_paste(&mut self, text: &str) { + let clean = sanitize_paste(text); + if clean.is_empty() { + self.notify("Clipboard is empty"); + return; + } + let n = clean.chars().count(); + let bracketed = self + .manager + .active_tab() + .map(|t| t.bracketed_paste()) + .unwrap_or(false); + let payload = if bracketed { bracket(&clean) } else { clean }; + if let Err(e) = self.manager.route_input(payload.as_bytes()) { + log::warn!("paste routing failed: {e}"); + } + self.notify(format!("Pasted {n} chars")); + } + + // ─── commands ──────────────────────────────────────────────────────────── + /// Apply a one-shot [`Command`] (from the palette, a macro, etc.). pub fn run_command(&mut self, cmd: &Command, cols: u16, rows: u16) -> InputAction { match cmd { @@ -125,8 +231,26 @@ impl App { self.quit = true; InputAction::Quit } + Command::CopySelection => { + self.copy_selection(); + InputAction::Handled + } + Command::PasteClipboard => { + self.paste(ClipboardKind::Clipboard); + InputAction::Handled + } + Command::PastePrimary => { + self.paste(ClipboardKind::Primary); + InputAction::Handled + } other => { + let active_before = self.manager.active; let action = self.manager.execute(other, cols, rows, &self.config); + // Switching tabs invalidates the selection (it lives in the + // previous tab's grid coordinates). + if self.manager.active != active_before { + self.session.reset(); + } if action == Action::Quit { self.quit = true; InputAction::Quit @@ -153,11 +277,8 @@ impl App { } AppEvent::FocusGained | AppEvent::FocusLost => InputAction::Continue, AppEvent::Paste(s) => { - // Paste as raw bytes into the active tab (or broadcast group). - let bytes = s.into_bytes(); - if let Err(e) = self.manager.route_input(&bytes) { - log::warn!("input routing failed: {e}"); - } + // Paste delivered by the host terminal (bracketed paste). + self.deliver_paste(&s); InputAction::Continue } AppEvent::Mouse(m) => self.handle_mouse(m), @@ -170,21 +291,32 @@ impl App { /// Handle a mouse event. /// - /// If the active program has enabled mouse reporting (via DECSET), the - /// event is encoded and sent to the PTY. Otherwise, the event drives - /// local text selection: press begins a selection, motion extends it, - /// release copies to clipboard (deferred — clipboard access requires - /// platform-specific code). - pub fn handle_mouse(&mut self, ev: crate::ui::event::AppMouseEvent) -> InputAction { + /// Priority order (matching xterm/Ghostty behavior): + /// 1. If the child enabled mouse reporting and Shift is not held, + /// encode the event (SGR-1006 when negotiated, else legacy X11) and + /// forward it to the PTY. Shift bypasses reporting so the user can + /// always select text. + /// 2. Otherwise the event drives local interaction: click-drag selects + /// (with double/triple-click word/line modes), the wheel scrolls + /// the scrollback viewport (or sends arrows on the alternate + /// screen), middle-click pastes the primary selection, and + /// right-click pastes the clipboard. + pub fn handle_mouse(&mut self, ev: AppMouseEvent) -> InputAction { let mouse_ev: MouseEvent = ev.into(); - let mode = self.session.mouse_mode; + let mode = self + .manager + .active_tab() + .map(|t| t.mouse_mode()) + .unwrap_or_default(); - // If the program is reporting mouse events, encode and forward. - if mode.any_reporting() { - // Try SGR-1006 first (preferred), then legacy X11. - if let Some(bytes) = crate::mouse::encode_sgr(mouse_ev, mode) - .or_else(|| crate::mouse::encode_x11(mouse_ev, mode)) - { + // 1. Mouse reporting for the child program. + if mode.any_reporting() && !mouse_ev.mods.shift { + let bytes = if mode.sgr { + crate::mouse::encode_sgr(mouse_ev, mode) + } else { + crate::mouse::encode_x11(mouse_ev, mode) + }; + if let Some(bytes) = bytes { if let Err(e) = self.manager.route_input(&bytes) { log::warn!("input routing failed: {e}"); } @@ -192,37 +324,193 @@ impl App { } } - // Otherwise, drive local selection. - match mouse_ev.kind { - MouseEventKind::Press => match mouse_ev.button { - MouseButton::Left => { - self.session.selection.begin(mouse_ev.col, mouse_ev.row); - } - MouseButton::Right => { - // Right-click: paste (placeholder — would call into clipboard). - self.session.selection.clear(); - } - _ => {} - }, - MouseEventKind::Motion => { - if self.session.selection.is_active() { - self.session.selection.extend(mouse_ev.col, mouse_ev.row); + // 2. Local interaction. + let now = Instant::now(); + match (mouse_ev.kind, mouse_ev.button) { + (MouseEventKind::Press, MouseButton::WheelUp) => { + if let Some(t) = self.manager.active_tab_mut() { + t.wheel_scroll(true); } } - MouseEventKind::Release => { - if self.session.selection.is_multi_cell() { - // Selection complete — would copy to clipboard here. - // For now we just leave the selection active so the - // renderer can highlight it; the user can clear it - // with a single click. - } else { - self.session.selection.clear(); + (MouseEventKind::Press, MouseButton::WheelDown) => { + if let Some(t) = self.manager.active_tab_mut() { + t.wheel_scroll(false); } } + (MouseEventKind::Press, MouseButton::Left) => { + self.begin_selection(mouse_ev, now); + } + (MouseEventKind::Motion, _) if self.session.selection.is_active() => { + self.extend_selection(mouse_ev); + } + (MouseEventKind::Release, MouseButton::Left) => { + self.finish_selection(); + } + (MouseEventKind::Press, MouseButton::Middle) => { + self.paste(ClipboardKind::Primary); + } + (MouseEventKind::Press, MouseButton::Right) => { + self.paste(ClipboardKind::Clipboard); + } + _ => {} } InputAction::Handled } + /// Left-press: start a Simple/Word/Line selection based on click cadence. + fn begin_selection(&mut self, ev: MouseEvent, now: Instant) { + let Some(tab) = self.manager.active_tab() else { return }; + let p = tab.grid_point(ev.row, ev.col); + let kind = self.session.clicks.register(now); + match kind { + crate::mouse::SelectionKind::Word => { + let (a, b) = tab.expand_word(p); + self.session.selection.set_span(kind, a, b); + } + crate::mouse::SelectionKind::Line => { + let (a, b) = tab.expand_line(p); + self.session.selection.set_span(kind, a, b); + } + crate::mouse::SelectionKind::Simple => { + self.session.selection.begin(kind, p); + } + } + } + + /// Drag: extend the selection, re-expanding word/line selections. + fn extend_selection(&mut self, ev: MouseEvent) { + let Some(tab) = self.manager.active_tab() else { return }; + let p = tab.grid_point(ev.row, ev.col); + let kind = self.session.selection.kind; + match kind { + crate::mouse::SelectionKind::Simple => { + self.session.selection.extend(p); + } + crate::mouse::SelectionKind::Word => { + // Union of the anchor word and the word under the pointer. + let origin = self + .session + .selection + .anchor + .or(Some(p)) + .unwrap_or(p); + let (aa, ab) = tab.expand_word(origin); + let (ba, bb) = tab.expand_word(p); + let lo = if (ba.line, ba.col) < (aa.line, aa.col) { ba } else { aa }; + let hi = if (bb.line, bb.col) > (ab.line, ab.col) { bb } else { ab }; + self.session.selection.set_span(kind, lo, hi); + } + crate::mouse::SelectionKind::Line => { + let origin = self.session.selection.anchor.unwrap_or(p); + let lo_line = origin.line.min(p.line); + let hi_line = origin.line.max(p.line); + let cols = tab.size().0 as usize; + self.session.selection.set_span( + kind, + SelPoint { line: lo_line, col: 0 }, + SelPoint { line: hi_line, col: cols.saturating_sub(1) }, + ); + } + } + } + + /// Left-release: copy-on-select (if enabled), collapse empty selections. + fn finish_selection(&mut self) { + let sel = &self.session.selection; + if sel.is_active() && (sel.is_multi_cell() || sel.kind != crate::mouse::SelectionKind::Simple) + { + if self.config.clipboard.copy_on_select { + if let Some(tab) = self.manager.active_tab() { + if let Some(text) = tab.selection_text(sel) { + if !text.is_empty() { + let n = text.chars().count(); + // Copy-on-select targets the primary selection + // (X11 convention) and the clipboard (so it also + // works on Wayland/macOS setups). + if let Err(e) = + self.clipboard.store(ClipboardKind::Primary, &text) + { + log::debug!("primary store failed: {e}"); + } + if let Err(e) = + self.clipboard.store(ClipboardKind::Clipboard, &text) + { + log::debug!("clipboard store failed: {e}"); + } + self.notify(format!("Copied {n} chars")); + } + } + } + } + } else { + self.session.selection.clear(); + } + } + + // ─── VT events from the child (title, OSC 52, bell, …) ─────────────────── + + /// Process VT events drained from the tabs after a PTY poll. + pub fn handle_tab_events(&mut self, events: Vec<(usize, TermEvent)>) { + for (idx, ev) in events { + self.handle_tab_event(idx, ev); + } + } + + fn handle_tab_event(&mut self, idx: usize, ev: TermEvent) { + match ev { + TermEvent::Title(title) => { + self.manager.set_title(idx, title); + } + TermEvent::ResetTitle => { + self.manager.reset_title(idx); + } + TermEvent::ClipboardStore(ty, text) => { + // A program (tmux, nvim, …) set the clipboard via OSC 52. + // Allow list-sized writes; refuse absurd ones. + if text.len() > crate::clipboard::OSC52_MAX_BYTES { + log::warn!( + "ignoring OSC 52 store from tab {idx}: {} bytes exceeds cap", + text.len() + ); + return; + } + let kind = ClipboardKind::from(ty); + if let Err(e) = self.clipboard.store(kind, &text) { + log::debug!("OSC 52 store failed: {e}"); + } + } + TermEvent::ClipboardLoad(ty, fmt) => { + // OSC 52 clipboard *query*. Off by default (security: don't + // let arbitrary programs read the clipboard silently). + if !self.config.clipboard.osc52_read { + return; + } + let kind = ClipboardKind::from(ty); + if let Ok(text) = self.clipboard.load(kind) { + let seq = fmt(&text); + if let Some(tab) = self.manager.tabs.get_mut(idx) { + if let Err(e) = tab.write_input(seq.as_bytes()) { + log::debug!("OSC 52 paste write failed: {e}"); + } + } + } + } + TermEvent::PtyWrite(s) => { + if let Some(tab) = self.manager.tabs.get_mut(idx) { + if let Err(e) = tab.write_input(s.as_bytes()) { + log::debug!("PtyWrite failed: {e}"); + } + } + } + TermEvent::Bell => { + self.notify("Bell"); + } + // Wakeup, MouseCursorDirty, … — the 20 Hz render loop already + // covers redraws; nothing to do. + _ => {} + } + } + /// Handle an [`AppKeyEvent`] (translated from crossterm or winit). pub fn handle_app_key(&mut self, ev: AppKeyEvent, cols: u16, rows: u16) -> InputAction { // Ignore key-release events (only key presses trigger commands). @@ -377,8 +665,12 @@ impl App { fn main_loop(&mut self, renderer: &mut dyn crate::ui::Renderer) -> anyhow::Result<()> { let poll_ms = 50; // 20 Hz input poll; PTY drain is per-tick. while !self.quit { - // 1. Drain PTYs. + // 1. Drain PTYs, then act on VT events (titles, OSC 52, bells). let (_active_changed, _any_eof) = self.manager.poll_all(); + let events = self.manager.drain_events(); + if !events.is_empty() { + self.handle_tab_events(events); + } // 2. Poll for input. while let Some(ev) = renderer.poll_event(poll_ms)? { @@ -444,6 +736,7 @@ fn app_key_to_crossterm(ev: AppKeyEvent) -> KeyEvent { mod tests { use super::*; use crate::cli::Cli; + use alacritty_terminal::grid::Dimensions; use clap::Parser; fn default_cli() -> Cli { @@ -571,4 +864,294 @@ mod tests { assert_eq!(action, InputAction::OpenPalette); assert!(app.palette.is_open()); } + + // ─── clipboard + selection + scrolling tests ────────────────────────── + + use crate::clipboard::MockClipboard; + use crate::ui::event::AppMouseEvent; + + fn mock_app() -> App { + let cli = default_cli(); + let cfg = Config::default(); + App::with_clipboard(cli, cfg, Box::new(MockClipboard::new())).unwrap() + } + + /// Replace the app's tabs with one child running `cmd`, then wait for + /// `marker` (printed after `cmd`) to appear in its grid. Only suitable + /// for one-shot commands; interactive children (`cat`) need a manual + /// profile that prints the marker first. + fn run_child(app: &mut App, cmd: &str, marker: &str) { + let profile = crate::config::Profile { + command: vec!["sh".into(), "-c".into(), format!("{cmd}; printf '{marker}'")], + ..crate::config::Profile::default() + }; + while !app.manager.tabs.is_empty() { + app.manager.close_tab(0); + } + app.manager.open_tab(&profile, Some("test".into()), 40, 10).unwrap(); + wait_grid(app, marker); + } + + /// Like [`run_child`], but the marker is printed *before* the (long + /// running) command starts — for interactive children like `cat`. + fn run_interactive_child(app: &mut App, cmd: &str, marker: &str) { + let profile = crate::config::Profile { + command: vec!["sh".into(), "-c".into(), format!("printf '{marker}'; {cmd}")], + ..crate::config::Profile::default() + }; + while !app.manager.tabs.is_empty() { + app.manager.close_tab(0); + } + app.manager.open_tab(&profile, Some("test".into()), 40, 10).unwrap(); + wait_grid(app, marker); + } + + /// Wait until the active tab's grid contains `needle`. + /// + /// VT events parsed along the way stay queued in the tab — tests pick + /// them up with `manager.drain_events()` afterwards. + fn wait_grid(app: &mut App, needle: &str) { + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline { + let _ = app.manager.poll_all(); + let tab = app.manager.active_tab().unwrap(); + let grid = tab.term.grid(); + let start = -(grid.display_offset() as i32); + let found = (0..grid.screen_lines()).any(|r| { + let s: String = (0..grid.columns()) + .map(|c| { + grid[crate::Point { + line: crate::Line(start + r as i32), + column: crate::Column(c), + }].c + }) + .collect(); + s.contains(needle) + }); + if found { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + panic!("timeout waiting for {needle:?} in grid"); + } + + fn mouse_ev( + button: crate::mouse::MouseButton, + col: u32, + row: u32, + kind: MouseEventKind, + ) -> AppMouseEvent { + AppMouseEvent { + button, + col, + row, + mods: crate::mouse::MouseMods::default(), + kind, + } + } + + #[test] + fn drag_select_then_release_copies_on_select() { + let mut app = mock_app(); + run_child(&mut app, "printf 'alpha beta gamma'", "MARK1"); + // Drag from col 6 to col 9 on row 0 → "beta". + app.handle_event(AppEvent::Mouse(mouse_ev( + crate::mouse::MouseButton::Left, + 6, + 0, + MouseEventKind::Press, + )), 40, 10); + app.handle_event(AppEvent::Mouse(mouse_ev( + crate::mouse::MouseButton::Left, + 9, + 0, + MouseEventKind::Motion, + )), 40, 10); + app.handle_event(AppEvent::Mouse(mouse_ev( + crate::mouse::MouseButton::Left, + 9, + 0, + MouseEventKind::Release, + )), 40, 10); + + let any = app.clipboard.as_any().unwrap(); + let mock = any.downcast_ref::().unwrap(); + let stored = mock + .stores + .iter() + .find(|(k, _)| matches!(k, crate::clipboard::ClipboardKind::Clipboard)) + .map(|(_, t)| t.clone()) + .expect("copy-on-select should store to clipboard"); + assert_eq!(stored, "beta"); + } + + #[test] + fn single_click_does_not_copy() { + let mut app = mock_app(); + run_child(&mut app, "printf 'alpha beta gamma'", "MARK2"); + // Click and release on the same cell — no multi-cell selection. + app.handle_event(AppEvent::Mouse(mouse_ev( + crate::mouse::MouseButton::Left, + 3, + 0, + MouseEventKind::Press, + )), 40, 10); + app.handle_event(AppEvent::Mouse(mouse_ev( + crate::mouse::MouseButton::Left, + 3, + 0, + MouseEventKind::Release, + )), 40, 10); + let any = app.clipboard.as_any().unwrap(); + let mock = any.downcast_ref::().unwrap(); + assert!(mock.stores.is_empty(), "single click must not copy"); + } + + #[test] + fn right_click_pastes_clipboard() { + let mut app = mock_app(); + run_interactive_child(&mut app, "cat", "MARK3"); + app.clipboard = Box::new(MockClipboard::with_content( + crate::clipboard::ClipboardKind::Clipboard, + "pasted-by-right-click", + )); + app.handle_event( + AppEvent::Mouse(mouse_ev( + crate::mouse::MouseButton::Right, + 1, + 1, + MouseEventKind::Press, + )), + 40, + 10, + ); + // `cat` echoes the paste back into the grid. + wait_grid(&mut app, "pasted-by-right"); + assert!(app.active_notice().is_some()); + } + + #[test] + fn paste_command_reaches_bracketed_child() { + let mut app = mock_app(); + run_interactive_child(&mut app, "printf '\\033[?2004h'; cat", "MARK4"); + assert!( + app.manager.active_tab().unwrap().bracketed_paste(), + "child should have bracketed paste on" + ); + app.clipboard = Box::new(MockClipboard::with_content( + crate::clipboard::ClipboardKind::Clipboard, + "bracketed-text", + )); + app.run_command(&Command::PasteClipboard, 40, 10); + wait_grid(&mut app, "bracketed-text"); + assert!(app.active_notice().unwrap().contains("Pasted")); + } + + #[test] + fn wheel_scrolls_active_tab_and_typing_snaps_back() { + let mut app = mock_app(); + run_child(&mut app, "seq 1 200", "MARK5"); + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline { + let _ = app.manager.poll_all(); + if app.manager.active_tab().unwrap().term.grid().history_size() > 100 { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + app.handle_event( + AppEvent::Mouse(mouse_ev( + crate::mouse::MouseButton::WheelUp, + 0, + 0, + MouseEventKind::Press, + )), + 40, + 10, + ); + assert_eq!(app.manager.active_tab().unwrap().display_offset(), 3); + // Typing snaps back to the bottom. + app.handle_event( + AppEvent::Key(AppKeyEvent::new(AppModifiers::empty(), AppKey::Enter)), + 40, + 10, + ); + assert!(app.manager.active_tab().unwrap().at_bottom()); + } + + #[test] + fn mouse_reporting_forwards_to_child_and_shift_bypasses() { + let mut app = mock_app(); + run_child(&mut app, "printf '\\033[?1000h\\033[?1006h'", "MARK6"); + + // Non-shift press is encoded and forwarded (not treated as selection). + app.handle_event( + AppEvent::Mouse(mouse_ev( + crate::mouse::MouseButton::Left, + 5, + 2, + MouseEventKind::Press, + )), + 40, + 10, + ); + assert!( + !app.session.selection.is_active(), + "press should go to child, not selection" + ); + + // Shift+click bypasses reporting → local selection begins. + let mut ev = mouse_ev(crate::mouse::MouseButton::Left, 1, 1, MouseEventKind::Press); + ev.mods.shift = true; + app.handle_event(AppEvent::Mouse(ev), 40, 10); + assert!(app.session.selection.is_active()); + } + + #[test] + fn osc52_store_from_child_sets_clipboard() { + let mut app = mock_app(); + // "prox" base64-encodes to cHJveA==. + run_child(&mut app, "printf '\\033]52;c;cHJveA==\\007'", "MARK7"); + let events = app.manager.drain_events(); + assert!(!events.is_empty()); + app.handle_tab_events(events); + let stored = app + .clipboard + .load(crate::clipboard::ClipboardKind::Clipboard) + .unwrap(); + assert_eq!(stored, "prox"); + } + + #[test] + fn title_from_child_renames_tab() { + let mut app = mock_app(); + run_child(&mut app, "printf '\\033]0;renamed-tab\\007'", "MARK8"); + let events = app.manager.drain_events(); + app.handle_tab_events(events); + assert_eq!(app.manager.active_tab().unwrap().title, "renamed-tab"); + } + + #[test] + fn tab_switch_clears_selection() { + let mut app = mock_app(); + app.run_command(&Command::NewTab, 40, 10); + assert_eq!(app.manager.tabs.len(), 2); + app.session.selection.begin( + crate::mouse::SelectionKind::Simple, + crate::mouse::SelPoint { line: 0, col: 0 }, + ); + app.run_command(&Command::PrevTab, 40, 10); + assert!( + !app.session.selection.is_active(), + "selection must reset on tab switch" + ); + } + + #[test] + fn copy_without_selection_notifies() { + let mut app = mock_app(); + app.run_command(&Command::CopySelection, 40, 10); + assert_eq!(app.active_notice(), Some("No selection")); + } } diff --git a/src/cli.rs b/src/cli.rs index b1a4a55..6cec7af 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -50,9 +50,18 @@ pub enum ConfigFormat { pub struct Cli { /// Program (and args) to run inside the first tab instead of $SHELL. /// - /// Mirrors the classic `mrxvt -e` flag. Everything after `--` is passed - /// to the child program verbatim. - #[arg(short = 'e', long = "exec", num_args = 1.., value_name = "COMMAND [ARGS]...")] + /// Mirrors the classic `mrxvt -e` / `xterm -e` flag: it takes the rest + /// of the command line verbatim, so put it last — + /// `rs-mrxvt -e vim -c 'startinsert' file` works, but anything after + /// `-e` belongs to the child program. + #[arg( + short = 'e', + long = "exec", + num_args = 1.., + trailing_var_arg = true, + allow_hyphen_values = true, + value_name = "COMMAND [ARGS]..." + )] pub exec: Option>, /// Window title (also used as the initial tab title). @@ -112,3 +121,52 @@ pub struct Cli { #[arg(long = "gpu-info")] pub gpu_info: bool, } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn exec_takes_trailing_args_verbatim() { + // xterm-style -e: everything after it is the child command, even + // tokens that look like flags. Regression: clap used to stop at + // "-c", silently running a bare interactive shell. + let cli = Cli::try_parse_from([ + "rs-mrxvt", "-e", "sh", "-c", "echo SMOKE-TEST-OK; sleep 0.5", + ]) + .unwrap(); + assert_eq!( + cli.exec.unwrap(), + vec![ + "sh".to_string(), + "-c".to_string(), + "echo SMOKE-TEST-OK; sleep 0.5".to_string() + ] + ); + } + + #[test] + fn exec_long_form_also_greedy() { + let cli = Cli::try_parse_from([ + "rs-mrxvt", "--exec", "vim", "-c", "set nu?", "/etc/motd", + ]) + .unwrap(); + assert_eq!(cli.exec.unwrap().len(), 4); + } + + #[test] + fn exec_optional() { + let cli = Cli::try_parse_from(["rs-mrxvt"]).unwrap(); + assert!(cli.exec.is_none()); + } + + #[test] + fn options_before_exec_still_parse() { + let cli = Cli::try_parse_from(["rs-mrxvt", "-n", "3", "-j", "-e", "htop"]) + .unwrap(); + assert_eq!(cli.tabs, 3); + assert!(cli.broadcast); + assert_eq!(cli.exec.unwrap(), vec!["htop".to_string()]); + } +} diff --git a/src/clipboard.rs b/src/clipboard.rs new file mode 100644 index 0000000..2d0fb7f --- /dev/null +++ b/src/clipboard.rs @@ -0,0 +1,571 @@ +// SPDX-License-Identifier: GPL-2.0-only +// +// rs-mrxvt — a modernized, distro-agnostic mrxvt-inspired terminal emulator. +// +// Copyright (C) 2024 rs-mrxvt contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see . + + +//! Clipboard integration: copy, paste, OSC 52. +//! +//! rs-mrxvt keeps its "no mandatory system dependencies" promise here too: +//! instead of linking a clipboard library, it shells out to the standard +//! clipboard tools that are already installed on virtually every desktop: +//! +//! | Environment | Copy | Paste | +//! |-------------|-------------------------|---------------------------| +//! | X11 | `xclip` / `xsel` | `xclip` / `xsel` | +//! | Wayland | `wl-copy` | `wl-paste` | +//! | macOS | `pbcopy` | `pbpaste` | +//! | Android | `termux-clipboard-set` | `termux-clipboard-get` | +//! +//! ## OSC 52 +//! +//! On copy, rs-mrxvt *also* emits an [OSC 52] escape sequence to its host +//! terminal. This is the same mechanism terminal-aware programs (tmux, ssh +//! through intermediate hosts, neovim) use, and it means copy works even +//! when rs-mrxvt runs over SSH with no local clipboard tool: the *client's* +//! terminal performs the clipboard write. +//! +//! The payload is base64-encoded and capped at [`OSC52_MAX_BYTES`] raw +//! bytes (the de-facto limit accepted by xterm-class terminals); larger +//! copies still go to the system clipboard via the external tool, only the +//! OSC 52 mirror is skipped. +//! +//! [OSC 52]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands + +use std::collections::HashMap; +use std::io::Write; +use std::process::{Command as StdCommand, Stdio}; +use std::sync::Mutex; + +/// Which clipboard to address. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ClipboardKind { + /// The system clipboard (Ctrl+C/V clipboard). + Clipboard, + /// The X11 "primary" selection (middle-click paste). + Primary, +} + +impl From for ClipboardKind { + fn from(ty: alacritty_terminal::term::ClipboardType) -> Self { + match ty { + alacritty_terminal::term::ClipboardType::Clipboard => ClipboardKind::Clipboard, + alacritty_terminal::term::ClipboardType::Selection => ClipboardKind::Primary, + } + } +} + +/// Raw payload cap for the OSC 52 mirror (xterm-class terminals accept +/// roughly 100 KiB of base64; we cap the *raw* bytes at 75 KiB to stay +/// comfortably inside that). +pub const OSC52_MAX_BYTES: usize = 75 * 1024; + +/// A clipboard backend. +/// +/// Production code uses [`SystemClipboard`]; tests use [`MockClipboard`]. +pub trait ClipboardBackend: Send { + /// Store `text` into the given clipboard. + fn store(&mut self, kind: ClipboardKind, text: &str) -> anyhow::Result<()>; + + /// Load the current contents of the given clipboard. + fn load(&mut self, kind: ClipboardKind) -> anyhow::Result; + + /// Human-readable name for status messages / logging. + fn describe(&self) -> &'static str { + "clipboard" + } + + /// Downcast hook so tests can inspect a [`MockClipboard`] behind the + /// trait object. Production backends return `None`. + fn as_any(&mut self) -> Option<&mut dyn std::any::Any> { + None + } +} + +// ─── external tool discovery ───────────────────────────────────────────────── + +/// A clipboard tool pair: (copy argv, paste argv), with `{primary}` support. +#[derive(Clone)] +struct ToolSpec { + name: &'static str, + copy: Vec, + paste: Vec, + /// Extra argv fragment inserted for the primary selection. + primary_flag: Vec, +} + +fn tool_specs() -> Vec { + fn s(args: &[&str]) -> Vec { + args.iter().map(|a| a.to_string()).collect() + } + vec![ + ToolSpec { + name: "xclip", + copy: s(&["xclip", "-in", "-selection", "{sel}"]), + paste: s(&["xclip", "-out", "-selection", "{sel}"]), + primary_flag: s(&["primary"]), + }, + ToolSpec { + name: "xsel", + copy: s(&["xsel", "--input", "{sel}"]), + paste: s(&["xsel", "--output", "{sel}"]), + primary_flag: s(&["primary"]), + }, + ToolSpec { + name: "wl-clipboard", + copy: s(&["wl-copy", "{primary}"]), + paste: s(&["wl-paste", "{primary}", "--no-newline"]), + primary_flag: s(&["--primary"]), + }, + ToolSpec { + name: "pbcopy/pbpaste", + copy: s(&["pbcopy"]), + paste: s(&["pbpaste"]), + primary_flag: s(&[]), + }, + ToolSpec { + name: "termux-clipboard", + copy: s(&["termux-clipboard-set"]), + paste: s(&["termux-clipboard-get"]), + primary_flag: s(&[]), + }, + ] +} + +fn which(program: &str) -> bool { + let Ok(path) = std::env::var("PATH") else { return false }; + path.split(':').any(|dir| { + let p = std::path::Path::new(dir).join(program); + p.is_file() + }) +} + +/// The tool families available on this machine, in priority order. +/// Detected once, then cached. +fn available_tools() -> Vec { + static CACHE: Mutex>> = Mutex::new(None); + let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(tools) = cache.clone() { + return tools; + } + let found: Vec = tool_specs() + .into_iter() + .filter(|t| { + let prog = t + .copy + .first() + .map(|c| c.split(' ').next().unwrap_or("").to_string()) + .unwrap_or_default(); + which(&prog) + }) + .collect(); + *cache = Some(found.clone()); + found +} + +fn expand_argv(argv: &[String], kind: ClipboardKind, primary_flag: &[String]) -> Vec { + let sel_word = match kind { + ClipboardKind::Clipboard => "clipboard", + ClipboardKind::Primary => "primary", + }; + let mut out = Vec::with_capacity(argv.len() + 1); + for a in argv { + if a == "{sel}" { + out.push(sel_word.to_string()); + } else if a == "{primary}" { + if kind == ClipboardKind::Primary { + out.extend(primary_flag.iter().cloned()); + } + } else { + out.push(a.clone()); + } + } + out.retain(|a| !a.is_empty()); + out +} + +// ─── system clipboard ──────────────────────────────────────────────────────── + +/// The production clipboard backend. +/// +/// Writes go to the first available external tool *and* (optionally) to the +/// host terminal via OSC 52. Reads come from the external tool; there is no +/// reliable synchronous way to read a host terminal's clipboard, so over +/// bare SSH without a tool, paste is simply unavailable and reports an +/// error the caller can surface. +pub struct SystemClipboard { + /// Explicit tool override from config (`clipboard.tool = "xclip"`). + tool: Option, + /// Mirror copies to the host terminal via OSC 52. + osc52: bool, + /// Suppresses OSC 52 in tests / when stdout is not a terminal. + osc52_enabled: bool, +} + +impl SystemClipboard { + pub fn new(tool: Option, osc52: bool) -> Self { + Self { + tool, + osc52, + osc52_enabled: true, + } + } + + /// Build from the TOML `[clipboard]` section. + pub fn from_config(cfg: &crate::config::ClipboardConfig) -> Self { + let tool = cfg.tool.as_ref().filter(|t| !t.trim().is_empty()).cloned(); + Self::new(tool, cfg.osc52) + } + + /// Disable OSC 52 emission entirely (used by tests). + pub fn disable_osc52(&mut self) { + self.osc52_enabled = false; + } + + fn tool_copy_argv(&self, kind: ClipboardKind) -> Option> { + if let Some(name) = &self.tool { + // User-forced tool: find its spec by name prefix. + let spec = tool_specs().into_iter().find(|t| { + t.copy + .first() + .map(|c| c.starts_with(name.as_str())) + .unwrap_or(false) + || t.name.starts_with(name.as_str()) + })?; + return Some(expand_argv(&spec.copy, kind, &spec.primary_flag)); + } + available_tools() + .first() + .map(|t| expand_argv(&t.copy, kind, &t.primary_flag)) + } + + fn tool_paste_argv(&self, kind: ClipboardKind) -> Option> { + if let Some(name) = &self.tool { + let spec = tool_specs().into_iter().find(|t| { + t.copy + .first() + .map(|c| c.starts_with(name.as_str())) + .unwrap_or(false) + || t.name.starts_with(name.as_str()) + })?; + return Some(expand_argv(&spec.paste, kind, &spec.primary_flag)); + } + available_tools() + .first() + .map(|t| expand_argv(&t.paste, kind, &t.primary_flag)) + } + + fn tool_store(&self, kind: ClipboardKind, text: &str) -> anyhow::Result<()> { + let argv = self + .tool_copy_argv(kind) + .ok_or_else(|| anyhow::anyhow!("no clipboard tool found (install xclip, xsel, or wl-clipboard)"))?; + let mut child = StdCommand::new(&argv[0]) + .args(&argv[1..]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| anyhow::anyhow!("failed to spawn {}: {e}", argv[0]))?; + if let Some(stdin) = child.stdin.as_mut() { + stdin.write_all(text.as_bytes())?; + } + // Dropping stdin closes the pipe; the tool then flushes. + let status = child.wait()?; + if !status.success() { + anyhow::bail!("{} exited with {status}", argv[0]); + } + Ok(()) + } + + fn tool_load(&self, kind: ClipboardKind) -> anyhow::Result { + let argv = self + .tool_paste_argv(kind) + .ok_or_else(|| anyhow::anyhow!("no clipboard tool found (install xclip, xsel, or wl-clipboard)"))?; + let out = StdCommand::new(&argv[0]) + .args(&argv[1..]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .map_err(|e| anyhow::anyhow!("failed to spawn {}: {e}", argv[0]))?; + if !out.status.success() { + anyhow::bail!("{} exited with {}", argv[0], out.status); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } + + /// Emit an OSC 52 "set clipboard" sequence to the host terminal. + /// + /// `ESC ] 52 ; ; BEL` where `` is `c` + /// for the clipboard and `p` for the primary selection. Terminals that + /// don't implement OSC 52 ignore unknown OSC sequences, so this is + /// always safe to write. + fn emit_osc52(&self, kind: ClipboardKind, text: &str) { + if !self.osc52 || !self.osc52_enabled { + return; + } + let raw = text.as_bytes(); + if raw.len() > OSC52_MAX_BYTES { + // Too large for OSC 52; the tool path already handled the copy. + log::debug!( + "OSC 52 mirror skipped: {} bytes > cap {OSC52_MAX_BYTES}", + raw.len() + ); + return; + } + let sel = match kind { + ClipboardKind::Clipboard => "c", + ClipboardKind::Primary => "p", + }; + let seq = format!("\x1b]52;{sel};{}\x07", base64_encode(raw)); + // Write outside of ratatui's frame: we're between draws here (event + // handling), so a direct write + flush cannot corrupt the frame. + let mut out = std::io::stdout(); + let _ = out.write_all(seq.as_bytes()); + let _ = out.flush(); + } +} + +impl Default for SystemClipboard { + fn default() -> Self { + Self::new(None, true) + } +} + +impl ClipboardBackend for SystemClipboard { + fn store(&mut self, kind: ClipboardKind, text: &str) -> anyhow::Result<()> { + if text.is_empty() { + return Ok(()); + } + // OSC 52 first (never fails), then the tool (may fail, e.g. no + // display over SSH — that's fine, the host terminal got it). + self.emit_osc52(kind, text); + match self.tool_store(kind, text) { + Ok(()) => Ok(()), + Err(e) => { + log::debug!("clipboard tool store failed: {e}"); + Ok(()) // OSC 52 already delivered the copy. + } + } + } + + fn load(&mut self, kind: ClipboardKind) -> anyhow::Result { + self.tool_load(kind) + } + + fn describe(&self) -> &'static str { + if available_tools().is_empty() { + "osc52-only" + } else { + "system" + } + } +} + +// ─── mock ──────────────────────────────────────────────────────────────────── + +/// In-memory clipboard for tests. +#[derive(Default)] +pub struct MockClipboard { + pub stores: Vec<(ClipboardKind, String)>, + pub loads: Vec, + pub storage: HashMap, + /// When set, `load` returns this error. + pub fail_load: bool, +} + +impl MockClipboard { + pub fn new() -> Self { + Self::default() + } + + /// Seed the mock so `load` returns `text`. + pub fn with_content(kind: ClipboardKind, text: &str) -> Self { + let mut m = Self::new(); + m.storage.insert(kind, text.to_string()); + m + } +} + +impl ClipboardBackend for MockClipboard { + fn store(&mut self, kind: ClipboardKind, text: &str) -> anyhow::Result<()> { + self.stores.push((kind, text.to_string())); + self.storage.insert(kind, text.to_string()); + Ok(()) + } + + fn load(&mut self, kind: ClipboardKind) -> anyhow::Result { + self.loads.push(kind); + if self.fail_load { + anyhow::bail!("mock clipboard read failure"); + } + Ok(self.storage.get(&kind).cloned().unwrap_or_default()) + } + + fn describe(&self) -> &'static str { + "mock" + } + + fn as_any(&mut self) -> Option<&mut dyn std::any::Any> { + Some(self) + } +} + +// ─── base64 ────────────────────────────────────────────────────────────────── + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Minimal standard base64 encoder (with padding). Enough for OSC 52; no +/// need to pull in a crate for ~20 lines. +pub fn base64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = chunk.get(1).copied().unwrap_or(0) as u32; + let b2 = chunk.get(2).copied().unwrap_or(0) as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(B64[(n >> 18) as usize & 63] as char); + out.push(B64[(n >> 12) as usize & 63] as char); + if chunk.len() > 1 { + out.push(B64[(n >> 6) as usize & 63] as char); + } else { + out.push('='); + } + if chunk.len() > 2 { + out.push(B64[n as usize & 63] as char); + } else { + out.push('='); + } + } + out +} + +// ─── paste sanitation ──────────────────────────────────────────────────────── + +/// Sanitize text before pasting it into a PTY. +/// +/// - C0 control characters other than `\t`, `\n` are stripped (they could +/// inject escape sequences or signals into the child). +/// - C1 control characters (0x80–0x9F as raw bytes in UTF-8 can't occur; +/// as decoded chars they can) are stripped for the same reason. +/// - `\r\n` and lone `\r` are normalized to `\n` — shells treat a raw `\r` +/// as a cursor return, which corrupts the command line. +/// - Trailing whitespace is trimmed so a trailing newline doesn't fire the +/// command immediately (matching what users expect from "paste"). +pub fn sanitize_paste(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\r' => { + if chars.peek() == Some(&'\n') { + chars.next(); // swallow the \n of \r\n + } + out.push('\n'); + } + '\t' | '\n' => out.push(c), + c if (c as u32) < 0x20 => { /* strip other C0 */ } + c if ('\u{7f}'..'\u{a0}').contains(&c) => { /* strip DEL + C1 */ } + c => out.push(c), + } + } + // Drop ONE trailing newline so paste doesn't auto-submit; keep the rest. + let trimmed = out.trim_end_matches([' ', '\t']); + trimmed.strip_suffix('\n').map(|s| s.to_string()).unwrap_or_else(|| trimmed.to_string()) +} + +/// Wrap text in bracketed-paste markers (`ESC[200~ … ESC[201~`). +pub fn bracket(text: &str) -> String { + format!("\x1b[200~{text}\x1b[201~") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_known_vectors() { + assert_eq!(base64_encode(b""), ""); + assert_eq!(base64_encode(b"f"), "Zg=="); + assert_eq!(base64_encode(b"fo"), "Zm8="); + assert_eq!(base64_encode(b"foo"), "Zm9v"); + assert_eq!(base64_encode(b"foob"), "Zm9vYg=="); + assert_eq!(base64_encode(b"fooba"), "Zm9vYmE="); + assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); + // RFC 4648 test vectors. + assert_eq!(base64_encode(b"hello world"), "aGVsbG8gd29ybGQ="); + } + + #[test] + fn mock_store_and_load() { + let mut m = MockClipboard::new(); + m.store(ClipboardKind::Clipboard, "abc").unwrap(); + assert_eq!(m.load(ClipboardKind::Clipboard).unwrap(), "abc"); + assert_eq!(m.stores.len(), 1); + // Primary is a different clipboard. + assert_eq!(m.load(ClipboardKind::Primary).unwrap(), ""); + } + + #[test] + fn sanitize_strips_control_chars() { + // ESC from a pasted arrow-key sequence is stripped; the rest kept. + assert_eq!(sanitize_paste("ls\x1b[Afoo"), "ls[Afoo"); + assert_eq!(sanitize_paste("echo\x07 hi"), "echo hi"); + assert_eq!(sanitize_paste("a\x00b"), "ab"); + // C1 controls are stripped too. + assert_eq!(sanitize_paste("a\u{9c}b"), "ab"); + } + + #[test] + fn sanitize_normalizes_newlines() { + assert_eq!(sanitize_paste("one\r\ntwo"), "one\ntwo"); + assert_eq!(sanitize_paste("one\rtwo"), "one\ntwo"); + } + + #[test] + fn sanitize_strips_single_trailing_newline_only() { + assert_eq!(sanitize_paste("cmd\n"), "cmd"); + assert_eq!(sanitize_paste("cmd\n\n"), "cmd\n"); + assert_eq!(sanitize_paste("cmd"), "cmd"); + } + + #[test] + fn sanitize_keeps_tabs_and_interior_newlines() { + assert_eq!(sanitize_paste("a\tb\nc"), "a\tb\nc"); + } + + #[test] + fn bracket_wraps() { + assert_eq!(bracket("x"), "\x1b[200~x\x1b[201~"); + } + + #[test] + fn argv_expansion_for_primary() { + let spec = &tool_specs()[0]; // xclip + let cb = expand_argv(&spec.copy, ClipboardKind::Clipboard, &spec.primary_flag); + assert_eq!(cb, vec!["xclip", "-in", "-selection", "clipboard"]); + let pr = expand_argv(&spec.copy, ClipboardKind::Primary, &spec.primary_flag); + assert_eq!(pr, vec!["xclip", "-in", "-selection", "primary"]); + } + + #[test] + fn osc52_sequence_format() { + // "hi" → base64 "aGk=" + let text = "hi"; + let seq = format!("\x1b]52;c;{}\x07", base64_encode(text.as_bytes())); + assert_eq!(seq, "\x1b]52;c;aGk=\x07"); + } +} diff --git a/src/command.rs b/src/command.rs index b9eb640..b6cd0b3 100644 --- a/src/command.rs +++ b/src/command.rs @@ -51,6 +51,24 @@ pub enum Command { ToggleBroadcastGroup(String), /// Tag the active tab with a group name. TagActiveTab(String), + /// Copy the active selection to the system clipboard. + CopySelection, + /// Paste the system clipboard into the terminal. + PasteClipboard, + /// Paste the X11 primary (middle-click) selection into the terminal. + PastePrimary, + /// Scroll the viewport one line up (into scrollback). + ScrollLineUp, + /// Scroll the viewport one line down (toward the live bottom). + ScrollLineDown, + /// Scroll the viewport one page up. + ScrollPageUp, + /// Scroll the viewport one page down. + ScrollPageDown, + /// Jump the viewport to the top of the scrollback. + ScrollToTop, + /// Jump the viewport back to the live bottom. + ScrollToBottom, /// Open the command palette. OpenPalette, /// Reset the active terminal (clear + send RIS). @@ -80,6 +98,15 @@ impl Command { CommandEntry { command: Command::GotoTab(3), name: "Go to Tab 4", category: "Tabs" }, CommandEntry { command: Command::GotoTab(4), name: "Go to Tab 5", category: "Tabs" }, CommandEntry { command: Command::ToggleBroadcastAll, name: "Toggle Broadcast (All)", category: "Broadcast" }, + CommandEntry { command: Command::CopySelection, name: "Copy Selection", category: "Clipboard" }, + CommandEntry { command: Command::PasteClipboard, name: "Paste from Clipboard", category: "Clipboard" }, + CommandEntry { command: Command::PastePrimary, name: "Paste Primary Selection", category: "Clipboard" }, + CommandEntry { command: Command::ScrollLineUp, name: "Scroll Line Up", category: "Scrollback" }, + CommandEntry { command: Command::ScrollLineDown, name: "Scroll Line Down", category: "Scrollback" }, + CommandEntry { command: Command::ScrollPageUp, name: "Scroll Page Up", category: "Scrollback" }, + CommandEntry { command: Command::ScrollPageDown, name: "Scroll Page Down", category: "Scrollback" }, + CommandEntry { command: Command::ScrollToTop, name: "Scroll to Top", category: "Scrollback" }, + CommandEntry { command: Command::ScrollToBottom, name: "Scroll to Bottom", category: "Scrollback" }, CommandEntry { command: Command::ResetTerminal, name: "Reset Terminal", category: "Terminal" }, CommandEntry { command: Command::OpenPalette, name: "Open Command Palette", category: "UI" }, CommandEntry { command: Command::Quit, name: "Quit", category: "App" }, @@ -99,6 +126,15 @@ impl fmt::Display for Command { Command::ToggleBroadcastAll => write!(f, "Toggle Broadcast (All)"), Command::ToggleBroadcastGroup(g) => write!(f, "Toggle Broadcast ({g})"), Command::TagActiveTab(g) => write!(f, "Tag Tab → {g}"), + Command::CopySelection => write!(f, "Copy Selection"), + Command::PasteClipboard => write!(f, "Paste from Clipboard"), + Command::PastePrimary => write!(f, "Paste Primary Selection"), + Command::ScrollLineUp => write!(f, "Scroll Line Up"), + Command::ScrollLineDown => write!(f, "Scroll Line Down"), + Command::ScrollPageUp => write!(f, "Scroll Page Up"), + Command::ScrollPageDown => write!(f, "Scroll Page Down"), + Command::ScrollToTop => write!(f, "Scroll to Top"), + Command::ScrollToBottom => write!(f, "Scroll to Bottom"), Command::OpenPalette => write!(f, "Open Command Palette"), Command::ResetTerminal => write!(f, "Reset Terminal"), Command::Quit => write!(f, "Quit"), diff --git a/src/config.rs b/src/config.rs index ae7e08a..9bccb02 100644 --- a/src/config.rs +++ b/src/config.rs @@ -60,6 +60,9 @@ pub struct Config { /// backend (wgpu or softbuffer). Ignored by the TUI backend. #[serde(default)] pub transparency: TransparencyConfig, + /// Clipboard (copy/paste) configuration. + #[serde(default)] + pub clipboard: ClipboardConfig, /// GPU detection and rendering configuration. /// /// Controls how the wgpu backend probes for hardware and what @@ -69,6 +72,41 @@ pub struct Config { pub gpu: GpuConfig, } +/// Clipboard behavior configuration (`[clipboard]` in config.toml). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClipboardConfig { + /// Copy the selection to the clipboard as soon as the mouse button is + /// released (classic xterm/mrxvt behavior). + #[serde(default = "default_true")] + pub copy_on_select: bool, + /// Mirror every copy to the host terminal via OSC 52. This is what + /// makes copy work over SSH — the *client's* terminal performs the + /// clipboard write. + #[serde(default = "default_true")] + pub osc52: bool, + /// Allow programs inside the terminal to *read* the clipboard via + /// OSC 52 queries (DECSET-style paste requests from tmux/nvim). + /// Disabled by default, matching xterm's secure default; copying + /// *out* is always allowed. + #[serde(default)] + pub osc52_read: bool, + /// Explicit clipboard tool to use (`"xclip"`, `"xsel"`, `"wl-copy"`). + /// Empty = auto-detect in priority order. + #[serde(default)] + pub tool: Option, +} + +impl Default for ClipboardConfig { + fn default() -> Self { + Self { + copy_on_select: true, + osc52: true, + osc52_read: false, + tool: None, + } + } +} + /// Transparency config (re-exported here so users don't need to import from /// the ui module when writing config files). #[cfg(feature = "gpu")] @@ -150,6 +188,7 @@ impl Default for Config { default_profile: default_profile(), macros: HashMap::new(), transparency: TransparencyConfig::default(), + clipboard: ClipboardConfig::default(), gpu: GpuConfig::default(), } } @@ -238,7 +277,7 @@ fn default_shell() -> String { } /// A named profile. The `default` profile is consulted when no `--exec` is given. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Profile { /// Command line (split on whitespace; no shell semantics). /// If empty, falls back to `terminal.shell`. @@ -250,9 +289,32 @@ pub struct Profile { /// Optional tag for grouped broadcasting. #[serde(default)] pub tag: Option, - /// Environment overrides (`KEY=value`). + /// Environment overrides (`KEY=value`). Applied after the inherited + /// environment and after the built-in `TERM`/`COLORTERM` defaults, so + /// profiles can override them. #[serde(default)] pub env: HashMap, + /// When `command` is empty (default shell), run it as a **login shell** + /// (argv0 `-bash`). Login shells source `/etc/profile` and + /// `~/.bash_profile`, which is where distros export the GUI session + /// environment (D-Bus address, flatpak/snap paths, XDG data dirs). + /// This is the classic mrxvt default (`loginShell: True`) and it is + /// what makes GUI apps launched from rs-mrxvt find their display + /// environment. Only meaningful for the default shell. + #[serde(default = "default_true")] + pub login_shell: bool, +} + +impl Default for Profile { + fn default() -> Self { + Self { + command: Vec::new(), + cwd: None, + tag: None, + env: HashMap::new(), + login_shell: true, + } + } } /// Abstraction over config sources so we can later swap TOML for Lua without @@ -300,9 +362,8 @@ impl Config { /// file extension (`.lua` → Lua, `.toml` or anything else → TOML). pub fn load(path: Option<&Path>) -> Result { #[cfg(feature = "lua")] - { - return crate::config_lua::load_config_smart(path); - } + return crate::config_lua::load_config_smart(path); + #[cfg(not(feature = "lua"))] { let path = path.map(|p| p.to_path_buf()).unwrap_or_else(Self::default_path); @@ -321,6 +382,7 @@ impl Config { cwd: None, tag: None, env: HashMap::new(), + login_shell: true, }, }) } diff --git a/src/config_lua.rs b/src/config_lua.rs index 6fd3c82..79bfff8 100644 --- a/src/config_lua.rs +++ b/src/config_lua.rs @@ -151,6 +151,17 @@ pub fn parse_lua_config(source: &str) -> Result { }; } + // clipboard = { copy_on_select = bool, osc52 = bool, osc52_read = bool, tool = "..." } + if let Some(Value::Table(t)) = opt_value(&table, "clipboard") { + let d = crate::config::ClipboardConfig::default(); + cfg.clipboard = crate::config::ClipboardConfig { + copy_on_select: opt_bool(&t, "copy_on_select").unwrap_or(d.copy_on_select), + osc52: opt_bool(&t, "osc52").unwrap_or(d.osc52), + osc52_read: opt_bool(&t, "osc52_read").unwrap_or(d.osc52_read), + tool: opt_string(&t, "tool").or(d.tool), + }; + } + // default_profile = "..." if let Some(s) = opt_string(&table, "default_profile") { cfg.default_profile = s; @@ -166,6 +177,7 @@ pub fn parse_lua_config(source: &str) -> Result { let cwd = opt_string(&pt, "cwd").map(PathBuf::from); let tag = opt_string(&pt, "tag"); let env = opt_string_map(&pt, "env").unwrap_or_default(); + let login_shell = opt_bool(&pt, "login_shell").unwrap_or(true); cfg.profiles.insert( k, Profile { @@ -173,6 +185,7 @@ pub fn parse_lua_config(source: &str) -> Result { cwd, tag, env, + login_shell, }, ); } diff --git a/src/config_watch.rs b/src/config_watch.rs index aa9f48a..270446c 100644 --- a/src/config_watch.rs +++ b/src/config_watch.rs @@ -139,9 +139,8 @@ impl Drop for ConfigWatcher { /// Load the config at the given path, picking the parser by extension. fn load_config_at(path: &Path) -> Result { #[cfg(feature = "lua")] - { - return crate::config_lua::load_config_smart(Some(path)); - } + return crate::config_lua::load_config_smart(Some(path)); + #[cfg(not(feature = "lua"))] { let raw = if path.exists() { diff --git a/src/images.rs b/src/images.rs index bfcab54..d582196 100644 --- a/src/images.rs +++ b/src/images.rs @@ -111,8 +111,8 @@ impl InlineImage { .read_image(&mut pixels) .map_err(ImageError::Decode)?; - let cell_width = (w + cell_w_px - 1) / cell_w_px; - let cell_height = (h + cell_h_px - 1) / cell_h_px; + let cell_width = w.div_ceil(cell_w_px); + let cell_height = h.div_ceil(cell_h_px); Ok(Self { pixels, @@ -258,16 +258,8 @@ pub fn parse_iterm2_sequence( let key = &kv[..eq]; let value = &kv[eq + 1..]; match key { - "width" => { - if value != "auto" { - width_cells = value.parse().ok(); - } - } - "height" => { - if value != "auto" { - height_cells = value.parse().ok(); - } - } + "width" if value != "auto" => width_cells = value.parse().ok(), + "height" if value != "auto" => height_cells = value.parse().ok(), _ => {} } } diff --git a/src/input/bindings.rs b/src/input/bindings.rs index 51f58e5..099966e 100644 --- a/src/input/bindings.rs +++ b/src/input/bindings.rs @@ -258,6 +258,19 @@ impl KeyBindingTable { t.bind("Alt+Left", Command::PrevTab); t.bind("Alt+Right", Command::NextTab); + // Clipboard: the modern xterm/ghostty-class chords, plus the + // classic Insert-key aliases. + t.bind("Ctrl+Shift+C", Command::CopySelection); + t.bind("Ctrl+Shift+V", Command::PasteClipboard); + t.bind("Ctrl+Insert", Command::CopySelection); + t.bind("Shift+Insert", Command::PasteClipboard); + + // Scrollback navigation (xterm-style Shift-modified navigation keys). + t.bind("Shift+PageUp", Command::ScrollPageUp); + t.bind("Shift+PageDown", Command::ScrollPageDown); + t.bind("Shift+Home", Command::ScrollToTop); + t.bind("Shift+End", Command::ScrollToBottom); + t } @@ -404,4 +417,44 @@ mod tests { let ev = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT); assert_eq!(t.resolve(ev), None); } + + #[test] + fn copy_paste_chords_bound() { + let t = KeyBindingTable::defaults(); + // crossterm reports Ctrl+Shift+C with an uppercase char. + let ev = KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL | KeyModifiers::SHIFT); + assert_eq!(t.resolve(ev), Some(Command::CopySelection)); + let ev = KeyEvent::new(KeyCode::Char('V'), KeyModifiers::CONTROL | KeyModifiers::SHIFT); + assert_eq!(t.resolve(ev), Some(Command::PasteClipboard)); + } + + #[test] + fn insert_key_clipboard_aliases_bound() { + let t = KeyBindingTable::defaults(); + let ev = KeyEvent::new(KeyCode::Insert, KeyModifiers::CONTROL); + assert_eq!(t.resolve(ev), Some(Command::CopySelection)); + let ev = KeyEvent::new(KeyCode::Insert, KeyModifiers::SHIFT); + assert_eq!(t.resolve(ev), Some(Command::PasteClipboard)); + } + + #[test] + fn shift_page_keys_scroll_scrollback() { + let t = KeyBindingTable::defaults(); + let ev = KeyEvent::new(KeyCode::PageUp, KeyModifiers::SHIFT); + assert_eq!(t.resolve(ev), Some(Command::ScrollPageUp)); + let ev = KeyEvent::new(KeyCode::PageDown, KeyModifiers::SHIFT); + assert_eq!(t.resolve(ev), Some(Command::ScrollPageDown)); + // Unshifted PageUp passes through to the child. + let ev = KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty()); + assert_eq!(t.resolve(ev), None); + } + + #[test] + fn shift_home_end_jump_scrollback() { + let t = KeyBindingTable::defaults(); + let ev = KeyEvent::new(KeyCode::Home, KeyModifiers::SHIFT); + assert_eq!(t.resolve(ev), Some(Command::ScrollToTop)); + let ev = KeyEvent::new(KeyCode::End, KeyModifiers::SHIFT); + assert_eq!(t.resolve(ev), Some(Command::ScrollToBottom)); + } } diff --git a/src/lib.rs b/src/lib.rs index c8fa2e2..c6b953f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,7 @@ //! - [`command`] — The `Command` enum consumed by the command palette and keybindings. pub mod cli; +pub mod clipboard; pub mod command; pub mod config; pub mod config_watch; diff --git a/src/mouse.rs b/src/mouse.rs index c7e0726..00a3101 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -136,6 +136,10 @@ pub enum MouseEventKind { /// flag — actually for SGR we just encode button+mods, motion is a separate /// M vs m signal). pub fn encode_sgr(ev: MouseEvent, mode: MouseMode) -> Option> { + if !mode.sgr { + // SGR encoding was not negotiated; fall back to legacy elsewhere. + return None; + } let code = button_code(ev.button, ev.mods); let suffix = match ev.kind { MouseEventKind::Press => { @@ -184,11 +188,24 @@ pub fn encode_x11(ev: MouseEvent, mode: MouseMode) -> Option> { if !_should { return None; } - let mut code = button_code(ev.button, ev.mods); - if ev.kind == MouseEventKind::Motion { - code |= 32; // motion flag + // Legacy protocol encodes release as button 3 ("no button") and motion + // with bit 5 set, regardless of the actual button. + let mod_bits = { + let mut bits = 0u32; + if ev.mods.shift { bits |= 4; } + if ev.mods.alt { bits |= 8; } + if ev.mods.ctrl { bits |= 16; } + bits + }; + let mut code = match ev.kind { + MouseEventKind::Release => 3 | mod_bits, + MouseEventKind::Motion => 32 | button_code(ev.button, MouseMods::default()) | mod_bits, + MouseEventKind::Press => button_code(ev.button, MouseMods::default()) | mod_bits, + }; + if code > 255 - 32 { + code = 255 - 32; } - let b = (code.min(255 - 32) + 32) as u8; + let b = (code + 32) as u8; // Coords: 1-indexed, clamped to 1..223 (bytes 33..255 after +32). // Clamp to 223 so that 223 + 32 = 255 = u8::MAX (no overflow). let col_clamped = (ev.col.saturating_add(1)).clamp(1, 223); @@ -215,13 +232,41 @@ fn button_code(button: MouseButton, mods: MouseMods) -> u32 { // ─── Selection state machine ───────────────────────────────────────────────── -/// A text selection. Used by renderers for click-drag-to-select. +/// A point in the terminal grid (not the viewport). `line` uses the +/// alacritty grid convention: `Line(0)` is the top visible row when the +/// display offset is 0, and negative values reach into scrollback history. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct SelPoint { + pub line: i32, + pub col: usize, +} + +/// How the selection was made — decides how it renders and copies. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum SelectionKind { + /// Click-drag: character granularity, line-based span. + #[default] + Simple, + /// Double-click: expanded to word boundaries. + Word, + /// Triple-click: whole lines. + Line, +} + +/// A text selection in grid coordinates. +/// +/// `anchor` is where the gesture began; `head` is the current extent. The +/// pair is normalized on demand so the selection is direction-independent. +/// The span is **line-based** (like every mainstream terminal): the first +/// and last lines are partially selected between the two points, and every +/// line between them is fully selected. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Selection { - /// Start point (cell coords). `None` = no active selection. - pub start: Option<(u32, u32)>, - /// End point (cell coords). `None` = single-click. - pub end: Option<(u32, u32)>, + pub kind: SelectionKind, + /// Where the gesture began. `None` = no active selection. + pub anchor: Option, + /// Current extent (`None` while the gesture hasn't moved). + pub head: Option, } impl Selection { @@ -229,47 +274,91 @@ impl Selection { Self::default() } - /// Begin a selection at the given cell. - pub fn begin(&mut self, col: u32, row: u32) { - self.start = Some((col, row)); - self.end = Some((col, row)); + /// Begin a selection of the given kind at a grid point. + pub fn begin(&mut self, kind: SelectionKind, p: SelPoint) { + self.kind = kind; + self.anchor = Some(p); + self.head = Some(p); } - /// Extend the selection to the given cell. - pub fn extend(&mut self, col: u32, row: u32) { - if self.start.is_some() { - self.end = Some((col, row)); + /// Extend the selection to a grid point (mouse drag / motion). + pub fn extend(&mut self, p: SelPoint) { + if self.anchor.is_some() { + self.head = Some(p); } } + /// Replace both endpoints (used for word/line re-selection on drag). + pub fn set_span(&mut self, kind: SelectionKind, a: SelPoint, b: SelPoint) { + self.kind = kind; + self.anchor = Some(a); + self.head = Some(b); + } + /// Clear the selection. pub fn clear(&mut self) { - self.start = None; - self.end = None; + self.anchor = None; + self.head = None; } /// Is there an active selection? pub fn is_active(&self) -> bool { - self.start.is_some() && self.end.is_some() + self.anchor.is_some() && self.head.is_some() } - /// Iterate over the selected cells in row-major order. - pub fn cells(&self) -> Vec<(u32, u32)> { - let Some((sx, sy)) = self.start else { return Vec::new(); }; - let Some((ex, ey)) = self.end else { return Vec::new(); }; - // Normalize: top-left to bottom-right. - let (x1, y1) = (sx.min(ex), sy.min(ey)); - let (x2, y2) = (sx.max(ex), sy.max(ey)); - (y1..=y2).flat_map(|y| (x1..=x2).map(move |x| (x, y))).collect() + /// Anchor/head ordered top-left → bottom-right. + pub fn normalized(&self) -> Option<(SelPoint, SelPoint)> { + let a = self.anchor?; + let b = self.head?; + if (b.line, b.col) < (a.line, a.col) { + Some((b, a)) + } else { + Some((a, b)) + } } /// True if the selection spans more than one cell. pub fn is_multi_cell(&self) -> bool { - if let (Some(s), Some(e)) = (self.start, self.end) { - s != e - } else { - false + self.normalized().map(|(a, b)| a != b).unwrap_or(false) + } + + /// For a given grid line, the selected column range `[start, end)` + /// (half-open), or `None` if the line isn't selected. `cols` is the + /// grid width, used for fully-selected interior lines. + pub fn line_span(&self, line: i32, cols: usize) -> Option<(usize, usize)> { + let (a, b) = self.normalized()?; + if line < a.line || line > b.line { + return None; } + let (start, end) = match self.kind { + SelectionKind::Line => (0, cols), + SelectionKind::Simple | SelectionKind::Word => { + if a.line == b.line { + (a.col, b.col + 1) + } else if line == a.line { + (a.col, cols) + } else if line == b.line { + (0, b.col + 1) + } else { + (0, cols) + } + } + }; + Some((start, end.min(cols))) + } + + /// Iterate over the selected grid lines (inclusive range). + pub fn lines(&self) -> Vec { + match self.normalized() { + Some((a, b)) => (a.line..=b.line).collect(), + None => Vec::new(), + } + } + + /// Does this line fall inside the selection at all? (Cheap check for + /// renderers deciding whether to inspect a row.) + pub fn includes_line(&self, line: i32) -> bool { + self.normalized().map(|(a, b)| line >= a.line && line <= b.line).unwrap_or(false) } } @@ -389,6 +478,21 @@ mod tests { assert!(encode_sgr(ev, mode).is_none()); } + #[test] + fn sgr_skipped_when_sgr_not_negotiated() { + // X11 reporting on but SGR off → SGR encoder must not fire; the + // caller falls back to the legacy encoder. + let ev = MouseEvent { + button: MouseButton::Left, + col: 0, row: 0, + mods: MouseMods::default(), + kind: MouseEventKind::Press, + }; + let mode = MouseMode { x11: true, ..Default::default() }; + assert!(encode_sgr(ev, mode).is_none()); + assert!(encode_x11(ev, mode).is_some()); + } + #[test] fn x11_legacy_encoding() { let ev = MouseEvent { @@ -423,48 +527,94 @@ mod tests { fn selection_begin_and_extend() { let mut s = Selection::new(); assert!(!s.is_active()); - s.begin(0, 0); + s.begin(SelectionKind::Simple, SelPoint { line: 0, col: 0 }); assert!(s.is_active()); - s.extend(5, 2); + s.extend(SelPoint { line: 2, col: 5 }); assert!(s.is_multi_cell()); } #[test] - fn selection_cells_row_major() { + fn selection_line_span_single_row() { let mut s = Selection::new(); - s.begin(0, 0); - s.extend(2, 1); - let cells = s.cells(); - // 3 cols × 2 rows = 6 cells. - assert_eq!(cells.len(), 6); - assert!(cells.contains(&(0, 0))); - assert!(cells.contains(&(2, 1))); + s.begin(SelectionKind::Simple, SelPoint { line: 3, col: 2 }); + s.extend(SelPoint { line: 3, col: 5 }); + assert_eq!(s.line_span(3, 80), Some((2, 6))); + assert_eq!(s.line_span(2, 80), None); + assert_eq!(s.line_span(4, 80), None); } #[test] - fn selection_normalizes_swapped_points() { + fn selection_line_span_multi_row_partial_ends() { let mut s = Selection::new(); - s.begin(5, 5); - s.extend(1, 1); - let cells = s.cells(); - // Should still cover the same rectangle regardless of direction. - assert_eq!(cells.len(), 5 * 5); - assert!(cells.contains(&(1, 1))); - assert!(cells.contains(&(5, 5))); + s.begin(SelectionKind::Simple, SelPoint { line: 1, col: 10 }); + s.extend(SelPoint { line: 3, col: 4 }); + // First row: from col 10 to end of line. + assert_eq!(s.line_span(1, 80), Some((10, 80))); + // Interior row: full width. + assert_eq!(s.line_span(2, 80), Some((0, 80))); + // Last row: start to col 4 inclusive. + assert_eq!(s.line_span(3, 80), Some((0, 5))); + assert_eq!(s.line_span(0, 80), None); + assert_eq!(s.line_span(4, 80), None); + } + + #[test] + fn selection_direction_independent() { + let mut s = Selection::new(); + // Drag bottom-right → top-left; spans must match the forward drag. + s.begin(SelectionKind::Simple, SelPoint { line: 3, col: 4 }); + s.extend(SelPoint { line: 1, col: 10 }); + assert_eq!(s.line_span(1, 80), Some((10, 80))); + assert_eq!(s.line_span(2, 80), Some((0, 80))); + assert_eq!(s.line_span(3, 80), Some((0, 5))); + let (a, b) = s.normalized().unwrap(); + assert_eq!((a.line, a.col), (1, 10)); + assert_eq!((b.line, b.col), (3, 4)); + } + + #[test] + fn line_selection_spans_full_rows() { + let mut s = Selection::new(); + s.begin(SelectionKind::Line, SelPoint { line: 2, col: 30 }); + s.extend(SelPoint { line: 4, col: 1 }); + assert_eq!(s.line_span(2, 80), Some((0, 80))); + assert_eq!(s.line_span(3, 80), Some((0, 80))); + assert_eq!(s.line_span(4, 80), Some((0, 80))); + } + + #[test] + fn word_selection_uses_point_bounds() { + // Word selections store pre-expanded boundaries, so the span logic + // is identical to Simple between those bounds. + let mut s = Selection::new(); + s.set_span(SelectionKind::Word, SelPoint { line: 0, col: 4 }, SelPoint { line: 0, col: 9 }); + assert_eq!(s.line_span(0, 80), Some((4, 10))); + } + + #[test] + fn selection_lines_iterates_inclusive() { + let mut s = Selection::new(); + s.begin(SelectionKind::Simple, SelPoint { line: -5, col: 0 }); + s.extend(SelPoint { line: -3, col: 0 }); + assert_eq!(s.lines(), vec![-5, -4, -3]); + assert!(s.includes_line(-4)); + assert!(!s.includes_line(-2)); } #[test] fn selection_clear() { let mut s = Selection::new(); - s.begin(0, 0); + s.begin(SelectionKind::Simple, SelPoint { line: 0, col: 0 }); s.clear(); assert!(!s.is_active()); + assert!(!s.is_multi_cell()); + assert_eq!(s.line_span(0, 80), None); } #[test] fn selection_single_cell_not_multi() { let mut s = Selection::new(); - s.begin(3, 3); + s.begin(SelectionKind::Simple, SelPoint { line: 3, col: 3 }); assert!(!s.is_multi_cell()); } } diff --git a/src/session.rs b/src/session.rs index 48e1c27..2ed4ec4 100644 --- a/src/session.rs +++ b/src/session.rs @@ -35,18 +35,58 @@ //! The renderers read this state each frame to draw selection highlights, //! underline hyperlinks, and composite inline images. +use std::time::{Duration, Instant}; + use crate::hyperlinks::HyperlinkStore; -use crate::mouse::{MouseMode, Selection}; +use crate::mouse::{MouseMode, Selection, SelectionKind}; #[cfg(feature = "images")] use crate::images::ImageStore; +/// Multi-click window: clicks within this gap count as double/triple clicks. +pub const MULTI_CLICK_WINDOW: Duration = Duration::from_millis(400); + +/// Tracks click cadence to distinguish single / double / triple clicks. +#[derive(Debug, Default)] +pub struct ClickTracker { + last_at: Option, + count: u8, +} + +impl ClickTracker { + /// Register a click; returns the selection kind it implies. + pub fn register(&mut self, now: Instant) -> SelectionKind { + let within = self + .last_at + .map(|t| now.duration_since(t) < MULTI_CLICK_WINDOW) + .unwrap_or(false); + self.count = if within { self.count.saturating_add(1) } else { 1 }; + self.last_at = Some(now); + match self.count % 3 { + 1 => SelectionKind::Simple, + 2 => SelectionKind::Word, + _ => SelectionKind::Line, + } + } + + /// The current click count in the active cadence window. + pub fn count(&self) -> u8 { + self.count + } +} + /// Bundle of session state shared between App, PTY reader, and renderers. pub struct SessionState { /// Current mouse reporting mode (set by the child via DECSET). + /// + /// Note: the authoritative source is the active tab's `TermMode` + /// (see `TerminalTab::mouse_mode`); this copy exists for renderers + /// and headless use. pub mouse_mode: MouseMode, /// Active text selection (when mouse reporting is off). pub selection: Selection, + /// Click-cadence tracker for double/triple-click selection. + pub clicks: ClickTracker, /// OSC 8 hyperlinks seen in the PTY stream. pub hyperlinks: HyperlinkStore, /// Inline images (iTerm2 + Sixel). Only available with `--features images`. @@ -65,6 +105,7 @@ impl SessionState { Self { mouse_mode: MouseMode::default(), selection: Selection::new(), + clicks: ClickTracker::default(), hyperlinks: HyperlinkStore::new(), #[cfg(feature = "images")] images: ImageStore::new(), @@ -99,7 +140,7 @@ mod tests { #[test] fn reset_clears_everything() { let mut s = SessionState::new(); - s.selection.begin(0, 0); + s.selection.begin(crate::mouse::SelectionKind::Simple, crate::mouse::SelPoint { line: 0, col: 0 }); s.hyperlinks.add(crate::hyperlinks::Hyperlink { uri: "x".into(), id: None, @@ -120,4 +161,27 @@ mod tests { s.mouse_mode.x11 = false; assert!(!s.mouse_mode.any_reporting()); } + + #[test] + fn click_tracker_cycles_kinds() { + let mut t = ClickTracker::default(); + let now = Instant::now(); + assert_eq!(t.register(now), crate::mouse::SelectionKind::Simple); + assert_eq!(t.register(now + Duration::from_millis(100)), crate::mouse::SelectionKind::Word); + assert_eq!(t.register(now + Duration::from_millis(200)), crate::mouse::SelectionKind::Line); + // Fourth click restarts the cycle. + assert_eq!(t.register(now + Duration::from_millis(300)), crate::mouse::SelectionKind::Simple); + } + + #[test] + fn click_tracker_resets_after_window() { + let mut t = ClickTracker::default(); + let now = Instant::now(); + assert_eq!(t.register(now), crate::mouse::SelectionKind::Simple); + // Beyond the 400ms window → single click again. + assert_eq!( + t.register(now + Duration::from_millis(500)), + crate::mouse::SelectionKind::Simple + ); + } } diff --git a/src/sixel.rs b/src/sixel.rs index c09fbff..b543f2f 100644 --- a/src/sixel.rs +++ b/src/sixel.rs @@ -70,8 +70,8 @@ pub fn parse_sixel( parser.feed(data)?; let (pixels, w, h) = parser.finalize(); - let cell_width = (w + cell_w_px - 1) / cell_w_px; - let cell_height = (h + cell_h_px - 1) / cell_h_px; + let cell_width = w.div_ceil(cell_w_px); + let cell_height = h.div_ceil(cell_h_px); Ok(InlineImage { pixels, diff --git a/src/terminal/manager.rs b/src/terminal/manager.rs index b344521..307c5ba 100644 --- a/src/terminal/manager.rs +++ b/src/terminal/manager.rs @@ -26,6 +26,9 @@ use anyhow::Result; +use alacritty_terminal::event::Event; +use alacritty_terminal::grid::Scroll; + use crate::command::Command; use crate::config::{Config, Profile}; @@ -189,31 +192,80 @@ impl TerminalManager { } } + /// Set a tab's title (from an OSC 0/2 title event). + pub fn set_title(&mut self, idx: usize, title: String) { + if let Some(t) = self.tabs.get_mut(idx) { + if !title.is_empty() { + t.title = title; + } + } + } + + /// Restore a tab's default title (from a ResetTitle event). + pub fn reset_title(&mut self, idx: usize) { + if let Some(t) = self.tabs.get_mut(idx) { + t.title = t.default_title.clone(); + } + } + + /// Drain VT events from every tab, tagged with the tab index. + /// + /// Call once per event-loop tick after `poll_all`; the caller acts on + /// title changes, OSC 52 clipboard requests, bells, and PTY writes. + pub fn drain_events(&mut self) -> Vec<(usize, Event)> { + let mut out = Vec::new(); + for (i, tab) in self.tabs.iter_mut().enumerate() { + for ev in tab.drain_events() { + out.push((i, ev)); + } + } + out + } + /// Route input bytes to the appropriate tab(s) per the current broadcast mode. + /// + /// Tabs that receive *keyboard* input are also scrolled back to the live + /// bottom (the classic terminal behavior: typing snaps you out of + /// scrollback review). pub fn route_input(&mut self, data: &[u8]) -> std::io::Result<()> { match &self.broadcast { BroadcastTarget::Active => { - if let Some(tab) = self.tabs.get(self.active) { + if let Some(tab) = self.tabs.get_mut(self.active) { tab.write_input(data)?; + tab.scroll_display(Scroll::Bottom); } Ok(()) } BroadcastTarget::All => { - self.tabs.iter() - .find_map(|tab| tab.write_input(data).err()) - .map_or(Ok(()), Err) + for tab in &mut self.tabs { + tab.write_input(data)?; + tab.scroll_display(Scroll::Bottom); + } + Ok(()) } BroadcastTarget::Group(tag) => { - let tagged: Vec<_> = self.tabs.iter() - .filter(|t| t.tag.as_deref() == Some(tag.as_str())) + let tagged: Vec = self + .tabs + .iter() + .enumerate() + .filter(|(_, t)| t.tag.as_deref() == Some(tag.as_str())) + .map(|(i, _)| i) .collect(); - let first_err = tagged.iter().find_map(|tab| tab.write_input(data).err()); - // If no tab matched the tag, fall back to active so the user - // isn't left typing into the void. if tagged.is_empty() { - if let Some(tab) = self.tabs.get(self.active) { + // If no tab matched the tag, fall back to active so the + // user isn't left typing into the void. + if let Some(tab) = self.tabs.get_mut(self.active) { tab.write_input(data)?; + tab.scroll_display(Scroll::Bottom); } + return Ok(()); + } + let mut first_err = None; + for i in tagged { + if let Err(e) = self.tabs[i].write_input(data) { + first_err = first_err.or(Some(e)); + } + self.tabs[i].scroll_display(Scroll::Bottom); } first_err.map_or(Ok(()), Err) } @@ -330,6 +382,47 @@ impl TerminalManager { } Action::Continue } + Command::ScrollLineUp => { + if let Some(t) = self.active_tab_mut() { + t.scroll_display(Scroll::Delta(1)); + } + Action::Continue + } + Command::ScrollLineDown => { + if let Some(t) = self.active_tab_mut() { + t.scroll_display(Scroll::Delta(-1)); + } + Action::Continue + } + Command::ScrollPageUp => { + if let Some(t) = self.active_tab_mut() { + t.scroll_display(Scroll::PageUp); + } + Action::Continue + } + Command::ScrollPageDown => { + if let Some(t) = self.active_tab_mut() { + t.scroll_display(Scroll::PageDown); + } + Action::Continue + } + Command::ScrollToTop => { + if let Some(t) = self.active_tab_mut() { + t.scroll_display(Scroll::Top); + } + Action::Continue + } + Command::ScrollToBottom => { + if let Some(t) = self.active_tab_mut() { + t.scroll_display(Scroll::Bottom); + } + Action::Continue + } + // Clipboard commands are handled at the App layer (they need + // access to the clipboard backend and session selection). + Command::CopySelection | Command::PasteClipboard | Command::PastePrimary => { + Action::Continue + } Command::OpenPalette => Action::Continue, Command::Quit => Action::Quit, } @@ -355,6 +448,7 @@ mod tests { cwd: None, tag: None, env: HashMap::new(), + login_shell: false, } } diff --git a/src/terminal/pty.rs b/src/terminal/pty.rs index e9e589f..60b3866 100644 --- a/src/terminal/pty.rs +++ b/src/terminal/pty.rs @@ -144,38 +144,101 @@ impl PtySession { } fn build_command(profile: &Profile, fallback_shell: &str) -> Result { - let argv: Vec = if profile.command.is_empty() { - vec![fallback_shell.to_string()] + let mut cmd = if profile.command.is_empty() { + // Default shell. A login shell (argv0 `-bash`) sources /etc/profile + // and ~/.bash_profile, where distros export the GUI session + // environment (D-Bus, flatpak/snap paths, XDG dirs). Without it, + // GUI apps launched from rs-mrxvt can fail to find their display + // environment. This matches the classic mrxvt `loginShell: True` + // default. + if profile.login_shell { + CommandBuilder::new_default_prog() + } else { + CommandBuilder::new(fallback_shell) + } } else { - profile.command.clone() + // Explicit command (`-e CMD`, profiles): plain exec, no login shell. + let mut cmd = CommandBuilder::new(&profile.command[0]); + for arg in &profile.command[1..] { + cmd.arg(arg); + } + cmd }; - let prog = argv[0].clone(); - let mut cmd = CommandBuilder::new(&prog); - argv[1..].iter().for_each(|arg| { cmd.arg(arg); }); - // Working directory. if let Some(cwd) = &profile.cwd { cmd.cwd(cwd); } - // Environment overrides. + // We are the terminal emulator, so we own TERM: advertise the + // emulation we actually provide. Set the defaults *before* profile + // env overrides so a profile can still override them. + cmd.env("TERM", "xterm-256color"); + cmd.env("COLORTERM", "truecolor"); + + // Environment overrides (last, so they win — including over TERM). for (k, v) in &profile.env { cmd.env(k, v); } - // Inherit TERM so programs know they're talking to a colour terminal. - cmd.env("TERM", "xterm-256color"); - cmd.env("COLORTERM", "truecolor"); + warn_if_no_graphical_env(&cmd); Ok(cmd) } +/// One-time diagnostic when the child environment has no display vars. +/// +/// The environment is inherited from rs-mrxvt's own process, so if neither +/// `DISPLAY` nor `WAYLAND_DISPLAY` is set, rs-mrxvt itself was started from +/// a non-graphical context (TTY, stripped service, `sudo`), and every GUI +/// app launched from its tabs will refuse to start. Tell the user once, +/// instead of leaving them to wonder why `firefox` "refuses to run". +fn warn_if_no_graphical_env(cmd: &CommandBuilder) { + use std::sync::atomic::{AtomicBool, Ordering}; + static WARNED: AtomicBool = AtomicBool::new(false); + let has_display = cmd + .get_env("DISPLAY") + .map(|v| !v.is_empty()) + .unwrap_or(false); + let has_wayland = cmd + .get_env("WAYLAND_DISPLAY") + .map(|v| !v.is_empty()) + .unwrap_or(false); + if !has_display && !has_wayland && !WARNED.swap(true, Ordering::Relaxed) { + log::warn!( + "neither DISPLAY nor WAYLAND_DISPLAY is set in the child environment; \ + GUI apps launched from rs-mrxvt will not start \ + (start rs-mrxvt from a graphical session)" + ); + } +} + #[cfg(test)] mod tests { use super::*; use crate::config::Profile; use std::collections::HashMap; + use std::time::{Duration, Instant}; + + /// Read from the PTY until `needle` appears or the timeout hits. + fn read_until(session: &mut PtySession, needle: &str, timeout: Duration) -> String { + let deadline = Instant::now() + timeout; + let mut acc = String::new(); + let mut buf = [0u8; 512]; + while Instant::now() < deadline { + match session.reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + acc.push_str(&String::from_utf8_lossy(&buf[..n])); + if acc.contains(needle) { + break; + } + } + Err(_) => break, + } + } + acc + } #[test] fn build_command_uses_profile_command() { @@ -184,6 +247,7 @@ mod tests { cwd: None, tag: None, env: HashMap::new(), + login_shell: false, }; let _cmd = build_command(&p, "/bin/sh").unwrap(); } @@ -194,6 +258,96 @@ mod tests { let _cmd = build_command(&p, "/bin/sh").unwrap(); } + #[test] + fn default_shell_runs_as_login_shell() { + // Force sh as $SHELL so the login-shell path is deterministic + // (portable-pty resolves $SHELL from the builder env). The PTY echo + // shows the literal `MARK-$0`; the shell's own output expands it, + // so "MARK--" can only come from a login shell's argv0 ("‑sh"). + let mut env = HashMap::new(); + env.insert("SHELL".to_string(), "/bin/sh".to_string()); + let p = Profile { + command: vec![], + cwd: None, + tag: None, + env, + login_shell: true, + }; + let mut session = PtySession::spawn(&p, "/bin/sh", 40, 10).unwrap(); + session.write_all(b"echo MARK-$0; exit\n").unwrap(); + let out = read_until(&mut session, "MARK--", Duration::from_secs(5)); + assert!( + out.contains("MARK--"), + "login shell should report $0 starting with '-', got: {out:?}" + ); + } + + #[test] + fn login_shell_can_be_disabled() { + let mut env = HashMap::new(); + env.insert("SHELL".to_string(), "/bin/sh".to_string()); + let p = Profile { + command: vec![], + cwd: None, + tag: None, + env, + login_shell: false, + }; + let mut session = PtySession::spawn(&p, "/bin/sh", 40, 10).unwrap(); + session.write_all(b"echo MARK-$0; exit\n").unwrap(); + // Non-login: $0 is the plain path ("/bin/sh"), never dash-prefixed. + let out = read_until(&mut session, "MARK-/", Duration::from_secs(5)); + assert!( + out.contains("MARK-/") && !out.contains("MARK--"), + "non-login shell should have a plain $0, got: {out:?}" + ); + } + + #[test] + fn child_env_matches_parent_for_display_vars() { + // Whatever display vars the parent has must reach the child — + // this is the invariant GUI apps depend on. + let p = Profile { + command: vec![ + "sh".into(), + "-c".into(), + "printf 'D=%s|W=%s' \"${DISPLAY:-none}\" \"${WAYLAND_DISPLAY:-none}\"".into(), + ], + cwd: None, + tag: None, + env: HashMap::new(), + login_shell: false, + }; + let mut session = PtySession::spawn(&p, "/bin/sh", 40, 10).unwrap(); + let out = read_until(&mut session, "|W=", Duration::from_secs(5)); + let want_display = std::env::var("DISPLAY").unwrap_or_else(|_| "none".into()); + let want_wayland = + std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "none".into()); + assert!( + out.contains(&format!("D={want_display}|W={want_wayland}")), + "child display env must match parent, got: {out:?}" + ); + } + + #[test] + fn profile_env_overrides_term() { + let mut env = HashMap::new(); + env.insert("TERM".to_string(), "myterm-9".to_string()); + let p = Profile { + command: vec!["sh".into(), "-c".into(), "printf 'T=%s' \"$TERM\"".into()], + cwd: None, + tag: None, + env, + login_shell: false, + }; + let mut session = PtySession::spawn(&p, "/bin/sh", 40, 10).unwrap(); + let out = read_until(&mut session, "T=", Duration::from_secs(5)); + assert!( + out.contains("T=myterm-9"), + "profile env must override the built-in TERM, got: {out:?}" + ); + } + #[test] fn spawn_echo_and_read_output() { // Spawns `sh -c 'echo hello; exit 0'` and reads "hello\n". @@ -202,6 +356,7 @@ mod tests { cwd: None, tag: None, env: HashMap::new(), + login_shell: false, }; let mut session = PtySession::spawn(&p, "/bin/sh", 40, 10).unwrap(); let mut buf = [0u8; 64]; @@ -227,6 +382,7 @@ mod tests { cwd: None, tag: None, env: HashMap::new(), + login_shell: false, }; let mut session = PtySession::spawn(&p, "/bin/sh", 40, 10).unwrap(); @@ -265,6 +421,7 @@ mod tests { cwd: None, tag: None, env: HashMap::new(), + login_shell: false, }; let mut session = PtySession::spawn(&p, "/bin/sh", 40, 10).unwrap(); diff --git a/src/terminal/tab.rs b/src/terminal/tab.rs index bbe0e27..2e32fd4 100644 --- a/src/terminal/tab.rs +++ b/src/terminal/tab.rs @@ -31,25 +31,46 @@ //! [`TerminalTab::poll_pty`]. This avoids one stalled tab blocking the //! whole UI — a real-world failure mode of the original mrxvt. +use std::collections::VecDeque; use std::sync::mpsc::{self, Receiver, TryRecvError}; use std::thread; use alacritty_terminal::event::{Event, EventListener}; -use alacritty_terminal::grid::Dimensions; -use alacritty_terminal::term::{Config as TermConfig, Term}; +use alacritty_terminal::grid::{Dimensions, Scroll}; +use alacritty_terminal::index::{Column, Line as ALine, Point as APoint}; +use alacritty_terminal::term::cell::{Cell, Flags as CellFlags}; +use alacritty_terminal::term::{Config as TermConfig, Term, TermMode}; use alacritty_terminal::vte::ansi::Processor; use crate::config::Profile; +use crate::mouse::{MouseMode, SelPoint, Selection}; use super::pty::PtySession; -/// Event listener that absorbs all VT events. We drive redraws ourselves, -/// so we don't need to react to bell/OSC etc. in the MVP. -#[derive(Clone, Default)] -pub struct NoopListener; +/// Default semantic escape chars for word (double-click) selection, +/// mirroring alacritty's default. +pub const SEMANTIC_ESCAPE_CHARS: &str = ",│`|:\"' ()[]{}<>\t"; -impl EventListener for NoopListener { - fn send_event(&self, _event: Event) {} +/// Event listener that forwards VT events (title changes, OSC 52 clipboard +/// requests, bell, …) into a channel the main loop drains after every PTY +/// poll. This is what makes programs like tmux and nvim able to set the +/// tab title or the system clipboard. +#[derive(Clone, Default)] +pub struct EventForwarder(Option>); + +impl EventForwarder { + fn channel() -> (Self, Receiver) { + let (tx, rx) = mpsc::channel(); + (Self(Some(tx)), rx) + } +} + +impl EventListener for EventForwarder { + fn send_event(&self, event: Event) { + if let Some(tx) = &self.0 { + let _ = tx.send(event); + } + } } /// A simple Dimensions impl used to size the Term on creation/resize. @@ -87,12 +108,19 @@ impl Dimensions for TermSize { pub struct TerminalTab { pub id: u32, pub title: String, + /// Title to restore when the child sends `ResetTitle` (OSC 0/2 without + /// an argument, or RIS). + pub default_title: String, pub tag: Option, pub pty: PtySession, - pub term: Term, + pub term: Term, parser: Processor, /// Channel fed by the reader thread. rx: Receiver>, + /// VT events forwarded by the [`EventForwarder`] during parsing. + events: Receiver, + /// Events drained from `events`, awaiting pickup by the main loop. + pending_events: VecDeque, /// Reader thread handle (kept so we can join on drop if needed). _reader_thread: thread::JoinHandle<()>, /// Tracks whether the child has exited. @@ -116,9 +144,11 @@ impl TerminalTab { let term_cfg = TermConfig { scrolling_history: scrollback, ..TermConfig::default() }; let size = TermSize::new(cols, rows); - let term = Term::new(term_cfg, &size, NoopListener); + let (listener, events) = EventForwarder::channel(); + let term = Term::new(term_cfg, &size, listener); let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let default_title = title.clone(); // Spawn a reader thread that does blocking reads and forwards bytes // through a channel. The main loop drains the channel without blocking. @@ -156,11 +186,14 @@ impl TerminalTab { Ok(Self { id, title, + default_title, tag: profile.tag.clone(), pty, term, parser: Processor::new(), rx, + events, + pending_events: VecDeque::new(), _reader_thread: reader_thread, eof_seen: false, }) @@ -170,6 +203,10 @@ impl TerminalTab { /// /// Non-blocking: returns immediately with `Ok(0)` if no data is available. /// Returns `Ok(0)` with `eof_seen = true` after the child process exits. + /// + /// VT events emitted while parsing (title changes, OSC 52 clipboard + /// requests, …) are collected and can be picked up with + /// [`TerminalTab::drain_events`]. pub fn poll_pty(&mut self) -> anyhow::Result { let mut total = 0; loop { @@ -178,9 +215,11 @@ impl TerminalTab { if bytes.is_empty() { // EOF sentinel from the reader thread. self.eof_seen = true; + self.drain_event_channel(); return Ok(total); } self.parser.advance(&mut self.term, &bytes); + self.drain_event_channel(); total += bytes.len(); } Err(TryRecvError::Empty) => return Ok(total), @@ -192,6 +231,19 @@ impl TerminalTab { } } + /// Move events from the channel into the pending queue (non-blocking). + fn drain_event_channel(&mut self) { + while let Ok(ev) = self.events.try_recv() { + self.pending_events.push_back(ev); + } + } + + /// Take all VT events emitted since the last call. + pub fn drain_events(&mut self) -> Vec { + self.drain_event_channel(); + self.pending_events.drain(..).collect() + } + /// Send input to the child process. pub fn write_input(&self, data: &[u8]) -> std::io::Result<()> { self.pty.write_all(data) @@ -215,12 +267,189 @@ impl TerminalTab { pub fn is_dead(&self) -> bool { self.eof_seen } + + // ─── mode bridges ──────────────────────────────────────────────────────── + + /// The child's negotiated mouse-reporting mode, as a [`MouseMode`]. + /// + /// Bridges alacritty's `TermMode` bits: + /// - `MOUSE_REPORT_CLICK` (DECSET 1000) → `x11` + /// - `MOUSE_DRAG` (1002) → `x11` + /// - `MOUSE_MOTION` (1003) → `motion` + /// - `SGR_MOUSE` (1006) → `sgr` + pub fn mouse_mode(&self) -> MouseMode { + let mode = self.term.mode(); + MouseMode { + x10: false, + x11: mode.contains(TermMode::MOUSE_REPORT_CLICK) + || mode.contains(TermMode::MOUSE_DRAG), + motion: mode.contains(TermMode::MOUSE_MOTION), + sgr: mode.contains(TermMode::SGR_MOUSE), + } + } + + /// Has the child enabled bracketed paste (DECSET 2004)? + pub fn bracketed_paste(&self) -> bool { + self.term.mode().contains(TermMode::BRACKETED_PASTE) + } + + /// Is the alternate screen active (e.g. vim, less, htop)? + pub fn alternate_screen(&self) -> bool { + self.term.mode().contains(TermMode::ALT_SCREEN) + } + + /// Does the child want wheel events translated to arrow keys while on + /// the alternate screen (DECSET 1007, on by default)? + pub fn alternate_scroll(&self) -> bool { + self.term.mode().contains(TermMode::ALTERNATE_SCROLL) + } + + // ─── scrolling ─────────────────────────────────────────────────────────── + + /// Scroll the viewport within the scrollback buffer. + pub fn scroll_display(&mut self, scroll: Scroll) { + self.term.scroll_display(scroll); + } + + /// Lines of scrollback the viewport is currently raised by. + pub fn display_offset(&self) -> usize { + self.term.grid().display_offset() + } + + /// True when the viewport is sitting at the live bottom. + pub fn at_bottom(&self) -> bool { + self.display_offset() == 0 + } + + /// Wheel scroll: one notch. On the alternate screen with alternate-scroll + /// enabled this is forwarded as arrow keys instead (so `less`/`vim` + /// scroll); otherwise it moves the scrollback viewport. + pub fn wheel_scroll(&mut self, up: bool) { + if self.alternate_screen() && self.alternate_scroll() { + // 3 arrows per notch, matching the common xterm/mrxvt default. + let seq: &[u8] = if up { b"\x1b[A" } else { b"\x1b[B" }; + let mut bytes = Vec::with_capacity(seq.len() * 3); + for _ in 0..3 { + bytes.extend_from_slice(seq); + } + let _ = self.write_input(&bytes); + } else if up { + self.scroll_display(Scroll::Delta(3)); + } else { + self.scroll_display(Scroll::Delta(-3)); + } + } + + // ─── selection ─────────────────────────────────────────────────────────── + + /// Convert viewport (mouse) coordinates into a grid point. + /// + /// `row` is the visible-area row (0 = top visible row); the returned + /// `line` is in grid coordinates (`0` = top visible line at display + /// offset 0, negative = scrollback history). + pub fn grid_point(&self, row: u32, col: u32) -> SelPoint { + let offset = self.term.grid().display_offset() as i32; + SelPoint { + line: -offset + row as i32, + col: col as usize, + } + } + + /// Extract the selected text from the grid. + /// + /// Line-based semantics matching mainstream terminals: the first and + /// last selected lines are cut at the selection points, interior lines + /// are taken whole. Trailing whitespace is trimmed per line, and lines + /// are joined with `\n`. Returns `None` if the selection is inactive. + pub fn selection_text(&self, sel: &Selection) -> Option { + if !sel.is_active() { + return None; + } + let grid = self.term.grid(); + let cols = grid.columns(); + let mut out = String::new(); + for line in sel.lines() { + let (start, end) = sel.line_span(line, cols)?; + let mut row = String::with_capacity(end - start); + for col in start..end { + let point = APoint { line: ALine(line), column: Column(col) }; + let cell: &Cell = &grid[point]; + // Skip wide-char spacer placeholders; the wide char itself + // is stored in the leading cell. + if cell + .flags + .intersects(CellFlags::WIDE_CHAR_SPACER | CellFlags::LEADING_WIDE_CHAR_SPACER) + { + continue; + } + row.push(if cell.c == '\0' { ' ' } else { cell.c }); + } + out.push_str(row.trim_end()); + out.push('\n'); + } + // Every line got a trailing \n; drop the last one. A selection that + // ends mid-line shouldn't end with a newline; one that ends at the + // far right edge of a line conceptually includes it, which matches + // what users expect when triple-clicking or selecting full lines. + Some(out.strip_suffix('\n').unwrap_or(&out).to_string()) + } + + /// Expand a grid point to word boundaries (double-click selection). + /// + /// Words are delimited by whitespace and [`SEMANTIC_ESCAPE_CHARS`]. + pub fn expand_word(&self, p: SelPoint) -> (SelPoint, SelPoint) { + let grid = self.term.grid(); + let cols = grid.columns(); + let escape: Vec = SEMANTIC_ESCAPE_CHARS.chars().collect(); + + let cell_char = |line: i32, col: usize| -> char { + let point = APoint { line: ALine(line), column: Column(col) }; + let c = grid[point].c; + if c == '\0' { ' ' } else { c } + }; + + // If we landed on a separator, select just the separator cell run. + let c0 = cell_char(p.line, p.col); + let is_sep = |c: char| c.is_whitespace() || escape.contains(&c); + + let mut start = p.col; + let mut end = p.col; + if is_sep(c0) { + while start > 0 && is_sep(cell_char(p.line, start - 1)) { + start -= 1; + } + while end + 1 < cols && is_sep(cell_char(p.line, end + 1)) { + end += 1; + } + } else { + while start > 0 && !is_sep(cell_char(p.line, start - 1)) { + start -= 1; + } + while end + 1 < cols && !is_sep(cell_char(p.line, end + 1)) { + end += 1; + } + } + ( + SelPoint { line: p.line, col: start }, + SelPoint { line: p.line, col: end }, + ) + } + + /// Expand a grid point to full-line bounds (triple-click selection). + pub fn expand_line(&self, p: SelPoint) -> (SelPoint, SelPoint) { + let cols = self.term.grid().columns(); + ( + SelPoint { line: p.line, col: 0 }, + SelPoint { line: p.line, col: cols.saturating_sub(1) }, + ) + } } #[cfg(test)] mod tests { use super::*; use crate::config::Profile; + use crate::mouse::SelectionKind; use std::collections::HashMap; fn make_test_tab(command: &str) -> TerminalTab { @@ -229,6 +458,7 @@ mod tests { cwd: None, tag: None, env: HashMap::new(), + login_shell: false, }; TerminalTab::new("test".into(), &p, "/bin/sh", 40, 10, 1_000).unwrap() } @@ -295,4 +525,200 @@ mod tests { let _ = tab.poll_pty(); assert!(tab.is_dead(), "tab should have observed EOF"); } + + // ─── selection / clipboard / mode tests ───────────────────────────── + + fn wait_for_events(tab: &mut TerminalTab, secs: u64) -> Vec { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs); + let mut seen = Vec::new(); + while std::time::Instant::now() < deadline { + let _ = tab.poll_pty(); + seen.extend(tab.drain_events()); + if !seen.is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + seen + } + + #[test] + fn selection_text_extracts_lines() { + let mut tab = make_test_tab("printf 'alpha beta\\ngamma delta\\nepsilon'"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while std::time::Instant::now() < deadline { + let _ = tab.poll_pty(); + let _ = tab.drain_events(); + // Wait until the grid actually contains the text. + let grid = tab.term.grid(); + let got = (0..grid.screen_lines()).any(|r| { + (0..grid.columns()) + .map(|c| grid[APoint { + line: ALine(-(grid.display_offset() as i32) + r as i32), + column: Column(c), + }].c) + .collect::() + .contains("epsilon") + }); + if got { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + + // Select "beta" on row 0 through "delta" on row 1 (cols 6..=15). + let mut sel = Selection::new(); + sel.begin( + SelectionKind::Simple, + SelPoint { line: 0, col: 6 }, + ); + sel.extend(SelPoint { line: 1, col: 10 }); + let text = tab.selection_text(&sel).unwrap(); + assert_eq!(text, "beta\ngamma delta"); + } + + #[test] + fn selection_text_word_and_line_kinds() { + let mut tab = make_test_tab("printf 'foo bar baz'"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while std::time::Instant::now() < deadline { + let _ = tab.poll_pty(); + let _ = tab.drain_events(); + let grid = tab.term.grid(); + let got = (0..grid.columns()) + .map(|c| grid[APoint { line: ALine(0), column: Column(c) }].c) + .collect::() + .contains("baz"); + if got { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + + // Word selection over "bar" (cols 4..6). + let (a, b) = tab.expand_word(SelPoint { line: 0, col: 5 }); + assert_eq!((a.col, b.col), (4, 6)); + let mut sel = Selection::new(); + sel.set_span(SelectionKind::Word, a, b); + assert_eq!(tab.selection_text(&sel).unwrap(), "bar"); + + // Line selection grabs the whole row. + let (a, b) = tab.expand_line(SelPoint { line: 0, col: 2 }); + let mut sel = Selection::new(); + sel.set_span(SelectionKind::Line, a, b); + assert_eq!(tab.selection_text(&sel).unwrap(), "foo bar baz"); + } + + #[test] + fn mouse_mode_bridges_from_decset() { + let mut tab = make_test_tab("printf '\\033[?1000h\\033[?1006h'; sleep 0.3"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while std::time::Instant::now() < deadline { + let _ = tab.poll_pty(); + if tab.mouse_mode().x11 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let m = tab.mouse_mode(); + assert!(m.x11, "DECSET 1000 should enable x11 reporting"); + assert!(m.sgr, "DECSET 1006 should enable SGR"); + assert!(!m.motion); + } + + #[test] + fn bracketed_paste_bridges_from_decset() { + let mut tab = make_test_tab("printf '\\033[?2004h'; sleep 0.3"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while std::time::Instant::now() < deadline { + let _ = tab.poll_pty(); + if tab.bracketed_paste() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(tab.bracketed_paste(), "DECSET 2004 should enable bracketed paste"); + } + + #[test] + fn title_event_forwarded() { + let mut tab = make_test_tab("printf '\\033]0;my-title\\007'; sleep 0.3"); + let events = wait_for_events(&mut tab, 3); + assert!( + events.iter().any(|e| matches!(e, Event::Title(t) if t == "my-title")), + "expected Title event, got {events:?}" + ); + } + + #[test] + fn osc52_store_event_forwarded() { + let mut tab = make_test_tab("printf '\\033]52;c;aGVsbG8=\\007'; sleep 0.3"); + let events = wait_for_events(&mut tab, 3); + assert!( + events + .iter() + .any(|e| matches!(e, Event::ClipboardStore(ty, text) + if matches!(ty, alacritty_terminal::term::ClipboardType::Clipboard) + && text == "hello")), + "expected ClipboardStore event, got {events:?}" + ); + } + + #[test] + fn scroll_display_moves_viewport() { + let mut tab = make_test_tab("seq 1 200; sleep 0.3"); + // Wait for the output to land. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while std::time::Instant::now() < deadline { + let _ = tab.poll_pty(); + if tab.term.grid().history_size() > 100 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(tab.at_bottom()); + tab.scroll_display(Scroll::Delta(10)); + assert_eq!(tab.display_offset(), 10); + tab.scroll_display(Scroll::Top); + assert_eq!(tab.display_offset(), tab.term.grid().history_size()); + tab.scroll_display(Scroll::Bottom); + assert!(tab.at_bottom()); + } + + #[test] + fn wheel_scrolls_scrollback_on_main_screen() { + let mut tab = make_test_tab("seq 1 200; sleep 0.3"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while std::time::Instant::now() < deadline { + let _ = tab.poll_pty(); + if tab.term.grid().history_size() > 100 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + tab.wheel_scroll(true); + assert_eq!(tab.display_offset(), 3); + tab.wheel_scroll(false); + assert_eq!(tab.display_offset(), 0); + } + + #[test] + fn grid_point_maps_viewport_into_scrollback() { + let mut tab = make_test_tab("seq 1 200; sleep 0.3"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while std::time::Instant::now() < deadline { + let _ = tab.poll_pty(); + if tab.term.grid().history_size() > 100 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + tab.scroll_display(Scroll::Delta(20)); + // Top visible row maps to Line(-20); col passthrough. + let p = tab.grid_point(0, 7); + assert_eq!(p.line, -20); + assert_eq!(p.col, 7); + // Row 3 → Line(-17). + assert_eq!(tab.grid_point(3, 0).line, -17); + } } diff --git a/src/ui/tui.rs b/src/ui/tui.rs index 96b7d1b..a44984e 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -43,7 +43,10 @@ use std::io::{self, stdout, Write}; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; -use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event}; +use crossterm::event::{ + self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event, +}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -59,10 +62,11 @@ use ratatui::Terminal as RataTerminal; use alacritty_terminal::grid::Dimensions; use alacritty_terminal::index::{Column, Line as ALine, Point}; use alacritty_terminal::term::cell::{Cell, Flags as CellFlags}; +use alacritty_terminal::term::TermMode; use alacritty_terminal::vte::ansi::{Color as AnsiColor, NamedColor}; use crate::app::App; -use crate::mouse::{MouseButton, MouseEventKind, MouseMods}; +use crate::mouse::{MouseButton, MouseEventKind, MouseMods, Selection}; use crate::terminal::manager::BroadcastTarget; use crate::ui::backend::BackendFactory; use crate::ui::event::{AppEvent, AppMouseEvent}; @@ -117,7 +121,7 @@ impl TuiRenderer { // ---- Terminal area ---- if let Some(tab) = manager.tabs.get(active_idx) { - render_terminal_grid(f, tab, chunks[1]); + render_terminal_grid(f, tab, chunks[1], &app.session.selection); } else { let empty = Paragraph::new("No tabs. Press Ctrl+Shift+T to create one.") .alignment(Alignment::Center); @@ -125,21 +129,47 @@ impl TuiRenderer { } // ---- Status bar ---- - let (status_text, status_style) = match &manager.broadcast { - BroadcastTarget::Active => ( - format!(" rs-mrxvt • tabs={} active={} ", manager.tabs.len(), active_idx + 1), - Style::default().fg(Color::Black).bg(Color::DarkGray), - ), - BroadcastTarget::All => ( - format!(" ● BROADCAST:All • tabs={} ", manager.tabs.len()), - Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::BOLD), - ), - BroadcastTarget::Group(g) => ( - format!(" ● BROADCAST:{} • tabs={} ", g, manager.tabs.len()), - Style::default().fg(Color::White).bg(Color::Magenta).add_modifier(Modifier::BOLD), + let mut status_left = match &manager.broadcast { + BroadcastTarget::Active => format!( + " rs-mrxvt • tabs={} active={} ", + manager.tabs.len(), + active_idx + 1 ), + BroadcastTarget::All => { + format!(" ● BROADCAST:All • tabs={} ", manager.tabs.len()) + } + BroadcastTarget::Group(g) => { + format!(" ● BROADCAST:{} • tabs={} ", g, manager.tabs.len()) + } }; - f.render_widget(Paragraph::new(status_text).style(status_style), chunks[2]); + let mut status_style = match &manager.broadcast { + BroadcastTarget::Active => { + Style::default().fg(Color::Black).bg(Color::DarkGray) + } + BroadcastTarget::All => Style::default() + .fg(Color::White) + .bg(Color::Red) + .add_modifier(Modifier::BOLD), + BroadcastTarget::Group(_) => Style::default() + .fg(Color::White) + .bg(Color::Magenta) + .add_modifier(Modifier::BOLD), + }; + + // Scrollback indicator when the viewport is raised. + if let Some(tab) = manager.tabs.get(active_idx) { + let off = tab.display_offset(); + if off > 0 { + status_left.push_str(&format!("↑{off} ")); + } + } + + // Transient notice (copy/paste feedback, bell, …). + if let Some(notice) = app.active_notice() { + status_style = Style::default().fg(Color::Black).bg(Color::Yellow); + status_left = format!(" {notice} • {status_left}"); + } + f.render_widget(Paragraph::new(status_left).style(status_style), chunks[2]); // ---- Command palette overlay ---- if palette_open { @@ -171,12 +201,16 @@ impl Renderer for TuiRenderer { flush_stdin("after enable_raw_mode"); // Step 3: Send our init escape sequences (alternate screen, mouse - // capture, cursor hide) and flush them to the outer terminal. + // capture, bracketed paste, cursor hide) and flush them to the + // outer terminal. Bracketed paste means the host terminal delivers + // its own paste (Ctrl+Shift+V / menu) as a Paste event instead of + // synthesizing keystrokes. let mut out = stdout(); execute!( out, EnterAlternateScreen, EnableMouseCapture, + EnableBracketedPaste, Hide ) .context("entering alt screen")?; @@ -208,7 +242,13 @@ impl Renderer for TuiRenderer { fn fini(&mut self) -> Result<()> { let mut out = stdout(); - if let Err(e) = execute!(out, LeaveAlternateScreen, DisableMouseCapture, Show) { + if let Err(e) = execute!( + out, + LeaveAlternateScreen, + DisableMouseCapture, + DisableBracketedPaste, + Show + ) { log::error!("failed to restore terminal: {e}"); } if let Err(e) = disable_raw_mode() { @@ -265,6 +305,7 @@ fn render_terminal_grid( f: &mut ratatui::Frame<'_>, tab: &crate::terminal::TerminalTab, area: Rect, + selection: &Selection, ) { let grid = tab.term.grid(); @@ -281,22 +322,59 @@ fn render_terminal_grid( let start_line = -i32::try_from(display_offset).unwrap_or(i32::MAX); let render_rows = rows.min(screen_lines); + // Block cursor position (viewport row), if visible. + let show_cursor = tab.term.mode().contains(TermMode::SHOW_CURSOR); + let cursor_pos: Option<(usize, usize)> = show_cursor + .then(|| { + let c = &grid.cursor.point; + let row = c.line.0 + start_line; // grid Line → screen row + let col = c.column.0; + (row >= 0 && (row as usize) < render_rows && col < cols).then_some((row as usize, col)) + }) + .flatten(); + let mut lines: Vec = Vec::with_capacity(render_rows); for row_idx in 0..render_rows as i32 { let line = ALine(start_line + row_idx); + let row = row_idx as usize; + + // Selected column span for this row, if any. + let sel_span = if selection.is_active() { + selection.line_span(line.0, cols) + } else { + None + }; + let mut spans: Vec = Vec::with_capacity(cols); for col_idx in 0..cols { let point = Point { line, column: Column(col_idx) }; let cell: &Cell = &grid[point]; + let mut style = cell_to_style(cell); + + // Selection highlight: reverse video with a distinct tint. + if let Some((sel_start, sel_end)) = sel_span { + if col_idx >= sel_start && col_idx < sel_end { + style = style + .add_modifier(Modifier::REVERSED) + .bg(Color::Rgb(60, 71, 100)) + .fg(color_to_ratatui(cell.fg)); + } + } + + // Block cursor. + if let Some((crow, ccol)) = cursor_pos { + if crow == row && ccol == col_idx { + style = style.add_modifier(Modifier::REVERSED); + } + } + if cell.c == ' ' || cell.c == '\0' { - let style = cell_to_style(cell); spans.push(Span::styled(" ", style)); continue; } - let style = cell_to_style(cell); spans.push(Span::styled(cell.c.to_string(), style)); } @@ -435,21 +513,27 @@ fn merge_spans(spans: Vec) -> Vec { /// Crossterm reports mouse positions in both pixel and cell coordinates; /// we use the cell coordinates (column, row) which crossterm computes /// from the terminal's reported cell size. +/// +/// Button releases keep their button identity (crossterm's `Up(b)` carries +/// the button) so local selection logic can distinguish left-release from +/// other releases; the mouse encoders ignore it (release is signaled by +/// the event kind). fn crossterm_mouse_to_app(m: crossterm::event::MouseEvent) -> AppMouseEvent { use crossterm::event::{MouseButton as CButton, MouseEventKind as CKind}; - let button = match m.kind { - CKind::Down(b) | CKind::Drag(b) => match b { - CButton::Left => MouseButton::Left, - CButton::Right => MouseButton::Right, - CButton::Middle => MouseButton::Middle, - }, - CKind::Up(_) => MouseButton::None, + let map_button = |b: &CButton| match b { + CButton::Left => MouseButton::Left, + CButton::Right => MouseButton::Right, + CButton::Middle => MouseButton::Middle, + }; + + let button = match &m.kind { + CKind::Down(b) | CKind::Drag(b) => map_button(b), + CKind::Up(b) => map_button(b), CKind::ScrollDown => MouseButton::WheelDown, CKind::ScrollUp => MouseButton::WheelUp, - // Horizontal scroll: treat as no button (rarely used by terminal apps). - CKind::ScrollLeft | CKind::Moved => MouseButton::None, - CKind::ScrollRight => MouseButton::None, + // Horizontal scroll / plain motion: no button. + CKind::ScrollLeft | CKind::ScrollRight | CKind::Moved => MouseButton::None, }; let kind = match m.kind { CKind::Down(_) => MouseEventKind::Press, diff --git a/tests/broadcasting.rs b/tests/broadcasting.rs index 3d011e2..33299a6 100644 --- a/tests/broadcasting.rs +++ b/tests/broadcasting.rs @@ -37,6 +37,7 @@ fn cat_profile(tag: Option) -> Profile { cwd: None, tag, env: HashMap::new(), + login_shell: false, } } diff --git a/tests/integration.rs b/tests/integration.rs index 1c7b255..7b053f8 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -37,6 +37,7 @@ fn sh_profile(cmd: &str) -> Profile { cwd: None, tag: None, env: HashMap::new(), + login_shell: false, } }