diff --git a/rklipd/src/database.rs b/rklipd/src/database.rs index 09ec52d..6aa51ce 100644 --- a/rklipd/src/database.rs +++ b/rklipd/src/database.rs @@ -5,6 +5,7 @@ use image::{ExtendedColorType, ImageEncoder}; use rusqlite::Connection; use std::error::Error; use std::fs; +use std::io::Cursor; use std::path::Path; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use uuid::Uuid; @@ -29,25 +30,44 @@ impl Database { PRAGMA foreign_keys=ON;", )?; - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL); - INSERT OR IGNORE INTO schema_version (version) - SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM schema_version);", - )?; - conn.execute( "CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, content TEXT NOT NULL, - timestamp INTEGER NOT NULL + timestamp INTEGER NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0 )", [], )?; + let version: i64 = conn + .query_row("SELECT version FROM schema_version", [], |r| r.get(0)) + .unwrap_or(1); + + if version < 2 { + let col_exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('history') WHERE name='pinned'", + [], + |r| r.get::<_, i64>(0), + ) + .unwrap_or(0) + > 0; + + if !col_exists { + conn.execute_batch( + "ALTER TABLE history ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;", + )?; + } + conn.execute("UPDATE schema_version SET version = 2", [])?; + println!("DB migrée → schema v2 (colonne `pinned`)"); + } + conn.execute_batch( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content); - CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content); + CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC); + CREATE INDEX IF NOT EXISTS idx_history_pinned ON history(pinned);", )?; conn.execute_batch( @@ -85,25 +105,25 @@ impl Database { .flat_map(|rgba| [rgba[0], rgba[1], rgba[2]]) .collect(); - let mut jpeg_buf = Vec::new(); - JpegEncoder::new_with_quality(&mut jpeg_buf, 70).write_image( + let mut buf = Vec::new(); + JpegEncoder::new_with_quality(Cursor::new(&mut buf), 70).write_image( &rgb, img.width, img.height, ExtendedColorType::Rgb8, )?; - if jpeg_buf.len() > self.max_entry_size_bytes { + if buf.len() > self.max_entry_size_bytes { eprintln!( - "Image rejetée dans DB : JPEG {} Ko > limite {} Ko", - jpeg_buf.len() / 1024, + "Image rejetée : JPEG {} Ko > limite {} Ko", + buf.len() / 1024, self.max_entry_size_bytes / 1024 ); return Ok(()); } let path = img.file_path(&self.dir_path); - fs::write(&path, &jpeg_buf)?; + fs::write(&path, &buf)?; } None => return Ok(()), } @@ -112,7 +132,13 @@ impl Database { }; self.conn.execute( - "INSERT OR REPLACE INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)", + "INSERT OR REPLACE INTO history (type, content, timestamp, pinned) + VALUES (?1, ?2, ?3, + COALESCE( + (SELECT pinned FROM history WHERE content = ?2), + 0 + ) + )", (kind, &content, ts), )?; @@ -130,10 +156,11 @@ impl Database { let image_files: Vec = { let mut stmt = tx.prepare( "SELECT content FROM history - WHERE type = 'image' - AND id NOT IN ( - SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1 - )", + WHERE type = 'image' AND pinned = 0 + AND id NOT IN ( + SELECT id FROM history WHERE pinned = 0 + ORDER BY timestamp DESC LIMIT ?1 + )", )?; stmt.query_map([self.max_entries as i64], |row| row.get(0))? .filter_map(|r| r.ok()) @@ -141,8 +168,11 @@ impl Database { }; tx.execute( - "DELETE FROM history WHERE id NOT IN ( - SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1 + "DELETE FROM history + WHERE pinned = 0 + AND id NOT IN ( + SELECT id FROM history WHERE pinned = 0 + ORDER BY timestamp DESC LIMIT ?1 )", [self.max_entries as i64], )?; @@ -163,7 +193,10 @@ impl Database { pub fn read_history(&self, limit: usize) -> Result, Box> { let mut stmt = self.conn.prepare( - "SELECT type, content, timestamp FROM history ORDER BY timestamp DESC LIMIT ?1", + "SELECT type, content, timestamp, pinned + FROM history + ORDER BY pinned DESC, timestamp DESC + LIMIT ?1", )?; let rows = stmt.query_map([limit as i64], |row| { @@ -171,12 +204,13 @@ impl Database { row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?, + row.get::<_, bool>(3)?, )) })?; let mut entries = Vec::new(); for row in rows { - let (ty, content, ts_ms) = row?; + let (ty, content, ts_ms, pinned) = row?; let timestamp = UNIX_EPOCH + Duration::from_millis(ts_ms as u64); let data = if ty == "text" { ClipboardData::Text(content) @@ -192,11 +226,23 @@ impl Database { entries.push(ClipboardEntry { content: data, timestamp, + pinned, }); } Ok(entries) } + pub fn set_pin(&self, content: &str, pinned: bool) -> Result<(), Box> { + let rows = self.conn.execute( + "UPDATE history SET pinned = ?1 WHERE content = ?2", + (pinned as i32, content), + )?; + if rows == 0 { + return Err(format!("Entrée introuvable pour pin : {content}").into()); + } + Ok(()) + } + pub fn delete_entry_by_content(&self, content: &str) -> Result<(), Box> { self.conn .execute("DELETE FROM history WHERE content = ?1", [content])?; @@ -221,14 +267,19 @@ impl Database { let tx = self.conn.unchecked_transaction()?; let image_files: Vec = { - let mut stmt = - tx.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?; + let mut stmt = tx.prepare( + "SELECT content FROM history + WHERE type = 'image' AND pinned = 0 AND timestamp < ?1", + )?; stmt.query_map([cutoff_ms], |row| row.get(0))? .filter_map(|r| r.ok()) .collect() }; - let count = tx.execute("DELETE FROM history WHERE timestamp < ?1", [cutoff_ms])?; + let count = tx.execute( + "DELETE FROM history WHERE timestamp < ?1 AND pinned = 0", + [cutoff_ms], + )?; tx.commit()?; diff --git a/rklipd/src/ipc.rs b/rklipd/src/ipc.rs index 7794fb2..94ce102 100644 --- a/rklipd/src/ipc.rs +++ b/rklipd/src/ipc.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::io::{Read, Write}; use std::os::unix::net::UnixListener; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -16,6 +16,8 @@ const IPC_MAX_REQUEST_BYTES: usize = 4 * 1024 * 1024; pub struct HistoryItem { pub content: String, pub timestamp: i64, + #[serde(default)] + pub pinned: bool, } #[derive(Serialize, Deserialize, Debug)] @@ -36,6 +38,10 @@ pub enum IpcRequest { AddEntry { content: String, }, + PinEntry { + content: String, + pinned: bool, + }, ClearHistory, } @@ -52,7 +58,12 @@ fn reply(stream: &mut std::os::unix::net::UnixStream, resp: IpcResponse) { } } -pub fn start_server(db: Arc>, crypto: Arc, socket_path: &Path) { +pub fn start_server( + db: Arc>, + crypto: Arc, + socket_path: &Path, + data_dir: Arc, +) { if socket_path.exists() { let _ = fs::remove_file(socket_path); } @@ -84,188 +95,194 @@ pub fn start_server(db: Arc>, crypto: Arc, socket_path: let db_clone = Arc::clone(&db); let crypto_clone = Arc::clone(&crypto); + let data_dir_clone = Arc::clone(&data_dir); std::thread::spawn(move || { - let mut buf = Vec::new(); - let mut tmp = [0u8; 4096]; - - loop { - match stream.read(&mut tmp) { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&tmp[..n]); - if buf.len() > IPC_MAX_REQUEST_BYTES { - eprintln!("IPC : requête trop grande, abandon"); - return; - } - } - Err(e) - if e.kind() == std::io::ErrorKind::WouldBlock - || e.kind() == std::io::ErrorKind::TimedOut => - { - eprintln!("IPC : timeout de lecture"); - return; - } - Err(e) => { - eprintln!("IPC read error : {e}"); - return; - } - } - } - - let buf_str = match String::from_utf8(buf) { - Ok(s) => s, - Err(e) => { - eprintln!("IPC : requête non-UTF8 : {e}"); - return; - } - }; - - let req = match serde_json::from_str::(&buf_str) { - Ok(r) => r, - Err(e) => { - eprintln!("IPC parse error : {e}"); - return; - } - }; - - match req { - IpcRequest::GetHistory { limit } => { - // Limite à 1000 pour éviter les requêtes abusives - let limit = limit.min(1000); - let lock = db_clone.lock().unwrap(); - let history = lock.read_history(limit).unwrap_or_default(); - let items: Vec = history - .into_iter() - .map(|e| { - let content = match e.content { - ClipboardData::Text(t) => t, - ClipboardData::Image(img) => format!("{}.jpg", img.id), - }; - let ts = e - .timestamp - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - as i64; - HistoryItem { - content, - timestamp: ts, - } - }) - .collect(); - reply(&mut stream, IpcResponse::History(items)); - } - - IpcRequest::SetClipboard { content } => { - let actual = if Crypto::is_legacy_encrypted(&content) { - crypto_clone.decrypt(&content).unwrap_or_else(|e| { - eprintln!("Impossible de déchiffrer l'entrée enc: : {e}"); - content.clone() - }) - } else if Crypto::is_password_encrypted(&content) { - reply( - &mut stream, - IpcResponse::Error( - "Entrée chiffrée par mot de passe : déchiffrez-la côté client avant de coller" - .to_string(), - ), - ); - return; - } else { - content - }; - - match arboard::Clipboard::new() { - Ok(mut cb) => { - if actual.ends_with(".jpg") || actual.ends_with(".png") { - if let Some(dirs) = - directories::ProjectDirs::from("com", "zefad", "rklipd") - { - let path = dirs.data_dir().join("images").join(&actual); - if let Ok(img) = image::open(&path) { - let rgba = img.into_rgba8(); - let (w, h) = - (rgba.width() as usize, rgba.height() as usize); - let _ = cb.set_image(arboard::ImageData { - width: w, - height: h, - bytes: std::borrow::Cow::Owned(rgba.into_raw()), - }); - reply(&mut stream, IpcResponse::Ok); - } else { - reply( - &mut stream, - IpcResponse::Error(format!( - "Image introuvable : {actual}" - )), - ); - } - } - } else { - let _ = cb.set_text(actual); - reply(&mut stream, IpcResponse::Ok); - } - } - Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())), - } - } - - IpcRequest::DeleteEntry { content } => { - { - let lock = db_clone.lock().unwrap(); - let _ = lock.delete_entry_by_content(&content); - } - if !content.starts_with("enc:") - && !content.starts_with("enc2:") - && (content.ends_with(".jpg") || content.ends_with(".png")) - { - if let Some(dirs) = - directories::ProjectDirs::from("com", "zefad", "rklipd") - { - let p = dirs.data_dir().join("images").join(&content); - if p.exists() { - let _ = fs::remove_file(p); - } - } - } - reply(&mut stream, IpcResponse::Ok); - } - - IpcRequest::UpdateEntry { - old_content, - new_content, - } => { - let lock = db_clone.lock().unwrap(); - match lock.update_entry_content(&old_content, &new_content) { - Ok(_) => reply(&mut stream, IpcResponse::Ok), - Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())), - } - } - - IpcRequest::AddEntry { content } => { - let entry = ClipboardEntry { - content: ClipboardData::Text(content), - timestamp: SystemTime::now(), - }; - let lock = db_clone.lock().unwrap(); - match lock.append(entry) { - Ok(_) => reply(&mut stream, IpcResponse::Ok), - Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())), - } - } - - IpcRequest::ClearHistory => { - let lock = db_clone.lock().unwrap(); - match lock.clear_history() { - Ok(_) => reply(&mut stream, IpcResponse::Ok), - Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())), - } - } - } + handle_connection(&mut stream, db_clone, crypto_clone, data_dir_clone); }); } Err(e) => eprintln!("Erreur connexion IPC : {e}"), } } } + +fn handle_connection( + stream: &mut std::os::unix::net::UnixStream, + db: Arc>, + crypto: Arc, + data_dir: Arc, +) { + let mut buf = Vec::new(); + let mut tmp = [0u8; 4096]; + + loop { + match stream.read(&mut tmp) { + Ok(0) => break, + Ok(n) => { + buf.extend_from_slice(&tmp[..n]); + if buf.len() > IPC_MAX_REQUEST_BYTES { + eprintln!("IPC : requête trop grande, abandon"); + return; + } + } + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + eprintln!("IPC : timeout de lecture"); + return; + } + Err(e) => { + eprintln!("IPC read error : {e}"); + return; + } + } + } + + let buf_str = match String::from_utf8(buf) { + Ok(s) => s, + Err(e) => { + eprintln!("IPC : requête non-UTF8 : {e}"); + return; + } + }; + + let req = match serde_json::from_str::(&buf_str) { + Ok(r) => r, + Err(e) => { + eprintln!("IPC parse error : {e}"); + return; + } + }; + + match req { + IpcRequest::GetHistory { limit } => { + let limit = limit.min(1000); + let lock = db.lock().unwrap_or_else(|p| p.into_inner()); + let history = lock.read_history(limit).unwrap_or_default(); + let items: Vec = history + .into_iter() + .map(|e| { + let content = match e.content { + ClipboardData::Text(t) => t, + ClipboardData::Image(img) => format!("{}.jpg", img.id), + }; + let ts = e + .timestamp + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + HistoryItem { + content, + timestamp: ts, + pinned: e.pinned, + } + }) + .collect(); + reply(stream, IpcResponse::History(items)); + } + + IpcRequest::SetClipboard { content } => { + let actual = if Crypto::is_legacy_encrypted(&content) { + crypto.decrypt(&content).unwrap_or_else(|e| { + eprintln!("Impossible de déchiffrer l'entrée enc: : {e}"); + content.clone() + }) + } else if Crypto::is_password_encrypted(&content) { + reply( + stream, + IpcResponse::Error( + "Entrée chiffrée par mot de passe : déchiffrez côté client avant de coller" + .to_string(), + ), + ); + return; + } else { + content + }; + + match arboard::Clipboard::new() { + Ok(mut cb) => { + if actual.ends_with(".jpg") || actual.ends_with(".png") { + let path = data_dir.join("images").join(&actual); + if let Ok(img) = image::open(&path) { + let rgba = img.into_rgba8(); + let (w, h) = (rgba.width() as usize, rgba.height() as usize); + let _ = cb.set_image(arboard::ImageData { + width: w, + height: h, + bytes: std::borrow::Cow::Owned(rgba.into_raw()), + }); + reply(stream, IpcResponse::Ok); + } else { + reply( + stream, + IpcResponse::Error(format!("Image introuvable : {actual}")), + ); + } + } else { + let _ = cb.set_text(actual); + reply(stream, IpcResponse::Ok); + } + } + Err(e) => reply(stream, IpcResponse::Error(e.to_string())), + } + } + + IpcRequest::DeleteEntry { content } => { + { + let lock = db.lock().unwrap_or_else(|p| p.into_inner()); + let _ = lock.delete_entry_by_content(&content); + } + if !Crypto::is_any_encrypted(&content) + && (content.ends_with(".jpg") || content.ends_with(".png")) + { + let p = data_dir.join("images").join(&content); + if p.exists() { + let _ = fs::remove_file(p); + } + } + reply(stream, IpcResponse::Ok); + } + + IpcRequest::UpdateEntry { + old_content, + new_content, + } => { + let lock = db.lock().unwrap_or_else(|p| p.into_inner()); + match lock.update_entry_content(&old_content, &new_content) { + Ok(_) => reply(stream, IpcResponse::Ok), + Err(e) => reply(stream, IpcResponse::Error(e.to_string())), + } + } + + IpcRequest::AddEntry { content } => { + let entry = ClipboardEntry { + content: ClipboardData::Text(content), + timestamp: SystemTime::now(), + pinned: false, + }; + let lock = db.lock().unwrap_or_else(|p| p.into_inner()); + match lock.append(entry) { + Ok(_) => reply(stream, IpcResponse::Ok), + Err(e) => reply(stream, IpcResponse::Error(e.to_string())), + } + } + + IpcRequest::PinEntry { content, pinned } => { + let lock = db.lock().unwrap_or_else(|p| p.into_inner()); + match lock.set_pin(&content, pinned) { + Ok(_) => reply(stream, IpcResponse::Ok), + Err(e) => reply(stream, IpcResponse::Error(e.to_string())), + } + } + + IpcRequest::ClearHistory => { + let lock = db.lock().unwrap_or_else(|p| p.into_inner()); + match lock.clear_history() { + Ok(_) => reply(stream, IpcResponse::Ok), + Err(e) => reply(stream, IpcResponse::Error(e.to_string())), + } + } + } +} diff --git a/rklipd/src/main.rs b/rklipd/src/main.rs index a41d82e..3df0a3d 100644 --- a/rklipd/src/main.rs +++ b/rklipd/src/main.rs @@ -31,17 +31,18 @@ fn main() -> Result<(), Box> { let proj_dirs = ProjectDirs::from("com", "zefad", "rklipd").expect("Impossible d'ouvrir le répertoire"); - let dir_path = proj_dirs.data_dir(); + let dir_path = proj_dirs.data_dir().to_path_buf(); let dir_path_str = dir_path.to_str().expect("Chemin invalide").to_string(); let db = Arc::new(Mutex::new(Database::init(&dir_path_str, &config)?)); - let crypto = Arc::new(Crypto::load_or_create(dir_path)?); + let crypto = Arc::new(Crypto::load_or_create(&dir_path)?); let socket_path = dir_path.join("rklip.sock"); let db_for_ipc = Arc::clone(&db); let crypto_for_ipc = Arc::clone(&crypto); + let data_dir = Arc::new(dir_path.clone()); std::thread::spawn(move || { - crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path); + crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path, data_dir); }); if let Some(days) = config.expiry_days { @@ -49,12 +50,10 @@ fn main() -> Result<(), Box> { std::thread::spawn(move || { loop { { - let lock = db_for_expiry.lock().unwrap(); + let lock = db_for_expiry.lock().unwrap_or_else(|p| p.into_inner()); match lock.delete_entries_older_than(days) { Ok(0) => {} - Ok(n) => { - println!("Expiration : {n} entrée(s) supprimée(s) (> {days} jours)") - } + Ok(n) => println!("Expiration : {n} entrée(s) > {days} jours supprimée(s)"), Err(e) => eprintln!("Erreur expiration : {e}"), } } diff --git a/rklipd/src/models.rs b/rklipd/src/models.rs index c06e0ad..fb3bb6d 100644 --- a/rklipd/src/models.rs +++ b/rklipd/src/models.rs @@ -7,6 +7,7 @@ use uuid::Uuid; pub struct ClipboardEntry { pub content: ClipboardData, pub timestamp: SystemTime, + pub pinned: bool, } #[derive(Debug, Clone)] diff --git a/rklipd/src/monitor.rs b/rklipd/src/monitor.rs index 95c1ad2..bfe0024 100644 --- a/rklipd/src/monitor.rs +++ b/rklipd/src/monitor.rs @@ -9,13 +9,10 @@ pub fn start(db: Arc>, clipboard: Clipboard) -> Result<(), Box l, - Err(poisoned) => { - eprintln!("Mutex DB empoisonné, récupération forcée"); - poisoned.into_inner() - } - }; + let lock = db.lock().unwrap_or_else(|poisoned| { + eprintln!("Mutex DB empoisonné, récupération forcée"); + poisoned.into_inner() + }); if let Err(e) = lock.append(entry) { eprintln!("SQLite write error: {e}"); } else { diff --git a/rklipd/src/ws/wayland.rs b/rklipd/src/ws/wayland.rs index 1b1b804..ea068f4 100644 --- a/rklipd/src/ws/wayland.rs +++ b/rklipd/src/ws/wayland.rs @@ -45,6 +45,7 @@ pub fn start( ClipboardEntry { content: ClipboardData::Text(text), timestamp: SystemTime::now(), + pinned: false, } } else { let hash = hash_bytes(data); @@ -83,6 +84,7 @@ pub fn start( id: Uuid::new_v4(), }), timestamp: SystemTime::now(), + pinned: false, } } Err(e) => { diff --git a/rklipd/src/ws/x11.rs b/rklipd/src/ws/x11.rs index 76fd6b2..afb5b39 100644 --- a/rklipd/src/ws/x11.rs +++ b/rklipd/src/ws/x11.rs @@ -94,6 +94,7 @@ fn handle_clipboard_event( .send(ClipboardEntry { content: ClipboardData::Text(text), timestamp: SystemTime::now(), + pinned: false, }) .is_err() { @@ -137,6 +138,7 @@ fn handle_clipboard_event( id: Uuid::new_v4(), }), timestamp: SystemTime::now(), + pinned: false, }) .is_err() { diff --git a/src/app.rs b/src/app.rs index 1cab53f..617dbd5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,7 +9,7 @@ use ratatui::widgets::ListState; use ratatui_image::{picker::Picker, protocol}; use regex::Regex; use std::collections::{HashMap, VecDeque}; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use syntect::easy::HighlightLines; use syntect::highlighting::{FontStyle as SynFontStyle, ThemeSet}; @@ -28,6 +28,26 @@ pub fn is_image(s: &str) -> bool { s.ends_with(".jpg") || s.ends_with(".png") } +pub fn is_url_only(content: &str) -> bool { + let t = content.trim(); + !t.contains('\n') + && !t.contains(' ') + && (t.starts_with("http://") || t.starts_with("https://")) + && t.len() > 10 +} + +pub fn extract_url(content: &str) -> Option { + static RE: OnceLock = OnceLock::new(); + let re = RE + .get_or_init(|| Regex::new(r#"https?://[^\s<>"'()\[\]{}]+"#).expect("URL regex invalide")); + re.find(content).map(|m| { + let url = m + .as_str() + .trim_end_matches(|c: char| matches!(c, '.' | ',' | ';' | ':' | '!' | '?')); + url.to_string() + }) +} + #[derive(PartialEq, Clone)] pub enum Mode { Normal, @@ -35,6 +55,7 @@ pub enum Mode { Search, ConfirmDelete, PasswordInput, + Help, } #[derive(Clone)] @@ -59,7 +80,6 @@ impl TypeFilter { Self::Image => Self::All, } } - pub fn label(self) -> &'static str { match self { Self::All => "Tous", @@ -102,6 +122,40 @@ fn syn_color(c: syntect::highlighting::Color) -> Color { Color::Rgb(c.r, c.g, c.b) } +fn detect_syntax<'a>( + content: &str, + syntax_set: &'a SyntaxSet, +) -> &'a syntect::parsing::SyntaxReference { + if let Some(s) = syntax_set.find_syntax_by_first_line(content) { + if s.name != "Plain Text" { + return s; + } + } + for line in content.lines().take(3) { + if let Some(word) = line.split_whitespace().last() { + if let Some(ext) = word.rsplit('.').next() { + if (1..=6).contains(&ext.len()) && ext.chars().all(|c| c.is_ascii_alphanumeric()) { + if let Some(s) = syntax_set.find_syntax_by_extension(ext) { + if s.name != "Plain Text" { + return s; + } + } + } + } + } + } + syntax_set.find_syntax_plain_text() +} + +pub fn detect_lang(content: &str, syntax_set: &SyntaxSet) -> Option { + let s = detect_syntax(content, syntax_set); + if s.name == "Plain Text" { + None + } else { + Some(s.name.clone()) + } +} + pub fn highlight_code( content: &str, syntax_set: &SyntaxSet, @@ -112,6 +166,8 @@ pub fn highlight_code( let mut h = HighlightLines::new(syntax, theme); let mut lines = Vec::new(); + let total_lines = content.lines().count(); + for (no, line) in LinesWithEndings::from(content) .enumerate() .take(MAX_HIGHLIGHT_LINES) @@ -134,7 +190,6 @@ pub fn highlight_code( lines.push(Line::from(spans)); } - let total_lines = content.lines().count(); if total_lines > MAX_HIGHLIGHT_LINES { lines.push(Line::from(Span::styled( format!( @@ -148,43 +203,6 @@ pub fn highlight_code( lines } -fn detect_syntax<'a>( - content: &str, - syntax_set: &'a SyntaxSet, -) -> &'a syntect::parsing::SyntaxReference { - if let Some(s) = syntax_set.find_syntax_by_first_line(content) { - if s.name != "Plain Text" { - return s; - } - } - - for line in content.lines().take(3) { - let trimmed = line.trim_start_matches(|c: char| !c.is_alphanumeric() && c != '.'); - if let Some(word) = trimmed.split_whitespace().last() { - if let Some(ext) = word.rsplit('.').next() { - if ext.len() <= 6 && ext.chars().all(|c| c.is_ascii_alphanumeric()) { - if let Some(s) = syntax_set.find_syntax_by_extension(ext) { - if s.name != "Plain Text" { - return s; - } - } - } - } - } - } - - syntax_set.find_syntax_plain_text() -} - -pub fn detect_lang(content: &str, syntax_set: &SyntaxSet) -> Option { - let s = detect_syntax(content, syntax_set); - if s.name == "Plain Text" { - None - } else { - Some(s.name.clone()) - } -} - impl App { pub fn new() -> Self { let items = ipc::fetch_history(PAGE_SIZE).unwrap_or_default(); @@ -244,24 +262,19 @@ impl App { 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 @@ -273,7 +286,6 @@ impl App { self.update_preview(); } } - self.set_status(format!("{} entrées chargées", self.all_items.len())); true } @@ -288,7 +300,6 @@ impl App { self.image_cache_order.push_back(filename.to_string()); return self.image_cache.get(filename).cloned(); } - let path = base_dir.join("images").join(filename); if !path.exists() { return None; @@ -299,7 +310,6 @@ impl App { } 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); @@ -318,9 +328,7 @@ impl App { match Local.timestamp_opt(secs, nsecs) { chrono::LocalResult::Single(dt) => { let today = Local::now().date_naive(); - let entry_date = dt.date_naive(); - let diff_days = (today - entry_date).num_days(); - + let diff_days = (today - dt.date_naive()).num_days(); if diff_days == 0 { dt.format("%H:%M:%S").to_string() } else if diff_days < 365 { @@ -405,9 +413,9 @@ impl App { let mut matched: Vec<(i64, HistoryItem)> = base .into_iter() .filter_map(|item| { - matcher - .fuzzy_match(&search_str(&item), &text_query) - .map(|s| (s, item)) + let score = matcher.fuzzy_match(&search_str(&item), &text_query)?; + let adjusted = score + if item.pinned { 1000 } else { 0 }; + Some((adjusted, item)) }) .collect(); matched.sort_by(|a, b| b.0.cmp(&a.0)); @@ -419,7 +427,6 @@ impl App { } else { Some(0) }); - self.update_preview(); } @@ -429,7 +436,6 @@ impl App { } let current = self.list_state.selected().unwrap_or(0); let last = self.filtered_items.len() - 1; - if current >= last { if self.try_load_more() { let current = self.list_state.selected().unwrap_or(0); @@ -508,6 +514,69 @@ impl App { self.update_preview(); } + pub fn toggle_pin(&mut self) { + let item = match self.get_selected_item() { + Some(i) => i.clone(), + None => return, + }; + let new_pinned = !item.pinned; + + if !ipc::pin_entry(item.content.clone(), new_pinned) { + self.set_error("Erreur pin : daemon injoignable".into()); + return; + } + + for e in self.all_items.iter_mut() { + if e.content == item.content { + e.pinned = new_pinned; + break; + } + } + + self.all_items + .sort_by(|a, b| b.pinned.cmp(&a.pinned).then(b.timestamp.cmp(&a.timestamp))); + + let sel_content = item.content.clone(); + self.update_search(); + + if let Some(pos) = self + .filtered_items + .iter() + .position(|x| x.content == sel_content) + { + self.list_state.select(Some(pos)); + self.last_selected_index = None; + self.update_preview(); + } + + self.set_status(if new_pinned { + "★ Épinglé".into() + } else { + "Désépinglé".into() + }); + } + + pub fn open_url_selected(&mut self) { + let content = match self.get_selected_item().map(|i| i.content.clone()) { + Some(c) => c, + None => return, + }; + match extract_url(&content) { + Some(url) => match std::process::Command::new("xdg-open").arg(&url).spawn() { + Ok(_) => { + let preview = if url.len() > 48 { + format!("{}…", &url[..48]) + } else { + url.clone() + }; + self.set_status(format!("Ouverture : {preview}")); + } + Err(e) => self.set_error(format!("xdg-open : {e}")), + }, + None => self.set_error("Aucune URL trouvée dans cette entrée".into()), + } + } + pub fn toggle_encrypt(&mut self) { let content = match self.get_selected_item() { Some(i) => i.content.clone(), @@ -703,7 +772,6 @@ impl App { pub fn scroll_preview_down(&mut self) { self.preview_scroll = self.preview_scroll.saturating_add(3); } - pub fn scroll_preview_up(&mut self) { self.preview_scroll = self.preview_scroll.saturating_sub(3); } @@ -731,13 +799,12 @@ impl App { .all_items .iter() .zip(&new) - .any(|(a, b)| a.content != b.content); + .any(|(a, b)| a.content != b.content || a.pinned != b.pinned); 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 @@ -755,7 +822,6 @@ impl App { pub fn set_error(&mut self, msg: String) { self.error_message = Some((msg, Instant::now())); } - pub fn set_status(&mut self, msg: String) { self.status_message = Some((msg, Instant::now())); } diff --git a/src/ipc.rs b/src/ipc.rs index 53549f5..304172e 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -6,6 +6,8 @@ use std::os::unix::net::UnixStream; pub struct HistoryItem { pub content: String, pub timestamp: i64, + #[serde(default)] + pub pinned: bool, } #[derive(Serialize, Deserialize, Debug)] @@ -26,6 +28,10 @@ pub enum IpcRequest { AddEntry { content: String, }, + PinEntry { + content: String, + pinned: bool, + }, ClearHistory, } @@ -84,6 +90,13 @@ pub fn add_entry(content: String) { let _ = send_request(&IpcRequest::AddEntry { content }); } +pub fn pin_entry(content: String, pinned: bool) -> bool { + matches!( + send_request(&IpcRequest::PinEntry { content, pinned }), + Some(IpcResponse::Ok) + ) +} + pub fn clear_history() -> bool { matches!( send_request(&IpcRequest::ClearHistory), diff --git a/src/main.rs b/src/main.rs index 74296c5..ea8abb9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,7 +43,8 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) if event::poll(Duration::from_millis(250))? { if let Event::Key(key) = event::read()? { - if key.modifiers.contains(KeyModifiers::CONTROL) { + // Ctrl+j / Ctrl+k : scroll prévisualisation (tous modes sauf aide) + if key.modifiers.contains(KeyModifiers::CONTROL) && app.mode != Mode::Help { match key.code { KeyCode::Char('j') => { app.scroll_preview_down(); @@ -58,75 +59,18 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) } match app.mode { - 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() { - 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; - } - }, + // ---------------------------------------------------------- + Mode::Help => { + // N'importe quelle touche ferme l'aide + app.mode = Mode::Normal; + } + // ---------------------------------------------------------- + Mode::Normal => { + last_d = handle_normal(app, key.code, last_d, &mut last_g); + } + + // ---------------------------------------------------------- Mode::Search => match key.code { KeyCode::Esc => { app.mode = Mode::Normal; @@ -136,6 +80,10 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) KeyCode::Enter => app.paste_selected(), KeyCode::Down => app.next(), KeyCode::Up => app.previous(), + KeyCode::Char('o') if app.input_buffer.is_empty() => { + // `o` sans texte saisi → ouvre URL + app.open_url_selected(); + } KeyCode::Char(c) => { app.input_buffer.push(c); app.update_search(); @@ -147,11 +95,11 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) _ => {} }, + // ---------------------------------------------------------- Mode::Command => match key.code { KeyCode::Esc => { app.mode = Mode::Normal; app.input_buffer.clear(); - app.update_search(); } KeyCode::Char(c) => app.input_buffer.push(c), KeyCode::Backspace => { @@ -168,12 +116,13 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) app.pending_action = None; app.mode = Mode::PasswordInput; } - _ => {} + _ => app.set_error(format!("Commande inconnue : {cmd}")), } } _ => {} }, + // ---------------------------------------------------------- Mode::ConfirmDelete => match key.code { KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { if let Some(item) = app.get_selected_item() { @@ -194,6 +143,7 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) _ => {} }, + // ---------------------------------------------------------- Mode::PasswordInput => match key.code { KeyCode::Esc => { app.mode = Mode::Normal; @@ -223,3 +173,105 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) } } } + +/// Gère les touches en mode Normal. Retourne le nouvel état de `last_d`. +fn handle_normal(app: &mut App, code: KeyCode, last_d: bool, last_g: &mut bool) -> bool { + match code { + KeyCode::Char('?') => { + app.mode = Mode::Help; + *last_g = false; + false + } + KeyCode::Enter => { + app.paste_selected(); + *last_g = false; + false + } + KeyCode::Char('j') | KeyCode::Down => { + app.next(); + *last_g = false; + false + } + KeyCode::Char('k') | KeyCode::Up => { + app.previous(); + *last_g = false; + false + } + 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_g = false; + false + } + KeyCode::Char('g') => { + 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; + } + false + } + KeyCode::Char('d') => { + *last_g = false; + if last_d { + app.mode = Mode::ConfirmDelete; + false + } else { + true // dernier appui était 'd' + } + } + KeyCode::Char('u') => { + app.undo_delete(); + *last_g = false; + false + } + KeyCode::Char('p') => { + app.toggle_pin(); + *last_g = false; + false + } + KeyCode::Char('o') => { + app.open_url_selected(); + *last_g = false; + false + } + KeyCode::Char('e') => { + app.toggle_encrypt(); + *last_g = false; + false + } + KeyCode::Char('t') => { + app.cycle_type_filter(); + *last_g = false; + false + } + KeyCode::Char('/') => { + app.mode = Mode::Search; + app.input_buffer.clear(); + app.update_search(); + *last_g = false; + false + } + KeyCode::Char(':') => { + app.mode = Mode::Command; + app.input_buffer.clear(); + *last_g = false; + false + } + KeyCode::Char('q') => { + app.should_quit = true; + false + } + _ => { + *last_g = false; + false + } + } +} diff --git a/src/models.rs b/src/models.rs index c06e0ad..2f08c69 100644 --- a/src/models.rs +++ b/src/models.rs @@ -7,6 +7,7 @@ use uuid::Uuid; pub struct ClipboardEntry { pub content: ClipboardData, pub timestamp: SystemTime, + pub pinned: bool, } #[derive(Debug, Clone)] @@ -29,7 +30,6 @@ impl Image { .join("images") .join(format!("{}.jpg", self.id)) } - pub fn load_bytes(&self, dir_path: &str) -> io::Result> { fs::read(self.file_path(dir_path)) } diff --git a/src/ui.rs b/src/ui.rs index ba9100a..1790f60 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,14 +1,18 @@ -use crate::app::{App, Mode, detect_lang, highlight_code}; +use crate::app::{App, Mode, detect_lang, highlight_code, is_image, is_url_only}; use crate::crypto::Crypto; use ratatui::{ Frame, - layout::{Alignment, Constraint, Direction, Layout}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph}, + widgets::{Block, BorderType, Borders, Clear, List, ListItem, Padding, Paragraph}, }; use ratatui_image::StatefulImage; +// --------------------------------------------------------------------------- +// Point d'entrée +// --------------------------------------------------------------------------- + pub fn render(f: &mut Frame, app: &mut App) { let outer = Layout::default() .direction(Direction::Vertical) @@ -20,6 +24,26 @@ pub fn render(f: &mut Frame, app: &mut App) { .constraints([Constraint::Length(46), Constraint::Min(0)]) .split(outer[0]); + // ---- Liste ---- + render_list(f, app, panels[0]); + + // ---- Prévisualisation ---- + render_preview(f, app, panels[1]); + + // ---- Barre de statut ---- + render_statusbar(f, app, outer[1]); + + // ---- Overlay aide (par-dessus tout le reste) ---- + if app.mode == Mode::Help { + render_help_overlay(f, f.area()); + } +} + +// --------------------------------------------------------------------------- +// Liste +// --------------------------------------------------------------------------- + +fn render_list(f: &mut Frame, app: &mut App, area: Rect) { let items: Vec = app .filtered_items .iter() @@ -30,8 +54,21 @@ pub fn render(f: &mut Frame, app: &mut App) { Style::default().fg(Color::Rgb(90, 90, 110)), ); + // Indicateur d'épingle (largeur fixe pour garder l'alignement) + let pin_span = if item.pinned { + Span::styled( + "★ ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + } else { + Span::raw(" ") + }; + if Crypto::is_any_encrypted(&item.content) { ListItem::new(Line::from(vec![ + pin_span, ts_span, Span::styled( "🔒 [Chiffré]", @@ -40,8 +77,9 @@ pub fn render(f: &mut Frame, app: &mut App) { .add_modifier(Modifier::ITALIC), ), ])) - } else if item.content.ends_with(".jpg") || item.content.ends_with(".png") { + } else if is_image(&item.content) { ListItem::new(Line::from(vec![ + pin_span, ts_span, Span::styled( format!("🖼 {}", &item.content), @@ -50,6 +88,19 @@ pub fn render(f: &mut Frame, app: &mut App) { .add_modifier(Modifier::BOLD), ), ])) + } else if is_url_only(&item.content) { + let preview: String = item.content.chars().take(26).collect(); + ListItem::new(Line::from(vec![ + pin_span, + ts_span, + Span::styled( + "[URL] ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled(preview, Style::default().fg(Color::Rgb(100, 180, 255))), + ])) } else { let preview: String = item .content @@ -57,9 +108,9 @@ pub fn render(f: &mut Frame, app: &mut App) { .find(|l| !l.trim().is_empty()) .unwrap_or("") .chars() - .take(28) + .take(26) .collect(); - ListItem::new(Line::from(vec![ts_span, Span::raw(preview)])) + ListItem::new(Line::from(vec![pin_span, ts_span, Span::raw(preview)])) } }) .collect(); @@ -86,8 +137,14 @@ pub fn render(f: &mut Frame, app: &mut App) { ) .highlight_symbol("▶ "); - f.render_stateful_widget(list, panels[0], &mut app.list_state); + f.render_stateful_widget(list, area, &mut app.list_state); +} +// --------------------------------------------------------------------------- +// Prévisualisation +// --------------------------------------------------------------------------- + +fn render_preview(f: &mut Frame, app: &mut App, area: Rect) { let selected_content = app.get_selected_item().map(|i| i.content.clone()); let preview_title = match &app.preview_lang { @@ -108,8 +165,8 @@ pub fn render(f: &mut Frame, app: &mut App) { .title_alignment(Alignment::Center) .padding(Padding::uniform(1)); - let inner = preview_block.inner(panels[1]); - f.render_widget(preview_block, panels[1]); + let inner = preview_block.inner(area); + f.render_widget(preview_block, area); let scroll = (app.preview_scroll, 0); @@ -122,17 +179,40 @@ pub fn render(f: &mut Frame, app: &mut App) { .scroll(scroll), inner, ); + } else if is_url_only(content) { + // Affiche l'URL complète + hint + let lines = vec![ + Line::from(Span::styled( + content.trim(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::UNDERLINED), + )), + Line::from(""), + Line::from(Span::styled( + " [o] Ouvrir dans le navigateur", + Style::default().fg(Color::DarkGray), + )), + ]; + f.render_widget(Paragraph::new(lines).scroll(scroll), inner); } else if let Some(lines) = &app.preview_highlighted { f.render_widget(Paragraph::new(lines.clone()).scroll(scroll), inner); } } +} +// --------------------------------------------------------------------------- +// Barre de statut +// --------------------------------------------------------------------------- + +fn render_statusbar(f: &mut Frame, app: &mut App, area: Rect) { let (mode_label, mode_color) = match &app.mode { Mode::Normal => (" NORMAL ", Color::Green), Mode::Search => (" RECHERCHE ", Color::Cyan), Mode::Command => (" COMMANDE ", Color::Yellow), Mode::ConfirmDelete => (" SUPPRIMER ? y/n ", Color::Red), Mode::PasswordInput => (" MOT DE PASSE ", Color::Magenta), + Mode::Help => (" AIDE ", Color::Blue), }; let filter_hint = match app.type_filter { @@ -156,6 +236,7 @@ pub fn render(f: &mut Frame, app: &mut App) { } Mode::Command => format!(" :{}", app.input_buffer), Mode::PasswordInput => format!(" {}", "●".repeat(app.input_buffer.len())), + Mode::Help => " ? ou Esc pour fermer".to_string(), _ => filter_hint, }; Span::raw(extra) @@ -177,7 +258,7 @@ pub fn render(f: &mut Frame, app: &mut App) { let status_cols = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Min(0), Constraint::Length(clen)]) - .split(outer[1]); + .split(area); f.render_widget( Paragraph::new(Line::from(vec![ @@ -192,6 +273,7 @@ pub fn render(f: &mut Frame, app: &mut App) { ])), status_cols[0], ); + f.render_widget( Paragraph::new(Line::from(Span::styled( counter, @@ -203,3 +285,147 @@ pub fn render(f: &mut Frame, app: &mut App) { status_cols[1], ); } + +// --------------------------------------------------------------------------- +// Overlay d'aide +// --------------------------------------------------------------------------- + +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + Rect::new(x, y, width.min(area.width), height.min(area.height)) +} + +fn help_lines() -> Vec> { + let k = |s: &'static str| { + Span::styled( + s, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + }; + let d = |s: &'static str| Span::styled(s, Style::default().fg(Color::White)); + let sep = || Span::raw(" "); + let h = |s: &'static str| { + Span::styled( + s, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + ) + }; + let dim = |s: &'static str| Span::styled(s, Style::default().fg(Color::DarkGray)); + + vec![ + Line::from(vec![h("Navigation")]), + Line::from(vec![ + k(" j / ↓"), + sep(), + d("Bas"), + sep(), + sep(), + k("k / ↑"), + sep(), + d("Haut"), + ]), + Line::from(vec![ + k(" g g"), + sep(), + d("Premier"), + sep(), + sep(), + k("G"), + sep(), + d("Dernier"), + ]), + Line::from(vec![ + k(" Ctrl+j"), + sep(), + d("Scroll prévisualisation ↓"), + sep(), + k("Ctrl+k"), + sep(), + d("↑"), + ]), + Line::from(""), + Line::from(vec![h("Actions")]), + Line::from(vec![k(" Entrée"), sep(), d("Coller & quitter")]), + Line::from(vec![ + k(" d d"), + sep(), + d("Supprimer (demande confirmation)"), + ]), + Line::from(vec![k(" u"), sep(), d("Annuler la suppression")]), + Line::from(vec![k(" p"), sep(), d("★ Épingler / désépingler")]), + Line::from(vec![k(" e"), sep(), d("🔒 Chiffrer / déchiffrer")]), + Line::from(vec![k(" o"), sep(), d("Ouvrir l'URL dans le navigateur")]), + Line::from(vec![ + k(" t"), + sep(), + d("Filtrer par type (Tous → Texte → Image)"), + ]), + Line::from(""), + Line::from(vec![h("Recherche (mode /)")]), + Line::from(vec![ + k(" /texte"), + sep(), + d("Fuzzy search"), + sep(), + k("//regex"), + sep(), + d("Regex (préfixe /)"), + ]), + Line::from(vec![ + k(" after:YYYY-MM-DD"), + sep(), + d("Après date"), + sep(), + k("before:..."), + sep(), + d("Avant date"), + ]), + Line::from(""), + Line::from(vec![h("Commandes (:)")]), + Line::from(vec![ + k(" :clear"), + sep(), + d("Effacer tout l'historique"), + sep(), + k(":password"), + sep(), + d("Mot de passe session"), + sep(), + k(":q"), + sep(), + d("Quitter"), + ]), + Line::from(""), + Line::from(vec![dim(" ? / Esc pour fermer cette aide")]), + ] +} + +fn render_help_overlay(f: &mut Frame, area: Rect) { + let popup = centered_rect(68, 25, area); + f.render_widget(Clear, popup); + + let block = Block::default() + .title(Span::styled( + " ? Aide — Raccourcis clavier ", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )) + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Cyan)) + .style(Style::default().bg(Color::Rgb(18, 18, 28))); + + let inner = block.inner(popup); + f.render_widget(block, popup); + f.render_widget( + Paragraph::new(help_lines()).style(Style::default().bg(Color::Rgb(18, 18, 28))), + inner, + ); +}