1use 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 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 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 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 pub(crate) fn perform_copy(&mut self) -> Option<AppEvent> {
54 use copy_modal::{CopyScope, thousands};
55 enum Planned {
58 Copy(clipboard::Payload, String),
59 Collect,
60 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 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 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 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 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 options.read_python = state.read_python().to_vec();
196 }
197 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 #[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 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 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 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 pub(crate) fn copy_value(&mut self, column: polars::prelude::Column, message: String) {
294 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 pub fn set_clipboard_destination(&mut self, destination: Box<dyn clipboard::Destination>) {
332 self.external.clipboard = Some(destination);
333 }
334}