use crate::terminal::{ClipboardEntry, ClipboardSlot};
use crate::ui_constants::{
CLIPBOARD_WINDOW_DEFAULT_HEIGHT, CLIPBOARD_WINDOW_DEFAULT_WIDTH, CLIPBOARD_WINDOW_MAX_HEIGHT,
};
use egui::{Context, Window};
pub struct ClipboardHistoryUI {
pub visible: bool,
search_query: String,
selected_index: Option<usize>,
cached_entries: Vec<ClipboardEntry>,
}
#[derive(Debug, Clone)]
pub enum ClipboardHistoryAction {
None,
Paste(String),
ClearSlot(ClipboardSlot),
ClearAll,
}
impl Default for ClipboardHistoryUI {
fn default() -> Self {
Self::new()
}
}
impl ClipboardHistoryUI {
pub fn new() -> Self {
Self {
visible: false,
search_query: String::new(),
selected_index: None,
cached_entries: Vec::new(),
}
}
pub fn toggle(&mut self) {
self.visible = !self.visible;
if self.visible {
self.selected_index = if self.cached_entries.is_empty() {
None
} else {
Some(0)
};
}
}
pub fn update_entries(&mut self, entries: Vec<ClipboardEntry>) {
self.cached_entries = entries;
if let Some(idx) = self.selected_index
&& idx >= self.cached_entries.len()
{
self.selected_index = if self.cached_entries.is_empty() {
None
} else {
Some(self.cached_entries.len() - 1)
};
}
}
pub fn select_previous(&mut self) {
if let Some(idx) = self.selected_index {
if idx > 0 {
self.selected_index = Some(idx - 1);
}
} else if !self.cached_entries.is_empty() {
self.selected_index = Some(self.cached_entries.len() - 1);
}
}
pub fn select_next(&mut self) {
if let Some(idx) = self.selected_index {
if idx < self.cached_entries.len().saturating_sub(1) {
self.selected_index = Some(idx + 1);
}
} else if !self.cached_entries.is_empty() {
self.selected_index = Some(0);
}
}
pub fn selected_entry(&self) -> Option<&ClipboardEntry> {
self.selected_index
.and_then(|idx| self.cached_entries.get(idx))
}
pub fn show(&mut self, ctx: &Context) -> ClipboardHistoryAction {
if !self.visible {
return ClipboardHistoryAction::None;
}
let mut action = ClipboardHistoryAction::None;
let mut open = true;
let screen_rect = ctx.content_rect();
let default_pos = egui::pos2(
(screen_rect.width() - CLIPBOARD_WINDOW_DEFAULT_WIDTH) / 2.0,
(screen_rect.height() - CLIPBOARD_WINDOW_DEFAULT_HEIGHT) / 2.0,
);
Window::new("Clipboard History")
.resizable(true)
.collapsible(false)
.default_width(CLIPBOARD_WINDOW_DEFAULT_WIDTH)
.default_height(CLIPBOARD_WINDOW_DEFAULT_HEIGHT)
.max_height(CLIPBOARD_WINDOW_MAX_HEIGHT)
.default_pos(default_pos)
.open(&mut open)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label("Search:");
ui.text_edit_singleline(&mut self.search_query);
});
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
let filtered_entries: Vec<(usize, &ClipboardEntry)> = self
.cached_entries
.iter()
.enumerate()
.filter(|(_, entry)| {
if self.search_query.is_empty() {
true
} else {
entry
.content
.to_lowercase()
.contains(&self.search_query.to_lowercase())
}
})
.collect();
if filtered_entries.is_empty() {
ui.label("No clipboard history entries");
} else {
for (original_idx, entry) in filtered_entries {
let is_selected = self.selected_index == Some(original_idx);
let preview = truncate_preview(&entry.content, 80);
let timestamp = format_timestamp(entry.timestamp);
let response = ui.selectable_label(
is_selected,
format!("[{}] {}", timestamp, preview),
);
if response.clicked() {
self.selected_index = Some(original_idx);
}
if response.double_clicked() {
action = ClipboardHistoryAction::Paste(entry.content.clone());
self.visible = false;
}
response.on_hover_text(&entry.content);
}
}
});
ui.separator();
ui.horizontal(|ui| {
if ui.button("Paste Selected").clicked()
&& let Some(entry) = self.selected_entry()
{
action = ClipboardHistoryAction::Paste(entry.content.clone());
self.visible = false;
}
if ui.button("Clear History").clicked() {
action = ClipboardHistoryAction::ClearAll;
}
if ui.button("Close").clicked() {
self.visible = false;
}
});
ui.separator();
ui.horizontal(|ui| {
ui.label("Hints:");
ui.label("\u{f062}\u{f063} Navigate");
ui.label("Enter Paste");
ui.label("Shift+Enter Transform");
ui.label("Esc Close");
});
});
if !open {
self.visible = false;
}
action
}
}
impl crate::traits::OverlayComponent for ClipboardHistoryUI {
type Action = ClipboardHistoryAction;
fn show(&mut self, ctx: &egui::Context) -> Self::Action {
ClipboardHistoryUI::show(self, ctx)
}
fn is_visible(&self) -> bool {
self.visible
}
fn set_visible(&mut self, visible: bool) {
self.visible = visible;
}
}
fn truncate_preview(content: &str, max_len: usize) -> String {
let single_line = content.replace('\n', "↵").replace('\r', "");
if single_line.len() <= max_len {
single_line
} else {
let boundary = single_line.floor_char_boundary(max_len);
format!("{}...", &single_line[..boundary])
}
}
fn format_timestamp(timestamp_us: u64) -> String {
use std::time::{Duration, SystemTime, UNIX_EPOCH};
let duration = Duration::from_micros(timestamp_us);
let time = UNIX_EPOCH + duration;
if let Ok(elapsed) = SystemTime::now().duration_since(time) {
let secs = elapsed.as_secs();
if secs < 60 {
format!("{}s ago", secs)
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86400)
}
} else {
"just now".to_string()
}
}