This commit is contained in:
2026-05-21 09:39:12 +02:00
parent fc085a8a83
commit 041e90a8f2
15 changed files with 269 additions and 207 deletions

View File

@ -20,3 +20,6 @@ serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
syntect = "5.3.0"
uuid = "1.22.0"
[profile.release]
debug = true

BIN
profile.json.gz Normal file

Binary file not shown.

View File

@ -20,3 +20,6 @@ x11rb = "0.13.2"
[features]
x11 = []
wayland = []
[profile.release]
debug = true

BIN
rklipd/profile.json.gz Normal file

Binary file not shown.

View File

@ -24,27 +24,31 @@ impl Config {
while i < args.len() {
match args[i].as_str() {
"--max-entries" => {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
cfg.max_entries = v;
i += 1;
} else {
eprintln!("--max-entries requiert une valeur entière positive");
match args.get(i).and_then(|s| s.parse::<usize>().ok()) {
Some(0) => eprintln!("--max-entries doit être > 0"),
Some(v) => cfg.max_entries = v,
None => eprintln!("--max-entries requiert une valeur entière positive"),
}
}
"--max-entry-size-kb" => {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
cfg.max_entry_size_kb = v;
i += 1;
} else {
eprintln!("--max-entry-size-kb requiert une valeur entière positive");
match args.get(i).and_then(|s| s.parse::<usize>().ok()) {
Some(0) => eprintln!("--max-entry-size-kb doit être > 0"),
Some(v) => cfg.max_entry_size_kb = v,
None => {
eprintln!("--max-entry-size-kb requiert une valeur entière positive")
}
}
}
"--expiry-days" => {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse::<u64>().ok()) {
cfg.expiry_days = Some(v);
i += 1;
} else {
eprintln!("--expiry-days requiert une valeur entière positive");
match args.get(i).and_then(|s| s.parse::<u64>().ok()) {
Some(0) => eprintln!(
"--expiry-days doit être > 0 (0 supprimerait tout immédiatement)"
),
Some(v) => cfg.expiry_days = Some(v),
None => eprintln!("--expiry-days requiert une valeur entière positive"),
}
}
"--help" | "-h" => {

View File

@ -101,9 +101,7 @@ impl Database {
ExtendedColorType::Rgb8,
)?;
}
None => {
return Ok(());
}
None => return Ok(()),
}
("image", format!("{}.jpg", img.id))
}
@ -115,7 +113,6 @@ impl Database {
)?;
self.trim_to_max()?;
Ok(())
}
@ -124,8 +121,10 @@ impl Database {
return Ok(());
}
let tx = self.conn.unchecked_transaction()?;
let image_files: Vec<String> = {
let mut stmt = self.conn.prepare(
let mut stmt = tx.prepare(
"SELECT content FROM history
WHERE type = 'image'
AND id NOT IN (
@ -137,13 +136,15 @@ impl Database {
.collect()
};
self.conn.execute(
tx.execute(
"DELETE FROM history WHERE id NOT IN (
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
)",
[self.max_entries as i64],
)?;
tx.commit()?;
for filename in image_files {
let path = Path::new(&self.dir_path).join("images").join(&filename);
if path.exists() {
@ -213,18 +214,19 @@ impl Database {
let cutoff_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i64
- (days as i64 * 86_400_000);
let tx = self.conn.unchecked_transaction()?;
let image_files: Vec<String> = {
let mut stmt = self
.conn
.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?;
let mut stmt =
tx.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?;
stmt.query_map([cutoff_ms], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect()
};
let count = self
.conn
.execute("DELETE FROM history WHERE timestamp < ?1", [cutoff_ms])?;
let count = tx.execute("DELETE FROM history WHERE timestamp < ?1", [cutoff_ms])?;
tx.commit()?;
for filename in image_files {
let path = Path::new(&self.dir_path).join("images").join(&filename);

View File

@ -48,14 +48,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let db_for_expiry = Arc::clone(&db);
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_secs(3600));
{
let lock = db_for_expiry.lock().unwrap();
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) supprimée(s) (> {days} jours)")
}
Err(e) => eprintln!("Erreur expiration : {e}"),
}
}
std::thread::sleep(Duration::from_secs(3600));
}
});
}

View File

@ -1,28 +1,42 @@
use crate::database::Database;
use crate::models::ClipboardEntry;
use arboard::Clipboard;
use std::error::Error;
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, mpsc};
pub fn start(db: Arc<Mutex<Database>>, clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
let (tx, rx) = mpsc::channel::<ClipboardEntry>();
std::thread::spawn(move || {
for entry in rx {
let lock = db.lock().unwrap();
if let Err(e) = lock.append(entry) {
eprintln!("SQLite write error: {e}");
} else {
println!("SQLite updated!");
}
}
});
#[cfg(all(feature = "wayland", not(feature = "x11")))]
{
crate::ws::wayland::start(db, clipboard)
crate::ws::wayland::start(tx, clipboard)
}
#[cfg(all(feature = "x11", not(feature = "wayland")))]
{
crate::ws::x11::start(db, clipboard)
crate::ws::x11::start(tx, clipboard)
}
#[cfg(all(feature = "x11", feature = "wayland"))]
{
let _ = (db, clipboard);
let _ = (tx, clipboard);
Err("Les features 'x11' et 'wayland' sont mutuellement exclusives".into())
}
#[cfg(not(any(feature = "x11", feature = "wayland")))]
{
let _ = (db, clipboard);
let _ = (tx, clipboard);
Err("Aucune feature de système de fenêtrage activée (--features x11 ou wayland)".into())
}
}

View File

@ -1,15 +1,22 @@
use crate::database::Database;
use crate::models::{ClipboardData, ClipboardEntry, Image};
use std::collections::hash_map::DefaultHasher;
use std::error::Error;
use std::sync::{Arc, Mutex};
use std::hash::{Hash, Hasher};
use std::sync::mpsc;
use std::time::SystemTime;
use uuid::Uuid;
use wayland_clipboard_listener::{WlClipboardPasteStream, WlListenType};
const MAX_IMAGE_PIXELS: usize = 3840 * 2160;
fn hash_bytes(data: &[u8]) -> u64 {
let mut hasher = DefaultHasher::new();
data.hash(&mut hasher);
hasher.finish()
}
pub fn start(
db: Arc<Mutex<Database>>,
tx: mpsc::Sender<ClipboardEntry>,
_clipboard: arboard::Clipboard,
) -> Result<(), Box<dyn Error>> {
let mut stream = WlClipboardPasteStream::init(WlListenType::ListenOnCopy)
@ -17,9 +24,11 @@ pub fn start(
println!("Écoute du presse-papier Wayland...");
let mut last_text: Option<String> = None;
let mut last_image_hash: Option<u64> = None;
for msg in stream.paste_stream().flatten() {
let context = &msg.context;
let data: &[u8] = context.context.as_slice();
let data: &[u8] = msg.context.context.as_slice();
if data.is_empty() {
continue;
@ -27,14 +36,22 @@ pub fn start(
let entry = if let Ok(text) = String::from_utf8(data.to_vec()) {
let text = text.trim_end_matches('\n').to_string();
if text.is_empty() {
if text.is_empty() || Some(&text) == last_text.as_ref() {
continue;
}
last_text = Some(text.clone());
last_image_hash = None;
println!("Clipboard update (texte)");
ClipboardEntry {
content: ClipboardData::Text(text),
timestamp: SystemTime::now(),
}
} else {
let hash = hash_bytes(data);
if Some(hash) == last_image_hash {
continue;
}
match image::load_from_memory(data) {
Ok(img) => {
let (width, height) = (img.width(), img.height());
@ -48,9 +65,15 @@ pub fn start(
3840,
2160
);
last_image_hash = Some(hash);
last_text = None;
continue;
}
last_image_hash = Some(hash);
last_text = None;
println!("Clipboard update (image)");
let rgba = img.into_rgba8();
ClipboardEntry {
content: ClipboardData::Image(Image {
@ -69,15 +92,10 @@ pub fn start(
}
};
println!("Clipboard update détecté");
let db_clone = Arc::clone(&db);
std::thread::spawn(move || {
let db_lock = db_clone.lock().unwrap();
if let Err(e) = db_lock.append(entry) {
eprintln!("SQLite error : {e}");
if tx.send(entry).is_err() {
eprintln!("Wayland : writer thread disparu, arrêt");
break;
}
});
}
Ok(())

View File

@ -1,10 +1,9 @@
use crate::database::Database;
use crate::models::{ClipboardData, ClipboardEntry, Image};
use arboard::Clipboard;
use std::collections::hash_map::DefaultHasher;
use std::error::Error;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, SystemTime};
use uuid::Uuid;
@ -22,7 +21,10 @@ fn hash_bytes(data: &[u8]) -> u64 {
hasher.finish()
}
pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
pub fn start(
tx: mpsc::Sender<ClipboardEntry>,
mut clipboard: Clipboard,
) -> Result<(), Box<dyn Error>> {
let (conn, screen_num) =
RustConnection::connect(None).map_err(|e| format!("Connexion X11 impossible : {e}"))?;
@ -49,15 +51,14 @@ pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), B
.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;
@ -68,14 +69,14 @@ pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), B
if let Event::XfixesSelectionNotify(_) = event {
thread::sleep(Duration::from_millis(50));
handle_clipboard_event(&mut clipboard, &db, &mut last_text, &mut last_image_hash);
handle_clipboard_event(&mut clipboard, &tx, &mut last_text, &mut last_image_hash);
}
}
}
fn handle_clipboard_event(
clipboard: &mut Clipboard,
db: &Arc<Mutex<Database>>,
tx: &mpsc::Sender<ClipboardEntry>,
last_text: &mut Option<String>,
last_image_hash: &mut Option<u64>,
) {
@ -89,13 +90,15 @@ fn handle_clipboard_event(
*last_image_hash = None;
println!("Clipboard update (texte)");
spawn_db_write(
Arc::clone(db),
ClipboardEntry {
if tx
.send(ClipboardEntry {
content: ClipboardData::Text(text),
timestamp: SystemTime::now(),
},
);
})
.is_err()
{
eprintln!("X11 : writer thread disparu");
}
}
Err(_) => {
@ -124,29 +127,20 @@ fn handle_clipboard_event(
*last_text = None;
println!("Clipboard update (image)");
spawn_db_write(
Arc::clone(db),
ClipboardEntry {
content: ClipboardData::Image(crate::models::Image {
if tx
.send(ClipboardEntry {
content: ClipboardData::Image(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(),
},
);
})
.is_err()
{
eprintln!("X11 : writer thread disparu");
}
}
}
}
fn spawn_db_write(db: Arc<Mutex<Database>>, entry: ClipboardEntry) {
thread::spawn(move || {
let lock = db.lock().unwrap();
if let Err(e) = lock.append(entry) {
eprintln!("SQLite write error: {e}");
} else {
println!("SQLite updated!");
}
});
}

View File

@ -3,19 +3,22 @@ use crate::ipc::{self, HistoryItem};
use chrono::{Local, NaiveDate, TimeZone};
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use image::DynamicImage;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::ListState;
use ratatui_image::{picker::Picker, protocol};
use regex::Regex;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::time::{Duration, Instant};
use syntect::highlighting::ThemeSet;
use syntect::easy::HighlightLines;
use syntect::highlighting::{FontStyle as SynFontStyle, ThemeSet};
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
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)]
@ -56,7 +59,9 @@ pub struct App {
pub type_filter: TypeFilter,
pub loaded_count: usize,
pub has_more: bool,
image_cache: HashMap<String, DynamicImage>,
pub preview_highlighted: Option<Vec<Line<'static>>>,
pub preview_lang: Option<String>,
image_cache: HashMap<String, Arc<DynamicImage>>,
image_cache_order: VecDeque<String>,
}
@ -85,6 +90,53 @@ impl TypeFilter {
}
}
fn syn_color(c: syntect::highlighting::Color) -> Color {
Color::Rgb(c.r, c.g, c.b)
}
pub fn highlight_code(
content: &str,
syntax_set: &SyntaxSet,
theme_set: &ThemeSet,
) -> Vec<Line<'static>> {
let theme = &theme_set.themes["base16-ocean.dark"];
let syntax = syntax_set
.find_syntax_by_first_line(content)
.unwrap_or_else(|| syntax_set.find_syntax_plain_text());
let mut h = HighlightLines::new(syntax, theme);
let mut lines = Vec::new();
for (no, line) in LinesWithEndings::from(content).enumerate() {
let ranges = h.highlight_line(line, syntax_set).unwrap_or_default();
let mut spans = vec![Span::styled(
format!("{:>4}", no + 1),
Style::default().fg(Color::Rgb(80, 80, 100)),
)];
for (style, text) in &ranges {
let mut s = Style::default().fg(syn_color(style.foreground));
if style.font_style.contains(SynFontStyle::BOLD) {
s = s.add_modifier(Modifier::BOLD);
}
if style.font_style.contains(SynFontStyle::ITALIC) {
s = s.add_modifier(Modifier::ITALIC);
}
spans.push(Span::styled(text.trim_end_matches('\n').to_string(), s));
}
lines.push(Line::from(spans));
}
lines
}
pub fn detect_lang(content: &str, syntax_set: &SyntaxSet) -> Option<String> {
let s = syntax_set.find_syntax_by_first_line(content)?;
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();
@ -94,9 +146,19 @@ impl App {
let picker = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks());
let salt = directories::ProjectDirs::from("com", "zefad", "rklipd")
.and_then(|d| Crypto::load_or_create_salt(d.data_dir()).ok())
.unwrap_or_else(|| vec![0u8; 32]);
let salt = match directories::ProjectDirs::from("com", "zefad", "rklipd") {
Some(dirs) => match Crypto::load_or_create_salt(dirs.data_dir()) {
Ok(s) => s,
Err(e) => {
eprintln!("Erreur sel cryptographique : {e}");
vec![0u8; 32]
}
},
None => {
eprintln!("Impossible de déterminer le répertoire de données");
vec![0u8; 32]
}
};
let mut app = Self {
mode: Mode::Normal,
@ -120,6 +182,8 @@ impl App {
type_filter: TypeFilter::All,
loaded_count: PAGE_SIZE,
has_more,
preview_highlighted: None,
preview_lang: None,
image_cache: HashMap::new(),
image_cache_order: VecDeque::new(),
};
@ -169,8 +233,13 @@ impl App {
&mut self,
filename: &str,
base_dir: &std::path::Path,
) -> Option<DynamicImage> {
if !self.image_cache.contains_key(filename) {
) -> Option<Arc<DynamicImage>> {
if self.image_cache.contains_key(filename) {
self.image_cache_order.retain(|k| k != filename);
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;
@ -187,11 +256,11 @@ impl App {
self.image_cache.remove(&oldest);
}
}
let arc = Arc::new(img);
self.image_cache_order.push_back(filename.to_string());
self.image_cache.insert(filename.to_string(), img);
}
self.image_cache.get(filename).cloned()
self.image_cache
.insert(filename.to_string(), Arc::clone(&arc));
Some(arc)
}
pub fn format_timestamp(ts_ms: i64) -> String {
@ -238,7 +307,15 @@ impl App {
return false;
}
}
true
match self.type_filter {
TypeFilter::All => true,
TypeFilter::Text => {
!item.content.ends_with(".jpg") && !item.content.ends_with(".png")
}
TypeFilter::Image => {
item.content.ends_with(".jpg") || item.content.ends_with(".png")
}
}
})
.cloned()
.collect();
@ -289,12 +366,6 @@ impl App {
matched.into_iter().map(|(_, i)| i).collect()
};
self.filtered_items.retain(|item| match self.type_filter {
TypeFilter::All => true,
TypeFilter::Text => !item.content.ends_with(".jpg") && !item.content.ends_with(".png"),
TypeFilter::Image => item.content.ends_with(".jpg") || item.content.ends_with(".png"),
});
self.list_state.select(if self.filtered_items.is_empty() {
None
} else {
@ -313,16 +384,12 @@ impl App {
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();
}
@ -367,13 +434,13 @@ impl App {
self.list_state.select(new_sel);
}
}
self.last_selected_index = None;
self.update_preview();
}
pub fn undo_delete(&mut self) {
if let Some(item) = self.undo_stack.pop() {
ipc::add_entry(item.content.clone());
// 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;
@ -545,6 +612,8 @@ impl App {
self.current_image = None;
self.image_cache.clear();
self.image_cache_order.clear();
self.preview_highlighted = None;
self.preview_lang = None;
self.loaded_count = PAGE_SIZE;
self.has_more = false;
self.set_status("Historique effacé".into());
@ -561,6 +630,8 @@ impl App {
self.last_selected_index = idx;
self.current_image = None;
self.preview_scroll = 0;
self.preview_highlighted = None;
self.preview_lang = None;
let content = match self.get_selected_item().map(|i| i.content.clone()) {
Some(c) => c,
@ -569,12 +640,15 @@ impl App {
if content.ends_with(".jpg") || content.ends_with(".png") {
if let Some(dirs) = directories::ProjectDirs::from("com", "zefad", "rklipd") {
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)
if let Some(arc_img) = self.get_cached_image(&content, dirs.data_dir()) {
let img = (*arc_img).clone();
self.current_image = Some(self.picker.new_resize_protocol(img));
}
}
} else if !Crypto::is_any_encrypted(&content) {
self.preview_lang = detect_lang(&content, &self.syntax_set);
self.preview_highlighted =
Some(highlight_code(&content, &self.syntax_set, &self.theme_set));
}
}
@ -596,7 +670,6 @@ impl App {
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()

View File

@ -34,6 +34,14 @@ impl Crypto {
if bytes.len() == SALT_LEN {
return Ok(bytes);
}
return Err(format!(
"Fichier sel corrompu ({} octets au lieu de {}). \
Supprimez {:?} manuellement si vous souhaitez réinitialiser le chiffrement.",
bytes.len(),
SALT_LEN,
path
)
.into());
}
let mut salt = vec![0u8; SALT_LEN];
OsRng.fill_bytes(&mut salt);

View File

@ -63,8 +63,11 @@ pub fn set_clipboard(content: String) -> bool {
)
}
pub fn delete_entry(content: String) {
let _ = send_request(&IpcRequest::DeleteEntry { content });
pub fn delete_entry(content: String) -> bool {
matches!(
send_request(&IpcRequest::DeleteEntry { content }),
Some(IpcResponse::Ok)
)
}
pub fn update_entry(old_content: String, new_content: String) -> bool {

View File

@ -177,8 +177,14 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
Mode::ConfirmDelete => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
if let Some(item) = app.get_selected_item() {
ipc::delete_entry(item.content.clone());
let content = item.content.clone();
if ipc::delete_entry(content) {
app.delete_selected();
} else {
app.set_error(
"Erreur : daemon injoignable, entrée non supprimée".into(),
);
}
}
app.mode = Mode::Normal;
}

View File

@ -1,4 +1,4 @@
use crate::app::{App, Mode};
use crate::app::{App, Mode, detect_lang, highlight_code};
use crate::crypto::Crypto;
use ratatui::{
Frame,
@ -8,55 +8,6 @@ use ratatui::{
widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph},
};
use ratatui_image::StatefulImage;
use syntect::easy::HighlightLines;
use syntect::highlighting::FontStyle as SynFontStyle;
use syntect::util::LinesWithEndings;
fn syn_color(c: syntect::highlighting::Color) -> Color {
Color::Rgb(c.r, c.g, c.b)
}
fn highlight_code(content: &str, app: &App) -> Vec<Line<'static>> {
let ps = &app.syntax_set;
let ts = &app.theme_set;
let theme = &ts.themes["base16-ocean.dark"];
let syntax = ps
.find_syntax_by_first_line(content)
.unwrap_or_else(|| ps.find_syntax_plain_text());
let mut h = HighlightLines::new(syntax, theme);
let mut lines = Vec::new();
for (no, line) in LinesWithEndings::from(content).enumerate() {
let ranges = h.highlight_line(line, ps).unwrap_or_default();
let mut spans = vec![Span::styled(
format!("{:>4}", no + 1),
Style::default().fg(Color::Rgb(80, 80, 100)),
)];
for (style, text) in &ranges {
let mut s = Style::default().fg(syn_color(style.foreground));
if style.font_style.contains(SynFontStyle::BOLD) {
s = s.add_modifier(Modifier::BOLD);
}
if style.font_style.contains(SynFontStyle::ITALIC) {
s = s.add_modifier(Modifier::ITALIC);
}
spans.push(Span::styled(text.trim_end_matches('\n').to_string(), s));
}
lines.push(Line::from(spans));
}
lines
}
fn detect_lang(content: &str, app: &App) -> Option<String> {
let s = app.syntax_set.find_syntax_by_first_line(content)?;
if s.name == "Plain Text" {
None
} else {
Some(s.name.clone())
}
}
pub fn render(f: &mut Frame, app: &mut App) {
let outer = Layout::default()
@ -103,7 +54,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
let preview: String = item
.content
.lines()
.next()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.chars()
.take(28)
@ -139,12 +90,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
let selected_content = app.get_selected_item().map(|i| i.content.clone());
let lang = selected_content
.as_deref()
.filter(|c| !Crypto::is_any_encrypted(c) && !c.ends_with(".jpg") && !c.ends_with(".png"))
.and_then(|c| detect_lang(c, app));
let preview_title = match &lang {
let preview_title = match &app.preview_lang {
Some(l) => format!(" Prévisualisation — {} ", l),
None => " Prévisualisation ".to_string(),
};
@ -165,20 +111,19 @@ pub fn render(f: &mut Frame, app: &mut App) {
let inner = preview_block.inner(panels[1]);
f.render_widget(preview_block, panels[1]);
if app.current_image.is_some() {
let state = app.current_image.as_mut().unwrap();
let scroll = (app.preview_scroll, 0);
if let Some(state) = app.current_image.as_mut() {
f.render_stateful_widget(StatefulImage::default(), inner, state);
} else if let Some(content) = &selected_content {
let scroll = (app.preview_scroll, 0);
if Crypto::is_any_encrypted(content) {
f.render_widget(
Paragraph::new("🔒 Contenu chiffré\n\nAppuyez sur [e] pour déchiffrer.")
.scroll(scroll),
inner,
);
} else {
let lines = highlight_code(content, app);
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);
}
}
@ -190,20 +135,6 @@ pub fn render(f: &mut Frame, app: &mut App) {
Mode::PasswordInput => (" MOT DE PASSE ", Color::Magenta),
};
let extra = match &app.mode {
Mode::Search => format!(" /{}", app.input_buffer),
Mode::Command => format!(" :{}", app.input_buffer),
Mode::PasswordInput => format!(" {}", "".repeat(app.input_buffer.len())),
_ => String::new(),
};
// let msg_span = if let Some((msg, _)) = &app.error_message {
// Span::styled(format!(" ⚠ {msg}"), Style::default().fg(Color::Red))
// } else if let Some((msg, _)) = &app.status_message {
// Span::styled(format!(" ✓ {msg}"), Style::default().fg(Color::Green))
// } else {
// Span::raw(extra)
// };
let filter_hint = match app.type_filter {
crate::app::TypeFilter::All => String::new(),
f => format!(" [{}]", f.label()),
@ -216,7 +147,6 @@ pub fn render(f: &mut Frame, app: &mut App) {
} else {
let extra = match &app.mode {
Mode::Search => {
// Indicateur visuel du mode de recherche actif (fuzzy vs regexp)
let mode_hint = if app.input_buffer.trim_start().starts_with('/') {
"re"
} else {