Skip to main content

datui_lib/app/keys/
copy_keys.rs

1//! The copy modal's keys: the shared form keys (`crate::app::form`), then what each row
2//! does with them.
3
4use crate::app::feedback::Confirm;
5use crate::app::form::FormKey;
6use crate::app::jobs::{Answer, Job};
7use crate::app::modals::copy_modal::CopyFocus;
8use crate::loading::open_options::OpenOptions;
9use crate::table::DataTableState;
10use crate::{
11    App, AppEvent, app::modals::copy_modal, clipboard, cloud::source, export::python_script,
12};
13use color_eyre::Result;
14use crossterm::event::{KeyCode, KeyEvent};
15use std::path::Path;
16
17impl App {
18    /// Keys in the copy modal.
19    pub(crate) fn copy_key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
20        if crate::app::form::picker_form_key(&mut self.copy_modal, event) {
21            return None;
22        }
23
24        // Any key means the form is being edited again: the accented gap line returns to
25        // plain (Enter re-arms it).
26        self.copy_modal.attention = false;
27
28        match crate::app::form::key(&mut self.copy_modal, event) {
29            FormKey::Cancel => {
30                self.close_overlay();
31            }
32            // Enter copies from anywhere; the spec line has echoed what it will do.
33            FormKey::Submit => {
34                if self.copy_modal.validation_error().is_some() {
35                    self.copy_modal.attention = true;
36                    return None;
37                }
38                return self.perform_copy();
39            }
40            FormKey::Step(CopyFocus::Scope, delta) => self.copy_modal.step_scope(delta),
41            FormKey::Step(CopyFocus::Format, delta) => self.copy_modal.step_format(delta),
42            FormKey::Step(CopyFocus::Column, delta) => self.copy_modal.step_column(delta),
43            FormKey::Act(CopyFocus::Header) => self.copy_modal.toggle_header(),
44            FormKey::Act(CopyFocus::Column) => self.copy_modal.open_picker(),
45            FormKey::Other if event.code == KeyCode::Char('?') => self.open_help_overlay(),
46            _ => {}
47        }
48        None
49    }
50
51    /// Enter in the copy dialog: buffer scopes copy and flash; the table scope checks
52    /// size, then collects off-thread.
53    pub(crate) fn perform_copy(&mut self) -> Option<AppEvent> {
54        use copy_modal::{CopyScope, thousands};
55        /// What Enter decided under the table borrow, acted on after: the clipboard needs
56        /// the whole app.
57        enum Planned {
58            Copy(clipboard::Payload, String),
59            Collect,
60            /// The size, if known (not while the count is coming or a binary column's width is
61            /// unknown).
62            Confirm(Option<usize>),
63        }
64        let format = self.copy_modal.format;
65        let header = self.copy_modal.header();
66        let scope = self.copy_modal.scope;
67        // The destination decides what is built: no HTML for one that cannot take it, and
68        // nothing past its cap.
69        let accepts = match self.copy_destination() {
70            Ok(destination) => destination.accepts(),
71            Err(e) => {
72                self.close_overlay();
73                self.error_modal.show(e);
74                return None;
75            }
76        };
77        let planned: Result<Planned, String> = match self.data_table_state.as_ref() {
78            None => Err("Nothing to copy: no table is open".to_string()),
79            Some(state) => match scope {
80                CopyScope::Cell => {
81                    let column = self.copy_modal.column.clone().unwrap_or_default();
82                    match state.copy_cell_value(&column) {
83                        Some(value) => {
84                            let row = state.selected_display_row().unwrap_or(0);
85                            Ok(Planned::Copy(
86                                clipboard::Payload::text(value),
87                                format!("Copied cell {column} of row {}", thousands(row)),
88                            ))
89                        }
90                        None => Err("Nothing to copy: the current row is not buffered".to_string()),
91                    }
92                }
93                CopyScope::Row => match state.copy_row_df() {
94                    Some(df) => clipboard::tabular_payload(&df, format, header, accepts.html).map(
95                        |payload| {
96                            let row = state.selected_display_row().unwrap_or(0);
97                            Planned::Copy(
98                                payload,
99                                format!("Copied row {} as {}", thousands(row), format.as_str()),
100                            )
101                        },
102                    ),
103                    None => Err("Nothing to copy: the current row is not buffered".to_string()),
104                },
105                CopyScope::View => match state.copy_view_df() {
106                    Some(df) => clipboard::tabular_payload(&df, format, header, accepts.html).map(
107                        |payload| {
108                            Planned::Copy(
109                                payload,
110                                format!(
111                                    "Copied {} rows as {}",
112                                    thousands(df.height()),
113                                    format.as_str()
114                                ),
115                            )
116                        },
117                    ),
118                    None => Err("Nothing to copy: no rows are on screen".to_string()),
119                },
120                CopyScope::Python => Ok(Planned::Copy(
121                    clipboard::Payload::text(self.python_script(state)),
122                    "Copied the view as Python".to_string(),
123                )),
124                CopyScope::Table => {
125                    // A capped destination's copy reads only to its cap: the smaller of the two.
126                    let cap = accepts
127                        .base64_limit
128                        .map_or(usize::MAX, |limit| limit / 4 * 3);
129                    match state.estimated_copy_bytes() {
130                        Some(bytes) if bytes > Self::COPY_REFUSE_BYTES => Err(format!(
131                            "The table is about {} — too much to hold on a clipboard. \
132                             Export it to a file instead (e).",
133                            crate::numfmt::bytes(bytes as u64)
134                        )),
135                        Some(bytes) if bytes.min(cap) > Self::COPY_CONFIRM_BYTES => {
136                            Ok(Planned::Confirm(Some(bytes)))
137                        }
138                        Some(_) => Ok(Planned::Collect),
139                        None if cap <= Self::COPY_CONFIRM_BYTES => Ok(Planned::Collect),
140                        // Size unknown (count still coming, binary width unknown): ask before collecting.
141                        None => Ok(Planned::Confirm(None)),
142                    }
143                }
144            },
145        };
146        self.close_overlay();
147        match planned {
148            Ok(Planned::Copy(payload, message)) => {
149                self.finish_copy(payload, message);
150                None
151            }
152            Ok(Planned::Collect) => Some(AppEvent::Applied(crate::Applied::CopyTable {
153                format,
154                header,
155            })),
156            Ok(Planned::Confirm(bytes)) => {
157                let counting = self
158                    .data_table_state
159                    .as_ref()
160                    .is_some_and(|state| state.num_rows_if_valid().is_none());
161                let message = match bytes {
162                    Some(bytes) => format!(
163                        "This copies about {} to the clipboard.\n\nCopy the whole table?",
164                        crate::numfmt::bytes(bytes as u64)
165                    ),
166                    None if counting => "The table's size is not known yet — the row count \
167                                         is still being read.\n\nCopy the whole table anyway?"
168                        .to_string(),
169                    None => "The size of the table's binary columns is not known.\n\n\
170                             Copy the whole table anyway?"
171                        .to_string(),
172                };
173                self.confirmation_modal
174                    .show(message, Confirm::Copy(format, header));
175                None
176            }
177            Err(message) => {
178                self.error_modal.show(message);
179                None
180            }
181        }
182    }
183
184    /// The view on screen as a Python Polars script: the open's reader, then every
185    /// step that made the view. See [`python_script`].
186    pub fn python_script(&self, state: &DataTableState) -> String {
187        let (paths, options) = match &self.source.opened {
188            Some((paths, options)) => (Some(paths.as_slice()), options.clone()),
189            None => (None, OpenOptions::default()),
190        };
191        let cloud = options.effective_cloud(&self.app_config.cloud);
192        let mut options = options;
193        if options.read_python.is_empty() {
194            // A decompressed file is read into its dataset directly, not through a scan.
195            options.read_python = state.read_python().to_vec();
196        }
197        // The source an `s3://<id>@bucket` URL names has its own endpoint and region.
198        let remote = paths
199            .and_then(|paths| paths.first())
200            .map(|p| p.to_string_lossy().into_owned())
201            .filter(|p| source::is_remote_url(Path::new(p)));
202        let source_of = remote
203            .as_deref()
204            .and_then(|url| source::split_source_id(url).0)
205            .and_then(|id| cloud.connections.iter().find(|c| c.name == id));
206        // What this session learned of the place: read unsigned, it is public.
207        #[cfg(feature = "cloud")]
208        let unsigned = remote
209            .as_deref()
210            .and_then(crate::cloud::cloud_sources::known_access)
211            .unwrap_or(false);
212        #[cfg(not(feature = "cloud"))]
213        let unsigned = false;
214        let record = python_script::OpenRecord {
215            paths,
216            options: &options,
217            format: state.read_as().or(options.format),
218            read_mode: state.read_mode(),
219            schema: state.source_schema(),
220            remote_objects: state
221                .remote_objects()
222                .unwrap_or_default()
223                .into_iter()
224                .map(|object| object.url)
225                .collect(),
226            s3_endpoint: source_of
227                .and_then(|c| c.endpoint_url.clone())
228                .or(cloud.s3_endpoint_url.clone())
229                .filter(|s| !s.trim().is_empty()),
230            s3_region: source_of
231                .and_then(|c| c.region.clone())
232                .or(cloud.s3_region.clone())
233                .filter(|s| !s.trim().is_empty()),
234            unsigned,
235            read_as_text: state.read_as_text().iter().map(|c| c.to_string()).collect(),
236            spec: state.format_read().map(|read| read.spec.name.clone()),
237        };
238        python_script::Script {
239            source: python_script::source(&record),
240            steps: state.python_steps(),
241        }
242        .render()
243    }
244
245    /// Hand a payload to the clipboard (built at the first copy), then flash or show
246    /// the error: a silent no-op copy is worse than a loud failure.
247    pub(crate) fn finish_copy(&mut self, payload: clipboard::Payload, message: String) {
248        let written = self
249            .copy_destination()
250            .and_then(|destination| destination.write(payload));
251        match written {
252            Ok(()) => self.flash_note(message),
253            Err(e) => self.error_modal.show(e),
254        }
255    }
256
257    /// The clipboard destination, built at the first copy.
258    pub(crate) fn copy_destination(&mut self) -> Result<&mut dyn clipboard::Destination, String> {
259        if self.external.clipboard.is_none() {
260            let choice = clipboard::BackendChoice::parse(&self.app_config.clipboard.backend)
261                .unwrap_or_default();
262            let limit = usize::try_from(self.app_config.clipboard.osc52_limit.bytes())
263                .unwrap_or(usize::MAX);
264            self.external.clipboard = Some(clipboard::destination(choice, limit)?);
265        }
266        Ok(self
267            .external
268            .clipboard
269            .as_deref_mut()
270            .expect("destination just built"))
271    }
272
273    /// Copy `text`, refused when it is over a capped destination's limit.
274    pub(crate) fn copy_string(&mut self, text: String, message: String) {
275        let limit = match self.copy_destination() {
276            Ok(destination) => destination.accepts().base64_limit,
277            Err(e) => {
278                self.error_modal.show(e);
279                return;
280            }
281        };
282        if let Some(limit) = limit
283            && text.len() > limit / 4 * 3
284        {
285            self.error_modal
286                .show(clipboard::over_osc52_limit(None, limit));
287            return;
288        }
289        self.finish_copy(clipboard::Payload::text(text), message);
290    }
291
292    /// Copy the one value of `column`, exact, and flash `message`.
293    pub(crate) fn copy_value(&mut self, column: polars::prelude::Column, message: String) {
294        // Destination first: a value over the terminal's cap is refused before formatting.
295        let limit = match self.copy_destination() {
296            Ok(destination) => destination.accepts().base64_limit,
297            Err(e) => {
298                self.error_modal.show(e);
299                return;
300            }
301        };
302        if let Some(limit) = limit {
303            let fits = limit / 4 * 3;
304            let over = column
305                .get(0)
306                .is_ok_and(|value| crate::exact::copy_len_floor(&value, fits) > fits);
307            if over {
308                self.error_modal
309                    .show(clipboard::over_osc52_limit(None, limit));
310                return;
311            }
312        }
313        if column.as_materialized_series().estimated_size() <= Self::FIELD_COPY_INLINE_BYTES {
314            match crate::exact::copy_text(&column) {
315                Ok(text) => self.finish_copy(clipboard::Payload::text(text), message),
316                Err(e) => self.error_modal.show(format!("Copy failed: {e}")),
317            }
318            return;
319        }
320        self.spawn_job(Job::Copy, Some("Copying..."), move |_| {
321            let text = crate::exact::copy_text(&column).map_err(|e| format!("Copy failed: {e}"))?;
322            Ok(Answer::Copied {
323                payload: clipboard::Payload::text(text),
324                message,
325            })
326        });
327    }
328
329    /// Replace the clipboard destination, so tests can watch copies without a display
330    /// or terminal.
331    pub fn set_clipboard_destination(&mut self, destination: Box<dyn clipboard::Destination>) {
332        self.external.clipboard = Some(destination);
333    }
334}