tui-panel-select 0.1.6

Panel-scoped mouse text selection and clipboard copy for ratatui apps
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! Copy-on-mouse-up for panel-scoped text selection.
//!
//! Primary path: pipe the text into a locally installed clipboard utility
//! (`xclip`/`xsel`/`wl-copy`/`pbcopy`/`clip.exe`, whichever fits the active
//! display server) — this works unconditionally on a local desktop session
//! regardless of whether the terminal emulator itself honours clipboard
//! escape sequences (several common terminals simply don't, which is what
//! this fallback chain exists to work around — GNOME Terminal/VTE, for one,
//! ignores OSC 52 clipboard writes entirely).
//!
//! Fallback: the OSC 52 "set clipboard" escape sequence, written straight to
//! the terminal (bypassing ratatui's own buffer, so it works regardless of
//! the alternate screen / raw mode). This is what actually reaches the
//! clipboard over SSH/remote sessions where no local clipboard tool is
//! reachable, provided the terminal supports OSC 52 — needs no platform
//! clipboard crate, since `base64` is already a dependency for other
//! features.
//!
//! OSC 52 is deliberately *last*, not first: writing the escape sequence to
//! stdout always "succeeds" whether or not the terminal acts on it, so there
//! is no failure to detect and nothing to fall back *from*. Preferring it
//! would silently drop the copy on every terminal that ignores it. The
//! external tools, by contrast, at least tell us whether they started.
//!
//! # Overriding the mechanism
//!
//! Set `TUI_PANEL_SELECT_CLIPBOARD` to pin the backend — see
//! [`ClipboardMode`]. Host applications that expose their own preference (or
//! whose test suites must not touch the developer's real clipboard) should
//! call [`set_clipboard_mode`] instead of relying on the environment.

use std::io::{self, Write};
use std::sync::atomic::{AtomicU8, Ordering};

use base64::Engine;
use base64::engine::general_purpose::STANDARD;

/// Which mechanism [`copy_to_clipboard`] should use.
///
/// [`ClipboardMode::Auto`] is the default and picks a local tool based on the
/// active display server, falling back to OSC 52 when none is reachable. The
/// remaining variants pin one mechanism, which is what an SSH session, an
/// unusual compositor, or a test harness needs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClipboardMode {
    /// Local tool if one is reachable, otherwise OSC 52. The default.
    Auto,
    /// Only ever use `xclip`/`xsel` (the X11 selection, incl. XWayland).
    X11,
    /// Only ever use `wl-copy` (the native Wayland selection).
    Wayland,
    /// Only ever write the OSC 52 escape sequence; never spawn anything.
    Osc52,
    /// Copy nothing at all. Intended for automated tests, which must never
    /// mutate whatever desktop clipboard is reachable from the machine
    /// running them.
    None,
}

impl ClipboardMode {
    /// Parse the spelling accepted in `TUI_PANEL_SELECT_CLIPBOARD`.
    /// Case-insensitive; unrecognised values yield `None` so the caller can
    /// fall back to [`ClipboardMode::Auto`] rather than silently disabling
    /// the clipboard because of a typo.
    pub fn parse(value: &str) -> Option<Self> {
        match value.trim().to_ascii_lowercase().as_str() {
            "auto" => Some(Self::Auto),
            "x11" | "xclip" | "xsel" => Some(Self::X11),
            "wayland" | "wl-copy" | "wl" => Some(Self::Wayland),
            "osc52" | "osc" | "terminal" => Some(Self::Osc52),
            "none" | "off" | "disabled" | "0" => Some(Self::None),
            _ => None,
        }
    }
}

// Encoded as a plain integer so the override is lock-free and usable from a
// draw/event path: 0 means "unset, read the environment instead", and the
// remaining values are `ClipboardMode` discriminants offset by one.
static MODE_OVERRIDE: AtomicU8 = AtomicU8::new(0);

/// Pin the clipboard mechanism process-wide, ignoring
/// `TUI_PANEL_SELECT_CLIPBOARD`.
///
/// The important case is testing: a host application's test suite should call
/// `set_clipboard_mode(ClipboardMode::None)` before exercising any code path
/// that copies. A `#[cfg(test)]` guard inside *this* crate cannot do that job
/// — `cfg(test)` is only set while this crate's own tests compile, so when it
/// is built as a dependency the guard is inert and the host's tests would
/// spawn real clipboard tools against a real desktop.
pub fn set_clipboard_mode(mode: ClipboardMode) {
    let encoded = match mode {
        ClipboardMode::Auto => 1,
        ClipboardMode::X11 => 2,
        ClipboardMode::Wayland => 3,
        ClipboardMode::Osc52 => 4,
        ClipboardMode::None => 5,
    };
    MODE_OVERRIDE.store(encoded, Ordering::Relaxed);
}

/// The mode currently in force: an explicit [`set_clipboard_mode`] call wins,
/// then `TUI_PANEL_SELECT_CLIPBOARD`, then the default.
///
/// The default is [`ClipboardMode::Auto`], except while *this crate's own*
/// tests are compiling, where it is [`ClipboardMode::None`] so the suite
/// cannot mutate the developer's real clipboard. That `cfg!(test)` is
/// deliberately only a default and not a hard guard: it is inert when the
/// crate is built as a dependency, so a host application's tests must call
/// [`set_clipboard_mode`] to get the same protection.
pub fn clipboard_mode() -> ClipboardMode {
    match MODE_OVERRIDE.load(Ordering::Relaxed) {
        1 => return ClipboardMode::Auto,
        2 => return ClipboardMode::X11,
        3 => return ClipboardMode::Wayland,
        4 => return ClipboardMode::Osc52,
        5 => return ClipboardMode::None,
        _ => {}
    }
    let fallback = if cfg!(test) {
        ClipboardMode::None
    } else {
        ClipboardMode::Auto
    };
    std::env::var("TUI_PANEL_SELECT_CLIPBOARD")
        .ok()
        .and_then(|v| ClipboardMode::parse(&v))
        .unwrap_or(fallback)
}

/// Build the OSC 52 escape sequence that asks the terminal to set the
/// system clipboard ("c" selection) to `text`. When running inside tmux the
/// raw sequence needs wrapping in a DCS passthrough (`\x1bPtmux;...\x1b\\`)
/// with any embedded ESC bytes doubled, or tmux swallows it instead of
/// relaying it to the outer terminal.
pub fn osc52_sequence(text: &str, in_tmux: bool) -> String {
    let encoded = STANDARD.encode(text.as_bytes());
    let inner = format!("\x1b]52;c;{encoded}\x07");
    if in_tmux {
        let escaped = inner.replace('\x1b', "\x1b\x1b");
        format!("\x1bPtmux;{escaped}\x1b\\")
    } else {
        inner
    }
}

/// Candidate local clipboard tools to try, in order, given which display
/// server (if any) is active and which `mode` is in force. A pure function
/// (no I/O) so the *selection* logic stays unit-testable without ever
/// actually spawning a process — the real spawning happens only in
/// `try_external_clipboard`.
///
/// **X11 tools come before `wl-copy` when both are usable**, which looks
/// backwards on a Wayland session but avoids a visible artefact. Setting the
/// Wayland selection requires a serial from an input event, hence keyboard
/// focus, hence a mapped surface — unless the compositor implements
/// `wlr-data-control`, which GNOME (among others) does not. So under GNOME
/// `wl-copy` maps a genuine `xdg_toplevel`, and the desktop lists it as a
/// running application for as long as it owns the selection, flashing an
/// entry into the app bar on every single copy. An X11 selection owner needs
/// no mapped window at all — `xclip` sits on an unmapped 1x1 window — and
/// XWayland's clipboard bridge propagates the selection to Wayland clients,
/// so the copy still lands everywhere while staying invisible.
///
/// The trade-off is a compositor that offers XWayland but no clipboard
/// bridging, where an X11-only copy would not reach native Wayland clients;
/// `TUI_PANEL_SELECT_CLIPBOARD=wayland` restores the old order for those.
fn clipboard_candidates(
    has_wayland: bool,
    has_x11: bool,
    is_macos: bool,
    mode: ClipboardMode,
) -> Vec<(&'static str, &'static [&'static str])> {
    let mut out: Vec<(&'static str, &'static [&'static str])> = Vec::new();
    let want_x11 = has_x11 && matches!(mode, ClipboardMode::Auto | ClipboardMode::X11);
    let want_wayland = has_wayland && matches!(mode, ClipboardMode::Auto | ClipboardMode::Wayland);
    if want_x11 {
        out.push(("xclip", &["-selection", "clipboard"]));
        out.push(("xsel", &["--clipboard", "--input"]));
    }
    if want_wayland {
        out.push(("wl-copy", &[]));
    }
    // A pinned display server means "use exactly this"; the platform
    // fallbacks below would quietly defeat that.
    if !matches!(mode, ClipboardMode::Auto) {
        return out;
    }
    if is_macos {
        out.push(("pbcopy", &[]));
    }
    // WSL's bridge to the Windows clipboard; harmless to always offer last —
    // spawning it simply fails (and falls through) everywhere else.
    out.push(("clip.exe", &[]));
    out
}

/// Try each candidate local clipboard tool (see `clipboard_candidates`) in
/// turn, piping `text` into its stdin. Returns `true` on the first one that
/// starts and accepts the write.
///
/// The child is reaped on a detached thread rather than with an inline
/// `wait()`. It cannot be left unreaped: `wl-copy`/`xclip` both fork a
/// background copy to keep serving the selection and let the foreground
/// process exit immediately, so *every* copy would otherwise strand a zombie
/// for the lifetime of the application — and a zombie cannot be killed, only
/// reaped, so they accumulate visibly in process and task lists. Nor can it
/// be waited on inline, since that would block the UI thread on a child whose
/// exit we don't care about. A detached waiter gets both.
///
/// On Unix the child is put into its own new session via `setsid()` (called
/// inside `pre_exec`, which runs in the forked child before `exec()`), so the
/// clipboard helper outlives us cleanly and is detached from the parent's
/// controlling terminal — without it the helper keeps a handle on the
/// terminal and can be killed by signals aimed at our process group.
fn try_external_clipboard(text: &str) -> bool {
    use std::process::{Command, Stdio};

    let has_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
    let has_x11 = std::env::var_os("DISPLAY").is_some();
    let is_macos = cfg!(target_os = "macos");
    for (cmd, args) in clipboard_candidates(has_wayland, has_x11, is_macos, clipboard_mode()) {
        let mut builder = Command::new(cmd);
        builder
            .args(args)
            .stdin(Stdio::piped())
            .stdout(Stdio::null())
            .stderr(Stdio::null());

        #[cfg(unix)]
        // SAFETY: The closure runs in the forked child between fork() and
        // exec() — only async-signal-safe calls are permitted. setsid() is
        // async-signal-safe per POSIX; we allocate nothing and take no locks.
        unsafe {
            use std::os::unix::process::CommandExt as _;
            builder.pre_exec(|| {
                libc::setsid();
                Ok(())
            });
        }

        let Ok(mut child) = builder.spawn() else {
            continue;
        };
        let Some(mut stdin) = child.stdin.take() else {
            // Reap even on this unlikely path, so a half-started helper
            // doesn't become the zombie we're trying to avoid.
            reap_in_background(child);
            continue;
        };
        let wrote = stdin.write_all(text.as_bytes()).is_ok();
        // The tool only takes ownership of the selection once it sees EOF,
        // so the pipe must be closed before anything waits on the child.
        drop(stdin);
        reap_in_background(child);
        if wrote {
            return true;
        }
    }
    false
}

/// Wait for `child` on a detached thread purely to reap it. See
/// `try_external_clipboard` for why this can be neither skipped nor done
/// inline.
fn reap_in_background(mut child: std::process::Child) {
    std::thread::spawn(move || {
        let _ = child.wait();
    });
}

/// Copy `text` to the system clipboard: try a local clipboard tool first
/// (see `try_external_clipboard`), falling back to an OSC 52 escape sequence
/// written directly to stdout when none is available. Best-effort: failures
/// are silently ignored since this is a convenience, not core functionality.
///
/// Honours [`clipboard_mode`], so [`ClipboardMode::None`] makes this a no-op
/// and [`ClipboardMode::Osc52`] skips the external tools entirely.
pub fn copy_to_clipboard(text: &str) {
    if text.is_empty() {
        return;
    }
    let mode = clipboard_mode();
    if mode == ClipboardMode::None {
        return;
    }
    if mode != ClipboardMode::Osc52 && try_external_clipboard(text) {
        return;
    }
    let in_tmux = std::env::var_os("TMUX").is_some();
    let seq = osc52_sequence(text, in_tmux);
    let _ = io::stdout().write_all(seq.as_bytes());
    let _ = io::stdout().flush();
}

#[cfg(test)]
mod tests {
    use super::*;

    const AUTO: ClipboardMode = ClipboardMode::Auto;

    #[test]
    fn builds_a_plain_osc52_sequence_outside_tmux() {
        let seq = osc52_sequence("hi", false);
        assert_eq!(seq, format!("\x1b]52;c;{}\x07", STANDARD.encode(b"hi")));
    }

    #[test]
    fn wraps_the_sequence_in_a_tmux_dcs_passthrough_and_doubles_escapes() {
        let seq = osc52_sequence("hi", true);
        let inner = format!("\x1b]52;c;{}\x07", STANDARD.encode(b"hi"));
        let expected = format!("\x1bPtmux;{}\x1b\\", inner.replace('\x1b', "\x1b\x1b"));
        assert_eq!(seq, expected);
        assert!(seq.starts_with("\x1bPtmux;"));
        assert!(seq.ends_with("\x1b\\"));
    }

    #[test]
    fn empty_text_still_produces_a_valid_sequence() {
        // copy_to_clipboard() itself short-circuits on empty text, but the
        // pure sequence builder should still behave sanely if ever called
        // directly with an empty string.
        let seq = osc52_sequence("", false);
        assert_eq!(seq, "\x1b]52;c;\x07");
    }

    #[test]
    fn x11_tools_are_tried_before_wayland_when_both_are_present() {
        // Deliberate: wl-copy has to map a real window on compositors without
        // wlr-data-control, which the desktop then shows in its app list.
        let c = clipboard_candidates(true, true, false, AUTO);
        assert_eq!(c[0].0, "xclip");
        assert_eq!(c[1].0, "xsel");
        assert!(c.iter().any(|(name, _)| *name == "wl-copy"));
    }

    #[test]
    fn wayland_is_used_when_no_x11_display_is_reachable() {
        let c = clipboard_candidates(true, false, false, AUTO);
        assert_eq!(c[0].0, "wl-copy");
        assert!(!c.iter().any(|(name, _)| *name == "xclip"));
    }

    #[test]
    fn x11_tools_are_offered_without_wayland() {
        let c = clipboard_candidates(false, true, false, AUTO);
        assert_eq!(c[0].0, "xclip");
        assert!(!c.iter().any(|(name, _)| *name == "wl-copy"));
    }

    #[test]
    fn macos_offers_pbcopy() {
        let c = clipboard_candidates(false, false, true, AUTO);
        assert!(c.iter().any(|(name, _)| *name == "pbcopy"));
    }

    #[test]
    fn clip_exe_is_always_offered_as_a_last_resort_for_wsl() {
        let c = clipboard_candidates(false, false, false, AUTO);
        assert_eq!(c, vec![("clip.exe", &[][..])]);
    }

    #[test]
    fn pinning_wayland_skips_x11_even_when_a_display_is_present() {
        let c = clipboard_candidates(true, true, false, ClipboardMode::Wayland);
        assert_eq!(c, vec![("wl-copy", &[][..])]);
    }

    #[test]
    fn pinning_x11_skips_wayland_and_the_platform_fallbacks() {
        let c = clipboard_candidates(true, true, true, ClipboardMode::X11);
        assert_eq!(
            c,
            vec![
                ("xclip", &["-selection", "clipboard"][..]),
                ("xsel", &["--clipboard", "--input"][..]),
            ]
        );
    }

    #[test]
    fn pinning_osc52_or_none_spawns_nothing_at_all() {
        assert!(clipboard_candidates(true, true, true, ClipboardMode::Osc52).is_empty());
        assert!(clipboard_candidates(true, true, true, ClipboardMode::None).is_empty());
    }

    #[test]
    fn parses_every_documented_mode_spelling_case_insensitively() {
        assert_eq!(ClipboardMode::parse("Auto"), Some(ClipboardMode::Auto));
        assert_eq!(ClipboardMode::parse(" x11 "), Some(ClipboardMode::X11));
        assert_eq!(ClipboardMode::parse("xclip"), Some(ClipboardMode::X11));
        assert_eq!(
            ClipboardMode::parse("WAYLAND"),
            Some(ClipboardMode::Wayland)
        );
        assert_eq!(ClipboardMode::parse("osc52"), Some(ClipboardMode::Osc52));
        assert_eq!(ClipboardMode::parse("off"), Some(ClipboardMode::None));
    }

    #[test]
    fn an_unrecognised_mode_is_rejected_rather_than_disabling_the_clipboard() {
        // A typo must not silently turn copying off, so `parse` reports the
        // failure and `clipboard_mode` falls back to `Auto`.
        assert_eq!(ClipboardMode::parse("waylnad"), None);
        assert_eq!(ClipboardMode::parse(""), None);
    }

    #[test]
    fn the_test_default_is_no_op_and_an_explicit_override_wins_over_it() {
        // Kept as a single test because `MODE_OVERRIDE` is process-global:
        // split across two tests they would race under the parallel runner.
        //
        // Guards the guard, too — if the default ever stopped being `None`,
        // the whole suite would start overwriting the developer's desktop
        // clipboard.
        assert_eq!(clipboard_mode(), ClipboardMode::None);
        copy_to_clipboard("should not reach any clipboard");

        set_clipboard_mode(ClipboardMode::Osc52);
        assert_eq!(clipboard_mode(), ClipboardMode::Osc52);

        // Restore the "unset" sentinel rather than storing `Auto`, so this
        // test can't leave a sibling test free to spawn a real tool.
        MODE_OVERRIDE.store(0, Ordering::Relaxed);
        assert_eq!(clipboard_mode(), ClipboardMode::None);
    }
}