Skip to main content

par_term/
clipboard_history_ui.rs

1use crate::ui_constants::{
2    CLIPBOARD_WINDOW_DEFAULT_HEIGHT, CLIPBOARD_WINDOW_DEFAULT_WIDTH, CLIPBOARD_WINDOW_MAX_HEIGHT,
3};
4use egui::{Context, Window};
5use par_term_terminal::{ClipboardEntry, ClipboardSlot};
6
7/// Clipboard history UI manager using egui
8pub struct ClipboardHistoryUI {
9    /// Whether the clipboard history window is currently visible
10    pub visible: bool,
11
12    /// Current search query
13    search_query: String,
14
15    /// Index of currently selected entry (for keyboard navigation)
16    selected_index: Option<usize>,
17
18    /// Cached clipboard history entries (refreshed when shown)
19    cached_entries: Vec<ClipboardEntry>,
20}
21
22/// Action to take after showing the UI
23#[derive(Debug, Clone)]
24pub enum ClipboardHistoryAction {
25    /// No action needed
26    None,
27    /// Paste the selected entry content
28    Paste(String),
29    /// Clear clipboard history for a slot
30    ClearSlot(ClipboardSlot),
31    /// Clear all clipboard history
32    ClearAll,
33}
34
35impl Default for ClipboardHistoryUI {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl ClipboardHistoryUI {
42    /// Create a new clipboard history UI
43    pub fn new() -> Self {
44        Self {
45            visible: false,
46            search_query: String::new(),
47            selected_index: None,
48            cached_entries: Vec::new(),
49        }
50    }
51
52    /// Toggle clipboard history window visibility
53    pub fn toggle(&mut self) {
54        self.visible = !self.visible;
55        if self.visible {
56            // Reset selection when opening
57            self.selected_index = if self.cached_entries.is_empty() {
58                None
59            } else {
60                Some(0)
61            };
62        }
63    }
64
65    /// Update cached entries from terminal
66    pub fn update_entries(&mut self, entries: Vec<ClipboardEntry>) {
67        self.cached_entries = entries;
68        // Reset selection if out of bounds
69        if let Some(idx) = self.selected_index
70            && idx >= self.cached_entries.len()
71        {
72            self.selected_index = if self.cached_entries.is_empty() {
73                None
74            } else {
75                Some(self.cached_entries.len() - 1)
76            };
77        }
78    }
79
80    /// Navigate selection up
81    pub fn select_previous(&mut self) {
82        if let Some(idx) = self.selected_index {
83            if idx > 0 {
84                self.selected_index = Some(idx - 1);
85            }
86        } else if !self.cached_entries.is_empty() {
87            self.selected_index = Some(self.cached_entries.len() - 1);
88        }
89    }
90
91    /// Navigate selection down
92    pub fn select_next(&mut self) {
93        if let Some(idx) = self.selected_index {
94            if idx < self.cached_entries.len().saturating_sub(1) {
95                self.selected_index = Some(idx + 1);
96            }
97        } else if !self.cached_entries.is_empty() {
98            self.selected_index = Some(0);
99        }
100    }
101
102    /// Get the currently selected entry
103    pub fn selected_entry(&self) -> Option<&ClipboardEntry> {
104        self.selected_index
105            .and_then(|idx| self.cached_entries.get(idx))
106    }
107
108    /// Show the clipboard history window and return any action to take
109    pub fn show(&mut self, ctx: &Context) -> ClipboardHistoryAction {
110        if !self.visible {
111            return ClipboardHistoryAction::None;
112        }
113
114        let mut action = ClipboardHistoryAction::None;
115        let mut open = true;
116
117        // Calculate center position for initial placement
118        let screen_rect = ctx.content_rect();
119        let default_pos = egui::pos2(
120            (screen_rect.width() - CLIPBOARD_WINDOW_DEFAULT_WIDTH) / 2.0,
121            (screen_rect.height() - CLIPBOARD_WINDOW_DEFAULT_HEIGHT) / 2.0,
122        );
123
124        Window::new("Clipboard History")
125            .resizable(true)
126            .collapsible(false)
127            .default_width(CLIPBOARD_WINDOW_DEFAULT_WIDTH)
128            .default_height(CLIPBOARD_WINDOW_DEFAULT_HEIGHT)
129            .max_height(CLIPBOARD_WINDOW_MAX_HEIGHT)
130            .default_pos(default_pos)
131            .open(&mut open)
132            .show(ctx, |ui| {
133                // Search bar
134                ui.horizontal(|ui| {
135                    ui.label("Search:");
136                    ui.text_edit_singleline(&mut self.search_query);
137                });
138
139                ui.separator();
140
141                // Entry list
142                egui::ScrollArea::vertical()
143                    .auto_shrink([false, false])
144                    .show(ui, |ui| {
145                        let filtered_entries: Vec<(usize, &ClipboardEntry)> = self
146                            .cached_entries
147                            .iter()
148                            .enumerate()
149                            .filter(|(_, entry)| {
150                                if self.search_query.is_empty() {
151                                    true
152                                } else {
153                                    entry
154                                        .content
155                                        .to_lowercase()
156                                        .contains(&self.search_query.to_lowercase())
157                                }
158                            })
159                            .collect();
160
161                        if filtered_entries.is_empty() {
162                            ui.label("No clipboard history entries");
163                        } else {
164                            for (original_idx, entry) in filtered_entries {
165                                let is_selected = self.selected_index == Some(original_idx);
166                                let preview = truncate_preview(&entry.content, 80);
167                                let timestamp = format_timestamp(entry.timestamp);
168
169                                let response = ui.selectable_label(
170                                    is_selected,
171                                    format!("[{}] {}", timestamp, preview),
172                                );
173
174                                if response.clicked() {
175                                    self.selected_index = Some(original_idx);
176                                }
177
178                                if response.double_clicked() {
179                                    action = ClipboardHistoryAction::Paste(entry.content.clone());
180                                    self.visible = false;
181                                }
182
183                                // Show tooltip with full content on hover
184                                response.on_hover_text(&entry.content);
185                            }
186                        }
187                    });
188
189                ui.separator();
190
191                // Action buttons
192                ui.horizontal(|ui| {
193                    if ui.button("Paste Selected").clicked()
194                        && let Some(entry) = self.selected_entry()
195                    {
196                        action = ClipboardHistoryAction::Paste(entry.content.clone());
197                        self.visible = false;
198                    }
199
200                    if ui.button("Clear History").clicked() {
201                        action = ClipboardHistoryAction::ClearAll;
202                    }
203
204                    if ui.button("Close").clicked() {
205                        self.visible = false;
206                    }
207                });
208
209                // Keyboard hints
210                ui.separator();
211                ui.horizontal(|ui| {
212                    ui.label("Hints:");
213                    ui.label("\u{f062}\u{f063} Navigate");
214                    ui.label("Enter Paste");
215                    ui.label("Shift+Enter Transform");
216                    ui.label("Esc Close");
217                });
218            });
219
220        // Handle window close
221        if !open {
222            self.visible = false;
223        }
224
225        action
226    }
227}
228
229impl crate::traits::OverlayComponent for ClipboardHistoryUI {
230    type Action = ClipboardHistoryAction;
231
232    fn show(&mut self, ctx: &egui::Context) -> Self::Action {
233        ClipboardHistoryUI::show(self, ctx)
234    }
235
236    fn is_visible(&self) -> bool {
237        self.visible
238    }
239
240    fn set_visible(&mut self, visible: bool) {
241        self.visible = visible;
242    }
243}
244
245/// Truncate content for preview display
246fn truncate_preview(content: &str, max_len: usize) -> String {
247    // Replace newlines with visible markers
248    let single_line = content.replace('\n', "↵").replace('\r', "");
249
250    if single_line.len() <= max_len {
251        single_line
252    } else {
253        let boundary = single_line.floor_char_boundary(max_len);
254        format!("{}...", &single_line[..boundary])
255    }
256}
257
258/// Format timestamp for display
259fn format_timestamp(timestamp_us: u64) -> String {
260    use std::time::{Duration, SystemTime, UNIX_EPOCH};
261
262    let duration = Duration::from_micros(timestamp_us);
263    let time = UNIX_EPOCH + duration;
264
265    if let Ok(elapsed) = SystemTime::now().duration_since(time) {
266        let secs = elapsed.as_secs();
267        if secs < 60 {
268            format!("{}s ago", secs)
269        } else if secs < 3600 {
270            format!("{}m ago", secs / 60)
271        } else if secs < 86400 {
272            format!("{}h ago", secs / 3600)
273        } else {
274            format!("{}d ago", secs / 86400)
275        }
276    } else {
277        "just now".to_string()
278    }
279}