Skip to main content

par_term/
paste_special_ui.rs

1//! Paste Special UI - Command palette for text transformations.
2//!
3//! Provides a fuzzy-searchable command palette for applying text transformations
4//! to clipboard content before pasting.
5
6use 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/// Action to take after showing the UI
14#[derive(Debug, Clone)]
15pub enum PasteSpecialAction {
16    /// No action needed
17    None,
18    /// Paste the transformed content
19    Paste(String),
20}
21
22/// Paste Special UI manager using egui
23pub struct PasteSpecialUI {
24    /// Whether the paste special window is currently visible
25    pub visible: bool,
26
27    /// Current search query for filtering transformations
28    search_query: String,
29
30    /// Index of currently selected transformation (for keyboard navigation)
31    selected_index: usize,
32
33    /// The clipboard content to transform
34    content: String,
35
36    /// Cached filtered transformations (updated when search changes)
37    filtered_transforms: Vec<PasteTransform>,
38
39    /// Preview of the transformed content (or error message)
40    preview_result: Result<String, String>,
41}
42
43impl Default for PasteSpecialUI {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl PasteSpecialUI {
50    /// Create a new paste special UI
51    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    /// Open the paste special UI with the given clipboard content
64    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    /// Close the paste special UI
74    pub fn close(&mut self) {
75        self.visible = false;
76        self.content.clear();
77        self.search_query.clear();
78    }
79
80    /// Navigate selection up
81    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    /// Navigate selection down
89    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    /// Get the currently selected transformation
97    pub fn selected_transform(&self) -> Option<PasteTransform> {
98        self.filtered_transforms.get(self.selected_index).copied()
99    }
100
101    /// Apply the selected transformation and return the result
102    pub fn apply_selected(&self) -> Option<String> {
103        self.selected_transform()
104            .and_then(|t| transform(&self.content, t).ok())
105    }
106
107    /// Update the filtered transformations based on search query
108    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        // Reset selection if out of bounds
116        if self.selected_index >= self.filtered_transforms.len() {
117            self.selected_index = 0;
118        }
119    }
120
121    /// Update the preview result for the current selection
122    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    /// Show the paste special window and return any action to take
131    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        // Calculate center position
141        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                // Search bar with focus
156                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                    // Request focus on the search box
163                    response.request_focus();
164                });
165
166                ui.separator();
167
168                // Track if we need to update preview after the UI loop
169                let mut clicked_index: Option<usize> = None;
170                let mut double_clicked = false;
171
172                // Two-column layout: transformations list and preview
173                ui.columns(2, |columns| {
174                    // Left column: transformation list
175                    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                                    // Show description on hover
208                                    response.on_hover_text(transform_type.description());
209                                }
210                            }
211                        });
212
213                    // Right column: preview
214                    columns[1].heading("Preview");
215                    columns[1].separator();
216
217                    // Show original content (truncated)
218                    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                    // Show transformed content (or error)
229                    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                // Handle click events after the borrow ends
246                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                // Action buttons
258                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                    // Show content length info
276                    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                // Keyboard hints
286                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        // Handle search changes
297        if search_changed {
298            self.update_filtered_transforms();
299            self.update_preview();
300        }
301
302        // Handle window close
303        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
327/// Truncate content for preview display
328fn truncate_preview(content: &str, max_len: usize) -> String {
329    // Replace newlines with visible markers
330    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        // Can't go below 0
375        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        // Find UPPERCASE transform
385        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        // All transforms initially
407        let all_count = PasteTransform::all().len();
408        assert_eq!(ui.filtered_transforms.len(), all_count);
409
410        // Filter to shell only
411        ui.search_query = "shell".to_string();
412        ui.update_filtered_transforms();
413        assert_eq!(ui.filtered_transforms.len(), 3); // Single, Double, Backslash
414
415        // Filter to base64
416        ui.search_query = "base64".to_string();
417        ui.update_filtered_transforms();
418        assert_eq!(ui.filtered_transforms.len(), 2); // Encode and Decode
419    }
420}