From fc085a8a8382a89822599de9651de2543d09492d Mon Sep 17 00:00:00 2001 From: zeefaad Date: Wed, 20 May 2026 23:49:53 +0200 Subject: [PATCH] huge opti --- Cargo.lock | 1 + rklipd/Cargo.lock | 1 + rklipd/Cargo.toml | 1 + rklipd/src/ws/x11.rs | 152 +++++++++++++++-------- src/app.rs | 283 ++++++++++++++++++++++++++++--------------- src/main.rs | 131 ++++++++++---------- src/ui.rs | 6 +- 7 files changed, 355 insertions(+), 220 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 761db55..51bed6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2487,6 +2487,7 @@ dependencies = [ "serde_json", "uuid", "wayland-clipboard-listener", + "x11rb", ] [[package]] diff --git a/rklipd/Cargo.lock b/rklipd/Cargo.lock index 3088d3d..7798115 100644 --- a/rklipd/Cargo.lock +++ b/rklipd/Cargo.lock @@ -1445,6 +1445,7 @@ dependencies = [ "serde_json", "uuid", "wayland-clipboard-listener", + "x11rb", ] [[package]] diff --git a/rklipd/Cargo.toml b/rklipd/Cargo.toml index d212d4e..7de2839 100644 --- a/rklipd/Cargo.toml +++ b/rklipd/Cargo.toml @@ -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 = [] diff --git a/rklipd/src/ws/x11.rs b/rklipd/src/ws/x11.rs index 7b8d4a7..11fe6c8 100644 --- a/rklipd/src/ws/x11.rs +++ b/rklipd/src/ws/x11.rs @@ -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>, mut clipboard: Clipboard) -> Result<(), Box> { - 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 = None; let mut last_image_hash: Option = 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>, + last_text: &mut Option, + last_image_hash: &mut Option, +) { + 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>, 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!"); } diff --git a/src/app.rs b/src/app.rs index 8193cf9..e9f05d5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, + image_cache_order: VecDeque, } #[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 { + 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(); } } } diff --git a/src/main.rs b/src/main.rs index 1c8a3ab..177d5ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,79 +58,74 @@ fn run_app(terminal: &mut Terminal>, 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>, 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>, app: &mut App) } } } else { - // Idle : synchronisation avec le daemon app.sync_with_daemon(); } diff --git a/src/ui.rs b/src/ui.rs index dfb522b..94eb52b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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()