rs-mrxvt - feature updates, copy/paste

This commit is contained in:
Jeremy Anderson 2026-09-27 02:35:52 -04:00
parent 74dcb9f19b
commit 3fc9fdc8a2
23 changed files with 7000 additions and 198 deletions

View File

@ -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<NoopListener> + 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<String>;
}
```
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 |
|-----------------------|------------------------------------|-------------------------|

4294
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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"

View File

@ -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<dyn ClipboardBackend>,
/// Transient status-bar message, if any.
pub notice: Option<Notice>,
}
impl App {
pub fn new(cli: Cli, config: Config) -> anyhow::Result<Self> {
let clipboard: Box<dyn ClipboardBackend> =
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<dyn ClipboardBackend>,
) -> anyhow::Result<Self> {
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<String>) {
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,35 +324,191 @@ 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);
// 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);
}
MouseButton::Right => {
// Right-click: paste (placeholder — would call into clipboard).
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);
}
_ => {}
},
MouseEventKind::Motion => {
if self.session.selection.is_active() {
self.session.selection.extend(mouse_ev.col, mouse_ev.row);
}
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"));
}
}
}
}
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();
}
}
// ─── 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.
_ => {}
}
InputAction::Handled
}
/// Handle an [`AppKeyEvent`] (translated from crossterm or winit).
@ -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::<MockClipboard>().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::<MockClipboard>().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"));
}
}

View File

@ -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<Vec<String>>,
/// 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()]);
}
}

571
src/clipboard.rs Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
//! 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<alacritty_terminal::term::ClipboardType> 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<String>;
/// 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<String>,
paste: Vec<String>,
/// Extra argv fragment inserted for the primary selection.
primary_flag: Vec<String>,
}
fn tool_specs() -> Vec<ToolSpec> {
fn s(args: &[&str]) -> Vec<String> {
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<ToolSpec> {
static CACHE: Mutex<Option<Vec<ToolSpec>>> = 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<ToolSpec> = 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<String> {
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<String>,
/// 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<String>, 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<Vec<String>> {
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<Vec<String>> {
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<String> {
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 ; <selection> ; <base64> BEL` where `<selection>` 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<String> {
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<ClipboardKind>,
pub storage: HashMap<ClipboardKind, String>,
/// 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<String> {
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");
}
}

View File

@ -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"),

View File

@ -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<String>,
}
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<String>,
/// 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<String, String>,
/// 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<Self> {
#[cfg(feature = "lua")]
{
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,
},
})
}

View File

@ -151,6 +151,17 @@ pub fn parse_lua_config(source: &str) -> Result<Config> {
};
}
// 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<Config> {
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<Config> {
cwd,
tag,
env,
login_shell,
},
);
}

View File

@ -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<Config> {
#[cfg(feature = "lua")]
{
return crate::config_lua::load_config_smart(Some(path));
}
#[cfg(not(feature = "lua"))]
{
let raw = if path.exists() {

View File

@ -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(),
_ => {}
}
}

View File

@ -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));
}
}

View File

@ -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;

View File

@ -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<Vec<u8>> {
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<Vec<u8>> {
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<SelPoint>,
/// Current extent (`None` while the gesture hasn't moved).
pub head: Option<SelPoint>,
}
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<i32> {
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());
}
}

View File

@ -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<Instant>,
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
);
}
}

View File

@ -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,

View File

@ -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<usize> = 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,
}
}

View File

@ -144,38 +144,101 @@ impl PtySession {
}
fn build_command(profile: &Profile, fallback_shell: &str) -> Result<CommandBuilder> {
let argv: Vec<String> = 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 {
profile.command.clone()
CommandBuilder::new(fallback_shell)
}
} else {
// 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();

View File

@ -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<mpsc::Sender<Event>>);
impl EventForwarder {
fn channel() -> (Self, Receiver<Event>) {
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<String>,
pub pty: PtySession,
pub term: Term<NoopListener>,
pub term: Term<EventForwarder>,
parser: Processor,
/// Channel fed by the reader thread.
rx: Receiver<Vec<u8>>,
/// VT events forwarded by the [`EventForwarder`] during parsing.
events: Receiver<Event>,
/// Events drained from `events`, awaiting pickup by the main loop.
pending_events: VecDeque<Event>,
/// 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<usize> {
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<Event> {
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<String> {
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<char> = 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<Event> {
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::<String>()
.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::<String>()
.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);
}
}

View File

@ -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<Line> = 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<Span> = 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<Span>) -> Vec<Span> {
/// 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 {
let map_button = |b: &CButton| match b {
CButton::Left => MouseButton::Left,
CButton::Right => MouseButton::Right,
CButton::Middle => MouseButton::Middle,
},
CKind::Up(_) => MouseButton::None,
};
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,

View File

@ -37,6 +37,7 @@ fn cat_profile(tag: Option<String>) -> Profile {
cwd: None,
tag,
env: HashMap::new(),
login_shell: false,
}
}

View File

@ -37,6 +37,7 @@ fn sh_profile(cmd: &str) -> Profile {
cwd: None,
tag: None,
env: HashMap::new(),
login_shell: false,
}
}