From 2a85f66481445af69054eba84144de06ad1a0f7a Mon Sep 17 00:00:00 2001 From: StudentWeis Date: Sun, 4 Jan 2026 14:25:34 +0800 Subject: [PATCH 1/3] feat: add preview functionality for clipboard records --- src/gui/board/mod.rs | 1 + src/gui/board/preview.rs | 119 +++++++++++++++++++++++++++++++++++++++ src/gui/board/render.rs | 22 +++++++- 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 src/gui/board/preview.rs diff --git a/src/gui/board/mod.rs b/src/gui/board/mod.rs index 3561b93..c430cae 100644 --- a/src/gui/board/mod.rs +++ b/src/gui/board/mod.rs @@ -1,5 +1,6 @@ mod about; mod actions; +mod preview; mod render; mod settings; diff --git a/src/gui/board/preview.rs b/src/gui/board/preview.rs new file mode 100644 index 0000000..317e4cb --- /dev/null +++ b/src/gui/board/preview.rs @@ -0,0 +1,119 @@ +/// Custom tooltip preview implementation that supports automatic line wrapping +use gpui::{AnyView, App, AppContext, IntoElement, ParentElement, Render, Styled, StyledImage, Window, div, img, px}; +use gpui_component::ActiveTheme; +use std::path::PathBuf; + +/// Create a tooltip preview that supports automatic line wrapping +/// +/// This implementation returns a View that will be correctly rendered by GPUI's tooltip system +/// +/// # Usage Example +/// ```rust +/// div() +/// .tooltip(|window, cx| { +/// simple_tooltip("This is tooltip content", window, cx) +/// }) +/// ``` +pub fn simple_tooltip(content: impl Into, window: &mut Window, cx: &mut App) -> AnyView { + let content = content.into(); + let window_width = window.bounds().size.width; + let max_width = (window_width - px(40.0)).into(); + + cx.new(move |_cx| TooltipView { content, max_width }).into() +} + +struct TooltipView { + content: String, + max_width: f32, +} + +impl Render for TooltipView { + fn render(&mut self, _window: &mut Window, cx: &mut gpui::Context) -> impl IntoElement { + div() + .flex() + .flex_row() + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .rounded_md() + .shadow_lg() + .px_3() + .py_2() + .max_w(px(self.max_width)) + .min_w_0() + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_color(cx.theme().popover_foreground) + .line_height(gpui::relative(1.5)) + .overflow_hidden() + .child(self.content.clone()), + ) + } +} + +/// Create an image tooltip preview +/// +/// # Usage Example +/// ```rust +/// div() +/// .tooltip(|window, cx| { +/// image_tooltip("/path/to/image.png", window, cx) +/// }) +/// ``` +pub fn image_tooltip(image_path: impl Into, window: &mut Window, cx: &mut App) -> AnyView { + let image_path = image_path.into(); + let window_width = window.bounds().size.width; + let window_height = window.bounds().size.height; + let max_width = (window_width * 0.8).min(px(600.0)); + let max_height = (window_height * 0.8).min(px(400.0)); + + cx.new(move |_cx| ImageTooltipView { + image_path, + max_width: max_width.into(), + max_height: max_height.into(), + }) + .into() +} + +struct ImageTooltipView { + image_path: String, + max_width: f32, + max_height: f32, +} + +impl Render for ImageTooltipView { + fn render(&mut self, _window: &mut Window, cx: &mut gpui::Context) -> impl IntoElement { + let path = PathBuf::from(&self.image_path); + let file_stem = path.file_stem().unwrap_or_default().to_string_lossy(); + let thumb_name = format!("{file_stem}.png"); + let thumb_path = path.parent().unwrap_or(&path).join(thumb_name); + + // Use thumbnail if exists, otherwise fallback to original + let display_path = if thumb_path.exists() { + thumb_path + } else { + path + }; + + div() + .flex() + .flex_col() + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .rounded_md() + .shadow_lg() + .p_2() + .max_w(px(self.max_width)) + .max_h(px(self.max_height)) + .child( + img(display_path) + .max_w(px(self.max_width - 16.0)) + .max_h(px(self.max_height - 16.0)) + .object_fit(gpui::ObjectFit::Contain), + ) + } +} diff --git a/src/gui/board/render.rs b/src/gui/board/render.rs index 20d0dd9..c97f5bc 100644 --- a/src/gui/board/render.rs +++ b/src/gui/board/render.rs @@ -16,7 +16,7 @@ use regex::Regex; use std::path::PathBuf; use std::sync::OnceLock; -use super::RopyBoard; +use super::{RopyBoard, preview}; fn get_hex_color(content: &str) -> Option { static HEX_REGEX: OnceLock = OnceLock::new(); @@ -215,6 +215,7 @@ impl RopyBoard { let content_type = record.content_type.clone(); let view_click = view.clone(); let view_delete = view.clone(); + let record_content = record.content.clone(); div() .pb_2() @@ -254,6 +255,25 @@ impl RopyBoard { }) .ok(); }) + .tooltip({ + let content_type_clone = content_type.clone(); + let record_content_clone = record_content.clone(); + move |window, cx| { + match content_type_clone { + ContentType::Image => { + preview::image_tooltip(record_content_clone.clone(), window, cx) + } + _ => { + let content = if record_content_clone.len() > 800 { + record_content_clone.chars().take(800).collect::() + } else { + record_content_clone.clone() + }; + preview::simple_tooltip(content, window, cx) + } + } + } + }) .child(match content_type { ContentType::Text => render_text_record(cx, record), ContentType::Image => render_image_record(record), From e5ac5a6daf972d1dd2e53a4e7827b8d0d50e992e Mon Sep 17 00:00:00 2001 From: StudentWeis Date: Sun, 4 Jan 2026 19:29:59 +0800 Subject: [PATCH 2/3] refactor: improve image tooltip handling --- src/gui/board/preview.rs | 82 ++++++++++++++++++++++------------------ src/gui/board/render.rs | 24 +++++++----- 2 files changed, 60 insertions(+), 46 deletions(-) diff --git a/src/gui/board/preview.rs b/src/gui/board/preview.rs index 317e4cb..3e903f9 100644 --- a/src/gui/board/preview.rs +++ b/src/gui/board/preview.rs @@ -1,6 +1,9 @@ /// Custom tooltip preview implementation that supports automatic line wrapping -use gpui::{AnyView, App, AppContext, IntoElement, ParentElement, Render, Styled, StyledImage, Window, div, img, px}; +use gpui::{ + AnyView, App, AppContext, IntoElement, ParentElement, Render, Styled, Window, div, img, px, +}; use gpui_component::ActiveTheme; +use image::ImageReader; use std::path::PathBuf; /// Create a tooltip preview that supports automatic line wrapping @@ -67,53 +70,60 @@ pub fn image_tooltip(image_path: impl Into, window: &mut Window, cx: &mu let image_path = image_path.into(); let window_width = window.bounds().size.width; let window_height = window.bounds().size.height; - let max_width = (window_width * 0.8).min(px(600.0)); - let max_height = (window_height * 0.8).min(px(400.0)); + let max_width = (window_width * 0.9).min(px(600.0)); + let max_height = (window_height * 0.9).min(px(400.0)); + + let (width, height) = calculate_image_size(&image_path, max_width, max_height); cx.new(move |_cx| ImageTooltipView { image_path, - max_width: max_width.into(), - max_height: max_height.into(), + width, + height, }) .into() } +fn calculate_image_size( + path: &str, + max_w: gpui::Pixels, + max_h: gpui::Pixels, +) -> (gpui::Pixels, gpui::Pixels) { + if let Ok(reader) = ImageReader::open(path).and_then(|r| r.with_guessed_format()) + && let Ok(dims) = reader.into_dimensions() + { + let w = dims.0 as f32; + let h = dims.1 as f32; + + let width_ratio = Into::::into(max_w) / w; + let height_ratio = Into::::into(max_h) / h; + let scale = width_ratio.min(height_ratio).min(1.0); + + return (px(w * scale), px(h * scale)); + } + (max_w, max_h) +} + struct ImageTooltipView { image_path: String, - max_width: f32, - max_height: f32, + width: gpui::Pixels, + height: gpui::Pixels, } impl Render for ImageTooltipView { fn render(&mut self, _window: &mut Window, cx: &mut gpui::Context) -> impl IntoElement { - let path = PathBuf::from(&self.image_path); - let file_stem = path.file_stem().unwrap_or_default().to_string_lossy(); - let thumb_name = format!("{file_stem}.png"); - let thumb_path = path.parent().unwrap_or(&path).join(thumb_name); - - // Use thumbnail if exists, otherwise fallback to original - let display_path = if thumb_path.exists() { - thumb_path - } else { - path - }; - - div() - .flex() - .flex_col() - .bg(cx.theme().popover) - .border_1() - .border_color(cx.theme().border) - .rounded_md() - .shadow_lg() - .p_2() - .max_w(px(self.max_width)) - .max_h(px(self.max_height)) - .child( - img(display_path) - .max_w(px(self.max_width - 16.0)) - .max_h(px(self.max_height - 16.0)) - .object_fit(gpui::ObjectFit::Contain), - ) + div().flex().flex_row().min_w_0().child( + div() + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .rounded_md() + .shadow_lg() + .p_2() + .child( + img(PathBuf::from(&self.image_path)) + .w(self.width) + .h(self.height), + ), + ) } } diff --git a/src/gui/board/render.rs b/src/gui/board/render.rs index c97f5bc..89dee4e 100644 --- a/src/gui/board/render.rs +++ b/src/gui/board/render.rs @@ -258,19 +258,23 @@ impl RopyBoard { .tooltip({ let content_type_clone = content_type.clone(); let record_content_clone = record_content.clone(); - move |window, cx| { - match content_type_clone { - ContentType::Image => { - preview::image_tooltip(record_content_clone.clone(), window, cx) - } - _ => { - let content = if record_content_clone.len() > 800 { - record_content_clone.chars().take(800).collect::() + move |window, cx| match content_type_clone { + ContentType::Image => preview::image_tooltip( + record_content_clone.clone(), + window, + cx, + ), + _ => { + let content = + if record_content_clone.len() > 800 { + record_content_clone + .chars() + .take(800) + .collect::() } else { record_content_clone.clone() }; - preview::simple_tooltip(content, window, cx) - } + preview::simple_tooltip(content, window, cx) } } }) From 27c23b9f39be9ad1f660c36783884683be190d1a Mon Sep 17 00:00:00 2001 From: StudentWeis Date: Sun, 4 Jan 2026 20:04:21 +0800 Subject: [PATCH 3/3] feat: implement preview toggle for clipboard records with hotkey --- src/gui/board/actions.rs | 9 ++ src/gui/board/mod.rs | 2 + src/gui/board/render.rs | 257 ++++++++++++++++++++++----------------- 3 files changed, 154 insertions(+), 114 deletions(-) diff --git a/src/gui/board/actions.rs b/src/gui/board/actions.rs index f1cdd14..26c68e7 100644 --- a/src/gui/board/actions.rs +++ b/src/gui/board/actions.rs @@ -36,6 +36,7 @@ impl RopyBoard { pub fn on_active_action(&mut self, _: &Active, window: &mut Window, cx: &mut Context) { self.selected_index = 0; + self.show_preview = false; self.list_state.scroll_to_reveal_item(self.selected_index); self.show_settings = false; window.resize(gpui::size(gpui::px(400.), gpui::px(600.))); @@ -69,6 +70,7 @@ impl RopyBoard { window.focus(&self.search_input.focus_handle(cx)); return; } + // If the search input is focused, ignore key presses if let Some(focused_handle) = window.focused(cx) && focused_handle == self.search_input.focus_handle(cx) @@ -76,6 +78,13 @@ impl RopyBoard { return; } + // If the space key is pressed, toggle preview + if event.keystroke.key.as_str() == "space" { + self.show_preview = !self.show_preview; + cx.notify(); + return; + } + // Map number keys to record selection let key = &event.keystroke.key; let index = match key.as_str() { diff --git a/src/gui/board/mod.rs b/src/gui/board/mod.rs index c430cae..dd56db6 100644 --- a/src/gui/board/mod.rs +++ b/src/gui/board/mod.rs @@ -42,6 +42,7 @@ pub struct RopyBoard { settings: Arc>, show_settings: bool, show_about: bool, + show_preview: bool, settings_activation_key_input: Entity, settings_max_history_input: Entity, selected_theme: usize, // 0: Light, 1: Dark, 2: System @@ -124,6 +125,7 @@ impl RopyBoard { copy_tx, show_settings: false, show_about: false, + show_preview: false, settings_activation_key_input, settings_max_history_input, selected_theme: theme_index, diff --git a/src/gui/board/render.rs b/src/gui/board/render.rs index 89dee4e..1e9e15b 100644 --- a/src/gui/board/render.rs +++ b/src/gui/board/render.rs @@ -4,7 +4,7 @@ use crate::gui::utils::start_window_drag; use crate::repository::ClipboardRecord; use crate::repository::models::ContentType; use gpui::{ - Context, Entity, div, img, list, + Context, Entity, anchored, deferred, div, img, list, prelude::{InteractiveElement, IntoElement, ParentElement, StatefulInteractiveElement, Styled}, px, }; @@ -201,14 +201,34 @@ fn render_text_record(cx: &mut gpui::App, record: &ClipboardRecord) -> gpui::Any } } +fn create_preview( + content_type: &ContentType, + record_content: &str, + window: &mut gpui::Window, + cx: &mut gpui::App, +) -> gpui::AnyView { + match content_type { + ContentType::Image => preview::image_tooltip(record_content, window, cx), + _ => { + let content = if record_content.len() > 800 { + record_content.chars().take(800).collect::() + } else { + record_content.to_string() + }; + preview::simple_tooltip(content, window, cx) + } + } +} + impl RopyBoard { /// Render the scrollable list of clipboard records pub fn render_records_list(&self, context: &mut Context<'_, RopyBoard>) -> impl IntoElement { let records = self.filtered_records.clone(); let list_state = self.list_state.clone(); let selected_index = self.selected_index; + let show_preview = self.show_preview; let view = context.weak_entity(); - list(list_state, move |index, _window, cx| { + list(list_state, move |index, window, cx| { let record = &records[index]; let record_id = record.id; let is_selected = index == selected_index; @@ -217,118 +237,127 @@ impl RopyBoard { let view_delete = view.clone(); let record_content = record.content.clone(); - div() - .pb_2() - .child( - v_flex() - .w_full() - .p_3() - .bg(if is_selected { - cx.theme().accent - } else { - cx.theme().secondary - }) - .rounded_md() - .border_1() - .border_color(if is_selected { - cx.theme().accent - } else { - cx.theme().border - }) - .hover(|style| style.bg(cx.theme().accent).border_color(cx.theme().accent)) - .id(("record", index)) - .child( - h_flex() - .justify_between() - .items_start() - .gap_2() - .child( - div() - .flex_1() - .min_w_0() - .cursor_pointer() - .id(("record-content", index)) - .on_click(move |_event, window, cx| { - view_click - .update(cx, |this, cx| { - this.confirm_record(window, cx, index); - }) - .ok(); - }) - .tooltip({ - let content_type_clone = content_type.clone(); - let record_content_clone = record_content.clone(); - move |window, cx| match content_type_clone { - ContentType::Image => preview::image_tooltip( - record_content_clone.clone(), - window, - cx, - ), - _ => { - let content = - if record_content_clone.len() > 800 { - record_content_clone - .chars() - .take(800) - .collect::() - } else { - record_content_clone.clone() - }; - preview::simple_tooltip(content, window, cx) - } - } - }) - .child(match content_type { - ContentType::Text => render_text_record(cx, record), - ContentType::Image => render_image_record(record), - _ => div().child("Unknown content").into_any_element(), - }) - .child( - h_flex() - .items_center() - .gap_1() - .mt_1() - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .bg(cx.theme().background) - .px_1() - .py_0() - .rounded_sm() - .child(format!("{}", index + 1)), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child( - record - .created_at - .format("%Y-%m-%d %H:%M:%S") - .to_string(), - ), - ), - ), - ) - .child( - Button::new(("delete-btn", index)) - .xsmall() - .ghost() - .label("×") - .on_click(move |_event, _window, cx| { - view_delete - .update(cx, |this, cx| { - this.delete_record(record_id); - // TODO Delete associated last copy state - cx.notify(); - }) - .ok(); - }), - ), - ), - ) - .into_any_element() + let preview_data = (content_type.clone(), record_content.clone()); + + let mut item = div().pb_2().relative().child( + v_flex() + .w_full() + .p_3() + .bg(if is_selected { + cx.theme().accent + } else { + cx.theme().secondary + }) + .rounded_md() + .border_1() + .border_color(if is_selected { + cx.theme().accent + } else { + cx.theme().border + }) + .hover(|style| style.bg(cx.theme().accent).border_color(cx.theme().accent)) + .id(("record", index)) + .child( + h_flex() + .justify_between() + .items_start() + .gap_2() + .child({ + let mut content_div = div() + .flex_1() + .min_w_0() + .cursor_pointer() + .id(("record-content", index)) + .on_click(move |_event, window, cx| { + view_click + .update(cx, |this, cx| { + this.confirm_record(window, cx, index); + }) + .ok(); + }); + + if !show_preview { + content_div = content_div.tooltip({ + let (content_type, record_content) = preview_data.clone(); + move |window, cx| { + create_preview( + &content_type, + &record_content, + window, + cx, + ) + } + }); + } + + content_div + .child(match content_type { + ContentType::Text => render_text_record(cx, record), + ContentType::Image => render_image_record(record), + _ => div().child("Unknown content").into_any_element(), + }) + .child( + h_flex() + .items_center() + .gap_1() + .mt_1() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .bg(cx.theme().background) + .px_1() + .py_0() + .rounded_sm() + .child(format!("{}", index + 1)), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child( + record + .created_at + .format("%Y-%m-%d %H:%M:%S") + .to_string(), + ), + ), + ) + }) + .child( + Button::new(("delete-btn", index)) + .xsmall() + .ghost() + .label("×") + .on_click(move |_event, _window, cx| { + view_delete + .update(cx, |this, cx| { + this.delete_record(record_id); + // TODO Delete associated last copy state + cx.notify(); + }) + .ok(); + }), + ), + ), + ); + + if is_selected && show_preview { + let (content_type, record_content) = preview_data; + item = + item.child( + deferred( + div().absolute().top_full().left_0().child( + anchored().snap_to_window().child(div().mt_1().child( + create_preview(&content_type, &record_content, window, cx), + )), + ), + ) + .with_priority(1), + ); + } + + item.into_any_element() }) .w_full() .flex_1()