Skip to content

Commit

Permalink
Merge pull request #24 from lusingander/image
Browse files Browse the repository at this point in the history
Support image preview
  • Loading branch information
lusingander authored Sep 11, 2024
2 parents 9dab95c + 74ddd11 commit 554328e
Show file tree
Hide file tree
Showing 24 changed files with 871 additions and 45 deletions.
585 changes: 580 additions & 5 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ chrono = "0.4.38"
clap = { version = "4.5.17", features = ["derive"] }
dirs = "5.0.1"
humansize = "2.1.3"
image = "0.25.2"
infer = "0.16.0"
itertools = "0.13.0"
itsuki = "0.2.0"
once_cell = "1.19.0"
open = "5.3.0"
ratatui = { version = "0.28.1", features = ["unstable-widget-ref"] }
ratatui-image = "1.0.5"
serde = { version = "1.0.210", features = ["derive"] }
syntect = { version = "5.2.0", default-features = false, features = [
"default-fancy",
Expand Down
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,12 @@ date_width = 19
date_format = "%Y-%m-%d %H:%M:%S"

[preview]
# Whether syntax highlighting is enabled in preview.
# Whether syntax highlighting is enabled in the object preview.
# type: bool
highlight = false
# Whether image file preview is enabled in the object preview.
# type: bool
image = false
```

## Features / Screenshots
Expand All @@ -159,11 +162,21 @@ highlight = false
- Show object details
- Show object versions
- Download object
- Preview object (text file only)
- syntax highlighting (by [syntect](https://github.com/trishume/syntect))
- Download the specified version
- Preview object
- Preview the specified version
- Copy resource name to clipboard

<img src="./img/object-detail.png" width=400> <img src="./img/object-version.png" width=400> <img src="./img/object-download.png" width=400> <img src="./img/object-preview.png" width=400> <img src="./img/object-details-copy.png" width=400>
<img src="./img/object-detail.png" width=400> <img src="./img/object-version.png" width=400> <img src="./img/object-download.png" width=400> <img src="./img/object-details-copy.png" width=400>

### Object preview

- syntax highlighting (by [syntect](https://github.com/trishume/syntect))
- It must be enabled in the [config](#config-file-format)
- image preview (by [ratatui-image](https://github.com/benjajaja/ratatui-image))
- It must be enabled in the [config](#config-file-format)

<img src="./img/object-preview.png" width=400> <img src="./img/object-preview-image.png" width=400>

## Troubleshooting

Expand Down
Binary file modified img/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/object-detail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/object-list-filter.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/object-list-simple.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/object-list-sort.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/object-preview-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/object-version.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,12 @@ impl App {
}
}
self.app_view_state.is_loading = false;

if let Page::ObjectPreview(page) = self.page_stack.current_page() {
if page.is_image_preview() {
self.tx.send(AppEventType::PreviewRerenderImage);
}
}
}

pub fn preview_object(&self, file_detail: FileDetail, version_id: Option<String>) {
Expand Down Expand Up @@ -648,6 +654,11 @@ impl App {
page.close_save_dialog();
}

pub fn preview_rerender_image(&mut self) {
let object_preview_page = self.page_stack.current_page_mut().as_mut_object_preview();
object_preview_page.enable_image_render();
}

pub fn copy_to_clipboard(&self, name: String, value: String) {
match copy_to_clipboard(value) {
Ok(_) => {
Expand Down
2 changes: 2 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ impl Default for UiObjectDetailConfig {
pub struct PreviewConfig {
#[serde(default)]
pub highlight: bool,
#[serde(default)]
pub image: bool,
}

impl Default for Config {
Expand Down
1 change: 1 addition & 0 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ pub enum AppEventType {
DetailDownloadObjectAs(FileDetail, String, Option<String>),
PreviewDownloadObject(RawObject, String),
PreviewDownloadObjectAs(FileDetail, String, Option<String>),
PreviewRerenderImage,
BucketListOpenManagementConsole,
ObjectListOpenManagementConsole,
ObjectDetailOpenManagementConsole,
Expand Down
143 changes: 111 additions & 32 deletions src/pages/object_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ use crate::{
key_code, key_code_char,
object::{FileDetail, ObjectKey, RawObject},
pages::util::{build_helps, build_short_helps},
widget::{InputDialog, InputDialogState, TextPreview, TextPreviewState},
widget::{
ImagePreview, ImagePreviewState, InputDialog, InputDialogState, TextPreview,
TextPreviewState,
},
};

#[derive(Debug)]
pub struct ObjectPreviewPage {
state: TextPreviewState,
preview_type: PreviewType,

file_detail: FileDetail,
file_version_id: Option<String>,
Expand All @@ -28,6 +31,12 @@ pub struct ObjectPreviewPage {
tx: Sender,
}

#[derive(Debug)]
enum PreviewType {
Text(TextPreviewState),
Image(ImagePreviewState),
}

#[derive(Debug, Default)]
enum ViewState {
#[default]
Expand All @@ -45,13 +54,23 @@ impl ObjectPreviewPage {
preview_config: PreviewConfig,
tx: Sender,
) -> Self {
let (state, msg) = TextPreviewState::new(&file_detail, &object, preview_config.highlight);
if let Some(msg) = msg {
tx.send(AppEventType::NotifyWarn(msg));
}
let preview_type = if infer::is_image(&object.bytes) {
let (state, msg) = ImagePreviewState::new(&object.bytes, preview_config.image);
if let Some(msg) = msg {
tx.send(AppEventType::NotifyWarn(msg));
}
PreviewType::Image(state)
} else {
let (state, msg) =
TextPreviewState::new(&file_detail, &object, preview_config.highlight);
if let Some(msg) = msg {
tx.send(AppEventType::NotifyWarn(msg));
}
PreviewType::Text(state)
};

Self {
state,
preview_type,
object,
file_detail,
file_version_id,
Expand All @@ -63,43 +82,43 @@ impl ObjectPreviewPage {
}

pub fn handle_key(&mut self, key: KeyEvent) {
match self.view_state {
ViewState::Default => match key {
match (&mut self.view_state, &mut self.preview_type) {
(ViewState::Default, PreviewType::Text(state)) => match key {
key_code!(KeyCode::Esc) => {
self.tx.send(AppEventType::Quit);
}
key_code!(KeyCode::Backspace) => {
self.tx.send(AppEventType::CloseCurrentPage);
}
key_code_char!('j') => {
self.state.scroll_lines_state.scroll_forward();
state.scroll_lines_state.scroll_forward();
}
key_code_char!('k') => {
self.state.scroll_lines_state.scroll_backward();
state.scroll_lines_state.scroll_backward();
}
key_code_char!('f') => {
self.state.scroll_lines_state.scroll_page_forward();
state.scroll_lines_state.scroll_page_forward();
}
key_code_char!('b') => {
self.state.scroll_lines_state.scroll_page_backward();
state.scroll_lines_state.scroll_page_backward();
}
key_code_char!('g') => {
self.state.scroll_lines_state.scroll_to_top();
state.scroll_lines_state.scroll_to_top();
}
key_code_char!('G') => {
self.state.scroll_lines_state.scroll_to_end();
state.scroll_lines_state.scroll_to_end();
}
key_code_char!('h') => {
self.state.scroll_lines_state.scroll_left();
state.scroll_lines_state.scroll_left();
}
key_code_char!('l') => {
self.state.scroll_lines_state.scroll_right();
state.scroll_lines_state.scroll_right();
}
key_code_char!('w') => {
self.state.scroll_lines_state.toggle_wrap();
state.scroll_lines_state.toggle_wrap();
}
key_code_char!('n') => {
self.state.scroll_lines_state.toggle_number();
state.scroll_lines_state.toggle_number();
}
key_code_char!('s') => {
self.download();
Expand All @@ -112,13 +131,34 @@ impl ObjectPreviewPage {
}
_ => {}
},
ViewState::SaveDialog(ref mut state) => match key {
(ViewState::Default, PreviewType::Image(_)) => match key {
key_code!(KeyCode::Esc) => {
self.tx.send(AppEventType::Quit);
}
key_code!(KeyCode::Backspace) => {
self.tx.send(AppEventType::CloseCurrentPage);
}
key_code_char!('s') => {
self.download();
}
key_code_char!('S') => {
self.open_save_dialog();
self.disable_image_render();
}
key_code_char!('?') => {
self.tx.send(AppEventType::OpenHelp);
}
_ => {}
},
(ViewState::SaveDialog(state), _) => match key {
key_code!(KeyCode::Esc) => {
self.close_save_dialog();
self.enable_image_render();
}
key_code!(KeyCode::Enter) => {
let input = state.input().into();
self.download_as(input);
// enable_image_render is called after download is completed
}
key_code_char!('?') => {
self.tx.send(AppEventType::OpenHelp);
Expand All @@ -131,11 +171,22 @@ impl ObjectPreviewPage {
}

pub fn render(&mut self, f: &mut Frame, area: Rect) {
let preview = TextPreview::new(
self.file_detail.name.as_str(),
self.file_version_id.as_deref(),
);
f.render_stateful_widget(preview, area, &mut self.state);
match self.preview_type {
PreviewType::Text(ref mut state) => {
let preview = TextPreview::new(
self.file_detail.name.as_str(),
self.file_version_id.as_deref(),
);
f.render_stateful_widget(preview, area, state);
}
PreviewType::Image(ref mut state) => {
let preview = ImagePreview::new(
self.file_detail.name.as_str(),
self.file_version_id.as_deref(),
);
f.render_stateful_widget(preview, area, state);
}
}

if let ViewState::SaveDialog(state) = &mut self.view_state {
let save_dialog = InputDialog::default().title("Save As").max_width(40);
Expand All @@ -147,8 +198,8 @@ impl ObjectPreviewPage {
}

pub fn helps(&self) -> Vec<String> {
let helps: &[(&[&str], &str)] = match self.view_state {
ViewState::Default => &[
let helps: &[(&[&str], &str)] = match (&self.view_state, &self.preview_type) {
(ViewState::Default, PreviewType::Text(_)) => &[
(&["Esc", "Ctrl-c"], "Quit app"),
(&["j/k"], "Scroll forward/backward"),
(&["f/b"], "Scroll page forward/backward"),
Expand All @@ -160,7 +211,13 @@ impl ObjectPreviewPage {
(&["s"], "Download object"),
(&["S"], "Download object as"),
],
ViewState::SaveDialog(_) => &[
(ViewState::Default, PreviewType::Image(_)) => &[
(&["Esc", "Ctrl-c"], "Quit app"),
(&["Backspace"], "Close preview"),
(&["s"], "Download object"),
(&["S"], "Download object as"),
],
(ViewState::SaveDialog(_), _) => &[
(&["Ctrl-c"], "Quit app"),
(&["Esc"], "Close save dialog"),
(&["Enter"], "Download object"),
Expand All @@ -171,16 +228,22 @@ impl ObjectPreviewPage {
}

pub fn short_helps(&self) -> Vec<(String, usize)> {
let helps: &[(&[&str], &str, usize)] = match self.view_state {
ViewState::Default => &[
let helps: &[(&[&str], &str, usize)] = match (&self.view_state, &self.preview_type) {
(ViewState::Default, PreviewType::Text(_)) => &[
(&["Esc"], "Quit", 0),
(&["j/k"], "Scroll", 2),
(&["g/G"], "Top/End", 4),
(&["s/S"], "Download", 3),
(&["Backspace"], "Close", 2),
(&["Backspace"], "Close", 1),
(&["?"], "Help", 0),
],
ViewState::SaveDialog(_) => &[
(ViewState::Default, PreviewType::Image(_)) => &[
(&["Esc"], "Quit", 0),
(&["s/S"], "Download", 2),
(&["Backspace"], "Close", 1),
(&["?"], "Help", 0),
],
(ViewState::SaveDialog(_), _) => &[
(&["Esc"], "Close", 2),
(&["Enter"], "Download", 1),
(&["?"], "Help", 0),
Expand All @@ -200,6 +263,22 @@ impl ObjectPreviewPage {
self.view_state = ViewState::Default;
}

pub fn enable_image_render(&mut self) {
if let PreviewType::Image(state) = &mut self.preview_type {
state.set_render(true);
}
}

pub fn disable_image_render(&mut self) {
if let PreviewType::Image(state) = &mut self.preview_type {
state.set_render(false);
}
}

pub fn is_image_preview(&self) -> bool {
matches!(self.preview_type, PreviewType::Image(_))
}

fn download(&self) {
// object has been already downloaded, so send completion event to save file
let obj = self.object.clone();
Expand Down
3 changes: 3 additions & 0 deletions src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ pub async fn run<B: Backend>(
AppEventType::PreviewDownloadObjectAs(file_detail, input, version_id) => {
app.preview_download_object_as(file_detail, input, version_id);
}
AppEventType::PreviewRerenderImage => {
app.preview_rerender_image();
}
AppEventType::BucketListOpenManagementConsole => {
app.bucket_list_open_management_console();
}
Expand Down
2 changes: 2 additions & 0 deletions src/widget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod copy_detail_dialog;
mod dialog;
mod divider;
mod header;
mod image_preview;
mod input_dialog;
mod scroll;
mod scroll_lines;
Expand All @@ -15,6 +16,7 @@ pub use copy_detail_dialog::{CopyDetailDialog, CopyDetailDialogState};
pub use dialog::Dialog;
pub use divider::Divider;
pub use header::Header;
pub use image_preview::{ImagePreview, ImagePreviewState};
pub use input_dialog::{InputDialog, InputDialogState};
pub use scroll::ScrollBar;
pub use scroll_lines::{ScrollLines, ScrollLinesOptions, ScrollLinesState};
Expand Down
Loading

0 comments on commit 554328e

Please sign in to comment.