Skip to main content

rusticity_term/
clipboard.rs

1//! One cross-platform clipboard path for text yanks and rendered snapshots.
2use ratatui::{buffer::Buffer, text::Span};
3use std::{
4    cell::RefCell,
5    time::{Duration, Instant},
6};
7
8const NOTICE_DURATION: Duration = Duration::from_secs(4);
9
10enum CopyRequest {
11    Text(String),
12    Snapshot,
13}
14
15pub struct CopyNotice {
16    pub message: String,
17    pub failed: bool,
18    created: Instant,
19}
20
21#[derive(Default)]
22pub struct ClipboardState {
23    pending: RefCell<Option<CopyRequest>>,
24    notice: Option<CopyNotice>,
25}
26
27impl ClipboardState {
28    /// Interior mutability lets read-only service actions queue a copy without
29    /// writing to the OS, borrowing App mutably, or accessing a global clipboard.
30    pub fn request_text(&self, text: impl Into<String>) {
31        self.pending.replace(Some(CopyRequest::Text(text.into())));
32    }
33    pub fn request_snapshot(&self) {
34        self.pending.replace(Some(CopyRequest::Snapshot));
35    }
36    pub fn snapshot_requested(&self) -> bool {
37        matches!(*self.pending.borrow(), Some(CopyRequest::Snapshot))
38    }
39    pub fn notice(&self) -> Option<&CopyNotice> {
40        self.notice
41            .as_ref()
42            .filter(|notice| notice.created.elapsed() < NOTICE_DURATION)
43    }
44    pub fn expire_notice(&mut self) -> bool {
45        if self
46            .notice
47            .as_ref()
48            .is_some_and(|notice| notice.created.elapsed() >= NOTICE_DURATION)
49        {
50            self.notice = None;
51            true
52        } else {
53            false
54        }
55    }
56    /// The injected writer keeps request/payload/result behavior testable without
57    /// modifying the developer's actual clipboard.
58    pub fn complete_with(
59        &mut self,
60        buffer: &Buffer,
61        write: impl FnOnce(String) -> Result<(), String>,
62    ) -> bool {
63        let Some(request) = self.pending.take() else {
64            return false;
65        };
66        let (text, success) = match request {
67            CopyRequest::Text(text) => (text, "Copied to clipboard"),
68            CopyRequest::Snapshot => (snapshot_text(buffer), "Snapshot copied to clipboard"),
69        };
70        let (message, failed) = match write(text) {
71            Ok(()) => (success.into(), false),
72            Err(error) => (format!("Failed to copy: {error}"), true),
73        };
74        self.notice = Some(CopyNotice {
75            message,
76            failed,
77            created: Instant::now(),
78        });
79        true
80    }
81}
82
83/// Keep the OS backend alive for the application lifetime (important for X11
84/// clipboard ownership), initializing only on the first actual copy request.
85#[derive(Default)]
86pub struct ClipboardService {
87    backend: Option<arboard::Clipboard>,
88}
89impl ClipboardService {
90    pub fn flush(&mut self, state: &mut ClipboardState, buffer: &Buffer) -> bool {
91        state.complete_with(buffer, |text| {
92            if self.backend.is_none() {
93                self.backend = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
94            }
95            self.backend
96                .as_mut()
97                .expect("clipboard initialized")
98                .set_text(text)
99                .map_err(|e| e.to_string())
100        })
101    }
102}
103
104/// Export exactly the visible characters, excluding the continuation cells of
105/// wide graphemes. Supports nonzero buffer origins and trims trailing padding.
106pub fn snapshot_text(buffer: &Buffer) -> String {
107    let area = buffer.area;
108    (area.y..area.bottom())
109        .map(|y| {
110            let mut line = String::new();
111            let mut x = area.x;
112            while x < area.right() {
113                let symbol = buffer[(x, y)].symbol();
114                line.push_str(symbol);
115                let width = Span::raw(symbol)
116                    .width()
117                    .max(1)
118                    .min((area.right() - x) as usize);
119                x += width as u16;
120            }
121            line.trim_end().to_string()
122        })
123        .collect::<Vec<_>>()
124        .join("\n")
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use ratatui::{layout::Rect, style::Style};
131
132    #[test]
133    fn snapshots_preserve_unicode_without_extra_wide_character_spaces() {
134        let mut buffer = Buffer::empty(Rect::new(3, 7, 18, 3));
135        buffer.set_string(3, 7, "界 e\u{301} ✅", Style::default());
136        buffer.set_string(3, 8, "  indented  ", Style::default());
137        assert_eq!(snapshot_text(&buffer), "界 e\u{301} ✅\n  indented\n");
138        assert_eq!(snapshot_text(&Buffer::empty(Rect::new(0, 0, 0, 0))), "");
139    }
140
141    #[test]
142    fn text_and_snapshots_share_the_writer_and_report_results() {
143        let mut state = ClipboardState::default();
144        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
145        buffer.set_string(0, 0, "screen", Style::default());
146        state.request_text("arn:test");
147        assert!(state.complete_with(&buffer, |text| {
148            assert_eq!(text, "arn:test");
149            Ok(())
150        }));
151        assert_eq!(state.notice().unwrap().message, "Copied to clipboard");
152        assert!(!state.notice().unwrap().failed);
153        assert!(!state.complete_with(&buffer, |_| panic!("No second write")));
154        state.request_snapshot();
155        assert!(state.snapshot_requested());
156        state.complete_with(&buffer, |text| {
157            assert_eq!(text, "screen");
158            Ok(())
159        });
160        assert!(!state.snapshot_requested());
161        assert_eq!(
162            state.notice().unwrap().message,
163            "Snapshot copied to clipboard"
164        );
165        state.request_text("secret-not-in-error-message");
166        state.complete_with(&buffer, |_| Err("clipboard unavailable".into()));
167        assert!(state.notice().unwrap().failed);
168        assert_eq!(
169            state.notice().unwrap().message,
170            "Failed to copy: clipboard unavailable"
171        );
172    }
173
174    #[test]
175    fn copy_notices_expire_and_latest_request_wins() {
176        let mut state = ClipboardState::default();
177        let buffer = Buffer::empty(Rect::new(0, 0, 0, 0));
178        state.request_snapshot();
179        state.request_text("latest");
180        assert!(!state.snapshot_requested());
181        state.complete_with(&buffer, |text| {
182            assert_eq!(text, "latest");
183            Ok(())
184        });
185        state.notice.as_mut().unwrap().created = Instant::now() - NOTICE_DURATION;
186        assert!(state.notice().is_none());
187        assert!(state.expire_notice());
188        assert!(!state.expire_notice());
189    }
190}
191
192#[cfg(test)]
193mod app_tests {
194    use super::*;
195    use crate::app::{App, Service};
196    use crate::keymap::{Action, Mode};
197    use ratatui::{backend::TestBackend, layout::Rect, Terminal};
198
199    #[test]
200    fn clipboard_service_yanks_keep_payloads_and_do_not_replace_aws_errors() {
201        let mut app = App::new_without_client("test".into(), None);
202        app.current_service = Service::DynamoDbTables;
203        app.service_selected = true;
204        app.mode = Mode::Normal;
205        app.dynamodb_state.tables.items = vec![rusticity_core::dynamodb::DdbTable {
206            name: "orders".into(),
207            table_arn: "arn:aws:dynamodb:us-east-2:123456789012:table/orders".into(),
208            ..Default::default()
209        }];
210        let buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
211        app.handle_action(Action::Yank);
212        app.clipboard.complete_with(&buffer, |text| {
213            assert_eq!(text, "orders");
214            Ok(())
215        });
216        app.dynamodb_state.current_table = Some("orders".into());
217        app.handle_action(Action::Yank);
218        app.clipboard.complete_with(&buffer, |text| {
219            assert_eq!(text, "arn:aws:dynamodb:us-east-2:123456789012:table/orders");
220            Ok(())
221        });
222        app.mode = Mode::ErrorModal;
223        app.error_message = Some("AWS access denied".into());
224        app.handle_action(Action::Yank);
225        app.clipboard.complete_with(&buffer, |text| {
226            assert_eq!(text, "AWS access denied");
227            Ok(())
228        });
229        assert_eq!(app.error_message.as_deref(), Some("AWS access denied"));
230        assert_eq!(app.mode, Mode::ErrorModal);
231        app.clipboard.request_text("retry");
232        app.clipboard
233            .complete_with(&buffer, |_| Err("no clipboard".into()));
234        assert_eq!(app.error_message.as_deref(), Some("AWS access denied"));
235        assert_eq!(app.mode, Mode::ErrorModal);
236    }
237
238    #[test]
239    fn clipboard_notices_render_in_status_bar_without_opening_a_modal() {
240        let mut app = App::new_without_client("test".into(), None);
241        app.mode = Mode::Normal;
242        let buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
243        for failed in [false, true] {
244            app.clipboard.request_text("data");
245            app.clipboard.complete_with(&buffer, |_| {
246                if failed {
247                    Err("unavailable".into())
248                } else {
249                    Ok(())
250                }
251            });
252            let mut terminal = Terminal::new(TestBackend::new(160, 25)).unwrap();
253            terminal
254                .draw(|frame| crate::ui::render(frame, &app))
255                .unwrap();
256            let text: String = terminal
257                .backend()
258                .buffer()
259                .content()
260                .iter()
261                .map(|cell| cell.symbol())
262                .collect();
263            assert!(text.contains(if failed {
264                "Failed to copy: unavailable"
265            } else {
266                "Copied to clipboard"
267            }));
268            assert_eq!(app.mode, Mode::Normal);
269            assert!(app.error_message.is_none());
270        }
271    }
272
273    #[test]
274    fn clipboard_inventory_actions_and_print_use_the_same_request_state() {
275        let mut app = App::new_without_client("test".into(), None);
276        app.service_selected = true;
277        app.current_service = Service::VpcVpcs;
278        app.mode = Mode::Normal;
279        app.vpc_state.vpcs.items = vec![rusticity_core::vpc::Vpc {
280            vpc_id: "vpc-test".into(),
281            arn: "arn:vpc:test".into(),
282            ..Default::default()
283        }];
284        let buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
285        app.handle_action(Action::Yank);
286        assert!(!app.clipboard.snapshot_requested());
287        app.clipboard.complete_with(&buffer, |text| {
288            assert_eq!(text, "arn:vpc:test");
289            Ok(())
290        });
291        app.handle_action(Action::CopyToClipboard);
292        assert!(app.clipboard.snapshot_requested());
293        app.clipboard.complete_with(&buffer, |text| {
294            assert_eq!(text, "");
295            Ok(())
296        });
297        assert!(!app.clipboard.snapshot_requested());
298    }
299}