huge opti

This commit is contained in:
2026-05-20 23:49:53 +02:00
parent 8ea259531e
commit fc085a8a83
7 changed files with 355 additions and 220 deletions

1
Cargo.lock generated
View File

@ -2487,6 +2487,7 @@ dependencies = [
"serde_json",
"uuid",
"wayland-clipboard-listener",
"x11rb",
]
[[package]]

1
rklipd/Cargo.lock generated
View File

@ -1445,6 +1445,7 @@ dependencies = [
"serde_json",
"uuid",
"wayland-clipboard-listener",
"x11rb",
]
[[package]]

View File

@ -15,6 +15,7 @@ serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
base64 = "0.22.1"
aes-gcm = "0.10.3"
x11rb = "0.13.2"
[features]
x11 = []

View File

@ -8,6 +8,11 @@ use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, SystemTime};
use uuid::Uuid;
use x11rb::connection::Connection;
use x11rb::protocol::Event;
use x11rb::protocol::xfixes::{ConnectionExt as XfixesExt, SelectionEventMask};
use x11rb::protocol::xproto::{ConnectionExt as XprotoExt, CreateWindowAux, WindowClass};
use x11rb::rust_connection::RustConnection;
const MAX_IMAGE_PIXELS: usize = 3840 * 2160;
@ -18,81 +23,128 @@ fn hash_bytes(data: &[u8]) -> u64 {
}
pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
println!("Clipboard monitor started (X11 polling mode)...");
let (conn, screen_num) =
RustConnection::connect(None).map_err(|e| format!("Connexion X11 impossible : {e}"))?;
let root = conn.setup().roots[screen_num].root;
let win = conn.generate_id()?;
conn.create_window(
0,
win,
root,
0,
0,
1,
1,
0,
WindowClass::INPUT_ONLY,
0,
&CreateWindowAux::new(),
)?
.check()?;
conn.xfixes_query_version(5, 0)
.map_err(|e| format!("Extension XFIXES indisponible : {e}"))?
.reply()?;
let clipboard_atom = conn.intern_atom(false, b"CLIPBOARD")?.reply()?.atom;
conn.xfixes_select_selection_input(
win,
clipboard_atom,
SelectionEventMask::SET_SELECTION_OWNER,
)?
.check()?;
conn.flush()?;
println!("Clipboard monitor démarré (X11 XFIXES — zéro polling)");
let mut last_text: Option<String> = None;
let mut last_image_hash: Option<u64> = None;
loop {
thread::sleep(Duration::from_millis(500));
let event = conn.wait_for_event()?;
match clipboard.get_text() {
Ok(raw) => {
let text = raw.trim_end_matches('\n').to_string();
if text.is_empty() || Some(&text) == last_text.as_ref() {
continue;
}
if let Event::XfixesSelectionNotify(_) = event {
thread::sleep(Duration::from_millis(50));
handle_clipboard_event(&mut clipboard, &db, &mut last_text, &mut last_image_hash);
}
}
}
last_text = Some(text.clone());
last_image_hash = None;
println!("Clipboard update (text)!");
fn handle_clipboard_event(
clipboard: &mut Clipboard,
db: &Arc<Mutex<Database>>,
last_text: &mut Option<String>,
last_image_hash: &mut Option<u64>,
) {
match clipboard.get_text() {
Ok(raw) => {
let text = raw.trim_end_matches('\n').to_string();
if text.is_empty() || Some(&text) == last_text.as_ref() {
return;
}
*last_text = Some(text.clone());
*last_image_hash = None;
println!("Clipboard update (texte)");
let entry = ClipboardEntry {
spawn_db_write(
Arc::clone(db),
ClipboardEntry {
content: ClipboardData::Text(text),
timestamp: SystemTime::now(),
};
spawn_db_write(Arc::clone(&db), entry);
},
);
}
Err(_) => {
let Ok(img_data) = clipboard.get_image() else {
return;
};
let pixel_count = img_data.width * img_data.height;
if pixel_count > MAX_IMAGE_PIXELS {
eprintln!(
"Image ignorée : {}×{} ({} Mpx > limite 4K)",
img_data.width,
img_data.height,
pixel_count / 1_000_000
);
*last_image_hash = Some(pixel_count as u64);
*last_text = None;
return;
}
Err(_) => {
let Ok(img_data) = clipboard.get_image() else {
continue;
};
let hash = hash_bytes(&img_data.bytes);
if Some(hash) == *last_image_hash {
return;
}
*last_image_hash = Some(hash);
*last_text = None;
println!("Clipboard update (image)");
let pixel_count = img_data.width * img_data.height;
if pixel_count > MAX_IMAGE_PIXELS {
eprintln!(
"Image ignorée : {}×{} ({} Mpx > limite {}×{})",
img_data.width,
img_data.height,
pixel_count / 1_000_000,
3840,
2160
);
last_image_hash = Some(pixel_count as u64);
last_text = None;
continue;
}
let hash = hash_bytes(&img_data.bytes);
if Some(hash) == last_image_hash {
continue;
}
last_image_hash = Some(hash);
last_text = None;
println!("Clipboard update (image)!");
let entry = ClipboardEntry {
content: ClipboardData::Image(Image {
spawn_db_write(
Arc::clone(db),
ClipboardEntry {
content: ClipboardData::Image(crate::models::Image {
raw_pixels: Some(img_data.bytes.into_owned()),
width: img_data.width as u32,
height: img_data.height as u32,
id: Uuid::new_v4(),
}),
timestamp: SystemTime::now(),
};
spawn_db_write(Arc::clone(&db), entry);
}
},
);
}
}
}
fn spawn_db_write(db: Arc<Mutex<Database>>, entry: ClipboardEntry) {
thread::spawn(move || {
let db_lock = db.lock().unwrap();
if let Err(e) = db_lock.append(entry) {
eprintln!("SQLite writing error: {}", e);
let lock = db.lock().unwrap();
if let Err(e) = lock.append(entry) {
eprintln!("SQLite write error: {e}");
} else {
println!("SQLite updated!");
}

View File

@ -2,9 +2,11 @@ use crate::crypto::Crypto;
use crate::ipc::{self, HistoryItem};
use chrono::{Local, NaiveDate, TimeZone};
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use image::DynamicImage;
use ratatui::widgets::ListState;
use ratatui_image::{picker::Picker, protocol};
use regex::Regex;
use std::collections::{HashMap, VecDeque};
use std::time::{Duration, Instant};
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
@ -12,6 +14,10 @@ use syntect::parsing::SyntaxSet;
const PREVIEW_MAX_WIDTH: u32 = 1280;
const PREVIEW_MAX_HEIGHT: u32 = 720;
const IMAGE_CACHE_MAX: usize = 8;
const PAGE_SIZE: usize = 50;
#[derive(PartialEq, Clone)]
pub enum Mode {
Normal,
@ -48,6 +54,10 @@ pub struct App {
pub syntax_set: SyntaxSet,
pub theme_set: ThemeSet,
pub type_filter: TypeFilter,
pub loaded_count: usize,
pub has_more: bool,
image_cache: HashMap<String, DynamicImage>,
image_cache_order: VecDeque<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -77,7 +87,8 @@ impl TypeFilter {
impl App {
pub fn new() -> Self {
let items = ipc::fetch_history(200).unwrap_or_default();
let items = ipc::fetch_history(PAGE_SIZE).unwrap_or_default();
let has_more = items.len() == PAGE_SIZE;
let mut list_state = ListState::default();
list_state.select(if items.is_empty() { None } else { Some(0) });
@ -107,11 +118,82 @@ impl App {
syntax_set: SyntaxSet::load_defaults_newlines(),
theme_set: ThemeSet::load_defaults(),
type_filter: TypeFilter::All,
loaded_count: PAGE_SIZE,
has_more,
image_cache: HashMap::new(),
image_cache_order: VecDeque::new(),
};
app.update_preview();
app
}
fn try_load_more(&mut self) -> bool {
if !self.has_more {
return false;
}
let new_limit = self.loaded_count + PAGE_SIZE;
let Some(items) = ipc::fetch_history(new_limit) else {
return false;
};
if items.len() <= self.all_items.len() {
self.has_more = false;
return false;
}
self.has_more = items.len() == new_limit;
self.loaded_count = new_limit;
let selected_content = self.get_selected_item().map(|i| i.content.clone());
self.all_items = items;
self.update_search();
if let Some(content) = selected_content {
if let Some(pos) = self
.filtered_items
.iter()
.position(|x| x.content == content)
{
self.list_state.select(Some(pos));
self.last_selected_index = None;
self.update_preview();
}
}
self.set_status(format!("{} entrées chargées", self.all_items.len()));
true
}
fn get_cached_image(
&mut self,
filename: &str,
base_dir: &std::path::Path,
) -> Option<DynamicImage> {
if !self.image_cache.contains_key(filename) {
let path = base_dir.join("images").join(filename);
if !path.exists() {
return None;
}
let img = image::open(&path).ok()?;
let img = if img.width() > PREVIEW_MAX_WIDTH || img.height() > PREVIEW_MAX_HEIGHT {
img.thumbnail(PREVIEW_MAX_WIDTH, PREVIEW_MAX_HEIGHT)
} else {
img
};
if self.image_cache.len() >= IMAGE_CACHE_MAX {
if let Some(oldest) = self.image_cache_order.pop_front() {
self.image_cache.remove(&oldest);
}
}
self.image_cache_order.push_back(filename.to_string());
self.image_cache.insert(filename.to_string(), img);
}
self.image_cache.get(filename).cloned()
}
pub fn format_timestamp(ts_ms: i64) -> String {
let secs = ts_ms / 1000;
let nsecs = ((ts_ms % 1000) * 1_000_000) as u32;
@ -222,14 +304,59 @@ impl App {
self.update_preview();
}
pub fn next(&mut self) {
if self.filtered_items.is_empty() {
return;
}
let current = self.list_state.selected().unwrap_or(0);
let last = self.filtered_items.len() - 1;
if current >= last {
if self.try_load_more() {
// try_load_more restaure la sélection sur le même item ;
// on peut maintenant avancer d'un cran
let current = self.list_state.selected().unwrap_or(0);
if current + 1 < self.filtered_items.len() {
self.list_state.select(Some(current + 1));
self.update_preview();
}
// Sinon (le filtre actif masque les nouveaux items) : on reste
} else {
// Fin réelle — wrap vers le haut
self.list_state.select(Some(0));
self.update_preview();
}
} else {
self.list_state.select(Some(current + 1));
self.update_preview();
}
}
pub fn previous(&mut self) {
if self.filtered_items.is_empty() {
return;
}
let i = self.list_state.selected().map_or(0, |i| {
if i == 0 {
self.filtered_items.len() - 1
} else {
i - 1
}
});
self.list_state.select(Some(i));
self.update_preview();
}
pub fn delete_selected(&mut self) {
if let Some(i) = self.list_state.selected() {
if i < self.filtered_items.len() {
let item = self.filtered_items.remove(i);
// On stocke juste l'item (plus l'index filtré qui était faux)
self.undo_stack.push(item.clone());
self.all_items.retain(|x| x.content != item.content);
if item.content.ends_with(".jpg") || item.content.ends_with(".png") {
self.image_cache.remove(&item.content);
self.image_cache_order.retain(|k| k != &item.content);
}
let new_sel = if self.filtered_items.is_empty() {
None
} else if i >= self.filtered_items.len() {
@ -246,15 +373,14 @@ impl App {
pub fn undo_delete(&mut self) {
if let Some(item) = self.undo_stack.pop() {
ipc::add_entry(item.content.clone());
if let Some(new_items) = ipc::fetch_history(200) {
// Re-sync depuis le daemon pour avoir l'ordre chronologique correct
if let Some(new_items) = ipc::fetch_history(self.loaded_count) {
self.has_more = new_items.len() == self.loaded_count;
self.all_items = new_items;
} else {
self.all_items.insert(0, item.clone());
}
self.update_search();
if let Some(pos) = self
.filtered_items
.iter()
@ -272,16 +398,13 @@ impl App {
Some(i) => i.content.clone(),
None => return,
};
if Crypto::is_legacy_encrypted(&content) {
self.set_error(
"Entrée chiffrée avec l'ancienne clé machine — non modifiable ici".into(),
);
return;
}
self.crypto = None;
if Crypto::is_password_encrypted(&content) {
self.pending_action = Some(PendingAction::DecryptSelected);
} else {
@ -319,14 +442,12 @@ impl App {
Some(i) => i.content.clone(),
None => return,
};
let encrypt_result = match &self.crypto {
Some(key) => key.encrypt(&content),
let result = match &self.crypto {
Some(k) => k.encrypt(&content),
None => return,
};
self.crypto = None;
match encrypt_result {
match result {
Ok(enc) => {
if ipc::update_entry(content.clone(), enc.clone()) {
self.replace_content(&content, enc);
@ -344,14 +465,12 @@ impl App {
Some(i) => i.content.clone(),
None => return,
};
let decrypt_result = match &self.crypto {
Some(key) => key.decrypt(&content),
let result = match &self.crypto {
Some(k) => k.decrypt(&content),
None => return,
};
self.crypto = None;
match decrypt_result {
match result {
Ok(plain) => {
if ipc::update_entry(content.clone(), plain.clone()) {
self.replace_content(&content, plain);
@ -360,11 +479,9 @@ impl App {
self.set_error("Erreur mise à jour BDD".into());
}
}
Err(_) => {
self.set_error(
"Déchiffrement échoué — mauvais mot de passe. Réessayez avec [e].".into(),
);
}
Err(_) => self.set_error(
"Déchiffrement échoué — mauvais mot de passe. Réessayez avec [e].".into(),
),
}
}
@ -373,14 +490,12 @@ impl App {
Some(i) => i.content.clone(),
None => return,
};
let decrypt_result = match &self.crypto {
Some(key) => key.decrypt(&content),
let result = match &self.crypto {
Some(k) => k.decrypt(&content),
None => return,
};
self.crypto = None;
match decrypt_result {
match result {
Ok(plain) => {
if ipc::set_clipboard(plain) {
self.should_quit = true;
@ -388,11 +503,9 @@ impl App {
self.set_error("Erreur : impossible de définir le presse-papier".into());
}
}
Err(_) => {
self.set_error(
"Déchiffrement échoué — mauvais mot de passe. Réessayez avec Entrée.".into(),
);
}
Err(_) => self.set_error(
"Déchiffrement échoué — mauvais mot de passe. Réessayez avec Entrée.".into(),
),
}
}
@ -430,6 +543,10 @@ impl App {
self.undo_stack.clear();
self.list_state.select(None);
self.current_image = None;
self.image_cache.clear();
self.image_cache_order.clear();
self.loaded_count = PAGE_SIZE;
self.has_more = false;
self.set_status("Historique effacé".into());
} else {
self.set_error("Erreur lors de l'effacement".into());
@ -452,18 +569,10 @@ impl App {
if content.ends_with(".jpg") || content.ends_with(".png") {
if let Some(dirs) = directories::ProjectDirs::from("com", "zefad", "rklipd") {
let path = dirs.data_dir().join("images").join(&content);
if path.exists() {
if let Ok(img) = image::open(&path) {
let img = if img.width() > PREVIEW_MAX_WIDTH
|| img.height() > PREVIEW_MAX_HEIGHT
{
img.thumbnail(PREVIEW_MAX_WIDTH, PREVIEW_MAX_HEIGHT)
} else {
img
};
self.current_image = Some(self.picker.new_resize_protocol(img));
}
if let Some(img) = self.get_cached_image(&content, dirs.data_dir()) {
// new_resize_protocol attend un DynamicImage — on clone depuis le cache
// (l'image est déjà redimensionnée ≤ 1280×720, clone = ~3,5 Mo max)
self.current_image = Some(self.picker.new_resize_protocol(img));
}
}
}
@ -476,36 +585,6 @@ impl App {
self.preview_scroll = self.preview_scroll.saturating_sub(3);
}
pub fn next(&mut self) {
if self.filtered_items.is_empty() {
return;
}
let i = self.list_state.selected().map_or(0, |i| {
if i >= self.filtered_items.len() - 1 {
0
} else {
i + 1
}
});
self.list_state.select(Some(i));
self.update_preview();
}
pub fn previous(&mut self) {
if self.filtered_items.is_empty() {
return;
}
let i = self.list_state.selected().map_or(0, |i| {
if i == 0 {
self.filtered_items.len() - 1
} else {
i - 1
}
});
self.list_state.select(Some(i));
self.update_preview();
}
pub fn get_selected_item(&self) -> Option<&HistoryItem> {
self.list_state
.selected()
@ -513,30 +592,34 @@ impl App {
}
pub fn sync_with_daemon(&mut self) {
if let Some(new) = ipc::fetch_history(200) {
let changed = self.all_items.len() != new.len()
|| self
.all_items
let Some(new) = ipc::fetch_history(self.loaded_count) else {
return;
};
// Mise à jour du flag has_more lors de chaque sync
self.has_more = new.len() == self.loaded_count;
let changed = self.all_items.len() != new.len()
|| self
.all_items
.iter()
.zip(&new)
.any(|(a, b)| a.content != b.content);
if changed {
let selected_content = self.get_selected_item().map(|i| i.content.clone());
self.all_items = new;
self.update_search();
if let Some(content) = selected_content {
if let Some(pos) = self
.filtered_items
.iter()
.zip(&new)
.any(|(a, b)| a.content != b.content);
if changed {
let selected_content = self.get_selected_item().map(|i| i.content.clone());
self.all_items = new;
self.update_search();
if let Some(content) = selected_content {
if let Some(pos) = self
.filtered_items
.iter()
.position(|x| x.content == content)
{
self.list_state.select(Some(pos));
self.last_selected_index = None;
self.update_preview();
}
.position(|x| x.content == content)
{
self.list_state.select(Some(pos));
self.last_selected_index = None;
self.update_preview();
}
}
}

View File

@ -58,79 +58,74 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
}
match app.mode {
// Dans la boucle, Mode::Normal :
Mode::Normal => {
// FIX: les deux blocs match étaient redondants et mal nommés.
// On fusionne la logique en un seul bloc clair.
match key.code {
KeyCode::Enter => app.paste_selected(),
KeyCode::Char('j') | KeyCode::Down => app.next(),
KeyCode::Char('k') | KeyCode::Up => app.previous(),
KeyCode::Char('G') => {
Mode::Normal => match key.code {
KeyCode::Enter => app.paste_selected(),
KeyCode::Char('j') | KeyCode::Down => app.next(),
KeyCode::Char('k') | KeyCode::Up => app.previous(),
KeyCode::Char('G') => {
if !app.filtered_items.is_empty() {
let l = app.filtered_items.len() - 1;
app.list_state.select(Some(l));
app.update_preview();
}
last_d = false;
last_g = false;
}
KeyCode::Char('g') => {
last_d = false;
if last_g {
if !app.filtered_items.is_empty() {
let l = app.filtered_items.len() - 1;
app.list_state.select(Some(l));
app.list_state.select(Some(0));
app.update_preview();
}
last_d = false;
last_g = false;
}
KeyCode::Char('g') => {
last_d = false;
if last_g {
if !app.filtered_items.is_empty() {
app.list_state.select(Some(0));
app.update_preview();
}
last_g = false;
} else {
last_g = true;
}
}
KeyCode::Char('d') => {
last_g = false;
if last_d {
app.mode = Mode::ConfirmDelete;
last_d = false;
} else {
last_d = true;
}
}
KeyCode::Char('u') => {
app.undo_delete();
last_d = false;
last_g = false;
}
KeyCode::Char('e') => {
app.toggle_encrypt();
last_d = false;
last_g = false;
}
KeyCode::Char('t') => {
app.cycle_type_filter();
last_d = false;
last_g = false;
}
KeyCode::Char('/') => {
app.mode = Mode::Search;
app.input_buffer.clear();
app.update_search();
last_d = false;
last_g = false;
}
KeyCode::Char(':') => {
app.mode = Mode::Command;
app.input_buffer.clear();
last_d = false;
last_g = false;
}
KeyCode::Char('q') => app.should_quit = true,
_ => {
last_d = false;
last_g = false;
} else {
last_g = true;
}
}
}
KeyCode::Char('d') => {
last_g = false;
if last_d {
app.mode = Mode::ConfirmDelete;
last_d = false;
} else {
last_d = true;
}
}
KeyCode::Char('u') => {
app.undo_delete();
last_d = false;
last_g = false;
}
KeyCode::Char('e') => {
app.toggle_encrypt();
last_d = false;
last_g = false;
}
KeyCode::Char('t') => {
app.cycle_type_filter();
last_d = false;
last_g = false;
}
KeyCode::Char('/') => {
app.mode = Mode::Search;
app.input_buffer.clear();
app.update_search();
last_d = false;
last_g = false;
}
KeyCode::Char(':') => {
app.mode = Mode::Command;
app.input_buffer.clear();
last_d = false;
last_g = false;
}
KeyCode::Char('q') => app.should_quit = true,
_ => {
last_d = false;
last_g = false;
}
},
Mode::Search => match key.code {
KeyCode::Esc => {
@ -169,7 +164,6 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
match cmd.as_str() {
"q" | "quit" => app.should_quit = true,
"clear" => app.clear_history(),
// :p pour définir/changer le mot de passe
"p" | "password" => {
app.pending_action = None;
app.mode = Mode::PasswordInput;
@ -215,7 +209,6 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
}
}
} else {
// Idle : synchronisation avec le daemon
app.sync_with_daemon();
}

View File

@ -237,7 +237,11 @@ pub fn render(f: &mut Frame, app: &mut App) {
} else {
app.list_state.selected().unwrap_or(0) + 1
};
let counter = format!(" {}/{} ", current, total);
let counter = if app.has_more {
format!(" {}/{}+ ", current, total)
} else {
format!(" {}/{} ", current, total)
};
let clen = counter.len() as u16;
let status_cols = Layout::default()