From 14112d212dae8a0c6e6658bb07485e956c351cce Mon Sep 17 00:00:00 2001 From: unsecretised Date: Fri, 10 Jul 2026 23:49:40 +0800 Subject: [PATCH 1/3] feat: clipboard history UI + support for detecting and opening URLs --- src/app/pages/clipboard.rs | 109 ++++++++++++++++++++++++++----------- src/app/tile.rs | 6 ++ src/app/tile/elm.rs | 5 +- src/app/tile/update.rs | 32 +++++------ src/clipboard.rs | 98 +++++++++++++++++++++++---------- src/commands.rs | 3 + src/styles.rs | 31 +++++++++-- 7 files changed, 198 insertions(+), 86 deletions(-) diff --git a/src/app/pages/clipboard.rs b/src/app/pages/clipboard.rs index 57e92da..b460ba3 100644 --- a/src/app/pages/clipboard.rs +++ b/src/app/pages/clipboard.rs @@ -1,10 +1,11 @@ //! The elements for the clipboard history page use iced::{ - ContentFit, + Color, ContentFit, border::Radius, widget::{ Scrollable, image::{Handle, Viewer}, + rule, scrollable::{Direction, Scrollbar}, text::Wrapping, text_input, @@ -14,7 +15,10 @@ use iced::{ use crate::{ app::{Editable, ToApp, pages::prelude::*}, clipboard::ClipBoardContentType, - styles::{delete_button_style, settings_text_input_item_style}, + styles::{ + delete_button_style, open_button_style, open_icon, settings_text_input_item_style, + trash_icon, url_icon, with_alpha, + }, }; /// The clipboard view @@ -54,15 +58,15 @@ pub fn clipboard_view( Some(content) => viewport_content(content, &theme), None => Text::new("").into(), }; + + let row_render_theme = theme.clone(); 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) + content.render_row(i == focussed_id as usize, &row_render_theme) })) - .width(WINDOW_WIDTH / 3.), + .width((WINDOW_WIDTH + 50.) / 3.), Direction::Vertical(Scrollbar::hidden()), ) .id("results"), @@ -70,11 +74,19 @@ pub fn clipboard_view( .height(10000) .style(move |_| result_row_container_style(&theme_clone_2, false)) .into(), + rule::vertical(0.3) + .style(|iced_theme: &iced::Theme| rule::Style { + color: with_alpha(iced_theme.palette().text, 0.7), + radius: Radius::new(0), + fill_mode: rule::FillMode::Full, + snap: true, + }) + .into(), container(viewport_content) .height(10000) .padding(10) .style(move |_| result_row_container_style(&theme_clone, false)) - .width((WINDOW_WIDTH / 3.) * 2.) + .width(((WINDOW_WIDTH + 50.) / 3.) * 2.) .into(), ])) .height(280) @@ -83,25 +95,27 @@ pub fn clipboard_view( fn viewport_content(content: &ClipBoardContentType, theme: &Theme) -> Element<'static, Message> { let viewer: Element<'static, Message> = match content { - ClipBoardContentType::Text(txt) => Scrollable::with_direction( - container( - Text::new(txt.to_owned()) - .height(Length::Fill) - .width(Length::Fill) - .align_x(Alignment::Start) - .font(theme.font()) - .size(16), + ClipBoardContentType::Text(txt) | ClipBoardContentType::Url(txt) => { + Scrollable::with_direction( + container( + Text::new(txt.to_owned()) + .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) - .height(Length::Fill), - Direction::Both { - vertical: Scrollbar::hidden(), - horizontal: Scrollbar::hidden(), - }, - ) - .height(Length::Fill) - .width(Length::Fill) - .into(), + .into() + } ClipBoardContentType::Image(data) => { let bytes = data.to_owned_img().into_owned_bytes(); @@ -124,6 +138,7 @@ fn viewport_content(content: &ClipBoardContentType, theme: &Theme) -> Element<'s }, ..Default::default() }) + .height(Length::Fill) .width(Length::Fill) .into() } @@ -131,16 +146,48 @@ fn viewport_content(content: &ClipBoardContentType, theme: &Theme) -> Element<'s let theme_clone = theme.clone(); let theme_clone_2 = theme.clone(); + let horizontal_line = rule::horizontal(0.3).style(|them: &iced::Theme| rule::Style { + color: with_alpha(them.palette().text, 0.7), + radius: Radius::new(0), + fill_mode: rule::FillMode::Full, + snap: true, + }); + let open_url_option = match content { + ClipBoardContentType::Url(url) => Button::new( + open_icon() + .width(Length::Fill) + .height(Length::Fill) + .center(), + ) + .on_press(Message::RunFunction( + crate::commands::Function::OpenWebsite(url.clone()), + )) + .height(30) + .width(30) + .style(open_button_style) + .into(), + _ => iced::widget::space().width(0).into(), + }; Column::from_iter([ viewer, + horizontal_line.into(), container( Row::from_iter([ - Button::new("Delete") - .on_press(Message::EditClipboardHistory(Editable::Delete( - content.to_owned(), - ))) - .style(move |_, _| delete_button_style(&theme_clone)) - .into(), + open_url_option, + iced::widget::space().width(Length::Fill).into(), + Button::new( + trash_icon() + .width(Length::Fill) + .height(Length::Fill) + .center(), + ) + .width(30) + .height(30) + .on_press(Message::EditClipboardHistory(Editable::Delete( + content.to_owned(), + ))) + .style(move |_, _| delete_button_style(&theme_clone)) + .into(), Button::new("Clear") .on_press(Message::ClearClipboardHistory) .style(move |_, _| delete_button_style(&theme_clone_2)) @@ -149,7 +196,7 @@ fn viewport_content(content: &ClipBoardContentType, theme: &Theme) -> Element<'s .spacing(10), ) .width(Length::Fill) - .align_x(Alignment::Center) + .align_x(Alignment::End) .padding(10) .into(), ]) diff --git a/src/app/tile.rs b/src/app/tile.rs index 88dfb58..d4d8269 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -31,6 +31,7 @@ use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use rayon::slice::ParallelSliceMut; use tokio::io::{AsyncBufReadExt, AsyncRead}; use tray_icon::TrayIcon; +use url::Url; use std::collections::HashMap; use std::fmt::Debug; @@ -386,6 +387,11 @@ fn handle_clipboard_history() -> impl futures::Stream { loop { let byte_rep = if let Ok(a) = clipboard.get_image() { Some(ClipBoardContentType::Image(a)) + } else if let Ok(a) = clipboard.get_text() + && !a.trim().is_empty() + && Url::parse(&a).is_ok() + { + Some(ClipBoardContentType::Url(a)) } 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 ff493d8..0d2def7 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -24,7 +24,8 @@ use crate::config::Theme; use crate::debounce::Debouncer; use crate::platform::macos::events::Event; use crate::styles::{ - contents_style, glass_border, glass_surface, results_scrollbar_style, rustcast_text_input_style, + contents_style, glass_border, glass_surface, results_scrollbar_style, + rustcast_text_input_style, with_alpha, }; use crate::{app::WINDOW_WIDTH, platform}; use crate::{app::pages::clipboard::clipboard_view, platform::get_installed_apps}; @@ -269,7 +270,7 @@ fn footer(theme: Theme, current_mode: String, text: String) -> Element<'static, ) .align_y(Alignment::Center) .center(Length::Fill) - .width(WINDOW_WIDTH) + .width(Length::Fill) .padding(5) .height(30) .style(move |_| container::Style { diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 8e09fc9..af44db0 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -339,13 +339,11 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Message::ResizeWindow(id, height) => { info!("Resizing rustcast window"); tile.height = height; - window::resize( - id, - iced::Size { - width: WINDOW_WIDTH, - height, - }, - ) + let width = match tile.page { + Page::ClipboardHistory => WINDOW_WIDTH + 50., + _ => WINDOW_WIDTH, + }; + window::resize(id, iced::Size { width, height }) } Message::LoadRanking => { for (name, rank) in &tile.ranking { @@ -489,7 +487,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { let id = x.unwrap(); Message::ResizeWindow( id, - ((7 * 30) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, + ((7 * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, ) }) } @@ -1144,16 +1142,16 @@ fn resize_for_results_count(id: Id, count: usize) -> Task { } fn open_result(tile: &mut Tile, id: usize) -> Task { - let results = if tile.page == Page::ClipboardHistory { - tile.clipboard_content - .iter() - .map(|x| x.to_app().to_owned()) - .collect() - } else { - tile.results.clone() - }; + if tile.page == Page::ClipboardHistory { + let Some(content) = tile.clipboard_content.get(id) else { + return Task::none(); + }; + return Task::done(message_for_open_command(&AppCommand::Function( + Function::CopyToClipboard(content.clone()), + ))); + } - let Some(app) = results.get(id).cloned() else { + let Some(app) = tile.results.get(id).cloned() else { return Task::none(); }; diff --git a/src/clipboard.rs b/src/clipboard.rs index 2e72966..f094863 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -1,12 +1,18 @@ //! This has all the logic regarding the cliboard history use arboard::ImageData; +use iced::{ + Alignment, Element, Length, + widget::{Button, Text, container, row}, +}; +use objc2_foundation::NSProgressFileOperationKindReceiving; use crate::{ app::{ - ToApp, + Message, ToApp, apps::{App, AppIcon}, }, commands::Function, + styles::{image_icon, result_button_style, result_row_container_style, text_icon, url_icon}, }; /// The kinds of clipboard content that rustcast can handle and their contents @@ -14,33 +20,16 @@ use crate::{ pub enum ClipBoardContentType { Text(String), Image(ImageData<'static>), + Url(String), } -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 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(); - - App { - ranking: 0, - open_command: crate::app::apps::AppCommand::Function(Function::CopyToClipboard( - self_clone.to_owned(), - )), - desc: "Clipboard Item".to_string(), - icons: AppIcon::None, - display_name, - search_name, - } +fn shorten(s: &str) -> String { + let s = s.trim(); + if s.len() <= 20 { + String::from(s) + } else { + let ind = s.floor_char_boundary(20); + [&s[..ind], "..."].concat() } } @@ -55,11 +44,60 @@ impl PartialEq for ClipBoardContentType { && let Self::Image(other_image_data) = other { return image_data.bytes == other_image_data.bytes; + } else if let Self::Url(a) = self + && let Self::Url(b) = other + { + return a == b; } false } } +impl ClipBoardContentType { + pub fn render_row( + &self, + selected: bool, + theme: &crate::config::Theme, + ) -> Element<'static, Message> { + let title_icon_fn = match self { + ClipBoardContentType::Image(_) => image_icon, + ClipBoardContentType::Text(_) => text_icon, + ClipBoardContentType::Url(_) => url_icon, + }; + + let first_few_chars = match self { + ClipBoardContentType::Text(text) | ClipBoardContentType::Url(text) => shorten(text), + ClipBoardContentType::Image(_) => "Image".to_string(), + } + .to_string(); + + let theme_1 = theme.clone(); + let theme_2 = theme.clone(); + + container( + Button::new( + row![ + title_icon_fn().size(22), + Text::new(first_few_chars) + .center() + .height(Length::Fill) + .width(Length::Fill) + ] + .padding(5) + .height(Length::Fill) + .height(30) + .align_y(Alignment::Center), + ) + .style(move |_, _| result_button_style(&theme_1)) + .on_press(Message::RunFunction(Function::CopyToClipboard( + self.clone(), + ))), + ) + .style(move |_| result_row_container_style(&theme_2, selected)) + .into() + } +} + #[cfg(test)] mod tests { use super::*; @@ -81,10 +119,10 @@ mod tests { let item = ClipBoardContentType::Text("abcdefghijklmnopqrstuvwxyz\nsecond line".to_string()); - let app = item.to_app(); + // let app = item.to_app(); - assert_eq!(app.display_name, "abcdefghijklmnopqrstuvwxy"); - assert_eq!(app.search_name, "abcdefghijklmnopqrstuvwxy"); - assert_eq!(app.desc, "Clipboard Item"); + // assert_eq!(app.display_name, "abcdefghijklmnopqrstuvwxy"); + // assert_eq!(app.search_name, "abcdefghijklmnopqrstuvwxy"); + // assert_eq!(app.desc, "Clipboard Item"); } } diff --git a/src/commands.rs b/src/commands.rs index 290e58b..7889c71 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -120,6 +120,9 @@ impl Function { ClipBoardContentType::Image(img) => { Clipboard::new().unwrap().set_image(img.to_owned_img()).ok(); } + ClipBoardContentType::Url(url) => { + Clipboard::new().unwrap().set_text(url.to_string()).ok(); + } }, Function::Quit => std::process::exit(0), diff --git a/src/styles.rs b/src/styles.rs index 47dfb56..bb1764f 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -40,11 +40,16 @@ icon!(emoji_icon = 57700); icon!(settings_icon = 58123); icon!(refresh_icon = 57669); icon!(quit_icon = 57476); -icon!(hide_icon = 58926); -icon!(unlock = 57612); -icon!(palette = 57821); -icon!(bolt = 58764); -icon!(info = 57593); +icon!(image_icon = 57590); +icon!(url_icon = 57603); +icon!(text_icon = 57752); +icon!(trash_icon = 57742); +icon!(open_icon = 58788); +// icon!(hide_icon = 58926); +// icon!(unlock = 57612); +// icon!(palette = 57821); +// icon!(bolt = 58764); +// icon!(info = 57593); /// Helper: apply alpha pub fn with_alpha(mut c: Color, a: f32) -> Color { @@ -100,6 +105,20 @@ pub fn picklist_menu_style(theme: &ConfigTheme) -> menu::Style { } } +pub fn open_button_style(theme: &iced::Theme, _: button::Status) -> button::Style { + let palette = theme.palette(); + button::Style { + background: Some(Background::Color(palette.background)), + text_color: palette.text, + border: Border { + color: palette.text, + width: 0.3, + radius: Radius::new(5), + }, + ..Default::default() + } +} + /// Container styling for all the elements in the rustcast window pub fn contents_style(theme: &ConfigTheme) -> container::Style { container::Style { @@ -122,7 +141,7 @@ pub fn delete_button_style(theme: &ConfigTheme) -> button::Style { border: Border { color: with_alpha(red_clr, 0.3), width: 0.5, - radius: Radius::new(15), + radius: Radius::new(5), }, ..Default::default() } From 1f06733969a62288d328983abbfe06ad9378270f Mon Sep 17 00:00:00 2001 From: unsecretised Date: Fri, 10 Jul 2026 23:53:23 +0800 Subject: [PATCH 2/3] chore: clippy --- src/app/pages/clipboard.rs | 6 +++--- src/app/tile/elm.rs | 5 ++--- src/clipboard.rs | 6 +----- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/app/pages/clipboard.rs b/src/app/pages/clipboard.rs index b460ba3..3b2df22 100644 --- a/src/app/pages/clipboard.rs +++ b/src/app/pages/clipboard.rs @@ -1,6 +1,6 @@ //! The elements for the clipboard history page use iced::{ - Color, ContentFit, + ContentFit, border::Radius, widget::{ Scrollable, @@ -13,11 +13,11 @@ use iced::{ }; use crate::{ - app::{Editable, ToApp, pages::prelude::*}, + app::{Editable, pages::prelude::*}, clipboard::ClipBoardContentType, styles::{ delete_button_style, open_button_style, open_icon, settings_text_input_item_style, - trash_icon, url_icon, with_alpha, + trash_icon, with_alpha, }, }; diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index 0d2def7..e654290 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -22,12 +22,11 @@ use crate::app::tile::{AppIndex, Hotkeys}; use crate::app::{DEFAULT_WINDOW_HEIGHT, SettingsTab, ToApp, ToApps}; use crate::config::Theme; use crate::debounce::Debouncer; +use crate::platform; use crate::platform::macos::events::Event; use crate::styles::{ - contents_style, glass_border, glass_surface, results_scrollbar_style, - rustcast_text_input_style, with_alpha, + contents_style, glass_border, glass_surface, results_scrollbar_style, rustcast_text_input_style, }; -use crate::{app::WINDOW_WIDTH, platform}; use crate::{app::pages::clipboard::clipboard_view, platform::get_installed_apps}; use crate::{ app::{Message, Page, apps::App, default_settings, tile::Tile}, diff --git a/src/clipboard.rs b/src/clipboard.rs index f094863..98eb11e 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -4,13 +4,9 @@ use iced::{ Alignment, Element, Length, widget::{Button, Text, container, row}, }; -use objc2_foundation::NSProgressFileOperationKindReceiving; use crate::{ - app::{ - Message, ToApp, - apps::{App, AppIcon}, - }, + app::Message, commands::Function, styles::{image_icon, result_button_style, result_row_container_style, text_icon, url_icon}, }; From 8d59c88f868ea01614f43b6f3bd64580d7d9c392 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Sat, 11 Jul 2026 00:01:55 +0800 Subject: [PATCH 3/3] add basic clipboard searching (alpha) --- src/app/pages/clipboard.rs | 19 ++++++++++++++++--- src/app/tile/elm.rs | 1 + 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/app/pages/clipboard.rs b/src/app/pages/clipboard.rs index 3b2df22..e50e34d 100644 --- a/src/app/pages/clipboard.rs +++ b/src/app/pages/clipboard.rs @@ -31,6 +31,7 @@ use crate::{ /// Returns: /// - the iced Element to render pub fn clipboard_view( + query: String, clipboard_content: Vec, focussed_id: u32, theme: Theme, @@ -60,12 +61,24 @@ pub fn clipboard_view( }; let row_render_theme = theme.clone(); + let query = query.clone(); container(Row::from_iter([ container( Scrollable::with_direction( - Column::from_iter(clipboard_content.iter().enumerate().map(|(i, content)| { - content.render_row(i == focussed_id as usize, &row_render_theme) - })) + Column::from_iter( + clipboard_content + .iter() + .filter(|x| match x { + ClipBoardContentType::Text(data) | ClipBoardContentType::Url(data) => { + data.to_lowercase().contains(&query) + } + ClipBoardContentType::Image(_) => query == "image", + } || query.trim().is_empty()) + .enumerate() + .map(|(i, content)| { + content.render_row(i == focussed_id as usize, &row_render_theme) + }), + ) .width((WINDOW_WIDTH + 50.) / 3.), Direction::Vertical(Scrollbar::hidden()), ) diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index e654290..d314b8e 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -142,6 +142,7 @@ pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { let results = match tile.page { Page::ClipboardHistory => clipboard_view( + tile.query_lc.clone(), tile.clipboard_content.clone(), tile.focus_id, tile.config.theme.clone(),