rs-mrxvt - feature updates, copy/paste
This commit is contained in:
parent
74dcb9f19b
commit
3fc9fdc8a2
|
|
@ -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 |
|
||||
|-----------------------|------------------------------------|-------------------------|
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
89
README.md
89
README.md
|
|
@ -28,6 +28,29 @@ Three opt-in feature flags extend the terminal in orthogonal directions:
|
|||
- **Multi-tab PTY** — each tab runs an independent shell via `portable-pty`,
|
||||
with VT emulation by `alacritty_terminal`. Tabs are managed by a single
|
||||
`TerminalManager`; one tab's heavy output never blocks the others.
|
||||
- **Copy & paste** — click-drag select (line-based, like xterm/Ghostty),
|
||||
double-click word, triple-click line. `Ctrl+Shift+C`/`Ctrl+Shift+V`,
|
||||
classic `Ctrl+Ins`/`Shift+Ins`, middle-click pastes the primary
|
||||
selection, right-click pastes the clipboard, and copy-on-select follows
|
||||
the classic mrxvt/xterm behavior. Copies are also mirrored to the host
|
||||
terminal via **OSC 52**, so copy works over SSH.
|
||||
- **Scrollback navigation** — mouse wheel, `Shift+PageUp/PageDown`,
|
||||
`Shift+Home/End`, and palette commands. The wheel does the right thing
|
||||
on the alternate screen (sends arrows to `less`/`vim`); typing snaps the
|
||||
viewport back to the live bottom.
|
||||
- **Mouse reporting** — DECSET 9/1000/1002/1003/1006 tracked from the
|
||||
child program; SGR-1006 and legacy X11 encodings both supported.
|
||||
`Shift+click` always bypasses reporting so you can select text in
|
||||
`vim`/`htop`.
|
||||
- **OSC 52 in and out** — programs (tmux, nvim) can set the clipboard;
|
||||
reads are off by default for security (`clipboard.osc52_read = true`
|
||||
to enable).
|
||||
- **Dynamic tab titles** — OSC 0/2 title sequences from the child rename
|
||||
the tab (e.g. `vim` shows the filename, `ssh` shows the host).
|
||||
- **Login shells by default** — default-shell tabs run as login shells
|
||||
(classic mrxvt `loginShell: True`), so `/etc/profile` and its distro
|
||||
GUI-session exports (D-Bus, flatpak/snap paths) reach every app you
|
||||
launch — including GUI apps.
|
||||
- **Input Broadcasting** — the classic mrxvt killer feature. Toggle
|
||||
broadcast-to-all with `Ctrl+Shift+I`, or broadcast only to tagged groups
|
||||
via `--tag` on the CLI or the `ToggleBroadcastGroup` command in the
|
||||
|
|
@ -42,7 +65,7 @@ Three opt-in feature flags extend the terminal in orthogonal directions:
|
|||
- **TOML config** — `~/.config/rs-mrxvt/config.toml` with profiles, macros,
|
||||
keybindings, and transparency settings.
|
||||
- **Per-tab fading** — inactive tabs dim smoothly via a lerp animation.
|
||||
- **Stress-tested** — 89+ unit + integration tests, plus a 50-instance
|
||||
- **Stress-tested** — 200+ unit + integration tests, plus a 50-instance
|
||||
Python stress harness that broadcasts a marker to 50 shells in ~30ms.
|
||||
|
||||
### With `--features lua`
|
||||
|
|
@ -87,15 +110,19 @@ Three opt-in feature flags extend the terminal in orthogonal directions:
|
|||
| Config hot-reload | ✅ shipped | Polling watcher, survives bad configs |
|
||||
| iTerm2 image protocol | ✅ shipped | PNG/JPEG/GIF/WebP/BMP inline |
|
||||
| Sixel image protocol | ✅ shipped | Pure-Rust parser, color registers, repeats |
|
||||
| Mouse + SGR-1006 | ✅ shipped | X10/X11/SGR encoders, selection state machine |
|
||||
| Mouse + SGR-1006 | ✅ shipped | X10/X11/SGR encoders, shift-bypass selection |
|
||||
| OSC 8 hyperlinks | ✅ shipped | Parser + scanner + cell-indexed store |
|
||||
| Alt+N / Alt+Arrows | ✅ shipped | New tab, shuffle forward/back |
|
||||
| Alt+Shift+X close | ✅ shipped | Closes focused tab |
|
||||
| Alt+Z zsh tab | ✅ shipped | Secondary shell on a hotkey |
|
||||
| True-color themes | ✅ shipped | Tokyo Night, Gruvbox, Dracula, Solarized |
|
||||
| Multi-distro sysprep | ✅ shipped | pacman/apt/dnf/zypper/xbps/apk/cast/emerge |
|
||||
| Renderer integration | 🚧 planned | Wire mouse/selection/links/images into UI loop |
|
||||
| Clipboard support | 🚧 planned | Copy selection, paste on right-click |
|
||||
| Clipboard | ✅ shipped | Select/copy/paste, OSC 52 mirror, tools+mock |
|
||||
| Scrollback navigation | ✅ shipped | Wheel, Shift+PgUp/PgDn/Home/End, palette cmds |
|
||||
| Selection modes | ✅ shipped | Drag/word/line, copy-on-select, highlight |
|
||||
| Tab title sync | ✅ shipped | OSC 0/2 from child renames the tab |
|
||||
| Login shells | ✅ shipped | GUI-session env reaches apps (mrxvt default) |
|
||||
| GUI backend polish | 🚧 planned | Selection/cursor highlight in wgpu+soft UIs |
|
||||
|
||||
## 🚀 Quick start
|
||||
|
||||
|
|
@ -147,15 +174,25 @@ sudo PREFIX=/usr make install # installs to /usr
|
|||
|
||||
| Shortcut | Action |
|
||||
|-----------------------|-----------------------------------------------|
|
||||
| `Ctrl+Shift+T` / `Alt+N` | New tab (bash, the default shell) |
|
||||
| `Ctrl+Shift+T` / `Alt+N` | New tab (login shell) |
|
||||
| `Alt+Z` | New tab running zsh (secondary shell) |
|
||||
| `Alt+Shift+X` | Close the currently focused tab |
|
||||
| `Ctrl+Shift+W` | Close tab (classic mrxvt binding) |
|
||||
| `Ctrl+Shift+C` / `Ctrl+Insert` | Copy selection to clipboard |
|
||||
| `Ctrl+Shift+V` / `Shift+Insert` | Paste from clipboard |
|
||||
| `Ctrl+Shift+I` | Toggle broadcast (all tabs) |
|
||||
| `Ctrl+Shift+P` | Open command palette |
|
||||
| `Ctrl+Tab` / `Alt+Right` | Next tab (wraps) |
|
||||
| `Ctrl+Shift+Tab` / `Alt+Left` | Previous tab (wraps) |
|
||||
| `Alt+1` … `Alt+0` | Go to tab N (1..10) |
|
||||
| `Shift+PageUp` / `Shift+PageDown` | Scroll scrollback a page |
|
||||
| `Shift+Home` / `Shift+End` | Jump to top / bottom of scrollback |
|
||||
|
||||
Mouse: drag selects, double-click selects a word, triple-click selects a
|
||||
line, release copies (copy-on-select), middle-click pastes the primary
|
||||
selection, right-click pastes the clipboard, and the wheel scrolls the
|
||||
scrollback (or the alternate-screen program). `Shift+click` bypasses
|
||||
application mouse reporting so you can always select text.
|
||||
|
||||
All bindings are rebindable in `config.toml` or `config.lua`.
|
||||
|
||||
|
|
@ -176,6 +213,29 @@ opacity = 0.85 # 1.0 = opaque, 0.0 = fully transparent
|
|||
# background_image = "/path/to/wallpaper.png"
|
||||
```
|
||||
|
||||
### Clipboard
|
||||
|
||||
```toml
|
||||
[clipboard]
|
||||
copy_on_select = true # selecting text copies it (classic mrxvt/xterm)
|
||||
osc52 = true # mirror copies to the host terminal — works over SSH
|
||||
osc52_read = false # let programs read the clipboard via OSC 52 (security)
|
||||
# tool = "xclip" # force a tool; otherwise auto-detected
|
||||
```
|
||||
|
||||
rs-mrxvt talks to the system clipboard through the standard tools
|
||||
(`xclip`/`xsel` on X11, `wl-clipboard` on Wayland, `pbcopy`/`pbpaste` on
|
||||
macOS, `termux-clipboard-*` on Android) — no clipboard library is linked.
|
||||
Every copy is *additionally* emitted as an OSC 52 escape sequence, so the
|
||||
**host terminal** performs the clipboard write too; over SSH with no
|
||||
local clipboard tool, that mirror is what makes copy work.
|
||||
|
||||
Paste sanitation matches mainstream terminals: control characters that
|
||||
could inject escape sequences are stripped, CRLF is normalized to LF, and
|
||||
a single trailing newline is dropped so a paste doesn't auto-submit your
|
||||
command. When the child enabled bracketed paste (DECSET 2004), pastes are
|
||||
wrapped in `ESC[200~ … ESC[201~`.
|
||||
|
||||
### Time-based theme (Lua)
|
||||
|
||||
```lua
|
||||
|
|
@ -207,6 +267,25 @@ Built-in true-color themes (set `ui.theme` in your config):
|
|||
Custom themes can be defined in TOML — see `examples/config.toml` for the
|
||||
full color list (16 ANSI colors + bg/fg/cursor).
|
||||
|
||||
## 🛠 Troubleshooting
|
||||
|
||||
**GUI apps refuse to start from a tab** (`cannot open display`, D-Bus
|
||||
errors): default-shell tabs run **login shells**, which source
|
||||
`/etc/profile` — that's where distros export the GUI session environment
|
||||
(`DBUS_SESSION_BUS_ADDRESS`, flatpak/snap paths). If tabs still can't
|
||||
launch GUI apps, rs-mrxvt itself was probably started from a
|
||||
non-graphical context (plain TTY, `sudo`, a stripped service): the child
|
||||
environment is inherited, so there is no `DISPLAY`/`WAYLAND_DISPLAY` to
|
||||
inherit. Start rs-mrxvt from your graphical session, or set the variables
|
||||
yourself. rs-mrxvt logs a warning at spawn when this is the case. If your
|
||||
`~/.bash_profile` is noisy, per-profile `login_shell = false` restores
|
||||
plain non-login shells.
|
||||
|
||||
**Copy works but paste doesn't (over SSH):** paste reads the *local*
|
||||
system clipboard, which needs a local tool (`xclip`, `xsel`,
|
||||
`wl-paste`). OSC 52 can only *write* the client's clipboard, not read it
|
||||
synchronously — that's a protocol limit, not a bug.
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
663
src/app.rs
663
src/app.rs
|
|
@ -32,21 +32,35 @@
|
|||
//! [`AppEvent`], a backend-agnostic enum — every renderer translates its
|
||||
//! native events into this type at the boundary.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use alacritty_terminal::event::Event as TermEvent;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::cli::Cli;
|
||||
use crate::clipboard::{bracket, sanitize_paste, ClipboardBackend, ClipboardKind, SystemClipboard};
|
||||
use crate::command::Command;
|
||||
use crate::config::Config;
|
||||
use crate::input::router::{InputAction, InputRouter};
|
||||
use crate::input::bindings::KeyBindingTable;
|
||||
use crate::mouse::{MouseButton, MouseEvent, MouseEventKind};
|
||||
use crate::mouse::{MouseButton, MouseEvent, MouseEventKind, SelPoint};
|
||||
use crate::session::SessionState;
|
||||
use crate::terminal::manager::{Action, BroadcastTarget, TerminalManager};
|
||||
use crate::ui::event::{AppEvent, AppKey, AppKeyEvent};
|
||||
use crate::ui::event::{AppEvent, AppKey, AppKeyEvent, AppMouseEvent};
|
||||
#[cfg(test)]
|
||||
use crate::ui::event::AppModifiers;
|
||||
use crate::ui::palette::PaletteState;
|
||||
|
||||
/// How long a status-bar notice stays visible.
|
||||
pub const NOTICE_TTL: Duration = Duration::from_millis(2500);
|
||||
|
||||
/// A transient status-bar message (e.g. "Copied 42 chars").
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Notice {
|
||||
pub text: String,
|
||||
pub expires: Instant,
|
||||
}
|
||||
|
||||
/// The app. Construct with [`App::new`], then run with a [`Renderer`].
|
||||
pub struct App {
|
||||
pub manager: TerminalManager,
|
||||
|
|
@ -56,10 +70,25 @@ pub struct App {
|
|||
pub config: Config,
|
||||
pub cli: Cli,
|
||||
pub quit: bool,
|
||||
/// Clipboard backend (system tools + OSC 52 in production, mock in tests).
|
||||
pub clipboard: Box<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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
src/cli.rs
64
src/cli.rs
|
|
@ -50,9 +50,18 @@ pub enum ConfigFormat {
|
|||
pub struct Cli {
|
||||
/// Program (and args) to run inside the first tab instead of $SHELL.
|
||||
///
|
||||
/// Mirrors the classic `mrxvt -e` flag. Everything after `--` is passed
|
||||
/// to the child program verbatim.
|
||||
#[arg(short = 'e', long = "exec", num_args = 1.., value_name = "COMMAND [ARGS]...")]
|
||||
/// Mirrors the classic `mrxvt -e` / `xterm -e` flag: it takes the rest
|
||||
/// of the command line verbatim, so put it last —
|
||||
/// `rs-mrxvt -e vim -c 'startinsert' file` works, but anything after
|
||||
/// `-e` belongs to the child program.
|
||||
#[arg(
|
||||
short = 'e',
|
||||
long = "exec",
|
||||
num_args = 1..,
|
||||
trailing_var_arg = true,
|
||||
allow_hyphen_values = true,
|
||||
value_name = "COMMAND [ARGS]..."
|
||||
)]
|
||||
pub exec: Option<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()]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
254
src/mouse.rs
254
src/mouse.rs
|
|
@ -136,6 +136,10 @@ pub enum MouseEventKind {
|
|||
/// flag — actually for SGR we just encode button+mods, motion is a separate
|
||||
/// M vs m signal).
|
||||
pub fn encode_sgr(ev: MouseEvent, mode: MouseMode) -> Option<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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
src/ui/tui.rs
138
src/ui/tui.rs
|
|
@ -43,7 +43,10 @@ use std::io::{self, stdout, Write};
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event};
|
||||
use crossterm::event::{
|
||||
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
||||
Event,
|
||||
};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
|
|
@ -59,10 +62,11 @@ use ratatui::Terminal as RataTerminal;
|
|||
use alacritty_terminal::grid::Dimensions;
|
||||
use alacritty_terminal::index::{Column, Line as ALine, Point};
|
||||
use alacritty_terminal::term::cell::{Cell, Flags as CellFlags};
|
||||
use alacritty_terminal::term::TermMode;
|
||||
use alacritty_terminal::vte::ansi::{Color as AnsiColor, NamedColor};
|
||||
|
||||
use crate::app::App;
|
||||
use crate::mouse::{MouseButton, MouseEventKind, MouseMods};
|
||||
use crate::mouse::{MouseButton, MouseEventKind, MouseMods, Selection};
|
||||
use crate::terminal::manager::BroadcastTarget;
|
||||
use crate::ui::backend::BackendFactory;
|
||||
use crate::ui::event::{AppEvent, AppMouseEvent};
|
||||
|
|
@ -117,7 +121,7 @@ impl TuiRenderer {
|
|||
|
||||
// ---- Terminal area ----
|
||||
if let Some(tab) = manager.tabs.get(active_idx) {
|
||||
render_terminal_grid(f, tab, chunks[1]);
|
||||
render_terminal_grid(f, tab, chunks[1], &app.session.selection);
|
||||
} else {
|
||||
let empty = Paragraph::new("No tabs. Press Ctrl+Shift+T to create one.")
|
||||
.alignment(Alignment::Center);
|
||||
|
|
@ -125,21 +129,47 @@ impl TuiRenderer {
|
|||
}
|
||||
|
||||
// ---- Status bar ----
|
||||
let (status_text, status_style) = match &manager.broadcast {
|
||||
BroadcastTarget::Active => (
|
||||
format!(" rs-mrxvt • tabs={} active={} ", manager.tabs.len(), active_idx + 1),
|
||||
Style::default().fg(Color::Black).bg(Color::DarkGray),
|
||||
),
|
||||
BroadcastTarget::All => (
|
||||
format!(" ● BROADCAST:All • tabs={} ", manager.tabs.len()),
|
||||
Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
BroadcastTarget::Group(g) => (
|
||||
format!(" ● BROADCAST:{} • tabs={} ", g, manager.tabs.len()),
|
||||
Style::default().fg(Color::White).bg(Color::Magenta).add_modifier(Modifier::BOLD),
|
||||
let mut status_left = match &manager.broadcast {
|
||||
BroadcastTarget::Active => format!(
|
||||
" rs-mrxvt • tabs={} active={} ",
|
||||
manager.tabs.len(),
|
||||
active_idx + 1
|
||||
),
|
||||
BroadcastTarget::All => {
|
||||
format!(" ● BROADCAST:All • tabs={} ", manager.tabs.len())
|
||||
}
|
||||
BroadcastTarget::Group(g) => {
|
||||
format!(" ● BROADCAST:{} • tabs={} ", g, manager.tabs.len())
|
||||
}
|
||||
};
|
||||
f.render_widget(Paragraph::new(status_text).style(status_style), chunks[2]);
|
||||
let mut status_style = match &manager.broadcast {
|
||||
BroadcastTarget::Active => {
|
||||
Style::default().fg(Color::Black).bg(Color::DarkGray)
|
||||
}
|
||||
BroadcastTarget::All => Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(Color::Red)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
BroadcastTarget::Group(_) => Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
};
|
||||
|
||||
// Scrollback indicator when the viewport is raised.
|
||||
if let Some(tab) = manager.tabs.get(active_idx) {
|
||||
let off = tab.display_offset();
|
||||
if off > 0 {
|
||||
status_left.push_str(&format!("↑{off} "));
|
||||
}
|
||||
}
|
||||
|
||||
// Transient notice (copy/paste feedback, bell, …).
|
||||
if let Some(notice) = app.active_notice() {
|
||||
status_style = Style::default().fg(Color::Black).bg(Color::Yellow);
|
||||
status_left = format!(" {notice} • {status_left}");
|
||||
}
|
||||
f.render_widget(Paragraph::new(status_left).style(status_style), chunks[2]);
|
||||
|
||||
// ---- Command palette overlay ----
|
||||
if palette_open {
|
||||
|
|
@ -171,12 +201,16 @@ impl Renderer for TuiRenderer {
|
|||
flush_stdin("after enable_raw_mode");
|
||||
|
||||
// Step 3: Send our init escape sequences (alternate screen, mouse
|
||||
// capture, cursor hide) and flush them to the outer terminal.
|
||||
// capture, bracketed paste, cursor hide) and flush them to the
|
||||
// outer terminal. Bracketed paste means the host terminal delivers
|
||||
// its own paste (Ctrl+Shift+V / menu) as a Paste event instead of
|
||||
// synthesizing keystrokes.
|
||||
let mut out = stdout();
|
||||
execute!(
|
||||
out,
|
||||
EnterAlternateScreen,
|
||||
EnableMouseCapture,
|
||||
EnableBracketedPaste,
|
||||
Hide
|
||||
)
|
||||
.context("entering alt screen")?;
|
||||
|
|
@ -208,7 +242,13 @@ impl Renderer for TuiRenderer {
|
|||
|
||||
fn fini(&mut self) -> Result<()> {
|
||||
let mut out = stdout();
|
||||
if let Err(e) = execute!(out, LeaveAlternateScreen, DisableMouseCapture, Show) {
|
||||
if let Err(e) = execute!(
|
||||
out,
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture,
|
||||
DisableBracketedPaste,
|
||||
Show
|
||||
) {
|
||||
log::error!("failed to restore terminal: {e}");
|
||||
}
|
||||
if let Err(e) = disable_raw_mode() {
|
||||
|
|
@ -265,6 +305,7 @@ fn render_terminal_grid(
|
|||
f: &mut ratatui::Frame<'_>,
|
||||
tab: &crate::terminal::TerminalTab,
|
||||
area: Rect,
|
||||
selection: &Selection,
|
||||
) {
|
||||
let grid = tab.term.grid();
|
||||
|
||||
|
|
@ -281,22 +322,59 @@ fn render_terminal_grid(
|
|||
let start_line = -i32::try_from(display_offset).unwrap_or(i32::MAX);
|
||||
let render_rows = rows.min(screen_lines);
|
||||
|
||||
// Block cursor position (viewport row), if visible.
|
||||
let show_cursor = tab.term.mode().contains(TermMode::SHOW_CURSOR);
|
||||
let cursor_pos: Option<(usize, usize)> = show_cursor
|
||||
.then(|| {
|
||||
let c = &grid.cursor.point;
|
||||
let row = c.line.0 + start_line; // grid Line → screen row
|
||||
let col = c.column.0;
|
||||
(row >= 0 && (row as usize) < render_rows && col < cols).then_some((row as usize, col))
|
||||
})
|
||||
.flatten();
|
||||
|
||||
let mut lines: Vec<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,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ fn cat_profile(tag: Option<String>) -> Profile {
|
|||
cwd: None,
|
||||
tag,
|
||||
env: HashMap::new(),
|
||||
login_shell: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ fn sh_profile(cmd: &str) -> Profile {
|
|||
cwd: None,
|
||||
tag: None,
|
||||
env: HashMap::new(),
|
||||
login_shell: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue