From c98d92fe1d561588490af3ff5d08e91f6b35a936 Mon Sep 17 00:00:00 2001 From: Chisom Uma Date: Fri, 27 Mar 2026 16:48:02 +0000 Subject: [PATCH 1/2] feat: Added rusqlite for clipboard management and persistence. Added image preview for copied media --- Cargo.toml | 1 + src/app/pages/clipboard.rs | 77 +++++++++++++ src/app/tile.rs | 11 +- src/app/tile/elm.rs | 15 ++- src/app/tile/update.rs | 42 +++++-- src/clipboard.rs | 44 +++++++- src/commands.rs | 3 + src/database.rs | 191 ++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/platform/macos/clipboard.rs | 50 +++++++++ src/platform/macos/mod.rs | 2 + src/platform/mod.rs | 18 +++ 12 files changed, 431 insertions(+), 24 deletions(-) create mode 100644 src/database.rs create mode 100644 src/platform/macos/clipboard.rs diff --git a/Cargo.toml b/Cargo.toml index 89afae40..3b8d9716 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ once_cell = "1.21.3" rand = "0.9.2" rayon = "1.11.0" rfd = "0.17.2" +rusqlite = { version = "0.39.0", features = ["bundled"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" tokio = { version = "1.48.0", features = ["full"] } diff --git a/src/app/pages/clipboard.rs b/src/app/pages/clipboard.rs index 57e92dad..83977e5f 100644 --- a/src/app/pages/clipboard.rs +++ b/src/app/pages/clipboard.rs @@ -127,6 +127,83 @@ fn viewport_content(content: &ClipBoardContentType, theme: &Theme) -> Element<'s .width(Length::Fill) .into() } + ClipBoardContentType::Files(files, img_opt) => { + let is_single_image = files.len() == 1 && { + let p = std::path::Path::new(&files[0]); + if let Some(ext) = p.extension().and_then(|s| s.to_str()) { + matches!( + ext.to_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "ico" | "tiff" + ) + } else { + false + } + }; + + if is_single_image { + container( + Viewer::new(Handle::from_path(&files[0])) + .content_fit(ContentFit::ScaleDown) + .scale_step(0.) + .max_scale(1.) + .min_scale(1.), + ) + .padding(10) + .style(|_| container::Style { + border: iced::Border { + color: iced::Color::WHITE, + width: 1., + radius: Radius::new(0.), + }, + ..Default::default() + }) + .width(Length::Fill) + .into() + } else if let Some(data) = img_opt { + let bytes = data.to_owned_img().into_owned_bytes(); + container( + Viewer::new( + Handle::from_rgba(data.width as u32, data.height as u32, bytes.to_vec()) + .clone(), + ) + .content_fit(ContentFit::ScaleDown) + .scale_step(0.) + .max_scale(1.) + .min_scale(1.), + ) + .padding(10) + .style(|_| container::Style { + border: iced::Border { + color: iced::Color::WHITE, + width: 1., + radius: Radius::new(0.), + }, + ..Default::default() + }) + .width(Length::Fill) + .into() + } else { + Scrollable::with_direction( + container( + Text::new(files.join("\n")) + .height(Length::Fill) + .width(Length::Fill) + .align_x(Alignment::Start) + .font(theme.font()) + .size(16), + ) + .width(Length::Fill) + .height(Length::Fill), + Direction::Both { + vertical: Scrollbar::hidden(), + horizontal: Scrollbar::hidden(), + }, + ) + .height(Length::Fill) + .width(Length::Fill) + .into() + } + } }; let theme_clone = theme.clone(); diff --git a/src/app/tile.rs b/src/app/tile.rs index 4f96eea9..9f3e5c36 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -34,6 +34,7 @@ use tray_icon::TrayIcon; use std::collections::HashMap; use std::fmt::Debug; use std::str::FromStr; +use std::sync::Arc; use std::time::Duration; /// This is a wrapper around the sender to disable dropping @@ -169,6 +170,7 @@ pub struct Tile { page: Page, pub height: f32, pub file_search_sender: Option)>>, + pub db: Arc, debouncer: Debouncer, } @@ -347,8 +349,13 @@ fn handle_clipboard_history() -> impl futures::Stream { let mut prev_byte_rep: Option = None; loop { - let byte_rep = if let Ok(a) = clipboard.get_image() { - Some(ClipBoardContentType::Image(a)) + let files_opt = crate::platform::get_copied_files(); + let img_opt = clipboard.get_image().ok(); + + let byte_rep = if let Some(files) = files_opt { + Some(ClipBoardContentType::Files(files, img_opt)) + } else if let Some(img) = img_opt { + Some(ClipBoardContentType::Image(img)) } else if let Ok(a) = clipboard.get_text() && !a.trim().is_empty() { diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index 5fc386dc..296dad17 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -2,7 +2,6 @@ //! architecture. If the subscription function becomes too large, it should be moved to this file use std::collections::HashMap; -use std::fs; use global_hotkey::hotkey::HotKey; use iced::border::Radius; @@ -33,6 +32,7 @@ use crate::{ config::Config, platform::transform_process_to_ui_element, }; +use std::sync::Arc; /// Initialise the base window pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { @@ -78,12 +78,10 @@ pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { shells: shells_map, }; - let home = std::env::var("HOME").unwrap_or("/".to_string()); - - let ranking = toml::from_str( - &fs::read_to_string(home + "/.config/rustcast/ranking.toml").unwrap_or("".to_string()), - ) - .unwrap_or(HashMap::new()); + let db = + Arc::new(crate::database::Database::new().expect("Failed to initialize SQLite database")); + let ranking = db.get_rankings().unwrap_or_default(); + let clipboard_content = db.get_clipboard_history(100).unwrap_or_default(); ( Tile { @@ -102,12 +100,13 @@ pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { config: config.clone(), ranking, theme: config.theme.to_owned().clone().into(), - clipboard_content: vec![], + clipboard_content, tray_icon: None, sender: None, page: Page::Main, height: DEFAULT_WINDOW_HEIGHT, file_search_sender: None, + db, debouncer: Debouncer::new(config.debounce_delay), }, Task::batch([open.map(|_| Message::OpenWindow)]), diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index a5c1ace8..2dcc32da 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -11,7 +11,7 @@ use iced::widget::operation::AbsoluteOffset; use iced::window; use iced::window::Id; use log::info; -use rayon::iter::IntoParallelRefIterator; +use crate::clipboard::ClipBoardContentType; use rayon::iter::ParallelIterator; use rayon::slice::ParallelSliceMut; @@ -238,10 +238,9 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Message::SaveRanking => { tile.ranking = tile.options.get_rankings(); - let string_rep = toml::to_string(&tile.ranking).unwrap_or("".to_string()); - let ranking_file_path = - std::env::var("HOME").unwrap_or("/".to_string()) + "/.config/rustcast/ranking.toml"; - fs::write(ranking_file_path, string_rep).ok(); + for (name, rank) in &tile.ranking { + let _ = tile.db.save_ranking(name, *rank); + } Task::none() } @@ -451,16 +450,31 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Message::EditClipboardHistory(action) => { match action { Editable::Create(content) => { - if !tile.clipboard_content.contains(&content) { - tile.clipboard_content.insert(0, content); + let old_item = tile.clipboard_content.iter().find(|x| { + if let (ClipBoardContentType::Files(f1, _), ClipBoardContentType::Files(f2, _)) = (x, &content) { + f1 == f2 + } else { + *x == &content + } + }).cloned(); + + if old_item.is_none() { + tile.clipboard_content.insert(0, content.clone()); + let _ = tile.db.save_clipboard_item(&content); return Task::none(); } let new_content_vec = tile .clipboard_content - .par_iter() + .iter() .filter_map(|x| { - if *x == content { + let is_match = if let (ClipBoardContentType::Files(f1, _), ClipBoardContentType::Files(f2, _)) = (x, &content) { + f1 == f2 + } else { + x == &content + }; + + if is_match { None } else { Some(x.to_owned()) @@ -469,7 +483,11 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { .collect(); tile.clipboard_content = new_content_vec; - tile.clipboard_content.insert(0, content); + tile.clipboard_content.insert(0, content.clone()); + if let Some(old) = old_item { + let _ = tile.db.delete_clipboard_item(&old); + } + let _ = tile.db.save_clipboard_item(&content); } Editable::Delete(content) => { tile.clipboard_content = tile @@ -483,6 +501,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } }) .collect(); + let _ = tile.db.delete_clipboard_item(&content); } Editable::Update { old, new } => { tile.clipboard_content = tile @@ -490,6 +509,8 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { .iter() .map(|x| if x == &old { new.clone() } else { x.to_owned() }) .collect(); + let _ = tile.db.delete_clipboard_item(&old); + let _ = tile.db.save_clipboard_item(&new); } } Task::none() @@ -693,6 +714,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Message::ClearClipboardHistory => { tile.clipboard_content.clear(); + let _ = tile.db.clear_clipboard(); Task::none() } diff --git a/src/clipboard.rs b/src/clipboard.rs index 4b0d2257..49e88444 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -11,15 +11,40 @@ use crate::{ pub enum ClipBoardContentType { Text(String), Image(ImageData<'static>), + Files(Vec, Option>), } impl ToApp for ClipBoardContentType { /// Returns the iced element for rendering the clipboard item, and the entire content since the /// display name is only the first line fn to_app(&self) -> App { - let mut display_name = match self { - ClipBoardContentType::Image(_) => "Image".to_string(), - ClipBoardContentType::Text(a) => a.get(0..25).unwrap_or(a).to_string(), + let (mut display_name, desc) = match self { + ClipBoardContentType::Image(_) => ("Image".to_string(), "Clipboard Item".to_string()), + ClipBoardContentType::Text(a) => ( + a.get(0..25).unwrap_or(a).to_string(), + "Clipboard Item".to_string(), + ), + ClipBoardContentType::Files(f, _) => { + if f.len() == 1 { + let path = std::path::Path::new(&f[0]); + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + // Fall back to the raw path string if the file name was entirely empty + let mut final_name = name; + if final_name.is_empty() { + final_name = f[0].clone(); + } + (final_name, f[0].clone()) + } else { + ( + format!("{} Files", f.len()), + "Multiple files copied".to_string(), + ) + } + } }; let self_clone = self.clone(); @@ -33,7 +58,7 @@ impl ToApp for ClipBoardContentType { open_command: crate::app::apps::AppCommand::Function(Function::CopyToClipboard( self_clone.to_owned(), )), - desc: "Clipboard Item".to_string(), + desc, icons: None, display_name, search_name, @@ -52,6 +77,17 @@ impl PartialEq for ClipBoardContentType { && let Self::Image(other_image_data) = other { return image_data.bytes == other_image_data.bytes; + } else if let Self::Files(f1, img1) = self + && let Self::Files(f2, img2) = other + { + if f1 != f2 { + return false; + } + return match (img1, img2) { + (Some(a), Some(b)) => a.bytes == b.bytes, + (None, None) => true, + _ => false, + }; } false } diff --git a/src/commands.rs b/src/commands.rs index a4d6d29a..c90de5ef 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -105,6 +105,9 @@ impl Function { ClipBoardContentType::Image(img) => { Clipboard::new().unwrap().set_image(img.to_owned_img()).ok(); } + ClipBoardContentType::Files(files, _) => { + crate::platform::put_copied_files(files); + } }, Function::Quit => std::process::exit(0), diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 00000000..4a4d5ace --- /dev/null +++ b/src/database.rs @@ -0,0 +1,191 @@ +use crate::clipboard::ClipBoardContentType; +use arboard::ImageData; +use rusqlite::{Connection, Result, params}; +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::Mutex; + +pub struct Database { + conn: Mutex, +} + +impl Database { + pub fn new() -> Result { + let home = std::env::var("HOME").unwrap_or("/".to_string()); + let db_path = format!("{}/.config/rustcast/rustcast.db", home); + + let conn = Connection::open(&db_path)?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS rankings ( + name TEXT PRIMARY KEY, + rank INTEGER NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS clipboard ( + id INTEGER PRIMARY KEY, + type TEXT NOT NULL, + content TEXT, + image_width INTEGER, + image_height INTEGER, + image_bytes BLOB, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )", + [], + )?; + + Ok(Self { + conn: Mutex::new(conn), + }) + } + + pub fn save_ranking(&self, name: &str, rank: i32) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO rankings (name, rank) VALUES (?1, ?2) + ON CONFLICT(name) DO UPDATE SET rank = excluded.rank", + params![name, rank], + )?; + Ok(()) + } + + pub fn get_rankings(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT name, rank FROM rankings")?; + let ranking_iter = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?)) + })?; + + let mut map = HashMap::new(); + for (name, rank) in ranking_iter.flatten() { + map.insert(name, rank); + } + Ok(map) + } + + pub fn save_clipboard_item(&self, item: &ClipBoardContentType) -> Result<()> { + let conn = self.conn.lock().unwrap(); + match item { + ClipBoardContentType::Text(text) => { + conn.execute( + "INSERT INTO clipboard (type, content) VALUES ('Text', ?1)", + params![text], + )?; + } + ClipBoardContentType::Image(img) => { + conn.execute( + "INSERT INTO clipboard (type, image_width, image_height, image_bytes) VALUES ('Image', ?1, ?2, ?3)", + params![img.width as i64, img.height as i64, img.bytes.as_ref()], + )?; + } + ClipBoardContentType::Files(files, img_opt) => { + if let Some(img) = img_opt { + conn.execute( + "INSERT INTO clipboard (type, content, image_width, image_height, image_bytes) VALUES ('Files', ?1, ?2, ?3, ?4)", + params![files.join("\n"), img.width as i64, img.height as i64, img.bytes.as_ref()], + )?; + } else { + conn.execute( + "INSERT INTO clipboard (type, content) VALUES ('Files', ?1)", + params![files.join("\n")], + )?; + } + } + } + Ok(()) + } + + pub fn delete_clipboard_item(&self, item: &ClipBoardContentType) -> Result<()> { + let conn = self.conn.lock().unwrap(); + match item { + ClipBoardContentType::Text(text) => { + conn.execute( + "DELETE FROM clipboard WHERE id = (SELECT id FROM clipboard WHERE type = 'Text' AND content = ?1 ORDER BY created_at DESC LIMIT 1)", + params![text], + )?; + } + ClipBoardContentType::Image(img) => { + conn.execute( + "DELETE FROM clipboard WHERE id = (SELECT id FROM clipboard WHERE type = 'Image' AND image_width = ?1 AND image_height = ?2 AND image_bytes = ?3 ORDER BY created_at DESC LIMIT 1)", + params![img.width as i64, img.height as i64, img.bytes.as_ref()], + )?; + } + ClipBoardContentType::Files(files, img_opt) => { + if let Some(img) = img_opt { + conn.execute( + "DELETE FROM clipboard WHERE id = (SELECT id FROM clipboard WHERE type = 'Files' AND content = ?1 AND image_width = ?2 AND image_height = ?3 AND image_bytes = ?4 ORDER BY created_at DESC LIMIT 1)", + params![files.join("\n"), img.width as i64, img.height as i64, img.bytes.as_ref()], + )?; + } else { + conn.execute( + "DELETE FROM clipboard WHERE id = (SELECT id FROM clipboard WHERE type = 'Files' AND content = ?1 AND image_bytes IS NULL ORDER BY created_at DESC LIMIT 1)", + params![files.join("\n")], + )?; + } + } + } + Ok(()) + } + + pub fn clear_clipboard(&self) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM clipboard", [])?; + Ok(()) + } + + pub fn get_clipboard_history(&self, limit: u32) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT type, content, image_width, image_height, image_bytes FROM clipboard ORDER BY created_at DESC LIMIT ?1" + )?; + + let history_iter = stmt.query_map([limit], |row| { + let typ: String = row.get(0)?; + if typ == "Text" { + let content: String = row.get(1)?; + Ok(ClipBoardContentType::Text(content)) + } else if typ == "Files" { + let content: String = row.get(1)?; + let files: Vec = content + .split('\n') + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + let bytes: Option> = row.get(4)?; + let img_opt = if let Some(b) = bytes { + let width: i64 = row.get(2)?; + let height: i64 = row.get(3)?; + Some(ImageData { + width: width as usize, + height: height as usize, + bytes: Cow::Owned(b), + }) + } else { + None + }; + + Ok(ClipBoardContentType::Files(files, img_opt)) + } else { + let width: i64 = row.get(2)?; + let height: i64 = row.get(3)?; + let bytes: Vec = row.get(4)?; + Ok(ClipBoardContentType::Image(ImageData { + width: width as usize, + height: height as usize, + bytes: Cow::Owned(bytes), + })) + } + })?; + + let mut items = Vec::new(); + for item in history_iter.flatten() { + items.push(item); + } + + Ok(items) + } +} diff --git a/src/main.rs b/src/main.rs index 5497ea2d..6ede9d0c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod calculator; mod clipboard; mod commands; mod config; +mod database; mod debounce; mod platform; mod quit; diff --git a/src/platform/macos/clipboard.rs b/src/platform/macos/clipboard.rs new file mode 100644 index 00000000..e0dcefa7 --- /dev/null +++ b/src/platform/macos/clipboard.rs @@ -0,0 +1,50 @@ +use objc2::rc::Retained; +use objc2::runtime::AnyObject; +use objc2_app_kit::NSPasteboard; +use objc2_foundation::NSString; + +/// Get any copied file URLs from the macOS general pasteboard. +pub fn get_copied_files() -> Option> { + unsafe { + let pb = NSPasteboard::generalPasteboard(); + let ns_filenames_type = NSString::from_str("NSFilenamesPboardType"); + + let data: Option> = + objc2::msg_send![&pb, propertyListForType: &*ns_filenames_type]; + + let mut files = Vec::new(); + if let Some(array) = data { + let count: usize = objc2::msg_send![&array, count]; + for i in 0..count { + let item: Option> = objc2::msg_send![&array, objectAtIndex: i]; + if let Some(s) = item { + files.push(s.to_string()); + } + } + } + + if files.is_empty() { None } else { Some(files) } + } +} + +/// Write paths back to the macOS pasteboard. +pub fn put_copied_files(paths: &[String]) { + unsafe { + let pb = NSPasteboard::generalPasteboard(); + pb.clearContents(); + + let ns_filenames_type = NSString::from_str("NSFilenamesPboardType"); + let ns_array_class = objc2::class!(NSMutableArray); + + // Use Retained to bypass strict array types + let array: Retained = + objc2::msg_send![ns_array_class, arrayWithCapacity: paths.len()]; + + for p in paths { + let ns_str = NSString::from_str(p); + let _: () = objc2::msg_send![&array, addObject: &*ns_str]; + } + + let _: bool = objc2::msg_send![&pb, setPropertyList: &*array, forType: &*ns_filenames_type]; + } +} diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index e8a88567..c5b448be 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -1,9 +1,11 @@ //! Macos specific logic, such as window settings, etc. +pub mod clipboard; pub mod discovery; pub mod haptics; use iced::wgpu::rwh::WindowHandle; +pub(super) use self::clipboard::{get_copied_files, put_copied_files}; pub(super) use self::discovery::get_installed_apps; pub(super) use self::haptics::perform_haptic; diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 38de61e1..8e5e1cc4 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -56,3 +56,21 @@ pub fn get_installed_apps(store_icons: bool) -> Vec { pub fn get_installed_apps(store_icons: bool) -> Vec { self::cross::get_installed_apps(store_icons) } + +#[cfg(target_os = "macos")] +pub fn get_copied_files() -> Option> { + self::macos::get_copied_files() +} + +#[cfg(not(target_os = "macos"))] +pub fn get_copied_files() -> Option> { + None +} + +#[cfg(target_os = "macos")] +pub fn put_copied_files(paths: &[String]) { + self::macos::put_copied_files(paths); +} + +#[cfg(not(target_os = "macos"))] +pub fn put_copied_files(_: &[String]) {} From 9e9cd5bd4859c02d4b02ad61bc7b175c6eb15623 Mon Sep 17 00:00:00 2001 From: Chisom Uma Date: Sun, 29 Mar 2026 14:44:17 +0000 Subject: [PATCH 2/2] fix: detected bugs, code refactor, handling database versioning and image indexing, mutex poison issues and better err handling --- Cargo.toml | 3 +- src/app/pages/clipboard.rs | 165 ++++++++++++++++++--------- src/app/tile.rs | 42 +++++-- src/app/tile/elm.rs | 4 +- src/app/tile/update.rs | 187 +++++++++++++++++++++--------- src/clipboard.rs | 195 ++++++++++++++++++++++++++------ src/commands.rs | 33 +++--- src/database.rs | 80 +++++++++---- src/platform/macos/clipboard.rs | 93 ++++++++++++++- src/platform/mod.rs | 46 ++++++++ src/styles.rs | 44 +++++-- 11 files changed, 683 insertions(+), 209 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4d65ad86..bddc5c85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,6 @@ homepage = "https://rustcast.app" repository = "https://github.com/RustCastLabs/rustcast" [dependencies] -arboard = "3.6.1" block2 = "0.6.2" emojis = "0.8.0" global-hotkey = "0.7.0" @@ -26,6 +25,7 @@ objc2-application-services = { version = "0.3.2", default-features = false, feat ] } objc2-core-foundation = "0.3.2" objc2-foundation = { version = "0.3.2", features = ["NSString"] } +objc2-service-management = "0.3.2" once_cell = "1.21.3" rand = "0.9.2" rayon = "1.11.0" @@ -33,6 +33,7 @@ rfd = "0.17.2" rusqlite = { version = "0.39.0", features = ["bundled"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +sha2 = "0.11.0" tokio = { version = "1.48.0", features = ["full"] } toml = "0.9.8" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } diff --git a/src/app/pages/clipboard.rs b/src/app/pages/clipboard.rs index 83977e5f..3f0e08a4 100644 --- a/src/app/pages/clipboard.rs +++ b/src/app/pages/clipboard.rs @@ -14,7 +14,7 @@ use iced::{ use crate::{ app::{Editable, ToApp, pages::prelude::*}, clipboard::ClipBoardContentType, - styles::{delete_button_style, settings_text_input_item_style}, + styles::{delete_button_style, settings_text_input_item_style, clipboard_image_border_style}, }; /// The clipboard view @@ -30,6 +30,8 @@ pub fn clipboard_view( clipboard_content: Vec, focussed_id: u32, theme: Theme, + rankings: &std::collections::HashMap, + search_query: &str, ) -> Element<'static, Message> { let theme_clone = theme.clone(); let theme_clone_2 = theme.clone(); @@ -49,19 +51,77 @@ pub fn clipboard_view( .into(); } + let mut apps: Vec<(crate::app::apps::App, ClipBoardContentType)> = clipboard_content + .into_iter() + .filter_map(|c| { + let mut app = c.to_app(); + if !search_query.is_empty() + && !app.search_name.to_lowercase().contains(search_query) + && !app.display_name.to_lowercase().contains(search_query) { + return None; + } + if let Some(r) = rankings.get(&app.search_name) { + app.ranking = *r; + } + Some((app, c)) + }) + .collect(); + + apps.sort_by(|a, b| { + let rank_a = if a.0.ranking == -1 { 0 } else { 1 }; + let rank_b = if b.0.ranking == -1 { 0 } else { 1 }; + rank_a.cmp(&rank_b) + }); + + let mut elements: Vec> = Vec::new(); + let mut has_pinned = false; + let mut has_copied = false; + + let apps_len = apps.len(); + for (i, (app, _)) in apps.iter().enumerate() { + if app.ranking == -1 && !has_pinned { + elements.push( + container( + Text::new("Pinned") + .font(iced::Font { weight: iced::font::Weight::Bold, ..theme.font() }) + .size(12) + .style(|_theme| iced::widget::text::Style { color: Some(iced::Color::from_rgb8(150, 150, 150)) }) + ) + .padding([5, 10]) + .into() + ); + has_pinned = true; + } else if app.ranking != -1 && !has_copied { + if has_pinned { + elements.push(Text::new("").size(10).into()); + } + elements.push( + container( + Text::new("Copied") + .font(iced::Font { weight: iced::font::Weight::Bold, ..theme.font() }) + .size(12) + .style(|_theme| iced::widget::text::Style { color: Some(iced::Color::from_rgb8(150, 150, 150)) }) + ) + .padding([5, 10]) + .into() + ); + has_copied = true; + } + elements.push(app.clone().render(theme.clone(), i as u32, focussed_id, None)); + } + let viewport_content: Element<'static, Message> = - match clipboard_content.get(focussed_id as usize) { - Some(content) => viewport_content(content, &theme), - None => Text::new("").into(), + if focussed_id < apps_len as u32 { + let (_, content) = &apps[focussed_id as usize]; + viewport_content(content, &theme) + } else { + Text::new("").into() }; + container(Row::from_iter([ container( Scrollable::with_direction( - Column::from_iter(clipboard_content.iter().enumerate().map(|(i, content)| { - content - .to_app() - .render(theme.clone(), i as u32, focussed_id, None) - })) + Column::from_iter(elements) .width(WINDOW_WIDTH / 3.), Direction::Vertical(Scrollbar::hidden()), ) @@ -104,10 +164,10 @@ fn viewport_content(content: &ClipBoardContentType, theme: &Theme) -> Element<'s .into(), ClipBoardContentType::Image(data) => { - let bytes = data.to_owned_img().into_owned_bytes(); + let bytes = data.bytes.to_vec(); container( Viewer::new( - Handle::from_rgba(data.width as u32, data.height as u32, bytes.to_vec()) + Handle::from_rgba(data.width as u32, data.height as u32, bytes) .clone(), ) .content_fit(ContentFit::ScaleDown) @@ -116,14 +176,7 @@ fn viewport_content(content: &ClipBoardContentType, theme: &Theme) -> Element<'s .min_scale(1.), ) .padding(10) - .style(|_| container::Style { - border: iced::Border { - color: iced::Color::WHITE, - width: 1., - radius: Radius::new(0.), - }, - ..Default::default() - }) + .style(|_| clipboard_image_border_style()) .width(Length::Fill) .into() } @@ -149,43 +202,28 @@ fn viewport_content(content: &ClipBoardContentType, theme: &Theme) -> Element<'s .min_scale(1.), ) .padding(10) - .style(|_| container::Style { - border: iced::Border { - color: iced::Color::WHITE, - width: 1., - radius: Radius::new(0.), - }, - ..Default::default() - }) - .width(Length::Fill) - .into() - } else if let Some(data) = img_opt { - let bytes = data.to_owned_img().into_owned_bytes(); - container( - Viewer::new( - Handle::from_rgba(data.width as u32, data.height as u32, bytes.to_vec()) - .clone(), - ) - .content_fit(ContentFit::ScaleDown) - .scale_step(0.) - .max_scale(1.) - .min_scale(1.), - ) - .padding(10) - .style(|_| container::Style { - border: iced::Border { - color: iced::Color::WHITE, - width: 1., - radius: Radius::new(0.), - }, - ..Default::default() - }) + .style(|_| clipboard_image_border_style()) .width(Length::Fill) .into() } else { - Scrollable::with_direction( + let display_text = if files.len() > 1 { + let mut s = format!("{} Files Copied", files.len()); + for f in files.iter().take(3) { + let fname = std::path::Path::new(f).file_name().unwrap_or_default().to_string_lossy(); + s.push_str(&format!("\n• {}", fname)); + } + if files.len() > 3 { + s.push_str(&format!("\n...and {} more", files.len() - 3)); + } + s + } else { + let fname = std::path::Path::new(&files[0]).file_name().unwrap_or_default().to_string_lossy(); + format!("File: {}", fname) + }; + + let text_elem = Scrollable::with_direction( container( - Text::new(files.join("\n")) + Text::new(display_text) .height(Length::Fill) .width(Length::Fill) .align_x(Alignment::Start) @@ -200,8 +238,29 @@ fn viewport_content(content: &ClipBoardContentType, theme: &Theme) -> Element<'s }, ) .height(Length::Fill) + .width(Length::Fill); + + if let Some(data) = img_opt { + let bytes = data.bytes.to_vec(); + let image_elem = container( + Viewer::new( + Handle::from_rgba(data.width as u32, data.height as u32, bytes) + .clone(), + ) + .content_fit(ContentFit::ScaleDown) + .scale_step(0.) + .max_scale(1.) + .min_scale(1.), + ) + .padding(10) + .style(|_| clipboard_image_border_style()) .width(Length::Fill) - .into() + .height(Length::Fixed(220.0)); + + Column::new().push(image_elem).push(text_elem).into() + } else { + text_elem.into() + } } } }; diff --git a/src/app/tile.rs b/src/app/tile.rs index 4bff87fa..0a204b01 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -9,7 +9,6 @@ use crate::config::Config; use crate::debounce::Debouncer; use crate::platform::default_app_paths; -use arboard::Clipboard; use global_hotkey::hotkey::HotKey; use global_hotkey::{GlobalHotKeyEvent, HotKeyState}; @@ -84,7 +83,7 @@ impl AppIndex { fn get_rankings(&self) -> HashMap { HashMap::from_iter(self.by_name.iter().filter_map(|(name, app)| { - if app.ranking > 0 { + if app.ranking != 0 { Some((name.to_owned(), app.ranking.to_owned())) } else { None @@ -358,32 +357,53 @@ fn handle_hotkeys() -> impl futures::Stream { /// This is the subscription function that handles the change in clipboard history fn handle_clipboard_history() -> impl futures::Stream { stream::channel(100, async |mut output| { - let mut clipboard = Clipboard::new().unwrap(); - let mut prev_byte_rep: Option = None; + let initial_files = crate::platform::get_copied_files(); + let initial_img = crate::platform::get_copied_image(); + + let mut prev_byte_rep = if let Some(files) = initial_files { + Some(ClipBoardContentType::Files(files, initial_img)) + } else if let Some(img) = initial_img { + Some(ClipBoardContentType::Image(img)) + } else if let Some(a) = crate::platform::get_copied_text() { + if !a.trim().is_empty() { Some(ClipBoardContentType::Text(a)) } else { None } + } else { + None + }; loop { let files_opt = crate::platform::get_copied_files(); - let img_opt = clipboard.get_image().ok(); + let img_opt = crate::platform::get_copied_image(); let byte_rep = if let Some(files) = files_opt { Some(ClipBoardContentType::Files(files, img_opt)) } else if let Some(img) = img_opt { Some(ClipBoardContentType::Image(img)) - } else if let Ok(a) = clipboard.get_text() - && !a.trim().is_empty() - { - Some(ClipBoardContentType::Text(a)) + } else if let Some(a) = crate::platform::get_copied_text() { + if !a.trim().is_empty() { + Some(ClipBoardContentType::Text(a)) + } else { + None + } } else { None }; if byte_rep != prev_byte_rep - && let Some(content) = &byte_rep + && let Some(content_ref) = &byte_rep { + let mut content = content_ref.clone(); + if let ClipBoardContentType::Files(ref files, ref mut img_opt) = content { + if files.len() > 1 { + if let Some(multi) = crate::clipboard::generate_multi_file_thumbnail(files) { + *img_opt = Some(multi); + } + } + } + info!("Adding item to cbhist"); output .send(Message::EditClipboardHistory(crate::app::Editable::Create( - content.to_owned(), + content, ))) .await .ok(); diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index 50db69eb..5d8d306a 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -81,7 +81,7 @@ pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { let db = Arc::new(crate::database::Database::new().expect("Failed to initialize SQLite database")); let ranking = db.get_rankings().unwrap_or_default(); - let clipboard_content = db.get_clipboard_history(100).unwrap_or_default(); + let clipboard_content = vec![]; ( Tile { @@ -144,6 +144,8 @@ pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { tile.clipboard_content.clone(), tile.focus_id, tile.config.theme.clone(), + &tile.ranking, + &tile.query_lc, ), Page::EmojiSearch => emoji_page( tile.config.theme.clone(), diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 3020f3cd..d7cb7049 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -34,6 +34,7 @@ use crate::commands::Function; use crate::config::Config; use crate::config::MainPage; use crate::debounce::DebouncePolicy; +use crate::platform::macos::{start_at_login, stop_at_login}; use crate::quit::get_open_apps; use crate::unit_conversion; use crate::utils::is_valid_url; @@ -96,6 +97,17 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Task::none() } + Message::ToggleAutoStartup(set_to) => { + if set_to { + start_at_login(); + tile.config.start_at_login = true + } else { + stop_at_login(); + tile.config.start_at_login = false + } + Task::none() + } + Message::EscKeyPressed(id) => { if !tile.query_lc.is_empty() { return Task::batch([ @@ -238,9 +250,13 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } Message::SaveRanking => { - tile.ranking = tile.options.get_rankings(); + for (name, rank) in tile.options.get_rankings() { + tile.ranking.insert(name, rank); + } for (name, rank) in &tile.ranking { - let _ = tile.db.save_ranking(name, *rank); + if let Err(e) = tile.db.save_ranking(name, *rank) { + log::error!("Database save ranking error: {}", e); + } } Task::none() } @@ -344,9 +360,49 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } Message::SwitchToPage(page) => { - tile.page = page; - let task = match tile.page { - Page::ClipboardHistory | Page::Settings => window::latest().map(|x| { + let task = match &page { + Page::ClipboardHistory => { + if !tile.config.cbhist { + return Task::none(); + } + let db_history = tile.db.get_clipboard_history(300).unwrap_or_default(); + let mut unique: Vec = Vec::new(); + for mut item in db_history { + let mut found_dup = false; + for x in &mut unique { + match (x, &mut item) { + (crate::clipboard::ClipBoardContentType::Files(f1, img1), crate::clipboard::ClipBoardContentType::Files(f2, img2)) => { + if f1 == f2 { + found_dup = true; + if img1.is_none() && img2.is_some() { + *img1 = img2.take(); + } + break; + } + } + (crate::clipboard::ClipBoardContentType::Text(t1), crate::clipboard::ClipBoardContentType::Text(t2)) => { + if t1 == t2 { found_dup = true; break; } + } + (crate::clipboard::ClipBoardContentType::Image(i1), crate::clipboard::ClipBoardContentType::Image(i2)) => { + if i1.bytes == i2.bytes { found_dup = true; break; } + } + _ => {} + } + } + if !found_dup { + unique.push(item); + } + } + tile.clipboard_content = unique; + window::latest().map(|x| { + let id = x.unwrap(); + Message::ResizeWindow( + id, + ((7 * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, + ) + }) + } + Page::Settings => window::latest().map(|x| { let id = x.unwrap(); Message::ResizeWindow( id, @@ -356,6 +412,8 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { _ => Task::none(), }; + tile.page = page; + let refresh_empty_main_query = if tile.page == Page::Main { window::latest() .map(|x| x.unwrap()) @@ -405,6 +463,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { tile.focused = false; tile.page = Page::Main; tile.focus_id = 0; + tile.clipboard_content.clear(); Task::batch([window::close(a), Task::done(Message::ClearSearchResults)]) } @@ -437,18 +496,23 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } Message::ToggleFavouriteApp(app_name) => { - let ranking = match tile.options.by_name.get(&app_name) { - None => return Task::none(), - Some(app) => { - if app.ranking == -1 { - 0 - } else { - -1 - } - } + let ranking = if let Some(app) = tile.options.by_name.get(&app_name) { + if app.ranking == -1 { 0 } else { -1 } + } else if let Some(&r) = tile.ranking.get(&app_name) { + if r == -1 { 0 } else { -1 } + } else { + -1 }; tile.options.set_ranking(&app_name, ranking); - Task::none() + if let Err(e) = tile.db.save_ranking(&app_name, ranking) { + log::error!("Database save ranking error: {}", e); + } + tile.ranking.insert(app_name, ranking); + + let query = tile.query.clone(); + window::latest() + .map(|x| x.unwrap()) + .map(move |id| Message::SearchQueryChanged(query.clone(), id)) } Message::UpdateApps => { @@ -476,48 +540,43 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } Message::EditClipboardHistory(action) => { + if !tile.config.cbhist { + return Task::none(); + } match action { - Editable::Create(content) => { - let old_item = tile.clipboard_content.iter().find(|x| { - if let (ClipBoardContentType::Files(f1, _), ClipBoardContentType::Files(f2, _)) = (x, &content) { - f1 == f2 - } else { - *x == &content - } - }).cloned(); - - if old_item.is_none() { - tile.clipboard_content.insert(0, content.clone()); - let _ = tile.db.save_clipboard_item(&content); - return Task::none(); - } - - let new_content_vec = tile - .clipboard_content - .iter() - .filter_map(|x| { - let is_match = if let (ClipBoardContentType::Files(f1, _), ClipBoardContentType::Files(f2, _)) = (x, &content) { - f1 == f2 - } else { - x == &content - }; - - if is_match { - None + Editable::Create(mut content) => { + if let ClipBoardContentType::Files(ref new_f, None) = content { + if let Some(ClipBoardContentType::Files(_, Some(old_img))) = tile.clipboard_content.iter().find(|x| { + if let ClipBoardContentType::Files(old_f, Some(_)) = x { + old_f == new_f } else { - Some(x.to_owned()) + false } - }) - .collect(); + }) { + content = ClipBoardContentType::Files(new_f.clone(), Some(old_img.clone())); + } + } - tile.clipboard_content = new_content_vec; + tile.clipboard_content.retain(|x| { + match (x, &content) { + (ClipBoardContentType::Files(f1, _), ClipBoardContentType::Files(f2, _)) => f1 != f2, + (ClipBoardContentType::Image(i1), ClipBoardContentType::Files(_, Some(i2))) => i1.bytes != i2.bytes, + (ClipBoardContentType::Files(_, Some(i1)), ClipBoardContentType::Image(i2)) => i1.bytes != i2.bytes, + _ => x != &content, + } + }); tile.clipboard_content.insert(0, content.clone()); - if let Some(old) = old_item { - let _ = tile.db.delete_clipboard_item(&old); + if let Err(e) = tile.db.save_clipboard_item(&content) { + log::error!("Database save clipboard error: {}", e); } - let _ = tile.db.save_clipboard_item(&content); } Editable::Delete(content) => { + let search_name = content.to_app().search_name; + tile.ranking.remove(&search_name); + if let Err(e) = tile.db.save_ranking(&search_name, 0) { + log::error!("Database pin wipe error: {}", e); + } + tile.clipboard_content = tile .clipboard_content .iter() @@ -529,7 +588,9 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } }) .collect(); - let _ = tile.db.delete_clipboard_item(&content); + if let Err(e) = tile.db.delete_clipboard_item(&content) { + log::error!("Database delete clipboard item error: {}", e); + } } Editable::Update { old, new } => { tile.clipboard_content = tile @@ -537,8 +598,12 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { .iter() .map(|x| if x == &old { new.clone() } else { x.to_owned() }) .collect(); - let _ = tile.db.delete_clipboard_item(&old); - let _ = tile.db.save_clipboard_item(&new); + if let Err(e) = tile.db.delete_clipboard_item(&old) { + log::error!("Database delete clipboard item error: {}", e); + } + if let Err(e) = tile.db.save_clipboard_item(&new) { + log::error!("Database save clipboard item error: {}", e); + } } } Task::none() @@ -627,6 +692,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { match config { SetConfigFields::ToggleHotkey(hk) => final_config.toggle_hotkey = hk, SetConfigFields::ClipboardHotkey(hk) => final_config.clipboard_hotkey = hk, + SetConfigFields::ClipboardHistory(cbhist) => final_config.cbhist = cbhist, SetConfigFields::Modes(Editable::Create((key, value))) => { final_config.modes.insert(key, value); } @@ -785,8 +851,18 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } Message::ClearClipboardHistory => { + for content in &tile.clipboard_content { + let name = content.to_app().search_name; + tile.ranking.remove(&name); + if let Err(e) = tile.db.save_ranking(&name, 0) { + log::error!("Database pin wipe error: {}", e); + } + } tile.clipboard_content.clear(); - let _ = tile.db.clear_clipboard(); + if let Err(e) = tile.db.clear_clipboard() { + log::error!("Database clear clipboard error: {}", e); + } + Task::none() } @@ -957,7 +1033,6 @@ fn execute_query(tile: &mut Tile, id: Id) -> Task { } "cbhist" => { task = task.chain(Task::done(Message::SwitchToPage(Page::ClipboardHistory))); - tile.page = Page::ClipboardHistory; } "main" => { if tile.page != Page::Main { @@ -965,6 +1040,10 @@ fn execute_query(tile: &mut Tile, id: Id) -> Task { return Task::batch([zero_item_resize_task(id), task]); } } + "fav" => { + tile.results = tile.options.get_favourites(); + return resize_for_results_count(id, tile.results.len()); + } query => 'a: { if !query.starts_with(">") || tile.page != Page::Main { break 'a; diff --git a/src/clipboard.rs b/src/clipboard.rs index 49e88444..7e12c68b 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -1,5 +1,12 @@ //! This has all the logic regarding the cliboard history -use arboard::ImageData; +use std::borrow::Cow; + +#[derive(Debug, Clone)] +pub struct ImageData<'a> { + pub width: usize, + pub height: usize, + pub bytes: Cow<'a, [u8]>, +} use crate::{ app::{ToApp, apps::App}, @@ -18,12 +25,9 @@ impl ToApp for ClipBoardContentType { /// Returns the iced element for rendering the clipboard item, and the entire content since the /// display name is only the first line fn to_app(&self) -> App { - let (mut display_name, desc) = match self { - ClipBoardContentType::Image(_) => ("Image".to_string(), "Clipboard Item".to_string()), - ClipBoardContentType::Text(a) => ( - a.get(0..25).unwrap_or(a).to_string(), - "Clipboard Item".to_string(), - ), + let mut display_name = match self { + ClipBoardContentType::Image(_) => "Image".to_string(), + ClipBoardContentType::Text(a) => a.get(0..25).unwrap_or(a).to_string(), ClipBoardContentType::Files(f, _) => { if f.len() == 1 { let path = std::path::Path::new(&f[0]); @@ -32,23 +36,30 @@ impl ToApp for ClipBoardContentType { .unwrap_or_default() .to_string_lossy() .to_string(); - // Fall back to the raw path string if the file name was entirely empty - let mut final_name = name; - if final_name.is_empty() { - final_name = f[0].clone(); + if name.is_empty() { + f[0].clone() + } else { + name } - (final_name, f[0].clone()) } else { - ( - format!("{} Files", f.len()), - "Multiple files copied".to_string(), - ) + format!("{} Files", f.len()) + } + } + }; + + let search_name = match self { + ClipBoardContentType::Image(img) => format!("Image ({})", img.bytes.len()), + ClipBoardContentType::Text(a) => a.to_string(), + ClipBoardContentType::Files(f, _) => { + if f.len() == 1 { + display_name.clone() + } else { + format!("{} Files: {}", f.len(), f.join(", ")) } } }; let self_clone = self.clone(); - let search_name = display_name.clone(); // only get the first line from the contents display_name = display_name.lines().next().unwrap_or("").to_string(); @@ -58,7 +69,7 @@ impl ToApp for ClipBoardContentType { open_command: crate::app::apps::AppCommand::Function(Function::CopyToClipboard( self_clone.to_owned(), )), - desc, + desc: "Clipboard Item".to_string(), icons: None, display_name, search_name, @@ -69,26 +80,136 @@ impl ToApp for ClipBoardContentType { impl PartialEq for ClipBoardContentType { /// Let cliboard items be comparable fn eq(&self, other: &Self) -> bool { - if let Self::Text(a) = self - && let Self::Text(b) = other - { - return a == b; - } else if let Self::Image(image_data) = self - && let Self::Image(other_image_data) = other - { - return image_data.bytes == other_image_data.bytes; - } else if let Self::Files(f1, img1) = self - && let Self::Files(f2, img2) = other - { - if f1 != f2 { - return false; + match (self, other) { + (Self::Text(a), Self::Text(b)) => a == b, + (Self::Image(a), Self::Image(b)) => a.bytes == b.bytes, + (Self::Files(f1, img1), Self::Files(f2, img2)) => { + if f1 != f2 { + return false; + } + match (img1, img2) { + (Some(a), Some(b)) => a.bytes == b.bytes, + (None, None) => true, + _ => false, + } + } + _ => false, + } + } +} + +pub fn rotate_image(img: &image::RgbaImage, angle_radians: f32) -> image::RgbaImage { + let (width, height) = img.dimensions(); + let cx = width as f32 / 2.0; + let cy = height as f32 / 2.0; + let cos_a = angle_radians.cos(); + let sin_a = angle_radians.sin(); + + let corners = [ + (-cx, -cy), + (width as f32 - cx, -cy), + (width as f32 - cx, height as f32 - cy), + (-cx, height as f32 - cy) + ]; + + let mut min_x = f32::MAX; + let mut max_x = f32::MIN; + let mut min_y = f32::MAX; + let mut max_y = f32::MIN; + for &(x, y) in &corners { + let rx = x * cos_a - y * sin_a; + let ry = x * sin_a + y * cos_a; + min_x = min_x.min(rx); + max_x = max_x.max(rx); + min_y = min_y.min(ry); + max_y = max_y.max(ry); + } + + let new_width = (max_x - min_x).ceil() as u32; + let new_height = (max_y - min_y).ceil() as u32; + let new_cx = new_width as f32 / 2.0; + let new_cy = new_height as f32 / 2.0; + + let mut res = image::RgbaImage::new(new_width, new_height); + for y in 0..new_height { + for x in 0..new_width { + let tx = x as f32 - new_cx; + let ty = y as f32 - new_cy; + + let src_x = tx * cos_a + ty * sin_a + cx; + let src_y = -tx * sin_a + ty * cos_a + cy; + + if src_x >= 0.0 && src_x < width as f32 && src_y >= 0.0 && src_y < height as f32 { + let p = img.get_pixel(src_x as u32, src_y as u32); + res.put_pixel(x, y, *p); } - return match (img1, img2) { - (Some(a), Some(b)) => a.bytes == b.bytes, - (None, None) => true, - _ => false, - }; } - false } + res +} + +pub fn generate_multi_file_thumbnail(files: &[String]) -> Option> { + use std::borrow::Cow; + + if files.len() <= 1 { return None; } + let limit = std::cmp::min(files.len(), 3); + let mut images = Vec::new(); + + for i in 0..limit { + let mut path_str: String = files[i].clone(); + if path_str.starts_with("file://") { + path_str = path_str.strip_prefix("file://").unwrap().replace("%20", " "); + } + + let p = std::path::Path::new(&path_str); + let is_image = if let Some(ext) = p.extension().and_then(|s| s.to_str()) { + matches!( + ext.to_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "ico" | "tiff" + ) + } else { + false + }; + + let mut loaded_img = None; + if is_image { + if let Ok(img) = image::open(&path_str) { + loaded_img = Some(img); + } + } + + if loaded_img.is_none() { + if let Some(sys_icon_bytes) = crate::platform::icon_of_path_ns(&path_str) { + if let Ok(img) = image::load_from_memory(&sys_icon_bytes) { + loaded_img = Some(img); + } + } + } + + if let Some(img) = loaded_img { + let resized = img.thumbnail(150, 150).into_rgba8(); + images.push(resized); + } + } + + if images.is_empty() { return None; } + + let mut canvas = image::RgbaImage::new(250, 250); + let rotations: [f32; 3] = [-0.15, 0.15, 0.0]; + + for (i, img) in images.iter().enumerate() { + let angle = rotations[i % rotations.len()]; + let rotated = rotate_image(img, angle); + + let cx = 125 - rotated.width() as i32 / 2; + let cy = 125 - rotated.height() as i32 / 2; + + image::imageops::overlay(&mut canvas, &rotated, cx as i64, cy as i64); + } + + Some(ImageData { + width: canvas.width() as usize, + height: canvas.height() as usize, + bytes: Cow::Owned(canvas.into_raw()) + }) } diff --git a/src/commands.rs b/src/commands.rs index c90de5ef..80cf1639 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -2,7 +2,6 @@ //! copying to clipboard, etc. use std::{process::Command, thread}; -use arboard::Clipboard; use objc2_app_kit::NSWorkspace; use objc2_foundation::NSURL; @@ -45,10 +44,7 @@ impl Function { Command::new("sh").arg("-c").arg(command).spawn().ok(); } Function::RandomVar(var) => { - Clipboard::new() - .unwrap() - .set_text(var.to_string()) - .unwrap_or(()); + crate::platform::put_copied_text(&var.to_string()); } Function::QuitAllApps => { @@ -92,23 +88,22 @@ impl Function { } Function::Calculate(expr) => { - Clipboard::new() - .unwrap() - .set_text(expr.eval().map(|x| x.to_string()).unwrap_or("".to_string())) - .unwrap_or(()); + crate::platform::put_copied_text(&expr.eval().map(|x| x.to_string()).unwrap_or("".to_string())); } - Function::CopyToClipboard(clipboard_content) => match clipboard_content { - ClipBoardContentType::Text(text) => { - Clipboard::new().unwrap().set_text(text).ok(); + Function::CopyToClipboard(content) => { + match content { + ClipBoardContentType::Text(text) => { + crate::platform::put_copied_text(&text); + } + ClipBoardContentType::Image(data) => { + crate::platform::put_copied_image(&data); + } + ClipBoardContentType::Files(paths, _) => { + crate::platform::put_copied_files(&paths); + } } - ClipBoardContentType::Image(img) => { - Clipboard::new().unwrap().set_image(img.to_owned_img()).ok(); - } - ClipBoardContentType::Files(files, _) => { - crate::platform::put_copied_files(files); - } - }, + } Function::Quit => std::process::exit(0), } diff --git a/src/database.rs b/src/database.rs index 4a4d5ace..0a5ea914 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,9 +1,9 @@ -use crate::clipboard::ClipBoardContentType; -use arboard::ImageData; +use crate::clipboard::{ClipBoardContentType, ImageData}; use rusqlite::{Connection, Result, params}; use std::borrow::Cow; use std::collections::HashMap; use std::sync::Mutex; +use sha2::{Sha256, Digest}; pub struct Database { conn: Mutex, @@ -12,7 +12,9 @@ pub struct Database { impl Database { pub fn new() -> Result { let home = std::env::var("HOME").unwrap_or("/".to_string()); - let db_path = format!("{}/.config/rustcast/rustcast.db", home); + let db_dir = format!("{}/Library/Application Support/rustcast", home); + std::fs::create_dir_all(&db_dir).ok(); + let db_path = format!("{}/rustcast.db", db_dir); let conn = Connection::open(&db_path)?; @@ -24,6 +26,25 @@ impl Database { [], )?; + let user_version: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0)).unwrap_or(0); + + if user_version < 1 { + let table_exists: bool = conn.query_row( + "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='clipboard'", + [], + |row| { + let count: i32 = row.get(0)?; + Ok(count > 0) + }, + ).unwrap_or(false); + + if table_exists { + // Ignore failure if column somehow exists + let _ = conn.execute("ALTER TABLE clipboard ADD COLUMN image_hash TEXT", []); + } + conn.execute("PRAGMA user_version = 1", [])?; + } + conn.execute( "CREATE TABLE IF NOT EXISTS clipboard ( id INTEGER PRIMARY KEY, @@ -32,18 +53,23 @@ impl Database { image_width INTEGER, image_height INTEGER, image_bytes BLOB, + image_hash TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )", [], )?; + // Ensure indices for speed + conn.execute("CREATE INDEX IF NOT EXISTS idx_clipboard_hash ON clipboard(image_hash)", [])?; + conn.execute("CREATE INDEX IF NOT EXISTS idx_clipboard_content ON clipboard(content)", [])?; + Ok(Self { conn: Mutex::new(conn), }) } pub fn save_ranking(&self, name: &str, rank: i32) -> Result<()> { - let conn = self.conn.lock().unwrap(); + let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner()); conn.execute( "INSERT INTO rankings (name, rank) VALUES (?1, ?2) ON CONFLICT(name) DO UPDATE SET rank = excluded.rank", @@ -53,7 +79,7 @@ impl Database { } pub fn get_rankings(&self) -> Result> { - let conn = self.conn.lock().unwrap(); + let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner()); let mut stmt = conn.prepare("SELECT name, rank FROM rankings")?; let ranking_iter = stmt.query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?)) @@ -67,29 +93,37 @@ impl Database { } pub fn save_clipboard_item(&self, item: &ClipBoardContentType) -> Result<()> { - let conn = self.conn.lock().unwrap(); + let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner()); match item { ClipBoardContentType::Text(text) => { conn.execute( - "INSERT INTO clipboard (type, content) VALUES ('Text', ?1)", + "INSERT OR REPLACE INTO clipboard (id, type, content) VALUES ((SELECT id FROM clipboard WHERE type = 'Text' AND content = ?1), 'Text', ?1)", params![text], )?; } ClipBoardContentType::Image(img) => { + let mut hasher = Sha256::new(); + hasher.update(&img.bytes); + let hash = hasher.finalize().iter().map(|b| format!("{:02x}", b)).collect::(); + conn.execute( - "INSERT INTO clipboard (type, image_width, image_height, image_bytes) VALUES ('Image', ?1, ?2, ?3)", - params![img.width as i64, img.height as i64, img.bytes.as_ref()], + "INSERT OR REPLACE INTO clipboard (id, type, image_width, image_height, image_bytes, image_hash) VALUES ((SELECT id FROM clipboard WHERE type = 'Image' AND image_hash = ?4), 'Image', ?1, ?2, ?3, ?4)", + params![img.width as i64, img.height as i64, img.bytes.as_ref(), hash], )?; } ClipBoardContentType::Files(files, img_opt) => { if let Some(img) = img_opt { + let mut hasher = Sha256::new(); + hasher.update(&img.bytes); + let hash = hasher.finalize().iter().map(|b| format!("{:02x}", b)).collect::(); + conn.execute( - "INSERT INTO clipboard (type, content, image_width, image_height, image_bytes) VALUES ('Files', ?1, ?2, ?3, ?4)", - params![files.join("\n"), img.width as i64, img.height as i64, img.bytes.as_ref()], + "INSERT OR REPLACE INTO clipboard (id, type, content, image_width, image_height, image_bytes, image_hash) VALUES ((SELECT id FROM clipboard WHERE type = 'Files' AND content = ?1 AND image_hash = ?5), 'Files', ?1, ?2, ?3, ?4, ?5)", + params![files.join("\n"), img.width as i64, img.height as i64, img.bytes.as_ref(), hash], )?; } else { conn.execute( - "INSERT INTO clipboard (type, content) VALUES ('Files', ?1)", + "INSERT OR REPLACE INTO clipboard (id, type, content) VALUES ((SELECT id FROM clipboard WHERE type = 'Files' AND content = ?1 AND image_bytes IS NULL), 'Files', ?1)", params![files.join("\n")], )?; } @@ -99,7 +133,7 @@ impl Database { } pub fn delete_clipboard_item(&self, item: &ClipBoardContentType) -> Result<()> { - let conn = self.conn.lock().unwrap(); + let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner()); match item { ClipBoardContentType::Text(text) => { conn.execute( @@ -108,16 +142,24 @@ impl Database { )?; } ClipBoardContentType::Image(img) => { + let mut hasher = Sha256::new(); + hasher.update(&img.bytes); + let hash = hasher.finalize().iter().map(|b| format!("{:02x}", b)).collect::(); + conn.execute( - "DELETE FROM clipboard WHERE id = (SELECT id FROM clipboard WHERE type = 'Image' AND image_width = ?1 AND image_height = ?2 AND image_bytes = ?3 ORDER BY created_at DESC LIMIT 1)", - params![img.width as i64, img.height as i64, img.bytes.as_ref()], + "DELETE FROM clipboard WHERE id = (SELECT id FROM clipboard WHERE type = 'Image' AND image_hash = ?1 ORDER BY created_at DESC LIMIT 1)", + params![hash], )?; } ClipBoardContentType::Files(files, img_opt) => { if let Some(img) = img_opt { + let mut hasher = Sha256::new(); + hasher.update(&img.bytes); + let hash = hasher.finalize().iter().map(|b| format!("{:02x}", b)).collect::(); + conn.execute( - "DELETE FROM clipboard WHERE id = (SELECT id FROM clipboard WHERE type = 'Files' AND content = ?1 AND image_width = ?2 AND image_height = ?3 AND image_bytes = ?4 ORDER BY created_at DESC LIMIT 1)", - params![files.join("\n"), img.width as i64, img.height as i64, img.bytes.as_ref()], + "DELETE FROM clipboard WHERE id = (SELECT id FROM clipboard WHERE type = 'Files' AND content = ?1 AND image_hash = ?2 ORDER BY created_at DESC LIMIT 1)", + params![files.join("\n"), hash], )?; } else { conn.execute( @@ -131,13 +173,13 @@ impl Database { } pub fn clear_clipboard(&self) -> Result<()> { - let conn = self.conn.lock().unwrap(); + let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner()); conn.execute("DELETE FROM clipboard", [])?; Ok(()) } pub fn get_clipboard_history(&self, limit: u32) -> Result> { - let conn = self.conn.lock().unwrap(); + let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner()); let mut stmt = conn.prepare( "SELECT type, content, image_width, image_height, image_bytes FROM clipboard ORDER BY created_at DESC LIMIT ?1" )?; diff --git a/src/platform/macos/clipboard.rs b/src/platform/macos/clipboard.rs index e0dcefa7..e5ee293d 100644 --- a/src/platform/macos/clipboard.rs +++ b/src/platform/macos/clipboard.rs @@ -4,6 +4,10 @@ use objc2_app_kit::NSPasteboard; use objc2_foundation::NSString; /// Get any copied file URLs from the macOS general pasteboard. +/// +/// # Safety +/// This function executes raw Objective-C messaging. To manually prevent segfaults, +/// it validates `isKindOfClass: NSArray` at runtime before iterating or invoking array-specific selectors. pub fn get_copied_files() -> Option> { unsafe { let pb = NSPasteboard::generalPasteboard(); @@ -14,11 +18,14 @@ pub fn get_copied_files() -> Option> { let mut files = Vec::new(); if let Some(array) = data { - let count: usize = objc2::msg_send![&array, count]; - for i in 0..count { - let item: Option> = objc2::msg_send![&array, objectAtIndex: i]; - if let Some(s) = item { - files.push(s.to_string()); + let is_array: bool = objc2::msg_send![&array, isKindOfClass: objc2::class!(NSArray)]; + if is_array { + let count: usize = objc2::msg_send![&array, count]; + for i in 0..count { + let item: Option> = objc2::msg_send![&array, objectAtIndex: i]; + if let Some(s) = item { + files.push(s.to_string()); + } } } } @@ -28,6 +35,10 @@ pub fn get_copied_files() -> Option> { } /// Write paths back to the macOS pasteboard. +/// +/// # Safety +/// Interacts directly with the Core Foundation pasteboard server. Standard Foundation classes +/// like `NSMutableArray` and `NSString` are utilized directly via `msg_send!` without external linkage. pub fn put_copied_files(paths: &[String]) { unsafe { let pb = NSPasteboard::generalPasteboard(); @@ -48,3 +59,75 @@ pub fn put_copied_files(paths: &[String]) { let _: bool = objc2::msg_send![&pb, setPropertyList: &*array, forType: &*ns_filenames_type]; } } + +pub fn get_copied_text() -> Option { + unsafe { + let pb = NSPasteboard::generalPasteboard(); + let ns_string_type = NSString::from_str("public.utf8-plain-text"); + let data: Option> = objc2::msg_send![&pb, stringForType: &*ns_string_type]; + data.map(|s| s.to_string()) + } +} + +pub fn get_copied_image() -> Option> { + unsafe { + let pb = NSPasteboard::generalPasteboard(); + + let tiff_data: Option> = + objc2::msg_send![&pb, dataForType: &*NSString::from_str("public.tiff")]; + + let png_data: Option> = + if tiff_data.is_none() { + objc2::msg_send![&pb, dataForType: &*NSString::from_str("public.png")] + } else { + None + }; + + let data = tiff_data.or(png_data)?; + + let slice = data.to_vec(); + + if let Ok(img) = image::load_from_memory(&slice) { + let rgba = img.into_rgba8(); + return Some(crate::clipboard::ImageData { + width: rgba.width() as usize, + height: rgba.height() as usize, + bytes: std::borrow::Cow::Owned(rgba.into_raw()), + }); + } + None + } +} + +pub fn put_copied_text(text: &str) { + unsafe { + let pb = NSPasteboard::generalPasteboard(); + pb.clearContents(); + + let ns_str = NSString::from_str(text); + let _: bool = objc2::msg_send![&pb, setString: &*ns_str, forType: &*NSString::from_str("public.utf8-plain-text")]; + } +} + +pub fn put_copied_image(img: &crate::clipboard::ImageData) { + unsafe { + let pb = NSPasteboard::generalPasteboard(); + pb.clearContents(); + + let mut buf = std::io::Cursor::new(Vec::new()); + if let Some(rgba) = image::RgbaImage::from_raw(img.width as u32, img.height as u32, img.bytes.to_vec()) { + if image::write_buffer_with_format( + &mut buf, + &rgba, + img.width as u32, + img.height as u32, + image::ColorType::Rgba8, + image::ImageFormat::Png + ).is_ok() { + let png_bytes = buf.into_inner(); + let ns_data = objc2_foundation::NSData::with_bytes(&png_bytes); + let _: bool = objc2::msg_send![&pb, setData: &*ns_data, forType: &*NSString::from_str("public.png")]; + } + } + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 8e5e1cc4..c9e64b8f 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -74,3 +74,49 @@ pub fn put_copied_files(paths: &[String]) { #[cfg(not(target_os = "macos"))] pub fn put_copied_files(_: &[String]) {} + +#[cfg(target_os = "macos")] +pub fn icon_of_path_ns(path: &str) -> Option> { + self::macos::discovery::icon_of_path_ns(path) +} + +#[cfg(not(target_os = "macos"))] +pub fn icon_of_path_ns(_: &str) -> Option> { + None +} + +#[cfg(target_os = "macos")] +pub fn get_copied_text() -> Option { + self::macos::clipboard::get_copied_text() +} + +#[cfg(not(target_os = "macos"))] +pub fn get_copied_text() -> Option { + None +} + +#[cfg(target_os = "macos")] +pub fn get_copied_image() -> Option> { + self::macos::clipboard::get_copied_image() +} + +#[cfg(not(target_os = "macos"))] +pub fn get_copied_image() -> Option> { + None +} + +#[cfg(target_os = "macos")] +pub fn put_copied_text(text: &str) { + self::macos::clipboard::put_copied_text(text); +} + +#[cfg(not(target_os = "macos"))] +pub fn put_copied_text(_: &str) {} + +#[cfg(target_os = "macos")] +pub fn put_copied_image(img: &crate::clipboard::ImageData) { + self::macos::clipboard::put_copied_image(img); +} + +#[cfg(not(target_os = "macos"))] +pub fn put_copied_image(_: &crate::clipboard::ImageData) {} diff --git a/src/styles.rs b/src/styles.rs index 869a77d2..58909c0e 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -27,14 +27,14 @@ pub fn rustcast_text_input_style(theme: &ConfigTheme) -> text_input::Style { text_input::Style { background: Background::Color(surface), border: Border { - color: glass_border(theme.text_color(1.0), focused), + color: glass_border(theme.text_color(0.), focused), width: 0., - radius: Radius::new(15.).bottom(0.), + radius: Radius::new(10.).bottom(0.), }, - icon: theme.text_color(0.75), - placeholder: theme.text_color(0.50), - value: theme.text_color(1.0), - selection: with_alpha(theme.text_color(1.0), 0.20), + icon: theme.text_color(0.), + placeholder: theme.text_color(0.2), + value: theme.text_color(0.9), + selection: theme.text_color(0.2), } } @@ -44,7 +44,7 @@ pub fn contents_style(theme: &ConfigTheme) -> container::Style { background: None, text_color: None, border: iced::Border { - color: theme.text_color(0.7), + color: theme.text_color(0.9), width: 0.4, radius: Radius::new(14.0), }, @@ -75,6 +75,21 @@ pub fn result_button_style(theme: &ConfigTheme) -> button::Style { } } +pub fn favourite_button_style(theme: &ConfigTheme, status: button::Status) -> button::Style { + let text_color = match status { + button::Status::Pressed => theme.text_color(1.), + button::Status::Hovered => theme.text_color(0.5), + button::Status::Active => theme.text_color(0.1), + button::Status::Disabled => theme.text_color(0.1), + }; + + button::Style { + text_color, + background: Some(Background::Color(theme.bg_color())), + ..Default::default() + } +} + pub fn results_scrollbar_style(tile: &ConfigTheme) -> scrollable::Style { let clr = with_alpha(tile.bg_color(), 0.7); @@ -260,8 +275,8 @@ pub fn settings_slider_style(theme: &ConfigTheme) -> slider::Style { /// Helper fn for making a color look like its glassy pub fn glass_surface(base: Color, focused: bool) -> Color { - let t = if focused { 0.3 } else { 0.06 }; - let a = if focused { 0.3 } else { 0.22 }; + let t = if focused { 0.2 } else { 0.06 }; + let a = if focused { 0.9 } else { 0.58 }; with_alpha(tint(base, t), a) } @@ -270,3 +285,14 @@ pub fn glass_border(base_text: Color, focused: bool) -> Color { let a = if focused { 0.35 } else { 0.22 }; with_alpha(base_text, a) } + +pub fn clipboard_image_border_style() -> container::Style { + container::Style { + border: Border { + color: Color::WHITE, + width: 1., + radius: Radius::new(0.), + }, + ..Default::default() + } +}