diff --git a/Cargo.toml b/Cargo.toml index fb0bd45..85f4897 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,10 @@ exclude = ["/.github", "/img", "/tool", "Makefile", "/docs"] [dependencies] ansi-to-tui = "8.0.0" anyhow = "1.0.100" -arboard = { version = "3.6.1", features = ["wayland-data-control"] } +arboard = { version = "3.6.1", features = [ + "image-data", + "wayland-data-control", +] } aws-config = "1.8.12" aws-sdk-s3 = "1.119.0" aws-smithy-types = "1.3.5" diff --git a/docs/src/features/object-preview.md b/docs/src/features/object-preview.md index c650520..a5247a5 100644 --- a/docs/src/features/object-preview.md +++ b/docs/src/features/object-preview.md @@ -10,7 +10,7 @@ - It must be enabled in the [config](../configurations/config-file-format.md#previewauto_detect_encoding) - Download object - Download a single selected object -- Copy the text content to clipboard +- Copy the content to clipboard ![Object Preview](https://raw.githubusercontent.com/lusingander/stu/refs/heads/master/img/object-preview.png) ![Object Preview Image](https://raw.githubusercontent.com/lusingander/stu/refs/heads/master/img/object-preview-image.png) diff --git a/src/app.rs b/src/app.rs index a16b362..04d4b83 100644 --- a/src/app.rs +++ b/src/app.rs @@ -26,7 +26,7 @@ use crate::{ CompleteLoadObjectsResult, CompletePreviewObjectResult, CompleteReloadBucketsResult, CompleteReloadObjectsResult, CompleteSaveObjectResult, Sender, }, - file::{copy_to_clipboard, create_binary_file, save_error_log}, + file::{copy_image_to_clipboard, copy_text_to_clipboard, create_binary_file, save_error_log}, keys::UserEventMapper, object::{AppObjects, DownloadObjectInfo, FileDetail, ObjectItem, ObjectKey, RawObject}, pages::page::{Page, PageStack}, @@ -808,8 +808,16 @@ impl App { object_preview_page.enable_image_render(); } - pub fn copy_to_clipboard(&self, name: String, value: String) { - match copy_to_clipboard(value) { + pub fn copy_text_to_clipboard(&self, name: String, value: String) { + self.copy_to_clipboard(name, || copy_text_to_clipboard(value)); + } + + pub fn copy_image_to_clipboard(&self, name: String, value: (usize, usize, Vec)) { + self.copy_to_clipboard(name, || copy_image_to_clipboard(value)); + } + + fn copy_to_clipboard Result<()>>(&self, name: String, copy: F) { + match copy() { Ok(_) => { let msg = format!("Copied '{name}' to clipboard successfully"); self.tx.send(AppEventType::NotifySuccess(msg)); diff --git a/src/event.rs b/src/event.rs index e29a3b8..de0f55d 100644 --- a/src/event.rs +++ b/src/event.rs @@ -60,7 +60,8 @@ pub enum AppEventType { ObjectDetailOpenManagementConsole(ObjectKey), CloseCurrentPage, OpenHelp, - CopyToClipboard(String, String), + CopyTextToClipboard(String, String), + CopyImageToClipboard(String, (usize, usize, Vec)), NotifyInfo(String), NotifySuccess(String), NotifyWarn(String), diff --git a/src/file.rs b/src/file.rs index 4c93b63..7d96c26 100644 --- a/src/file.rs +++ b/src/file.rs @@ -45,8 +45,20 @@ fn create_dirs>(path: P) -> Result<()> { } } -pub fn copy_to_clipboard(value: String) -> Result<()> { +pub fn copy_text_to_clipboard(value: String) -> Result<()> { Clipboard::new() .and_then(|mut c| c.set_text(value)) .map_err(|e| AppError::new("Failed to copy to clipboard", e)) } + +pub fn copy_image_to_clipboard(value: (usize, usize, Vec)) -> Result<()> { + let (width, height, bytes) = value; + let image = arboard::ImageData { + width, + height, + bytes: bytes.into(), + }; + Clipboard::new() + .and_then(|mut c| c.set_image(image)) + .map_err(|e| AppError::new("Failed to copy to clipboard", e)) +} diff --git a/src/pages/bucket_list.rs b/src/pages/bucket_list.rs index a417d4c..2c781db 100644 --- a/src/pages/bucket_list.rs +++ b/src/pages/bucket_list.rs @@ -179,7 +179,7 @@ impl BucketListPage { } UserEvent::SelectDialogSelect => { let (name, value) = state.selected_name_and_value(); - self.tx.send(AppEventType::CopyToClipboard(name, value)); + self.tx.send(AppEventType::CopyTextToClipboard(name, value)); } UserEvent::Help => { self.tx.send(AppEventType::OpenHelp); diff --git a/src/pages/object_detail.rs b/src/pages/object_detail.rs index c88f836..6ffc987 100644 --- a/src/pages/object_detail.rs +++ b/src/pages/object_detail.rs @@ -175,7 +175,7 @@ impl ObjectDetailPage { } UserEvent::SelectDialogSelect => { let (name, value) = state.selected_name_and_value(); - self.tx.send(AppEventType::CopyToClipboard(name, value)); + self.tx.send(AppEventType::CopyTextToClipboard(name, value)); } UserEvent::SelectDialogDown => { state.select_next(); diff --git a/src/pages/object_list.rs b/src/pages/object_list.rs index 42f927c..e85cea2 100644 --- a/src/pages/object_list.rs +++ b/src/pages/object_list.rs @@ -195,7 +195,7 @@ impl ObjectListPage { } UserEvent::SelectDialogSelect => { let (name, value) = state.selected_name_and_value(); - self.tx.send(AppEventType::CopyToClipboard(name, value)); + self.tx.send(AppEventType::CopyTextToClipboard(name, value)); } UserEvent::Help => { self.tx.send(AppEventType::OpenHelp); diff --git a/src/pages/object_preview.rs b/src/pages/object_preview.rs index df5a0a0..9f15103 100644 --- a/src/pages/object_preview.rs +++ b/src/pages/object_preview.rs @@ -163,7 +163,7 @@ impl ObjectPreviewPage { self.disable_image_render(); } UserEvent::ObjectPreviewCopy => { - self.tx.send(AppEventType::NotifyWarn("Cannot copy image content. Copy is only available for text files.".to_string())); + self.copy_image_content(); } UserEvent::Help => { self.tx.send(AppEventType::OpenHelp); @@ -279,6 +279,7 @@ impl ObjectPreviewPage { BuildHelpsItem::new(UserEvent::ObjectPreviewBack, "Close preview"), BuildHelpsItem::new(UserEvent::ObjectPreviewDownload, "Download object"), BuildHelpsItem::new(UserEvent::ObjectPreviewDownloadAs, "Download object as"), + BuildHelpsItem::new(UserEvent::ObjectPreviewCopy, "Copy content to clipboard"), ] }, (ViewState::SaveDialog(_), _) => { @@ -320,6 +321,7 @@ impl ObjectPreviewPage { vec![ BuildShortHelpsItem::single(UserEvent::Quit, "Quit", 0), BuildShortHelpsItem::group(vec![UserEvent::ObjectPreviewDownload, UserEvent::ObjectPreviewDownloadAs], "Download", 2), + BuildShortHelpsItem::single(UserEvent::ObjectPreviewCopy, "Copy", 3), BuildShortHelpsItem::single(UserEvent::ObjectPreviewBack, "Close", 1), BuildShortHelpsItem::single(UserEvent::Help, "Help", 0), ] @@ -356,13 +358,24 @@ impl ObjectPreviewPage { let (content, _, _) = encoding.decode(&self.object.bytes); let content_string = content.into_owned(); - self.tx.send(AppEventType::CopyToClipboard( + self.tx.send(AppEventType::CopyTextToClipboard( self.file_detail.name.clone(), content_string, )); } } + fn copy_image_content(&mut self) { + if let PreviewType::Image(state) = &self.preview_type { + if let Some((width, height, bytes)) = state.base_image_data() { + self.tx.send(AppEventType::CopyImageToClipboard( + self.file_detail.name.clone(), + (width, height, bytes), + )); + } + } + } + fn close_save_dialog(&mut self) { self.view_state = ViewState::Default; } @@ -447,13 +460,7 @@ mod tests { use super::*; use chrono::{DateTime, Local, NaiveDateTime}; - use ratatui::{ - backend::TestBackend, - buffer::Buffer, - crossterm::event::{KeyCode, KeyModifiers}, - style::Color, - Terminal, - }; + use ratatui::{backend::TestBackend, buffer::Buffer, style::Color, Terminal}; fn object(ss: &[&str]) -> RawObject { RawObject { @@ -616,96 +623,4 @@ mod tests { object_url: "https://bucket-1.s3.ap-northeast-1.amazonaws.com/file.txt".to_string(), } } - - #[tokio::test] - async fn test_copy_text_content() { - let ctx = Rc::default(); - let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - let tx = Sender::new(tx); - - let file_detail = file_detail(); - let preview = ["Hello, world!", "This is test content."]; - let object = object(&preview); - let mut page = ObjectPreviewPage::new(file_detail, None, object, ctx, tx); - - page.handle_key( - vec![UserEvent::ObjectPreviewCopy], - KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()), - ); - - assert!(matches!(page.preview_type, PreviewType::Text(_))); - } - - #[tokio::test] - async fn test_copy_image_content_shows_warning() { - use crate::event::AppEventType; - - let ctx = Rc::default(); - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let tx = Sender::new(tx); - - let image_bytes = vec![ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature - 0x00, 0x00, 0x00, 0x0D, // IHDR chunk size - 0x49, 0x48, 0x44, 0x52, // "IHDR" - 0x00, 0x00, 0x00, 0x01, // width: 1 - 0x00, 0x00, 0x00, 0x01, // height: 1 - 0x08, 0x02, // bit depth: 8, color type: 2 (RGB) - 0x00, 0x00, 0x00, // compression, filter, interlace - ]; - let object = RawObject { bytes: image_bytes }; - - let mut file_detail = file_detail(); - file_detail.name = "image.png".to_string(); - file_detail.content_type = "image/png".to_string(); - - let mut page = ObjectPreviewPage::new(file_detail, None, object, ctx, tx); - - assert!(matches!(page.preview_type, PreviewType::Image(_))); - - // NOTE: Clear any initial warning messages (like "Image preview is disabled") - while rx.try_recv().is_ok() { - // Drain events - } - - page.handle_key( - vec![UserEvent::ObjectPreviewCopy], - KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()), - ); - - if let Ok(event) = rx.try_recv() { - match event { - AppEventType::NotifyWarn(msg) => { - assert!( - msg.contains("Cannot copy image content"), - "Message was: {}", - msg - ); - } - _ => panic!("Expected NotifyWarn event, got: {:?}", event), - } - } else { - panic!("Expected NotifyWarn event to be sent"); - } - } - - #[test] - fn test_copy_respects_encoding() { - let ctx = Rc::default(); - let tx = sender(); - - let text = "Hello, 世界!"; - let utf16_bytes: Vec = text.encode_utf16().flat_map(|c| c.to_be_bytes()).collect(); - - let object = RawObject { bytes: utf16_bytes }; - - let file_detail = file_detail(); - let page = ObjectPreviewPage::new(file_detail, None, object, ctx, tx); - - if let PreviewType::Text(ref state) = page.preview_type { - assert!(matches!(state.encoding, _)); - } else { - panic!("Expected text preview type"); - } - } } diff --git a/src/run.rs b/src/run.rs index 3a8a00b..4d64aed 100644 --- a/src/run.rs +++ b/src/run.rs @@ -192,8 +192,11 @@ pub async fn run>( AppEventType::OpenHelp => { app.open_help(); } - AppEventType::CopyToClipboard(name, value) => { - app.copy_to_clipboard(name, value); + AppEventType::CopyTextToClipboard(name, value) => { + app.copy_text_to_clipboard(name, value); + } + AppEventType::CopyImageToClipboard(name, value) => { + app.copy_image_to_clipboard(name, value); } AppEventType::NotifyInfo(msg) => { app.info_notification(msg); diff --git a/src/widget/image_preview.rs b/src/widget/image_preview.rs index e304789..1f29256 100644 --- a/src/widget/image_preview.rs +++ b/src/widget/image_preview.rs @@ -3,7 +3,7 @@ use std::{ io::Cursor, }; -use image::{DynamicImage, ImageReader}; +use image::{DynamicImage, GenericImageView, ImageReader}; use ratatui::{ buffer::Buffer, layout::Rect, @@ -15,6 +15,7 @@ use crate::{environment::Environment, format::format_version}; pub struct ImagePreviewState { protocol: Option, + base_image: Option, // to control image rendering when dialogs are overlapped... render: bool, } @@ -34,9 +35,10 @@ pub enum ImagePicker { impl ImagePreviewState { pub fn new(bytes: &[u8], image_picker: ImagePicker) -> (Self, Option) { match build_image_protocol(bytes, image_picker) { - Ok(protocol) => { + Ok((protocol, img)) => { let state = ImagePreviewState { protocol: Some(protocol), + base_image: Some(img), render: true, }; (state, None) @@ -44,6 +46,7 @@ impl ImagePreviewState { Err(e) => { let state = ImagePreviewState { protocol: None, + base_image: None, render: true, }; (state, Some(e)) @@ -54,12 +57,20 @@ impl ImagePreviewState { pub fn set_render(&mut self, render: bool) { self.render = render; } + + pub fn base_image_data(&self) -> Option<(usize, usize, Vec)> { + self.base_image.as_ref().map(|img| { + let (w, h) = img.dimensions(); + let bytes = img.to_rgba8().into_raw(); + (w as usize, h as usize, bytes) + }) + } } fn build_image_protocol( bytes: &[u8], image_picker: ImagePicker, -) -> Result { +) -> Result<(StatefulProtocol, DynamicImage), String> { match image_picker { ImagePicker::Ok(picker) => { let reader = ImageReader::new(Cursor::new(bytes)) @@ -68,7 +79,8 @@ fn build_image_protocol( let img: DynamicImage = reader .decode() .map_err(|e| format!("Failed to decode image: {e}"))?; - Ok(picker.new_resize_protocol(img)) + let protocol = picker.new_resize_protocol(img.clone()); + Ok((protocol, img)) } ImagePicker::Error(e) => Err(format!("Failed to create picker: {e}")), ImagePicker::Disabled => Err("Image preview is disabled".into()),