Compare commits
6 Commits
62fb8cd330
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20f33f5694 | |||
| 989e0aef91 | |||
| 54ddc9851c | |||
| dcc863c451 | |||
| 9e56322705 | |||
| 5f691c2e2f |
1762
Cargo.lock
generated
1762
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
11
Cargo.toml
11
Cargo.toml
@ -3,7 +3,14 @@ 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 = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.149"
|
||||||
|
uuid = "1.22.0"
|
||||||
|
|||||||
3
rklipd/Cargo.lock
generated
3
rklipd/Cargo.lock
generated
@ -1303,6 +1303,8 @@ dependencies = [
|
|||||||
"directories",
|
"directories",
|
||||||
"image",
|
"image",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wayland-clipboard-listener",
|
"wayland-clipboard-listener",
|
||||||
]
|
]
|
||||||
@ -1370,6 +1372,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -11,6 +11,8 @@ uuid = {version = "1.22.0", features = ["v4", "serde"]}
|
|||||||
rusqlite = "0.38.0"
|
rusqlite = "0.38.0"
|
||||||
wayland-clipboard-listener = "0.6.0"
|
wayland-clipboard-listener = "0.6.0"
|
||||||
directories = "6.0.0"
|
directories = "6.0.0"
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.149"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
x11 = []
|
x11 = []
|
||||||
|
|||||||
@ -112,4 +112,10 @@ impl Database {
|
|||||||
|
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn delete_entry_by_content(&self, content: &str) -> Result<(), Box<dyn Error>> {
|
||||||
|
self.conn
|
||||||
|
.execute("DELETE FROM history WHERE content = ?1", [content])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
125
rklipd/src/ipc.rs
Normal file
125
rklipd/src/ipc.rs
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
use crate::database::Database;
|
||||||
|
use crate::models::ClipboardData;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::os::unix::net::UnixListener;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub enum IpcRequest {
|
||||||
|
GetHistory { limit: usize },
|
||||||
|
SetClipboard { content: String },
|
||||||
|
DeleteEntry { content: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub enum IpcResponse {
|
||||||
|
History(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_server(db: Arc<Mutex<Database>>, socket_path: &Path) {
|
||||||
|
if socket_path.exists() {
|
||||||
|
let _ = fs::remove_file(socket_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener = match UnixListener::bind(socket_path) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error while creating socket {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
println!("ipc server listening {:?}", socket_path);
|
||||||
|
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
match stream {
|
||||||
|
Ok(mut stream) => {
|
||||||
|
let db_clone = Arc::clone(&db);
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut buffer = String::new();
|
||||||
|
|
||||||
|
if stream.read_to_string(&mut buffer).is_ok() {
|
||||||
|
if let Ok(request) = serde_json::from_str::<IpcRequest>(&buffer) {
|
||||||
|
match request {
|
||||||
|
IpcRequest::GetHistory { limit } => {
|
||||||
|
let db_lock = db_clone.lock().unwrap();
|
||||||
|
|
||||||
|
// TODO Implem read_history(limit)
|
||||||
|
let history = db_lock.read_history().unwrap_or_default();
|
||||||
|
|
||||||
|
let items: Vec<String> = history
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.take(limit)
|
||||||
|
.map(|entry| match entry.content {
|
||||||
|
ClipboardData::Text(t) => t,
|
||||||
|
ClipboardData::Image(img) => format!("{}.jpg", img.id),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let response = IpcResponse::History(items);
|
||||||
|
let response_json = serde_json::to_string(&response).unwrap();
|
||||||
|
let _ = stream.write_all(response_json.as_bytes());
|
||||||
|
}
|
||||||
|
IpcRequest::SetClipboard { content } => {
|
||||||
|
if let Ok(mut clipboard) = arboard::Clipboard::new() {
|
||||||
|
if content.ends_with(".jpg") || content.ends_with(".png") {
|
||||||
|
if let Some(proj_dirs) = directories::ProjectDirs::from(
|
||||||
|
"com", "zefad", "rklipd",
|
||||||
|
) {
|
||||||
|
let img_path = proj_dirs
|
||||||
|
.data_dir()
|
||||||
|
.join("images")
|
||||||
|
.join(&content);
|
||||||
|
if let Ok(img) = image::open(&img_path) {
|
||||||
|
let rgba = img.into_rgba8();
|
||||||
|
let img_data = arboard::ImageData {
|
||||||
|
width: rgba.width() as usize,
|
||||||
|
height: rgba.height() as usize,
|
||||||
|
bytes: std::borrow::Cow::Borrowed(
|
||||||
|
rgba.as_raw(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let _ = clipboard.set_image(img_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = clipboard.set_text(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcRequest::DeleteEntry { content } => {
|
||||||
|
{
|
||||||
|
let db_lock = db_clone.lock().unwrap();
|
||||||
|
let _ = db_lock.delete_entry_by_content(&content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.ends_with(".jpg") || content.ends_with(".png") {
|
||||||
|
if let Some(proj_dirs) =
|
||||||
|
directories::ProjectDirs::from("com", "zefad", "rklipd")
|
||||||
|
{
|
||||||
|
let img_path =
|
||||||
|
proj_dirs.data_dir().join("images").join(&content);
|
||||||
|
if img_path.exists() {
|
||||||
|
if let Err(e) = std::fs::remove_file(&img_path) {
|
||||||
|
eprintln!("Error while deleting image: {}", e);
|
||||||
|
} else {
|
||||||
|
println!("Image deleted : {}", content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("Erreur de connexion IPC: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
mod clipboard;
|
mod clipboard;
|
||||||
mod database;
|
mod database;
|
||||||
|
mod ipc;
|
||||||
mod models;
|
mod models;
|
||||||
mod monitor;
|
mod monitor;
|
||||||
mod ws;
|
mod ws;
|
||||||
@ -18,8 +19,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
let db = Arc::new(Mutex::new(Database::init(&dir_path_str)?));
|
let db = Arc::new(Mutex::new(Database::init(&dir_path_str)?));
|
||||||
|
|
||||||
// println!("{:#?}", db.lock().unwrap().read_history());
|
let socket_path = dir_path.join("rklip.sock");
|
||||||
|
let db_for_ipc = Arc::clone(&db);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
crate::ipc::start_server(db_for_ipc, &socket_path);
|
||||||
|
});
|
||||||
|
|
||||||
|
println!("rklipd starting...");
|
||||||
monitor::start(db, clipboard)?;
|
monitor::start(db, clipboard)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
198
src/app.rs
Normal file
198
src/app.rs
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
use crate::ipc;
|
||||||
|
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
|
||||||
|
use ratatui::widgets::ListState;
|
||||||
|
use ratatui_image::{picker::Picker, protocol};
|
||||||
|
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
pub enum Mode {
|
||||||
|
Normal,
|
||||||
|
Command,
|
||||||
|
Search,
|
||||||
|
ConfirmDelete,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ipc::fetch_history(100).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut list_state = ListState::default();
|
||||||
|
if items.is_empty() {
|
||||||
|
list_state.select(None);
|
||||||
|
} else {
|
||||||
|
list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
.and_then(|i| self.filtered_items.get(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sync_with_daemon(&mut self) {
|
||||||
|
if let Some(new_history) = crate::ipc::fetch_history(100) {
|
||||||
|
if self.all_items != new_history {
|
||||||
|
self.all_items = new_history;
|
||||||
|
self.update_search();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/ipc.rs
Normal file
65
src/ipc.rs
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub enum IpcRequest {
|
||||||
|
GetHistory { limit: usize },
|
||||||
|
SetClipboard { content: String },
|
||||||
|
DeleteEntry { content: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub enum IpcResponse {
|
||||||
|
History(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_history(limit: usize) -> Option<Vec<String>> {
|
||||||
|
let base_dir = directories::ProjectDirs::from("com", "zefad", "rklipd")?
|
||||||
|
.data_dir()
|
||||||
|
.to_path_buf();
|
||||||
|
let socket_path = base_dir.join("rklip.sock");
|
||||||
|
|
||||||
|
if let Ok(mut stream) = UnixStream::connect(&socket_path) {
|
||||||
|
let req = IpcRequest::GetHistory { limit };
|
||||||
|
let req_json = serde_json::to_string(&req).unwrap();
|
||||||
|
|
||||||
|
let _ = stream.write_all(req_json.as_bytes());
|
||||||
|
let _ = stream.shutdown(std::net::Shutdown::Write);
|
||||||
|
|
||||||
|
let mut response_buffer = String::new();
|
||||||
|
if stream.read_to_string(&mut response_buffer).is_ok() {
|
||||||
|
if let Ok(IpcResponse::History(items)) = serde_json::from_str(&response_buffer) {
|
||||||
|
return Some(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_clipboard(content: String) {
|
||||||
|
if let Some(base_dir) = directories::ProjectDirs::from("com", "zefad", "rklipd") {
|
||||||
|
let socket_path = base_dir.data_dir().join("rklip.sock");
|
||||||
|
if let Ok(mut stream) = UnixStream::connect(&socket_path) {
|
||||||
|
let req = IpcRequest::SetClipboard { content };
|
||||||
|
if let Ok(req_json) = serde_json::to_string(&req) {
|
||||||
|
let _ = stream.write_all(req_json.as_bytes());
|
||||||
|
let _ = stream.shutdown(std::net::Shutdown::Write);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_entry(content: String) {
|
||||||
|
if let Some(base_dir) = directories::ProjectDirs::from("com", "zefad", "rklipd") {
|
||||||
|
let socket_path = base_dir.data_dir().join("rklip.sock");
|
||||||
|
if let Ok(mut stream) = UnixStream::connect(&socket_path) {
|
||||||
|
let req = IpcRequest::DeleteEntry { content };
|
||||||
|
if let Ok(req_json) = serde_json::to_string(&req) {
|
||||||
|
let _ = stream.write_all(req_json.as_bytes());
|
||||||
|
let _ = stream.shutdown(std::net::Shutdown::Write);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
177
src/main.rs
177
src/main.rs
@ -1 +1,176 @@
|
|||||||
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;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
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 event::poll(Duration::from_millis(500))? {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
match app.mode {
|
||||||
|
Mode::Normal => match key.code {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if let Some(selected) = app.get_selected_item() {
|
||||||
|
crate::ipc::set_clipboard(selected.clone());
|
||||||
|
app.should_quit = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.mode = Mode::ConfirmDelete;
|
||||||
|
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 {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.mode = Mode::Normal;
|
||||||
|
app.input_buffer.clear();
|
||||||
|
app.update_search();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if let Some(selected) = app.get_selected_item() {
|
||||||
|
crate::ipc::set_clipboard(selected.clone());
|
||||||
|
app.should_quit = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.input_buffer.push(c);
|
||||||
|
app.update_search();
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.input_buffer.pop();
|
||||||
|
app.update_search();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Mode::ConfirmDelete => match key.code {
|
||||||
|
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
|
||||||
|
if let Some(selected) = app.get_selected_item() {
|
||||||
|
crate::ipc::delete_entry(selected.clone());
|
||||||
|
app.delete_selected();
|
||||||
|
}
|
||||||
|
app.mode = Mode::Normal;
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
||||||
|
app.mode = Mode::Normal;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.sync_with_daemon();
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.should_quit {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
36
src/models.rs
Normal file
36
src/models.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/ui.rs
Normal file
167
src/ui.rs
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
use crate::app::{App, Mode};
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, BorderType, 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::Length(45), Constraint::Min(0)])
|
||||||
|
.split(main_chunks[0]);
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = app
|
||||||
|
.filtered_items
|
||||||
|
.iter()
|
||||||
|
.map(|i| {
|
||||||
|
if i.ends_with(".jpg") || i.ends_with(".png") {
|
||||||
|
ListItem::new(Line::from(Span::styled(
|
||||||
|
format!("🖼️ {}", i),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Magenta)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)))
|
||||||
|
} else {
|
||||||
|
ListItem::new(Line::from(format!(" {}", i)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(Color::DarkGray))
|
||||||
|
.title(Span::styled(
|
||||||
|
" History ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::White)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
|
.title_alignment(Alignment::Center),
|
||||||
|
)
|
||||||
|
.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Rgb(40, 44, 52))
|
||||||
|
.fg(Color::White)
|
||||||
|
.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)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(Color::DarkGray))
|
||||||
|
.title(Span::styled(
|
||||||
|
" Previsualisation ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::White)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
|
.title_alignment(Alignment::Center)
|
||||||
|
.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 current_color = match app.mode {
|
||||||
|
Mode::Normal => Color::Green,
|
||||||
|
Mode::ConfirmDelete => Color::Red,
|
||||||
|
Mode::Command => Color::Yellow,
|
||||||
|
Mode::Search => Color::Cyan,
|
||||||
|
};
|
||||||
|
|
||||||
|
let bottom_block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(current_color));
|
||||||
|
|
||||||
|
let inner_bottom_area = bottom_block.inner(main_chunks[1]);
|
||||||
|
f.render_widget(bottom_block, main_chunks[1]);
|
||||||
|
|
||||||
|
let bottom_chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Min(0), Constraint::Length(15)])
|
||||||
|
.split(inner_bottom_area);
|
||||||
|
|
||||||
|
let mode_text = match app.mode {
|
||||||
|
Mode::Normal => Line::from(vec![Span::styled(
|
||||||
|
" NORMAL ",
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Green)
|
||||||
|
.fg(Color::Black)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)]),
|
||||||
|
Mode::ConfirmDelete => Line::from(vec![Span::styled(
|
||||||
|
" Delete ? (y/n) ",
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Red)
|
||||||
|
.fg(Color::White)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)]),
|
||||||
|
Mode::Command => Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" COMMAND ",
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Yellow)
|
||||||
|
.fg(Color::Black)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(format!(" :{}", app.input_buffer)),
|
||||||
|
]),
|
||||||
|
Mode::Search => Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" SEARCH ",
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Cyan)
|
||||||
|
.fg(Color::Black)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(format!(" /{}", app.input_buffer)),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(mode_text).block(Block::default().padding(Padding::horizontal(1))),
|
||||||
|
bottom_chunks[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
let total = app.filtered_items.len();
|
||||||
|
let current = if total == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
app.list_state.selected().unwrap_or(0) + 1
|
||||||
|
};
|
||||||
|
|
||||||
|
let stats_text = Line::from(vec![Span::styled(
|
||||||
|
format!("{}/{} ", current, total),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::DarkGray)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)])
|
||||||
|
.alignment(Alignment::Right);
|
||||||
|
|
||||||
|
f.render_widget(Paragraph::new(stats_text), bottom_chunks[1]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user