Skip to content

Commit 1a9dc72

Browse files
authored
Merge pull request #92 from lusingander/copy-preview
Implement image copy in object preview
2 parents fd342ca + 2dfe835 commit 1a9dc72

11 files changed

Lines changed: 71 additions & 117 deletions

File tree

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ exclude = ["/.github", "/img", "/tool", "Makefile", "/docs"]
1717
[dependencies]
1818
ansi-to-tui = "8.0.0"
1919
anyhow = "1.0.100"
20-
arboard = { version = "3.6.1", features = ["wayland-data-control"] }
20+
arboard = { version = "3.6.1", features = [
21+
"image-data",
22+
"wayland-data-control",
23+
] }
2124
aws-config = "1.8.12"
2225
aws-sdk-s3 = "1.119.0"
2326
aws-smithy-types = "1.3.5"

docs/src/features/object-preview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
- It must be enabled in the [config](../configurations/config-file-format.md#previewauto_detect_encoding)
1111
- Download object
1212
- Download a single selected object
13-
- Copy the text content to clipboard
13+
- Copy the content to clipboard
1414

1515
![Object Preview](https://raw.githubusercontent.com/lusingander/stu/refs/heads/master/img/object-preview.png)
1616
![Object Preview Image](https://raw.githubusercontent.com/lusingander/stu/refs/heads/master/img/object-preview-image.png)

src/app.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use crate::{
2626
CompleteLoadObjectsResult, CompletePreviewObjectResult, CompleteReloadBucketsResult,
2727
CompleteReloadObjectsResult, CompleteSaveObjectResult, Sender,
2828
},
29-
file::{copy_to_clipboard, create_binary_file, save_error_log},
29+
file::{copy_image_to_clipboard, copy_text_to_clipboard, create_binary_file, save_error_log},
3030
keys::UserEventMapper,
3131
object::{AppObjects, DownloadObjectInfo, FileDetail, ObjectItem, ObjectKey, RawObject},
3232
pages::page::{Page, PageStack},
@@ -808,8 +808,16 @@ impl App {
808808
object_preview_page.enable_image_render();
809809
}
810810

811-
pub fn copy_to_clipboard(&self, name: String, value: String) {
812-
match copy_to_clipboard(value) {
811+
pub fn copy_text_to_clipboard(&self, name: String, value: String) {
812+
self.copy_to_clipboard(name, || copy_text_to_clipboard(value));
813+
}
814+
815+
pub fn copy_image_to_clipboard(&self, name: String, value: (usize, usize, Vec<u8>)) {
816+
self.copy_to_clipboard(name, || copy_image_to_clipboard(value));
817+
}
818+
819+
fn copy_to_clipboard<F: FnOnce() -> Result<()>>(&self, name: String, copy: F) {
820+
match copy() {
813821
Ok(_) => {
814822
let msg = format!("Copied '{name}' to clipboard successfully");
815823
self.tx.send(AppEventType::NotifySuccess(msg));

src/event.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ pub enum AppEventType {
6060
ObjectDetailOpenManagementConsole(ObjectKey),
6161
CloseCurrentPage,
6262
OpenHelp,
63-
CopyToClipboard(String, String),
63+
CopyTextToClipboard(String, String),
64+
CopyImageToClipboard(String, (usize, usize, Vec<u8>)),
6465
NotifyInfo(String),
6566
NotifySuccess(String),
6667
NotifyWarn(String),

src/file.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,20 @@ fn create_dirs<P: AsRef<Path>>(path: P) -> Result<()> {
4545
}
4646
}
4747

48-
pub fn copy_to_clipboard(value: String) -> Result<()> {
48+
pub fn copy_text_to_clipboard(value: String) -> Result<()> {
4949
Clipboard::new()
5050
.and_then(|mut c| c.set_text(value))
5151
.map_err(|e| AppError::new("Failed to copy to clipboard", e))
5252
}
53+
54+
pub fn copy_image_to_clipboard(value: (usize, usize, Vec<u8>)) -> Result<()> {
55+
let (width, height, bytes) = value;
56+
let image = arboard::ImageData {
57+
width,
58+
height,
59+
bytes: bytes.into(),
60+
};
61+
Clipboard::new()
62+
.and_then(|mut c| c.set_image(image))
63+
.map_err(|e| AppError::new("Failed to copy to clipboard", e))
64+
}

src/pages/bucket_list.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ impl BucketListPage {
179179
}
180180
UserEvent::SelectDialogSelect => {
181181
let (name, value) = state.selected_name_and_value();
182-
self.tx.send(AppEventType::CopyToClipboard(name, value));
182+
self.tx.send(AppEventType::CopyTextToClipboard(name, value));
183183
}
184184
UserEvent::Help => {
185185
self.tx.send(AppEventType::OpenHelp);

src/pages/object_detail.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ impl ObjectDetailPage {
175175
}
176176
UserEvent::SelectDialogSelect => {
177177
let (name, value) = state.selected_name_and_value();
178-
self.tx.send(AppEventType::CopyToClipboard(name, value));
178+
self.tx.send(AppEventType::CopyTextToClipboard(name, value));
179179
}
180180
UserEvent::SelectDialogDown => {
181181
state.select_next();

src/pages/object_list.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ impl ObjectListPage {
195195
}
196196
UserEvent::SelectDialogSelect => {
197197
let (name, value) = state.selected_name_and_value();
198-
self.tx.send(AppEventType::CopyToClipboard(name, value));
198+
self.tx.send(AppEventType::CopyTextToClipboard(name, value));
199199
}
200200
UserEvent::Help => {
201201
self.tx.send(AppEventType::OpenHelp);

src/pages/object_preview.rs

Lines changed: 16 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ impl ObjectPreviewPage {
163163
self.disable_image_render();
164164
}
165165
UserEvent::ObjectPreviewCopy => {
166-
self.tx.send(AppEventType::NotifyWarn("Cannot copy image content. Copy is only available for text files.".to_string()));
166+
self.copy_image_content();
167167
}
168168
UserEvent::Help => {
169169
self.tx.send(AppEventType::OpenHelp);
@@ -279,6 +279,7 @@ impl ObjectPreviewPage {
279279
BuildHelpsItem::new(UserEvent::ObjectPreviewBack, "Close preview"),
280280
BuildHelpsItem::new(UserEvent::ObjectPreviewDownload, "Download object"),
281281
BuildHelpsItem::new(UserEvent::ObjectPreviewDownloadAs, "Download object as"),
282+
BuildHelpsItem::new(UserEvent::ObjectPreviewCopy, "Copy content to clipboard"),
282283
]
283284
},
284285
(ViewState::SaveDialog(_), _) => {
@@ -320,6 +321,7 @@ impl ObjectPreviewPage {
320321
vec![
321322
BuildShortHelpsItem::single(UserEvent::Quit, "Quit", 0),
322323
BuildShortHelpsItem::group(vec![UserEvent::ObjectPreviewDownload, UserEvent::ObjectPreviewDownloadAs], "Download", 2),
324+
BuildShortHelpsItem::single(UserEvent::ObjectPreviewCopy, "Copy", 3),
323325
BuildShortHelpsItem::single(UserEvent::ObjectPreviewBack, "Close", 1),
324326
BuildShortHelpsItem::single(UserEvent::Help, "Help", 0),
325327
]
@@ -356,13 +358,24 @@ impl ObjectPreviewPage {
356358
let (content, _, _) = encoding.decode(&self.object.bytes);
357359
let content_string = content.into_owned();
358360

359-
self.tx.send(AppEventType::CopyToClipboard(
361+
self.tx.send(AppEventType::CopyTextToClipboard(
360362
self.file_detail.name.clone(),
361363
content_string,
362364
));
363365
}
364366
}
365367

368+
fn copy_image_content(&mut self) {
369+
if let PreviewType::Image(state) = &self.preview_type {
370+
if let Some((width, height, bytes)) = state.base_image_data() {
371+
self.tx.send(AppEventType::CopyImageToClipboard(
372+
self.file_detail.name.clone(),
373+
(width, height, bytes),
374+
));
375+
}
376+
}
377+
}
378+
366379
fn close_save_dialog(&mut self) {
367380
self.view_state = ViewState::Default;
368381
}
@@ -447,13 +460,7 @@ mod tests {
447460

448461
use super::*;
449462
use chrono::{DateTime, Local, NaiveDateTime};
450-
use ratatui::{
451-
backend::TestBackend,
452-
buffer::Buffer,
453-
crossterm::event::{KeyCode, KeyModifiers},
454-
style::Color,
455-
Terminal,
456-
};
463+
use ratatui::{backend::TestBackend, buffer::Buffer, style::Color, Terminal};
457464

458465
fn object(ss: &[&str]) -> RawObject {
459466
RawObject {
@@ -616,96 +623,4 @@ mod tests {
616623
object_url: "https://bucket-1.s3.ap-northeast-1.amazonaws.com/file.txt".to_string(),
617624
}
618625
}
619-
620-
#[tokio::test]
621-
async fn test_copy_text_content() {
622-
let ctx = Rc::default();
623-
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
624-
let tx = Sender::new(tx);
625-
626-
let file_detail = file_detail();
627-
let preview = ["Hello, world!", "This is test content."];
628-
let object = object(&preview);
629-
let mut page = ObjectPreviewPage::new(file_detail, None, object, ctx, tx);
630-
631-
page.handle_key(
632-
vec![UserEvent::ObjectPreviewCopy],
633-
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()),
634-
);
635-
636-
assert!(matches!(page.preview_type, PreviewType::Text(_)));
637-
}
638-
639-
#[tokio::test]
640-
async fn test_copy_image_content_shows_warning() {
641-
use crate::event::AppEventType;
642-
643-
let ctx = Rc::default();
644-
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
645-
let tx = Sender::new(tx);
646-
647-
let image_bytes = vec![
648-
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
649-
0x00, 0x00, 0x00, 0x0D, // IHDR chunk size
650-
0x49, 0x48, 0x44, 0x52, // "IHDR"
651-
0x00, 0x00, 0x00, 0x01, // width: 1
652-
0x00, 0x00, 0x00, 0x01, // height: 1
653-
0x08, 0x02, // bit depth: 8, color type: 2 (RGB)
654-
0x00, 0x00, 0x00, // compression, filter, interlace
655-
];
656-
let object = RawObject { bytes: image_bytes };
657-
658-
let mut file_detail = file_detail();
659-
file_detail.name = "image.png".to_string();
660-
file_detail.content_type = "image/png".to_string();
661-
662-
let mut page = ObjectPreviewPage::new(file_detail, None, object, ctx, tx);
663-
664-
assert!(matches!(page.preview_type, PreviewType::Image(_)));
665-
666-
// NOTE: Clear any initial warning messages (like "Image preview is disabled")
667-
while rx.try_recv().is_ok() {
668-
// Drain events
669-
}
670-
671-
page.handle_key(
672-
vec![UserEvent::ObjectPreviewCopy],
673-
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()),
674-
);
675-
676-
if let Ok(event) = rx.try_recv() {
677-
match event {
678-
AppEventType::NotifyWarn(msg) => {
679-
assert!(
680-
msg.contains("Cannot copy image content"),
681-
"Message was: {}",
682-
msg
683-
);
684-
}
685-
_ => panic!("Expected NotifyWarn event, got: {:?}", event),
686-
}
687-
} else {
688-
panic!("Expected NotifyWarn event to be sent");
689-
}
690-
}
691-
692-
#[test]
693-
fn test_copy_respects_encoding() {
694-
let ctx = Rc::default();
695-
let tx = sender();
696-
697-
let text = "Hello, 世界!";
698-
let utf16_bytes: Vec<u8> = text.encode_utf16().flat_map(|c| c.to_be_bytes()).collect();
699-
700-
let object = RawObject { bytes: utf16_bytes };
701-
702-
let file_detail = file_detail();
703-
let page = ObjectPreviewPage::new(file_detail, None, object, ctx, tx);
704-
705-
if let PreviewType::Text(ref state) = page.preview_type {
706-
assert!(matches!(state.encoding, _));
707-
} else {
708-
panic!("Expected text preview type");
709-
}
710-
}
711626
}

src/run.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,11 @@ pub async fn run<B: Backend<Error = std::io::Error>>(
192192
AppEventType::OpenHelp => {
193193
app.open_help();
194194
}
195-
AppEventType::CopyToClipboard(name, value) => {
196-
app.copy_to_clipboard(name, value);
195+
AppEventType::CopyTextToClipboard(name, value) => {
196+
app.copy_text_to_clipboard(name, value);
197+
}
198+
AppEventType::CopyImageToClipboard(name, value) => {
199+
app.copy_image_to_clipboard(name, value);
197200
}
198201
AppEventType::NotifyInfo(msg) => {
199202
app.info_notification(msg);

0 commit comments

Comments
 (0)