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