use egui::Context;
use par_term_terminal::{ClipboardEntry, ClipboardSlot};
const VISIBLE_ROWS: usize = 12;
const PASTE_SPECIAL_KEY: [crate::app::overlay::picker::ExtraKey; 1] =
[crate::app::overlay::picker::ExtraKey {
modifiers: egui::Modifiers::SHIFT,
key: egui::Key::Enter,
label: "Shift+Enter paste special",
}];
pub struct ClipboardHistoryUI {
pub visible: bool,
search_query: String,
nav: crate::app::overlay::picker::ListNav,
request_focus: bool,
toggle_chord: Option<String>,
cached_entries: Vec<ClipboardEntry>,
}
#[derive(Debug, Clone)]
pub enum ClipboardHistoryAction {
None,
Paste(String),
OpenPasteSpecial(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(),
nav: Default::default(),
request_focus: false,
toggle_chord: None,
cached_entries: Vec::new(),
}
}
pub fn toggle(&mut self) {
self.visible = !self.visible;
if self.visible {
self.nav.reset();
self.request_focus = true;
}
}
pub fn update_entries(&mut self, entries: Vec<ClipboardEntry>) {
self.cached_entries = entries;
}
fn filtered(&self) -> Vec<&ClipboardEntry> {
let query = self.search_query.to_lowercase();
self.cached_entries
.iter()
.filter(|e| query.is_empty() || e.content.to_lowercase().contains(&query))
.collect()
}
pub fn selected_entry(&self) -> Option<&ClipboardEntry> {
self.filtered().get(self.nav.selected).copied()
}
pub(crate) fn sync_toggle_chord(
&mut self,
registry: &par_term_keybindings::KeybindingRegistry,
) {
self.toggle_chord = registry
.chord_for_action("toggle_clipboard_history")
.map(|c| crate::command_palette::catalog::chord_display(&c));
}
#[cfg(test)]
pub(crate) fn toggle_chord(&self) -> Option<&str> {
self.toggle_chord.as_deref()
}
pub fn show(&mut self, ctx: &Context) -> ClipboardHistoryAction {
use crate::app::overlay::picker::{self, Activation, ListConfig, ListHooks, ListOutcome};
if !self.visible {
return ClipboardHistoryAction::None;
}
let matches: Vec<ClipboardEntry> = self.filtered().into_iter().cloned().collect();
let config = ListConfig {
id: "Clipboard History",
hint: "Search clipboard history",
visible_rows: VISIBLE_ROWS,
width: crate::app::overlay::theme::WIDTH_MEDIUM,
empty_text: "No clipboard history entries",
enter_verb: "paste",
toggle_chord: self.toggle_chord.as_deref(),
alternates: false,
alternate_labels: None,
extra_keys: &PASTE_SPECIAL_KEY,
multi_select: false,
};
let mut clear_requested = false;
let mut clear_button = |ui: &mut egui::Ui| {
if ui.button("Clear History").clicked() {
clear_requested = true;
}
};
let (outcome, query_changed) = picker::show_list_with(
ctx,
&config,
ListHooks {
keys_follow_filter: false,
above: None,
below: Some(&mut clear_button),
},
&mut self.search_query,
&mut self.request_focus,
&mut self.nav,
matches.len(),
|ui, index, selected| {
let entry = &matches[index];
let label = format!(
"[{}] {}",
format_timestamp(entry.timestamp),
truncate_preview(&entry.content, 80)
);
ui.selectable_label(selected, label)
.on_hover_text(&entry.content)
.clicked()
},
);
if query_changed {
self.nav.reset();
}
if clear_requested {
return ClipboardHistoryAction::ClearAll;
}
match outcome {
ListOutcome::Chosen { index, how } => {
let content = matches.get(index).map(|e| e.content.clone());
self.visible = false;
match (content, how) {
(Some(c), Activation::Extra(_)) => ClipboardHistoryAction::OpenPasteSpecial(c),
(Some(c), _) => ClipboardHistoryAction::Paste(c),
(None, _) => ClipboardHistoryAction::None,
}
}
ListOutcome::Closed => {
self.visible = false;
ClipboardHistoryAction::None
}
ListOutcome::Open | ListOutcome::ToggleMark(_) => ClipboardHistoryAction::None,
}
}
}
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()
}
}
#[cfg(test)]
mod b70_tests {
use super::*;
fn key_event(key: egui::Key, shift: bool) -> egui::Event {
egui::Event::Key {
key,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers {
shift,
..Default::default()
},
}
}
fn frame(
ctx: &egui::Context,
ui: &mut ClipboardHistoryUI,
events: Vec<egui::Event>,
) -> ClipboardHistoryAction {
let raw = egui::RawInput {
events,
..Default::default()
};
ctx.begin_pass(raw);
let action = ui.show(ctx);
ctx.end_pass().textures_delta.clear();
action
}
fn entries() -> Vec<ClipboardEntry> {
vec![
ClipboardEntry {
content: "b70-clip-BBB".into(),
timestamp: 3_000_000_000,
label: None,
},
ClipboardEntry {
content: "b70-clip-AAA".into(),
timestamp: 1_000_000_000,
label: None,
},
]
}
#[test]
fn arrows_and_enter_paste_the_second_row() {
let ctx = egui::Context::default();
let mut ui = ClipboardHistoryUI::new();
ui.update_entries(entries());
ui.toggle();
assert_eq!(ui.nav.selected, 0);
let _ = frame(&ctx, &mut ui, vec![key_event(egui::Key::ArrowDown, false)]);
assert_eq!(ui.nav.selected, 1);
let action = frame(&ctx, &mut ui, vec![key_event(egui::Key::Enter, false)]);
assert!(!ui.visible, "Enter closes the panel");
match action {
ClipboardHistoryAction::Paste(content) => assert_eq!(content, "b70-clip-AAA"),
other => panic!("expected Paste, got {other:?}"),
}
}
#[test]
fn shift_enter_opens_paste_special_instead_of_pasting() {
let ctx = egui::Context::default();
let mut ui = ClipboardHistoryUI::new();
ui.update_entries(entries());
ui.toggle();
let action = frame(&ctx, &mut ui, vec![key_event(egui::Key::Enter, true)]);
assert!(!ui.visible, "Shift+Enter closes the history panel");
match action {
ClipboardHistoryAction::OpenPasteSpecial(content) => {
assert_eq!(content, "b70-clip-BBB")
}
other => panic!("expected OpenPasteSpecial, got {other:?}"),
}
}
#[test]
fn escape_closes_without_an_action() {
let ctx = egui::Context::default();
let mut ui = ClipboardHistoryUI::new();
ui.update_entries(entries());
ui.toggle();
let action = frame(&ctx, &mut ui, vec![key_event(egui::Key::Escape, false)]);
assert!(!ui.visible);
assert!(matches!(action, ClipboardHistoryAction::None));
}
#[test]
fn the_footer_names_paste_special_and_the_live_chord() {
let mut ui = ClipboardHistoryUI::new();
ui.toggle_chord = Some("Cmd+Shift+V".into());
let config = crate::app::overlay::picker::ListConfig {
id: "t",
hint: "",
visible_rows: VISIBLE_ROWS,
width: 0.0,
empty_text: "",
enter_verb: "paste",
toggle_chord: ui.toggle_chord(),
alternates: false,
alternate_labels: None,
extra_keys: &PASTE_SPECIAL_KEY,
multi_select: false,
};
let footer = crate::app::overlay::picker::footer_text(&config);
assert!(footer.contains("Shift+Enter paste special"));
assert!(!footer.contains("Cmd+Enter"));
assert!(footer.ends_with("Esc or Cmd+Shift+V close"));
}
#[test]
fn cmd_enter_is_not_an_alternate() {
let ctx = egui::Context::default();
let mut ui = ClipboardHistoryUI::new();
ui.update_entries(entries());
ui.toggle();
let cmd_enter = egui::Event::Key {
key: egui::Key::Enter,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::COMMAND,
};
let action = frame(&ctx, &mut ui, vec![cmd_enter]);
assert!(matches!(action, ClipboardHistoryAction::None));
assert!(ui.visible, "Cmd+Enter neither pastes nor closes");
}
#[test]
fn a_filtered_list_selects_among_the_matches() {
let mut ui = ClipboardHistoryUI::new();
ui.update_entries(entries());
ui.search_query = "AAA".into();
assert_eq!(ui.selected_entry().unwrap().content, "b70-clip-AAA");
}
}