par_term/
clipboard_history_ui.rs1use 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
7pub struct ClipboardHistoryUI {
9 pub visible: bool,
11
12 search_query: String,
14
15 selected_index: Option<usize>,
17
18 cached_entries: Vec<ClipboardEntry>,
20}
21
22#[derive(Debug, Clone)]
24pub enum ClipboardHistoryAction {
25 None,
27 Paste(String),
29 ClearSlot(ClipboardSlot),
31 ClearAll,
33}
34
35impl Default for ClipboardHistoryUI {
36 fn default() -> Self {
37 Self::new()
38 }
39}
40
41impl ClipboardHistoryUI {
42 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 pub fn toggle(&mut self) {
54 self.visible = !self.visible;
55 if self.visible {
56 self.selected_index = if self.cached_entries.is_empty() {
58 None
59 } else {
60 Some(0)
61 };
62 }
63 }
64
65 pub fn update_entries(&mut self, entries: Vec<ClipboardEntry>) {
67 self.cached_entries = entries;
68 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 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 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 pub fn selected_entry(&self) -> Option<&ClipboardEntry> {
104 self.selected_index
105 .and_then(|idx| self.cached_entries.get(idx))
106 }
107
108 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 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 ui.horizontal(|ui| {
135 ui.label("Search:");
136 ui.text_edit_singleline(&mut self.search_query);
137 });
138
139 ui.separator();
140
141 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 response.on_hover_text(&entry.content);
185 }
186 }
187 });
188
189 ui.separator();
190
191 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 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 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
245fn truncate_preview(content: &str, max_len: usize) -> String {
247 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
258fn 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}