diff --git a/Cargo.lock b/Cargo.lock index 6b4afc7..805657c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,6 +1156,7 @@ dependencies = [ "fork", "i18n-embed", "i18n-embed-fl", + "image 0.24.8", "lexical-sort", "libcosmic", "log", diff --git a/Cargo.toml b/Cargo.toml index e40fe92..c7c66ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ rust-version = "1.71" chrono = { version = "0.4", features = ["unstable-locales"] } dirs = "5.0.1" env_logger = "0.11" +image = "0.24" once_cell = "1.19" lexical-sort = "0.3.1" log = "0.4" diff --git a/src/app.rs b/src/app.rs index 8e07f6a..59cf293 100644 --- a/src/app.rs +++ b/src/app.rs @@ -359,6 +359,9 @@ impl App { for item in items.iter() { if item.selected { children.push(item.property_view(&self.core, tab.config.icon_sizes)); + // Only show one property view to avoid issues like hangs when generating + // preview images on thousands of files + break; } } } @@ -1158,6 +1161,16 @@ impl Application for App { )); } + for entity in self.tab_model.iter() { + if let Some(tab) = self.tab_model.data::(entity) { + subscriptions.push( + tab.subscription() + .with(entity) + .map(|(entity, tab_msg)| Message::TabMessage(Some(entity), tab_msg)), + ); + } + } + Subscription::batch(subscriptions) } } diff --git a/src/dialog.rs b/src/dialog.rs index 4f99e19..5835aa9 100644 --- a/src/dialog.rs +++ b/src/dialog.rs @@ -632,6 +632,7 @@ impl Application for App { } }, ), + self.tab.subscription().map(Message::TabMessage), ]) } } diff --git a/src/tab.rs b/src/tab.rs index 8ccf525..45348c8 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -3,7 +3,9 @@ use cosmic::{ cosmic_theme, iced::{ alignment::{Horizontal, Vertical}, + futures::SinkExt, keyboard::Modifiers, + subscription::{self, Subscription}, //TODO: export in cosmic::widget widget::horizontal_rule, Alignment, @@ -234,8 +236,6 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec { let mime_guess = MimeGuess::from_path(&path); - //TODO: previews of images - let (icon_handle_dialog, icon_handle_grid, icon_handle_list) = if metadata.is_dir() { ( @@ -273,6 +273,10 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec { icon_handle_dialog, icon_handle_grid, icon_handle_list, + thumbnail_res_opt: match mime_guess.first() { + Some(mime) if mime.type_() == "image" => None, + _ => Some(Err(())), + }, selected: false, click_time: None, }); @@ -355,6 +359,7 @@ pub fn scan_trash(sizes: IconSizes) -> Vec { icon_handle_dialog, icon_handle_grid, icon_handle_list, + thumbnail_res_opt: Some(Err(())), selected: false, click_time: None, }); @@ -397,6 +402,7 @@ pub enum Message { Location(Location), LocationUp, RightClick(usize), + Thumbnail(PathBuf, Result), ToggleShowHidden, View(View), } @@ -435,25 +441,39 @@ pub struct Item { pub icon_handle_dialog: widget::icon::Handle, pub icon_handle_grid: widget::icon::Handle, pub icon_handle_list: widget::icon::Handle, + pub thumbnail_res_opt: Option>, pub selected: bool, pub click_time: Option, } impl Item { pub fn property_view(&self, core: &Core, sizes: IconSizes) -> Element { - let mut section = widget::settings::view_section(""); + let cosmic_theme::Spacing { space_xxxs, .. } = core.system_theme().cosmic().spacing; - section = section.add(widget::icon::icon(self.icon_handle_grid.clone()).size(sizes.grid())); + let mut column = widget::column().spacing(space_xxxs); - section = section.add(widget::settings::item::item_row(vec![ - widget::text::heading(self.name.clone()).into(), + let is_image = if let Some(mime) = self.mime_guess.first() { + mime.type_() == "image" && self.path.is_file() + } else { + false + }; + + column = column.push(widget::row::with_children(vec![ + widget::horizontal_space(Length::Fill).into(), + if is_image { + widget::image::Image::new(&self.path).into() + } else { + widget::icon::icon(self.icon_handle_grid.clone()) + .size(sizes.grid()) + .into() + }, + widget::horizontal_space(Length::Fill).into(), ])); + column = column.push(widget::text::heading(self.name.clone())); + if let Some(mime) = self.mime_guess.first() { - section = section.add(widget::settings::item( - "Type", - widget::text(format!("{}", mime)), - )); + column = column.push(widget::text(format!("Type: {}", mime))); } //TODO: translate! @@ -461,48 +481,33 @@ impl Item { match &self.metadata { ItemMetadata::Path { metadata, children } => { if metadata.is_dir() { - section = section.add(widget::settings::item::item( - "Items", - widget::text(format!("{}", children)), - )); + column = column.push(widget::text(format!("Items: {}", children))); } else { - section = section.add(widget::settings::item::item( - "Size", - widget::text(format_size(metadata.len())), - )); + column = column.push(widget::text(format!( + "Size: {}", + format_size(metadata.len()) + ))); } if let Ok(time) = metadata.created() { - section = section.add(widget::settings::item( - "Created", - widget::text( - chrono::DateTime::::from(time) - .format(TIME_FORMAT) - .to_string(), - ), - )); + column = column.push(widget::text(format!( + "Created: {}", + chrono::DateTime::::from(time).format(TIME_FORMAT) + ))); } if let Ok(time) = metadata.modified() { - section = section.add(widget::settings::item( - "Modified", - widget::text( - chrono::DateTime::::from(time) - .format(TIME_FORMAT) - .to_string(), - ), - )); + column = column.push(widget::text(format!( + "Modified: {}", + chrono::DateTime::::from(time).format(TIME_FORMAT) + ))); } if let Ok(time) = metadata.accessed() { - section = section.add(widget::settings::item( - "Accessed", - widget::text( - chrono::DateTime::::from(time) - .format(TIME_FORMAT) - .to_string(), - ), - )); + column = column.push(widget::text(format!( + "Accessed: {}", + chrono::DateTime::::from(time).format(TIME_FORMAT) + ))); } } ItemMetadata::Trash { .. } => { @@ -510,7 +515,7 @@ impl Item { } } - section.into() + column.into() } } @@ -678,6 +683,27 @@ impl Tab { } } } + Message::Thumbnail(path, thumbnail_res) => { + if let Some(ref mut items) = self.items_opt { + for item in items.iter_mut() { + if item.path == path { + if let Ok(thumbnail) = &thumbnail_res { + //TODO: pass handles already generated to avoid blocking main thread + let handle = widget::icon::from_raster_pixels( + thumbnail.width(), + thumbnail.height(), + thumbnail.as_raw().clone(), + ); + item.icon_handle_dialog = handle.clone(); + item.icon_handle_grid = handle.clone(); + item.icon_handle_list = handle; + } + item.thumbnail_res_opt = Some(thumbnail_res); + break; + } + } + } + } Message::ToggleShowHidden => self.config.show_hidden = !self.config.show_hidden, Message::View(view) => { self.view = view; @@ -1077,6 +1103,87 @@ impl Tab { .width(Length::Fill) .into() } + + pub fn subscription(&self) -> Subscription { + if let Some(items) = &self.items_opt { + //TODO: how many thumbnail loads should be in flight at once? + let jobs = 8; + let mut subscriptions = Vec::with_capacity(jobs); + for item in items.iter() { + match item.thumbnail_res_opt { + Some(_) => continue, + None => { + let path = item.path.clone(); + subscriptions.push(subscription::channel( + path.clone(), + 1, + |mut output| async move { + let (path, thumbnail_res) = + tokio::task::spawn_blocking(move || { + let start = std::time::Instant::now(); + let thumbnail_res = match image::io::Reader::open(&path) { + Ok(reader) => match reader.decode() { + Ok(image) => { + //TODO: configurable thumbnail size + let thumbnail = image.thumbnail(256, 256); + Ok(thumbnail.to_rgba8()) + } + Err(err) => { + log::warn!( + "failed to decode {:?}: {}", + path, + err + ); + Err(()) + } + }, + Err(err) => { + log::warn!("failed to read {:?}: {}", path, err); + Err(()) + } + }; + log::info!( + "thumbnailed {:?} in {:?}", + path, + start.elapsed() + ); + (path, thumbnail_res) + }) + .await + .unwrap(); + + match output + .send(Message::Thumbnail(path.clone(), thumbnail_res)) + .await + { + Ok(()) => {} + Err(err) => { + log::warn!( + "failed to send thumbnail for {:?}: {}", + path, + err + ); + } + } + + //TODO: how to properly kill this task? + loop { + tokio::time::sleep(std::time::Duration::new(1, 0)).await; + } + }, + )); + } + } + + if subscriptions.len() >= jobs { + break; + } + } + Subscription::batch(subscriptions) + } else { + Subscription::none() + } + } } #[cfg(test)]