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 3561b93..dd56db6 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; @@ -41,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 @@ -123,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/preview.rs b/src/gui/board/preview.rs new file mode 100644 index 0000000..3e903f9 --- /dev/null +++ b/src/gui/board/preview.rs @@ -0,0 +1,129 @@ +/// Custom tooltip preview implementation that supports automatic line wrapping +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 +/// +/// 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.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, + 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, + width: gpui::Pixels, + height: gpui::Pixels, +} + +impl Render for ImageTooltipView { + fn render(&mut self, _window: &mut Window, cx: &mut gpui::Context) -> impl IntoElement { + 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 20d0dd9..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, }; @@ -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(); @@ -201,110 +201,163 @@ 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; 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() - .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(); - }) - .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()