This commit is contained in:
2026-03-08 17:52:16 +01:00
parent 62fb8cd330
commit 5f691c2e2f
7 changed files with 2155 additions and 56 deletions

1759
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,13 @@ name = "rklip"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[target.'cfg(target_os = "linux")'.dependencies]
[dependencies] [dependencies]
arboard = "3.6.1" crossterm = "0.29.0"
directories = "6.0.0"
fuzzy-matcher = "0.3.7"
image = "0.25.9"
ratatui = "0.30.0"
ratatui-image = { version = "10.0.6", features = ["crossterm"] }
rklipd = {path = "rklipd"} rklipd = {path = "rklipd"}
serde = "1.0.228"
uuid = "1.22.0"

184
src/app.rs Normal file
View File

@ -0,0 +1,184 @@
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use ratatui::widgets::ListState;
use ratatui_image::{picker::Picker, protocol};
use std::path::Path;
#[derive(PartialEq)]
pub enum Mode {
Normal,
Command,
Search,
}
pub struct App {
pub mode: Mode,
pub all_items: Vec<String>,
pub filtered_items: Vec<String>,
pub list_state: ListState,
pub input_buffer: String,
pub should_quit: bool,
pub undo_stack: Vec<(usize, String)>,
pub current_image: Option<protocol::StatefulProtocol>,
pub last_selected_index: Option<usize>,
pub picker: Picker,
}
impl App {
pub fn new() -> Self {
let mut list_state = ListState::default();
list_state.select(Some(0));
let items = vec![
"Ceci est un texte copié.".to_string(),
"https://github.com/ratatui-org/ratatui".to_string(),
"30426b4d-26e0-45af-9fa4-25f4476387a8.jpg".to_string(),
"35789d6a-dea4-46de-90da-aee693a16031.jpg".to_string(),
"fn main() {\n println!(\"Hello\");\n}".to_string(),
];
let picker = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks());
let mut app = Self {
mode: Mode::Normal,
filtered_items: items.clone(),
all_items: items,
list_state,
input_buffer: String::new(),
should_quit: false,
undo_stack: Vec::new(),
current_image: None,
last_selected_index: None,
picker,
};
app.update_preview();
app
}
pub fn update_search(&mut self) {
if self.input_buffer.is_empty() {
self.filtered_items = self.all_items.clone();
} else {
let matcher = SkimMatcherV2::default();
let mut matched: Vec<(i64, String)> = self
.all_items
.iter()
.filter_map(|item| {
matcher
.fuzzy_match(item, &self.input_buffer)
.map(|score| (score, item.clone()))
})
.collect();
matched.sort_by(|a, b| b.0.cmp(&a.0));
self.filtered_items = matched.into_iter().map(|(_, item)| item).collect();
self.update_preview();
}
self.list_state.select(if self.filtered_items.is_empty() {
None
} else {
Some(0)
});
}
pub fn delete_selected(&mut self) {
if let Some(i) = self.list_state.selected() {
if i < self.filtered_items.len() {
let item_to_remove = self.filtered_items.remove(i);
self.undo_stack.push((i, item_to_remove.clone()));
if let Some(pos) = self.all_items.iter().position(|x| *x == item_to_remove) {
self.all_items.remove(pos);
}
if self.filtered_items.is_empty() {
self.list_state.select(None);
} else if i >= self.filtered_items.len() {
self.list_state.select(Some(self.filtered_items.len() - 1));
}
}
}
self.update_preview();
}
pub fn undo_delete(&mut self) {
if let Some((i, item)) = self.undo_stack.pop() {
self.all_items.insert(i, item.clone());
self.update_search();
self.list_state.select(Some(i));
}
self.update_preview();
}
pub fn update_preview(&mut self) {
let current_index = self.list_state.selected();
if self.last_selected_index == current_index {
return;
}
self.last_selected_index = current_index;
self.current_image = None;
if let Some(selected_text) = self.get_selected_item() {
// To change later with entry type
if selected_text.ends_with(".jpg") || selected_text.ends_with(".png") {
let base_dir = directories::ProjectDirs::from("com", "zefad", "rklipd")
.expect("No home dir")
.data_dir()
.to_path_buf();
let img_path = base_dir.join("images").join(selected_text);
if img_path.exists() {
if let Ok(img) = image::open(&img_path) {
let protocol = self.picker.new_resize_protocol(img);
self.current_image = Some(protocol);
}
}
}
}
}
pub fn next(&mut self) {
if self.filtered_items.is_empty() {
return;
}
let i = match self.list_state.selected() {
Some(i) => {
if i >= self.filtered_items.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.list_state.select(Some(i));
self.update_preview();
}
pub fn previous(&mut self) {
if self.filtered_items.is_empty() {
return;
}
let i = match self.list_state.selected() {
Some(i) => {
if i == 0 {
self.filtered_items.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.list_state.select(Some(i));
self.update_preview();
}
pub fn get_selected_item(&self) -> Option<&String> {
self.list_state.selected().map(|i| &self.filtered_items[i])
}
}

0
src/ipc.rs Normal file
View File

View File

@ -1 +1,147 @@
fn main() {} mod app;
mod ipc;
mod models;
mod ui;
use app::{App, Mode};
use crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use std::io;
fn main() -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
let res = run_app(&mut terminal, &mut app);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
Ok(())
}
fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> io::Result<()> {
let mut last_key_was_d = false;
let mut last_key_was_g = false;
loop {
terminal.draw(|f| ui::render(f, app))?;
if let Event::Key(key) = event::read()? {
match app.mode {
Mode::Normal => match key.code {
KeyCode::Char('j') => {
app.next();
last_key_was_d = false;
}
KeyCode::Char('k') => {
app.previous();
last_key_was_d = false;
}
KeyCode::Char('d') => {
if last_key_was_d {
app.delete_selected();
last_key_was_d = false;
} else {
last_key_was_d = true;
}
last_key_was_g = false;
}
KeyCode::Char('u') => {
app.undo_delete();
last_key_was_d = false;
}
KeyCode::Char('g') => {
if last_key_was_g {
if !app.filtered_items.is_empty() {
app.list_state.select(Some(0));
}
last_key_was_g = false;
} else {
last_key_was_g = true;
}
last_key_was_d = false;
}
KeyCode::Char('G') => {
if !app.filtered_items.is_empty() {
app.list_state.select(Some(app.filtered_items.len() - 1));
}
last_key_was_d = false;
}
KeyCode::Char(':') => {
app.mode = Mode::Command;
app.input_buffer.clear();
last_key_was_d = false;
}
KeyCode::Char('/') => {
app.mode = Mode::Search;
app.input_buffer.clear();
app.update_search();
last_key_was_d = false;
}
KeyCode::Char('q') => {
app.should_quit = true;
}
_ => {
last_key_was_d = false;
}
},
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 => {
app.input_buffer.pop();
}
KeyCode::Enter => {
if app.input_buffer == "q" {
app.should_quit = true;
}
app.mode = Mode::Normal;
app.input_buffer.clear();
app.update_search();
}
_ => {}
},
Mode::Search => match key.code {
// ... ton code de recherche ...
KeyCode::Esc | KeyCode::Enter => {
app.mode = Mode::Normal;
app.input_buffer.clear();
app.update_search();
}
KeyCode::Char(c) => {
app.input_buffer.push(c);
app.update_search();
}
KeyCode::Backspace => {
app.input_buffer.pop();
app.update_search();
}
_ => {}
},
}
}
if app.should_quit {
return Ok(());
}
}
}

36
src/models.rs Normal file
View File

@ -0,0 +1,36 @@
use std::path::PathBuf;
use std::time::SystemTime;
use std::{fs, io};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct ClipboardEntry {
pub content: ClipboardData,
pub timestamp: SystemTime,
}
#[derive(Debug, Clone)]
pub enum ClipboardData {
Text(String),
Image(Image),
}
#[derive(Debug, Clone)]
pub struct Image {
pub raw_pixels: Option<Vec<u8>>,
pub width: u32,
pub height: u32,
pub id: Uuid,
}
impl Image {
pub fn file_path(&self, base_dir: &str) -> PathBuf {
std::path::Path::new(base_dir)
.join("images")
.join(format!("{}.jpg", self.id))
}
pub fn load_bytes(&self, dir_path: &str) -> io::Result<Vec<u8>> {
fs::read(self.file_path(dir_path))
}
}

74
src/ui.rs Normal file
View File

@ -0,0 +1,74 @@
use crate::app::{App, Mode};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Padding, Paragraph},
};
use ratatui_image::StatefulImage;
pub fn render(f: &mut Frame, app: &mut App) {
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(3)])
.split(f.area());
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(main_chunks[0]);
let items: Vec<ListItem> = app
.filtered_items
.iter()
.map(|i| ListItem::new(i.as_str()))
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(" History "))
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
f.render_stateful_widget(list, content_chunks[0], &mut app.list_state);
let right_panel_block = Block::default()
.borders(Borders::ALL)
.title(" Prev ")
.padding(Padding::uniform(1));
let inner_right_area = right_panel_block.inner(content_chunks[1]);
f.render_widget(right_panel_block, content_chunks[1]);
if let Some(state) = &mut app.current_image {
let image_widget = StatefulImage::default();
f.render_stateful_widget(image_widget, inner_right_area, state);
} else {
let preview_text = app.get_selected_item().cloned().unwrap_or_default();
let preview_paragraph =
Paragraph::new(preview_text).wrap(ratatui::widgets::Wrap { trim: true });
f.render_widget(preview_paragraph, inner_right_area);
}
let bottom_text = match app.mode {
Mode::Normal => Line::from(vec![Span::styled(
"-- NORMAL --",
Style::default().fg(Color::Green),
)]),
Mode::Command => Line::from(vec![
Span::styled(":", Style::default().fg(Color::Yellow)),
Span::raw(&app.input_buffer),
]),
Mode::Search => Line::from(vec![
Span::styled("/", Style::default().fg(Color::Cyan)),
Span::raw(&app.input_buffer),
]),
};
let bottom_bar = Paragraph::new(bottom_text).block(Block::default().borders(Borders::ALL));
f.render_widget(bottom_bar, main_chunks[1]);
}