Skip to main content

datui_lib/
clipboard.rs

1//! The system clipboard, reached two ways, and the shapes a copy takes.
2//!
3//! **native**: the display server via arboard. On Wayland the copy lives only while
4//! datui runs, unless a clipboard manager keeps it. Tabular copies offer `text/html` (a
5//! real table) beside plain text, so spreadsheets and email paste a table and terminals
6//! paste TSV.
7//!
8//! **osc52**: an `OSC 52` escape for the terminal, which works over SSH. Written straight
9//! to stdout (the ratatui buffer is sanitized by [`crate::sanitize`]). Terminals cap
10//! OSC 52, so payloads are capped first (configurable); table copies are read in batches
11//! up to the cap, without an HTML flavor.
12//!
13//! **auto**: native where it initializes, else osc52, decided at the first copy; over
14//! SSH with no display forwarded, osc52 without asking.
15
16use polars::prelude::*;
17use std::io::Write as _;
18
19/// Which clipboard mechanism `[clipboard] backend` asks for.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum BackendChoice {
22    #[default]
23    Auto,
24    Native,
25    Osc52,
26}
27
28impl BackendChoice {
29    pub fn parse(s: &str) -> Option<Self> {
30        match s.trim().to_ascii_lowercase().as_str() {
31            "auto" => Some(Self::Auto),
32            "native" => Some(Self::Native),
33            "osc52" => Some(Self::Osc52),
34            _ => None,
35        }
36    }
37}
38
39/// One copy, ready for whichever destination takes it. The text flavor is
40/// always there; the HTML flavor rides along when the copy is a table and the
41/// destination can offer both.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Payload {
44    pub text: String,
45    pub html: Option<String>,
46}
47
48impl Payload {
49    pub fn text(text: String) -> Self {
50        Self { text, html: None }
51    }
52}
53
54/// Somewhere a payload can go. A trait so the integration tests can hand the
55/// app a destination that only records what it was given.
56pub trait Destination {
57    /// Takes the payload: a destination that keeps it owns it, without a copy.
58    fn write(&mut self, payload: Payload) -> Result<(), String>;
59    /// One word for the flash and for errors: "clipboard" or "terminal".
60    fn describe(&self) -> &'static str;
61    fn accepts(&self) -> Accepts {
62        Accepts {
63            html: true,
64            base64_limit: None,
65        }
66    }
67}
68
69/// What a destination takes, known before a copy is built.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct Accepts {
72    /// Offers an HTML flavor beside the text.
73    pub html: bool,
74    /// The longest copy it takes, in bytes of base64; none for no cap.
75    pub base64_limit: Option<usize>,
76}
77
78/// arboard, kept alive for the life of the app: on Wayland and X11 the copy
79/// dies with the process that owns it, so dropping the handle early would
80/// revoke the copy the flash just announced.
81pub struct Native {
82    clipboard: arboard::Clipboard,
83}
84
85impl Native {
86    pub fn new() -> Result<Self, String> {
87        arboard::Clipboard::new()
88            .map(|clipboard| Self { clipboard })
89            .map_err(|e| format!("clipboard unavailable: {e}"))
90    }
91}
92
93impl Destination for Native {
94    fn write(&mut self, payload: Payload) -> Result<(), String> {
95        let result = match payload.html {
96            Some(html) => self.clipboard.set_html(html, Some(payload.text)),
97            None => self.clipboard.set_text(payload.text),
98        };
99        result.map_err(|e| format!("copy failed: {e}"))
100    }
101
102    fn describe(&self) -> &'static str {
103        "clipboard"
104    }
105}
106
107/// The `OSC 52` writer. Carries only the cap; stdout is fetched per write.
108pub struct Osc52 {
109    /// Longest base64 payload to attempt, in bytes.
110    pub limit: usize,
111}
112
113impl Destination for Osc52 {
114    fn write(&mut self, payload: Payload) -> Result<(), String> {
115        let sequence = osc52_sequence(&payload.text, self.limit)?;
116        // Encoded, the text is not needed while the sequence is written.
117        drop(payload);
118        let mut out = std::io::stdout();
119        out.write_all(sequence.as_bytes())
120            .and_then(|()| out.flush())
121            .map_err(|e| format!("copy failed: {e}"))
122    }
123
124    fn describe(&self) -> &'static str {
125        "terminal"
126    }
127
128    fn accepts(&self) -> Accepts {
129        Accepts {
130            html: false,
131            base64_limit: Some(self.limit),
132        }
133    }
134}
135
136/// The length of `bytes` bytes in padded base64.
137pub fn base64_len(bytes: usize) -> usize {
138    bytes.div_ceil(3).saturating_mul(4)
139}
140
141/// The OSC 52 sequence setting the clipboard, or why not; separate from
142/// [`Osc52::write`] so tests can read the bytes. Size is checked before encoding.
143pub fn osc52_sequence(text: &str, limit: usize) -> Result<String, String> {
144    use base64::Engine as _;
145    let encoded = base64_len(text.len());
146    if encoded > limit {
147        return Err(over_osc52_limit(Some(encoded), limit));
148    }
149    let mut sequence = String::with_capacity(encoded + 8);
150    sequence.push_str("\x1b]52;c;");
151    base64::engine::general_purpose::STANDARD.encode_string(text.as_bytes(), &mut sequence);
152    sequence.push('\x07');
153    Ok(sequence)
154}
155
156/// Why a copy does not go through the terminal. `encoded` is its size in base64,
157/// or none when it stopped being built at the cap.
158pub(crate) fn over_osc52_limit(encoded: Option<usize>, limit: usize) -> String {
159    let size = match encoded {
160        Some(bytes) => format_kb(bytes),
161        None => format!("over {}", format_kb(limit)),
162    };
163    format!(
164        "the copy is {size} of base64 and the terminal path is capped at {} \
165         (raise [clipboard] osc52_limit, or export to a file)",
166        format_kb(limit),
167    )
168}
169
170fn format_kb(bytes: usize) -> String {
171    format!("{} KB", bytes.div_ceil(1024))
172}
173
174/// The destination a backend choice names, built at the first copy.
175pub fn destination(
176    choice: BackendChoice,
177    osc52_limit: usize,
178) -> Result<Box<dyn Destination>, String> {
179    let terminal = || Box::new(Osc52 { limit: osc52_limit }) as Box<dyn Destination>;
180    match choice {
181        BackendChoice::Native => Native::new().map(|n| Box::new(n) as Box<dyn Destination>),
182        BackendChoice::Osc52 => Ok(terminal()),
183        BackendChoice::Auto if no_display_here(|name| std::env::var(name).ok()) => Ok(terminal()),
184        BackendChoice::Auto => Ok(match Native::new() {
185            Ok(native) => Box::new(native),
186            // No display server to talk to is exactly what the escape-sequence path is
187            // for.
188            Err(_) => terminal(),
189        }),
190    }
191}
192
193/// Whether `auto` goes straight to the terminal: over SSH with no display forwarded,
194/// where no display server can answer, so none is asked. A forwarded display
195/// (`ssh -X`) is tried first, as anywhere else.
196pub(crate) fn no_display_here(env: impl Fn(&str) -> Option<String>) -> bool {
197    let set = |name: &str| env(name).is_some_and(|v| !v.trim().is_empty());
198    (set("SSH_CONNECTION") || set("SSH_TTY")) && !set("DISPLAY") && !set("WAYLAND_DISPLAY")
199}
200
201// ----- The shapes a copy takes -----
202
203/// The formats the dialog offers for tabular scopes.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
205pub enum CopyFormat {
206    #[default]
207    Tsv,
208    Csv,
209    Markdown,
210}
211
212impl CopyFormat {
213    pub const ALL: [Self; 3] = [Self::Tsv, Self::Csv, Self::Markdown];
214
215    pub fn as_str(self) -> &'static str {
216        match self {
217            Self::Tsv => "TSV",
218            Self::Csv => "CSV",
219            Self::Markdown => "Markdown",
220        }
221    }
222}
223
224/// A DataFrame as delimited text quoted as spreadsheets parse pastes
225/// (`QuoteStyle::Necessary`). Raw values as in export; null is empty, never `∅`.
226pub fn delimited(df: &DataFrame, separator: u8, header: bool) -> Result<String, String> {
227    let mut out = Vec::new();
228    let mut df = crate::export::nested_json::frame_as_json(df).map_err(|e| e.to_string())?;
229    CsvWriter::new(&mut out)
230        .with_separator(separator)
231        .include_header(header)
232        .finish(&mut df)
233        .map_err(|e| e.to_string())?;
234    let mut text = String::from_utf8(out).map_err(|e| e.to_string())?;
235    // The writer ends the last record with a newline; a paste target treats
236    // that as an empty extra row.
237    while text.ends_with('\n') || text.ends_with('\r') {
238        text.pop();
239    }
240    Ok(text)
241}
242
243/// A DataFrame as a Markdown table, always with a header (the delimiter row makes it a
244/// table). Numbers right-aligned, pipes escaped, newlines flattened to spaces.
245pub fn markdown(df: &DataFrame) -> Result<String, String> {
246    let mut layout = MarkdownLayout::new(df);
247    layout.measure(df)?;
248    let mut out = String::with_capacity(layout.len(df.height()));
249    layout.write(df, &mut out)?;
250    Ok(out)
251}
252
253/// A Markdown table's columns: their names, alignment and widths. Widths are
254/// measured over every row before a row is written, a cell at a time, so no
255/// cell's text is held beyond its own.
256struct MarkdownLayout {
257    names: Vec<String>,
258    numeric: Vec<bool>,
259    widths: Vec<usize>,
260}
261
262impl MarkdownLayout {
263    fn new(df: &DataFrame) -> Self {
264        let names: Vec<String> = df
265            .get_column_names()
266            .iter()
267            .map(|name| markdown_escape(name))
268            .collect();
269        let widths = names.iter().map(|n| n.chars().count().max(3)).collect();
270        let numeric = df
271            .columns()
272            .iter()
273            .map(|c| c.dtype().is_primitive_numeric())
274            .collect();
275        Self {
276            names,
277            numeric,
278            widths,
279        }
280    }
281
282    /// Widen the columns to fit the rows of `df`.
283    fn measure(&mut self, df: &DataFrame) -> Result<(), String> {
284        for (column, width) in df.columns().iter().zip(&mut self.widths) {
285            let series = column.as_materialized_series();
286            for row in 0..df.height() {
287                *width = (*width).max(markdown_cell(series, row)?.chars().count());
288            }
289        }
290        Ok(())
291    }
292
293    /// The table's length over `rows` rows at the widths measured so far, in
294    /// characters: no more than its bytes, and only ever growing as rows widen
295    /// the columns.
296    fn len(&self, rows: usize) -> usize {
297        let line = self.widths.iter().sum::<usize>() + 3 * self.widths.len() + 1;
298        (rows + 2) * line + rows + 1
299    }
300
301    fn write_line<'a>(&self, out: &mut String, cells: impl Iterator<Item = &'a str>) {
302        out.push_str("| ");
303        for (i, ((cell, &width), &right)) in cells.zip(&self.widths).zip(&self.numeric).enumerate()
304        {
305            if i > 0 {
306                out.push_str(" | ");
307            }
308            // A numeric column is padded to the right, so the raw text reads the
309            // way the `---:` delimiter tells a renderer to draw it; the two agree.
310            let fill = width - cell.chars().count();
311            if right {
312                out.extend(std::iter::repeat_n(' ', fill));
313                out.push_str(cell);
314            } else {
315                out.push_str(cell);
316                out.extend(std::iter::repeat_n(' ', fill));
317            }
318        }
319        out.push_str(" |");
320    }
321
322    /// Append the header, the delimiter row and the rows of `df`.
323    fn write(&self, df: &DataFrame, out: &mut String) -> Result<(), String> {
324        self.write_line(out, self.names.iter().map(String::as_str));
325        out.push_str("\n|");
326        for (i, (&width, &right)) in self.widths.iter().zip(&self.numeric).enumerate() {
327            if i > 0 {
328                out.push('|');
329            }
330            out.push(' ');
331            if right {
332                out.extend(std::iter::repeat_n('-', width.saturating_sub(1)));
333                out.push(':');
334            } else {
335                out.extend(std::iter::repeat_n('-', width));
336            }
337            out.push(' ');
338        }
339        out.push('|');
340        self.write_rows(df, out)
341    }
342
343    /// Append the rows of `df`, each on a line of its own.
344    fn write_rows(&self, df: &DataFrame, out: &mut String) -> Result<(), String> {
345        let series: Vec<&Series> = df
346            .columns()
347            .iter()
348            .map(Column::as_materialized_series)
349            .collect();
350        let mut cells = Vec::with_capacity(series.len());
351        for row in 0..df.height() {
352            cells.clear();
353            for s in &series {
354                cells.push(markdown_cell(s, row)?);
355            }
356            out.push('\n');
357            self.write_line(out, cells.iter().map(String::as_str));
358        }
359        Ok(())
360    }
361}
362
363fn markdown_escape(s: &str) -> String {
364    s.replace('|', "\\|").replace(['\n', '\r'], " ")
365}
366
367/// One Markdown cell: the value escaped, a null empty.
368fn markdown_cell(series: &Series, row: usize) -> Result<String, String> {
369    Ok(match series.get(row).map_err(|e| e.to_string())? {
370        AnyValue::Null => String::new(),
371        // Exact, as the TSV and CSV writers are: Polars' own display would
372        // round a float to its compact preview.
373        v => markdown_escape(&crate::exact::value_text(&v)),
374    })
375}
376
377/// A DataFrame as an HTML table, the rich flavor beside a TSV or CSV copy.
378/// Everything is escaped; a null is an empty cell.
379pub fn html_table(df: &DataFrame, header: bool) -> Result<String, String> {
380    let escape = |s: &str| {
381        s.replace('&', "&amp;")
382            .replace('<', "&lt;")
383            .replace('>', "&gt;")
384    };
385    let column_names = df.get_column_names_owned();
386    let mut out = String::from("<table>");
387    if header {
388        out.push_str("<thead><tr>");
389        for name in &column_names {
390            out.push_str(&format!("<th>{}</th>", escape(name)));
391        }
392        out.push_str("</tr></thead>");
393    }
394    out.push_str("<tbody>");
395    for row in 0..df.height() {
396        out.push_str("<tr>");
397        for name in &column_names {
398            let value = df
399                .column(name)
400                .map_err(|e| e.to_string())?
401                .as_materialized_series()
402                .get(row)
403                .map_err(|e| e.to_string())?;
404            let text = match value {
405                AnyValue::Null => String::new(),
406                v => crate::exact::value_text(&v),
407            };
408            out.push_str(&format!("<td>{}</td>", escape(&text)));
409        }
410        out.push_str("</tr>");
411    }
412    out.push_str("</tbody></table>");
413    Ok(out)
414}
415
416/// A tabular copy's payload: the chosen format as text, plus HTML beside TSV or CSV when
417/// `html` asks. Markdown stays Markdown. Lists and structs are JSON, as in CSV export.
418pub fn tabular_payload(
419    df: &DataFrame,
420    format: CopyFormat,
421    header: bool,
422    html: bool,
423) -> Result<Payload, String> {
424    let df = &crate::export::nested_json::frame_as_cells(df).map_err(|e| e.to_string())?;
425    let text = match format {
426        CopyFormat::Tsv => delimited(df, b'\t', header)?,
427        CopyFormat::Csv => delimited(df, b',', header)?,
428        CopyFormat::Markdown => markdown(df)?,
429    };
430    let html = match format {
431        CopyFormat::Tsv | CopyFormat::Csv if html => Some(html_table(df, header)?),
432        _ => None,
433    };
434    Ok(Payload { text, html })
435}
436
437/// Rows a table copy to a capped destination is read in: what runs past the cap
438/// is at most this many rows.
439const BOUNDED_BATCH_ROWS: usize = 1024;
440
441/// A table copy's text and row count, read from `lf` in batches and abandoned at the
442/// first batch past `limit` base64 bytes, so a copy the terminal refuses is never built
443/// whole. Text only. Upstream operations (sorts, joins) still take their memory;
444/// Markdown holds rows until widths are known, bounded by the cap.
445pub fn bounded_table_text(
446    lf: LazyFrame,
447    format: CopyFormat,
448    header: bool,
449    limit: usize,
450) -> Result<(String, usize), String> {
451    use std::sync::{Arc, Mutex};
452    let polars_error = |e: PolarsError| crate::error_display::user_message_from_polars(&e);
453    let schema = lf.clone().collect_schema().map_err(polars_error)?;
454    let state = Arc::new(Mutex::new(BoundedText::new(format, header, limit)));
455    let sink_state = Arc::clone(&state);
456    let sink = lf
457        .sink_batches(
458            PlanCallback::new(move |batch: DataFrame| {
459                let mut text = sink_state
460                    .lock()
461                    .map_err(|_| PolarsError::ComputeError("copy lock failed".into()))?;
462                // True stops the read: the copy is over the cap, or failed.
463                Ok(text.take(batch))
464            }),
465            true,
466            std::num::NonZeroUsize::new(BOUNDED_BATCH_ROWS),
467        )
468        .map_err(polars_error)?;
469    // Streaming whatever the setting: the in-memory engine collects the whole result
470    // before the first batch, which is what stopping at the cap is here to avoid.
471    crate::analysis::statistics::collect_lazy(sink, true).map_err(polars_error)?;
472    let mut text = std::mem::replace(
473        &mut *state.lock().map_err(|_| "copy lock failed".to_string())?,
474        BoundedText::new(format, header, limit),
475    );
476    if !text.started {
477        // No batch came: the header alone, from the schema.
478        text.take(DataFrame::empty_with_schema(&schema));
479    }
480    text.finish()
481}
482
483/// A table copy's text as its batches come in, against the cap.
484struct BoundedText {
485    format: CopyFormat,
486    header: bool,
487    limit: usize,
488    started: bool,
489    text: String,
490    rows: usize,
491    /// A Markdown copy's columns and the rows to write once they are measured.
492    markdown: Option<(MarkdownLayout, Vec<DataFrame>)>,
493    over: bool,
494    error: Option<String>,
495}
496
497impl BoundedText {
498    fn new(format: CopyFormat, header: bool, limit: usize) -> Self {
499        Self {
500            format,
501            header,
502            limit,
503            started: false,
504            text: String::new(),
505            rows: 0,
506            markdown: None,
507            over: false,
508            error: None,
509        }
510    }
511
512    /// Add a batch; true once the copy cannot go on.
513    fn take(&mut self, batch: DataFrame) -> bool {
514        if self.over || self.error.is_some() {
515            return true;
516        }
517        if let Err(e) = self.try_take(batch) {
518            self.error = Some(e);
519        }
520        self.over || self.error.is_some()
521    }
522
523    fn try_take(&mut self, batch: DataFrame) -> Result<(), String> {
524        let first = !self.started;
525        self.started = true;
526        self.rows += batch.height();
527        let batch =
528            crate::export::nested_json::frame_as_cells(&batch).map_err(|e| e.to_string())?;
529        let separator = match self.format {
530            CopyFormat::Tsv => b'\t',
531            CopyFormat::Csv => b',',
532            CopyFormat::Markdown => {
533                let (layout, rows) = self
534                    .markdown
535                    .get_or_insert_with(|| (MarkdownLayout::new(&batch), Vec::new()));
536                layout.measure(&batch)?;
537                // The widths so far are a floor on the final ones.
538                self.over = base64_len(layout.len(self.rows)) > self.limit;
539                rows.push(batch);
540                return Ok(());
541            }
542        };
543        let mut out = Vec::new();
544        let mut batch =
545            crate::export::nested_json::frame_as_json(&batch).map_err(|e| e.to_string())?;
546        CsvWriter::new(&mut out)
547            .with_separator(separator)
548            .include_header(first && self.header)
549            .finish(&mut batch)
550            .map_err(|e| e.to_string())?;
551        self.text
552            .push_str(&String::from_utf8(out).map_err(|e| e.to_string())?);
553        // The last record's newline is trimmed at the end.
554        self.over = base64_len(self.text.len().saturating_sub(1)) > self.limit;
555        Ok(())
556    }
557
558    fn finish(mut self) -> Result<(String, usize), String> {
559        if let Some(e) = self.error {
560            return Err(e);
561        }
562        if self.over {
563            return Err(over_osc52_limit(None, self.limit));
564        }
565        if let Some((layout, frames)) = self.markdown.take() {
566            self.text.reserve(layout.len(self.rows));
567            let mut frames = frames.iter();
568            if let Some(first) = frames.next() {
569                layout.write(first, &mut self.text)?;
570            }
571            for frame in frames {
572                layout.write_rows(frame, &mut self.text)?;
573            }
574        }
575        while self.text.ends_with('\n') || self.text.ends_with('\r') {
576            self.text.pop();
577        }
578        let encoded = base64_len(self.text.len());
579        if encoded > self.limit {
580            return Err(over_osc52_limit(Some(encoded), self.limit));
581        }
582        Ok((self.text, self.rows))
583    }
584}
585
586#[cfg(test)]
587mod tests;