par_term/
paste_special_ui.rs1use crate::paste_transform::{PasteTransform, transform};
7use crate::ui_constants::{
8 PASTE_SPECIAL_TRANSFORMS_MAX_HEIGHT, PASTE_SPECIAL_WINDOW_DEFAULT_HEIGHT,
9 PASTE_SPECIAL_WINDOW_DEFAULT_WIDTH,
10};
11use egui::{Color32, Context, RichText, Window};
12
13#[derive(Debug, Clone)]
15pub enum PasteSpecialAction {
16 None,
18 Paste(String),
20}
21
22pub struct PasteSpecialUI {
24 pub visible: bool,
26
27 search_query: String,
29
30 selected_index: usize,
32
33 content: String,
35
36 filtered_transforms: Vec<PasteTransform>,
38
39 preview_result: Result<String, String>,
41}
42
43impl Default for PasteSpecialUI {
44 fn default() -> Self {
45 Self::new()
46 }
47}
48
49impl PasteSpecialUI {
50 pub fn new() -> Self {
52 let filtered = PasteTransform::all().to_vec();
53 Self {
54 visible: false,
55 search_query: String::new(),
56 selected_index: 0,
57 content: String::new(),
58 filtered_transforms: filtered,
59 preview_result: Ok(String::new()),
60 }
61 }
62
63 pub fn open(&mut self, content: String) {
65 self.visible = true;
66 self.content = content;
67 self.search_query.clear();
68 self.selected_index = 0;
69 self.update_filtered_transforms();
70 self.update_preview();
71 }
72
73 pub fn close(&mut self) {
75 self.visible = false;
76 self.content.clear();
77 self.search_query.clear();
78 }
79
80 pub fn select_previous(&mut self) {
82 if self.selected_index > 0 {
83 self.selected_index -= 1;
84 self.update_preview();
85 }
86 }
87
88 pub fn select_next(&mut self) {
90 if self.selected_index < self.filtered_transforms.len().saturating_sub(1) {
91 self.selected_index += 1;
92 self.update_preview();
93 }
94 }
95
96 pub fn selected_transform(&self) -> Option<PasteTransform> {
98 self.filtered_transforms.get(self.selected_index).copied()
99 }
100
101 pub fn apply_selected(&self) -> Option<String> {
103 self.selected_transform()
104 .and_then(|t| transform(&self.content, t).ok())
105 }
106
107 fn update_filtered_transforms(&mut self) {
109 self.filtered_transforms = PasteTransform::all()
110 .iter()
111 .filter(|t| t.matches_query(&self.search_query))
112 .copied()
113 .collect();
114
115 if self.selected_index >= self.filtered_transforms.len() {
117 self.selected_index = 0;
118 }
119 }
120
121 fn update_preview(&mut self) {
123 if let Some(t) = self.selected_transform() {
124 self.preview_result = transform(&self.content, t);
125 } else {
126 self.preview_result = Ok(self.content.clone());
127 }
128 }
129
130 pub fn show(&mut self, ctx: &Context) -> PasteSpecialAction {
132 if !self.visible {
133 return PasteSpecialAction::None;
134 }
135
136 let mut action = PasteSpecialAction::None;
137 let mut open = true;
138 let mut search_changed = false;
139
140 let screen_rect = ctx.content_rect();
142 let default_pos = egui::pos2(
143 (screen_rect.width() - PASTE_SPECIAL_WINDOW_DEFAULT_WIDTH) / 2.0,
144 (screen_rect.height() - PASTE_SPECIAL_WINDOW_DEFAULT_HEIGHT) / 2.0,
145 );
146
147 Window::new("Paste Special")
148 .resizable(true)
149 .collapsible(false)
150 .default_width(PASTE_SPECIAL_WINDOW_DEFAULT_WIDTH)
151 .default_height(PASTE_SPECIAL_WINDOW_DEFAULT_HEIGHT)
152 .default_pos(default_pos)
153 .open(&mut open)
154 .show(ctx, |ui| {
155 ui.horizontal(|ui| {
157 ui.label("Search:");
158 let response = ui.text_edit_singleline(&mut self.search_query);
159 if response.changed() {
160 search_changed = true;
161 }
162 response.request_focus();
164 });
165
166 ui.separator();
167
168 let mut clicked_index: Option<usize> = None;
170 let mut double_clicked = false;
171
172 ui.columns(2, |columns| {
174 columns[0].heading("Transformations");
176 egui::ScrollArea::vertical()
177 .id_salt("transforms_scroll")
178 .auto_shrink([false, false])
179 .max_height(PASTE_SPECIAL_TRANSFORMS_MAX_HEIGHT)
180 .show(&mut columns[0], |ui| {
181 if self.filtered_transforms.is_empty() {
182 ui.label("No matching transformations");
183 } else {
184 for (idx, transform_type) in
185 self.filtered_transforms.iter().enumerate()
186 {
187 let is_selected = idx == self.selected_index;
188 let text = if is_selected {
189 RichText::new(transform_type.display_name())
190 .strong()
191 .color(Color32::WHITE)
192 } else {
193 RichText::new(transform_type.display_name())
194 };
195
196 let response = ui.selectable_label(is_selected, text);
197
198 if response.clicked() {
199 clicked_index = Some(idx);
200 }
201
202 if response.double_clicked() {
203 clicked_index = Some(idx);
204 double_clicked = true;
205 }
206
207 response.on_hover_text(transform_type.description());
209 }
210 }
211 });
212
213 columns[1].heading("Preview");
215 columns[1].separator();
216
217 columns[1].label(RichText::new("Original:").small().color(Color32::GRAY));
219 let original_preview = truncate_preview(&self.content, 100);
220 columns[1].label(
221 RichText::new(&original_preview)
222 .monospace()
223 .color(Color32::LIGHT_GRAY),
224 );
225
226 columns[1].add_space(8.0);
227
228 columns[1].label(RichText::new("Result:").small().color(Color32::GRAY));
230 match &self.preview_result {
231 Ok(result) => {
232 let result_preview = truncate_preview(result, 100);
233 columns[1].label(
234 RichText::new(&result_preview)
235 .monospace()
236 .color(Color32::LIGHT_GREEN),
237 );
238 }
239 Err(error) => {
240 columns[1].label(RichText::new(error).monospace().color(Color32::RED));
241 }
242 }
243 });
244
245 if let Some(idx) = clicked_index {
247 self.selected_index = idx;
248 self.update_preview();
249 if double_clicked && let Some(result) = self.apply_selected() {
250 action = PasteSpecialAction::Paste(result);
251 self.visible = false;
252 }
253 }
254
255 ui.separator();
256
257 ui.horizontal(|ui| {
259 let can_apply =
260 self.preview_result.is_ok() && !self.filtered_transforms.is_empty();
261
262 if ui
263 .add_enabled(can_apply, egui::Button::new("Apply & Paste"))
264 .clicked()
265 && let Some(result) = self.apply_selected()
266 {
267 action = PasteSpecialAction::Paste(result);
268 self.visible = false;
269 }
270
271 if ui.button("Cancel").clicked() {
272 self.visible = false;
273 }
274
275 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
277 ui.label(
278 RichText::new(format!("{} chars", self.content.len()))
279 .small()
280 .color(Color32::GRAY),
281 );
282 });
283 });
284
285 ui.separator();
287 ui.horizontal(|ui| {
288 ui.label(
289 RichText::new("\u{f062}\u{f063} Navigate Enter Apply Esc Cancel")
290 .small()
291 .color(Color32::GRAY),
292 );
293 });
294 });
295
296 if search_changed {
298 self.update_filtered_transforms();
299 self.update_preview();
300 }
301
302 if !open {
304 self.visible = false;
305 }
306
307 action
308 }
309}
310
311impl crate::traits::OverlayComponent for PasteSpecialUI {
312 type Action = PasteSpecialAction;
313
314 fn show(&mut self, ctx: &egui::Context) -> Self::Action {
315 PasteSpecialUI::show(self, ctx)
316 }
317
318 fn is_visible(&self) -> bool {
319 self.visible
320 }
321
322 fn set_visible(&mut self, visible: bool) {
323 self.visible = visible;
324 }
325}
326
327fn truncate_preview(content: &str, max_len: usize) -> String {
329 let single_line = content
331 .replace('\n', "↵")
332 .replace('\r', "")
333 .replace('\t', "→");
334
335 if single_line.chars().count() <= max_len {
336 single_line
337 } else {
338 let truncated: String = single_line.chars().take(max_len).collect();
339 format!("{}...", truncated)
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn test_open_and_close() {
349 let mut ui = PasteSpecialUI::new();
350 assert!(!ui.visible);
351
352 ui.open("test content".to_string());
353 assert!(ui.visible);
354 assert_eq!(ui.content, "test content");
355
356 ui.close();
357 assert!(!ui.visible);
358 assert!(ui.content.is_empty());
359 }
360
361 #[test]
362 fn test_navigation() {
363 let mut ui = PasteSpecialUI::new();
364 ui.open("test".to_string());
365
366 assert_eq!(ui.selected_index, 0);
367
368 ui.select_next();
369 assert_eq!(ui.selected_index, 1);
370
371 ui.select_previous();
372 assert_eq!(ui.selected_index, 0);
373
374 ui.select_previous();
376 assert_eq!(ui.selected_index, 0);
377 }
378
379 #[test]
380 fn test_apply_selected() {
381 let mut ui = PasteSpecialUI::new();
382 ui.open("hello world".to_string());
383
384 ui.search_query = "UPPER".to_string();
386 ui.update_filtered_transforms();
387
388 let result = ui.apply_selected();
389 assert!(result.is_some());
390 assert_eq!(result.unwrap(), "HELLO WORLD");
391 }
392
393 #[test]
394 fn test_truncate_preview() {
395 assert_eq!(truncate_preview("hello", 10), "hello");
396 assert_eq!(truncate_preview("hello world", 5), "hello...");
397 assert_eq!(truncate_preview("line1\nline2", 20), "line1↵line2");
398 assert_eq!(truncate_preview("tab\there", 20), "tab→here");
399 }
400
401 #[test]
402 fn test_search_filtering() {
403 let mut ui = PasteSpecialUI::new();
404 ui.open("test".to_string());
405
406 let all_count = PasteTransform::all().len();
408 assert_eq!(ui.filtered_transforms.len(), all_count);
409
410 ui.search_query = "shell".to_string();
412 ui.update_filtered_transforms();
413 assert_eq!(ui.filtered_transforms.len(), 3); ui.search_query = "base64".to_string();
417 ui.update_filtered_transforms();
418 assert_eq!(ui.filtered_transforms.len(), 2); }
420}