From 57ca12849777bc0f61b423bec936d99698761d9f Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Fri, 30 Jan 2026 08:51:37 +0900 Subject: [PATCH 1/7] Rename copy_to_clipboard --- src/app.rs | 6 +++--- src/event.rs | 2 +- src/file.rs | 2 +- src/pages/bucket_list.rs | 2 +- src/pages/object_detail.rs | 2 +- src/pages/object_list.rs | 2 +- src/pages/object_preview.rs | 2 +- src/run.rs | 4 ++-- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/app.rs b/src/app.rs index a16b362..e553714 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_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,8 @@ 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) { + match copy_text_to_clipboard(value) { 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..9531f1a 100644 --- a/src/event.rs +++ b/src/event.rs @@ -60,7 +60,7 @@ pub enum AppEventType { ObjectDetailOpenManagementConsole(ObjectKey), CloseCurrentPage, OpenHelp, - CopyToClipboard(String, String), + CopyTextToClipboard(String, String), NotifyInfo(String), NotifySuccess(String), NotifyWarn(String), diff --git a/src/file.rs b/src/file.rs index 4c93b63..dca5280 100644 --- a/src/file.rs +++ b/src/file.rs @@ -45,7 +45,7 @@ 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)) 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..d0142ab 100644 --- a/src/pages/object_preview.rs +++ b/src/pages/object_preview.rs @@ -356,7 +356,7 @@ 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, )); diff --git a/src/run.rs b/src/run.rs index 3a8a00b..070eecf 100644 --- a/src/run.rs +++ b/src/run.rs @@ -192,8 +192,8 @@ 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::NotifyInfo(msg) => { app.info_notification(msg); From c20bcd365c7c7764ed52c5136ef1129b92ab2884 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Fri, 30 Jan 2026 09:08:21 +0900 Subject: [PATCH 2/7] Update arboard features to include image-data --- Cargo.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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" From 4f3eba485c47990d544aae54da1ea3a45444842f Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Fri, 30 Jan 2026 09:14:32 +0900 Subject: [PATCH 3/7] Preserve base_image to ImagePreviewState --- src/widget/image_preview.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) 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()), From 0140ba50114c29d9d8969949a80debed865122e7 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Fri, 30 Jan 2026 17:58:14 +0900 Subject: [PATCH 4/7] Implement image copy in object preview --- src/app.rs | 12 ++++++++++-- src/event.rs | 1 + src/file.rs | 12 ++++++++++++ src/pages/object_preview.rs | 13 ++++++++++++- src/run.rs | 3 +++ 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/app.rs b/src/app.rs index e553714..04d4b83 100644 --- a/src/app.rs +++ b/src/app.rs @@ -26,7 +26,7 @@ use crate::{ CompleteLoadObjectsResult, CompletePreviewObjectResult, CompleteReloadBucketsResult, CompleteReloadObjectsResult, CompleteSaveObjectResult, Sender, }, - file::{copy_text_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}, @@ -809,7 +809,15 @@ impl App { } pub fn copy_text_to_clipboard(&self, name: String, value: String) { - match copy_text_to_clipboard(value) { + 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 9531f1a..de0f55d 100644 --- a/src/event.rs +++ b/src/event.rs @@ -61,6 +61,7 @@ pub enum AppEventType { CloseCurrentPage, OpenHelp, 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 dca5280..7d96c26 100644 --- a/src/file.rs +++ b/src/file.rs @@ -50,3 +50,15 @@ pub fn copy_text_to_clipboard(value: String) -> Result<()> { .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/object_preview.rs b/src/pages/object_preview.rs index d0142ab..6a61fef 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); @@ -363,6 +363,17 @@ impl ObjectPreviewPage { } } + 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; } diff --git a/src/run.rs b/src/run.rs index 070eecf..4d64aed 100644 --- a/src/run.rs +++ b/src/run.rs @@ -195,6 +195,9 @@ pub async fn run>( 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); } From 6d3e0050cc2d7a7bea70a2e3dedb6a0b032c510b Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Fri, 30 Jan 2026 18:25:14 +0900 Subject: [PATCH 5/7] Remove tests --- src/pages/object_preview.rs | 100 +----------------------------------- 1 file changed, 1 insertion(+), 99 deletions(-) diff --git a/src/pages/object_preview.rs b/src/pages/object_preview.rs index 6a61fef..03d945a 100644 --- a/src/pages/object_preview.rs +++ b/src/pages/object_preview.rs @@ -458,13 +458,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 { @@ -627,96 +621,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"); - } - } } From 2c31ca739f6cecd56949a7d8728b1fabe0e5e861 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Fri, 30 Jan 2026 18:29:21 +0900 Subject: [PATCH 6/7] Fix helps --- src/pages/object_preview.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/object_preview.rs b/src/pages/object_preview.rs index 03d945a..9f15103 100644 --- a/src/pages/object_preview.rs +++ b/src/pages/object_preview.rs @@ -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), ] From 2dfe8352d7908e90dd8b593b9ca0560313cb7cf5 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Fri, 30 Jan 2026 18:30:58 +0900 Subject: [PATCH 7/7] Update docs --- docs/src/features/object-preview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)